• .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

  • 相关阅读:
    linux安装mysql5.7.24
    如何解决svn Authorization failed错误
    vux配置i18n
    vue项目使用vux框架配置教程
    EL函数
    Android的taskAffinity对四种launchMode的影响
    Activity生命周期-Android
    为什么用服务不用线程-Android
    Hibernate总结--MyEclipse的小bug
    EL表达式隐含对象
  • 原文地址:https://www.cnblogs.com/sparkleDai/p/7604999.html
Copyright © 2020-2023  润新知