.NET的装箱与拆箱内幕

装箱与拆箱是.NET中非常重要的概念。

装箱是将值类型转换成引用类型,或者是实现了接口的值类型。装箱将数据存储的空间由Thread stack转存到了Managed Heap中。凡是在Managed Heap中开辟空间,都将触发GC(垃圾回收),在Thread statck将不会触发垃圾回收。

拆箱就是将数据从Managed Heap中提取出来,并拷贝到Thread stack中。所以拆箱会形成两份数据,一分在Managed Heap中,一份在Thread Statck中。

先来看一段装箱和拆箱的代码

        public static void BoxUnbox()
        {
            int i = 123;
            object o = i;//隐式装箱
            object p=(object)i;//显式装箱
            int j = (int)p;//拆箱
        }

IL的代码


堆栈图


可以看到i到o、i到p进行了装箱,而且o和p的数据存储到了Managed Heap中。而p到j是拆箱,数据复制了一份到Thread Stack中。

装箱可以是显式或者隐式的,但拆箱是显式的。乍一看,装箱和拆箱是互逆的操作,但从上图中可以看到,并非如此。装箱需要在Managed Heap中开辟空间,同时在空间中必须设置相应的指针(Type object ptr)和同步块索引(Sync bolck index),之后才是将Thread Stack中的数据拷贝进去。而拆箱,只是从Managed Heap中将数据拷贝到Thread Stack中,并且紧接在相应的字段后面。所以装箱和拆箱并不是完全互逆的操作。而且从消耗上讲,拆箱的消耗会少于装箱的消耗。

我们来看一段测试代码

  internal struct Point
        {
            private Int32 _x, _y;
            public Point(Int32 x, Int32 y)
            {
                _x = x;
                _y = y;
            }
            public void Change(Int32 x, Int32 y)
            {
                _x = x; _y = y;
            }
            public override String ToString()
            {
                return String.Format("({0}, {1})", _x.ToString(), _y.ToString());
            }
        }

   public static void TypeTest()
        {
            Point p = new Point(1, 1);
            Console.WriteLine("p:"+p);
            p.Change(2, 2);
            Console.WriteLine("p:" + p);
            Object o = p;
            Console.WriteLine("o:"+o);
            ((Point)o).Change(3, 3);
            Console.WriteLine("o:" + o);
        }

输出结果


从结果中可以看到,p初始值为(1,1),所以第一次输出时为(1,1)。经过Change函数后,第二次输出为(2,2)。将p转换成o后,输出o为(3,3)。这都是预料之中的。

但是经过(Point)o强转,又执行了Change(3,3)之后,输出的结果并不是所期望的(3,3)。这是为什么?

我们可以通过前面的Thread Stack和Managed Heap对比图知道,在将对象o拆箱为Point的时候,会将o的数据拷贝一份到Thread Statck中,这样一来,Change(3,3)的操作只是针对Thread Statck中的,而在输出时的数据是还在Managed Heap中的o。这样一想,结果就变的理所当然了。

那如果将Point继承自一个接口呢?结果又会如何?为了与Point区别,这里将Point变成Pointex。代码如下

 
 // Interface defining a Change method 
    internal interface IChangeBoxedPoint
    {
        void Change(Int32 x, Int32 y);
    }
    // Point is a value type. 
    internal struct Pointex : IChangeBoxedPoint
    {
        private Int32 _x, _y;
        public Pointex(Int32 x, Int32 y)
        {
            _x = x;
            _y = y;
        }
        public void Change(Int32 x, Int32 y)
        {
            _x = x; _y = y;
        }
        public override String ToString()
        {
            return String.Format("({0}, {1})", _x.ToString(), _y.ToString());
        }
    }

 public static void TypeTestPointex()
        {
            Pointex p = new Pointex(1, 1);
            Console.WriteLine(p);
            p.Change(2, 2);
            Console.WriteLine(p);
            Object o = p;
            Console.WriteLine(o);
            ((Pointex)o).Change(3, 3);
            Console.WriteLine(o);
            // Boxes p, changes the boxed object and discards it 
            ((IChangeBoxedPoint)p).Change(4, 4);
            Console.WriteLine(p);
            // Changes the boxed object and shows it 
            ((IChangeBoxedPoint)o).Change(5, 5);
            Console.WriteLine(o);
        }
结果如下图


从结果中可以看到,p初始值为(1,1),所以第一次输出时为(1,1)。经过Change函数后,第二次输出为(2,2)。将o强转为Pointex并执行Change(3,3),输出为(2,2)。这在上一例中已经作出了解译。那(IChangedBoxedPoint)强转p和o输出的结果为什么是(2,2)和(5,5)呢。

可以思考一下前面的Thread Statck和Managed Heap。p在经过IChangedBoxedPoint强制转换时,经过了装箱(box),在Managed Heap会开辟一个空间来储存Point的x和y,在这里执行Change(4,4)操作,同时触发了GC,在执行完Changed返回时,GC自动将这一部分空间回收。p还是原来在Thread Stack中的p。所以输出的是(2,2)。

对于IChangedBoxedPoint强制转换o时,本来也是要有一个装箱操作的,不过这里的o是object,已经是装箱过的,所以不再装箱。所以Change(5,5)会改变这里的数据,同时由于IChangedBoxedPoint执行完Change后返回时,由于使用的是o空间的数据,而o还存在着,所以生命周期并没有结束,GC也就不会回收这部分数据。所以输出的是(5,5)。

据此,我们可以思考一下下面这段代码

 public static void TestWriteLine()
        {
            int i = 1;
            Console.WriteLine("{0},{1},{2}",i,i,i);
            object o = i;
            Console.WriteLine("{0},{1},{2}",o,o,o);
        }
两个Console.WriteLine输出的结果是一样的,但是内部却有差异。我们来查看一下IL代码


可以看到Console.WriteLine("{0},{1},{2}",i,i,i)进行三次的装箱操作,因为Console.WriteLine这时调用的是三个obj参数的方法,见下图。i是int类型,是值类型,要转换成object,所以需要装箱操作,因为是三个obj,所以有三次装箱(box)。


再看Console.WriteLine("{0},{1},{2}",o,o,o),只进行了次装箱操作,同时Console.WriteLine这时调用的是另一个方法,见下图。这里通过对象o将i进行了一次装箱,所以后面的Console.WriteLine调用时,就不再需要装箱。


由此,可以看到,虽然输出的结果一致,但因为内部的装箱操作次数不同,可以预见,两者在性能上必然是后者优于前者。

综上,我们可以得出以下结论:

1.装箱是将值类型转换成引用类型,或者是继承了接口的值类型。如果装箱后的值类型需要改变内部的字段,需要通过接口来实现。

2.装箱时,必然会在Managed Heap中开辟相应的空间,并触发GC。

3.拆箱时,会将数据从Managed Headp中拷贝一份到Thread Stack中。

4.装箱和拆箱并不完全互逆。

5.拆箱的消耗要小于装箱的消耗。

附MSDN的说明http://msdn.microsoft.com/en-us/library/yz2be5wk.aspx

转载请注明出处http://blog.csdn.net/xxdddail/article/details/36892781

posted @ 2014-07-04 16:50  _学而时习之  阅读(262)  评论(0编辑  收藏  举报