【随笔】CLR:.net的类型,内部到底长啥样?

前言

  一提到.net的类型,首当其冲的就是“引用类型”、“值类型”;咱们在面试中,也会常常被问“来讲说值类型和引用类型。。。。”,这时候第一反应就是:“哎呀,这还不简单,值类型是传递的值的copy,值对象存储在栈中;引用类型传的是引用,对该引用对象的修改都会影响到本来的内容,引用对象存储在堆中”,额。。。每每第一时间想到此处,彷佛就“词穷”了,不知道你有木有这样的感受。哈哈哈哈!可是真理每每没那么简单 - -!html

引用类型(Reference Type)

  引用类型和值类型其实有一个很大的、而且很明显,但容易被你们忽视的区别 “引用类型的对象是受GC控制的,而值类型的对象则不受GC控制”。linux

“鲜为人知”的开销

  其实CLR针对于引用类型对象的建立,会额外有2个字段的开销,一个是同步块索引(SBI),另外一个是方法表指针(MT Pointer)。每一个引用类型的对象,在内容中都会额外建立这2个对象git

你们看到这2个名词的时候,彷佛以为既熟悉又陌生,不过其实也没那么玄奥,让咱们往下看↓(下面的截图中的地址,可能先后对不上,由于我本地重启过github

  让咱们先建立一个简单的Person类:面试

    public class Person
    {
        public int Id { get; set; }
    }

  而后打个断点:docker

 接下来咱们按F5开启调试,而且打开3个神器窗口windows

 

 在解释以前呢,我想说一句你们都知道的一句话 “经过C#编写的cs文件,都是通过csc.exe编译器,编译成IL中间语言(.exe, .dll),而后由JIT即时转化成本机代码执行”,就目前而言,最终就是汇编代码了,这也是为啥要打开这3个窗口来剖析的缘由。囧~~~app

咱们能够经过内存窗口,看看咱们建立的对象,在内存中究竟是怎么布局的。函数

当断点执行到这个地方的时候,在内存中已经建立了Person对象,能够经过寄存器指令ECX所对应的值02585DCC(都是16进制的),贴到内存窗口中布局

回到咱们上面说的,一个引用类型的对象,除了方法表指针,应该还有一个同步块索引,那SBI在哪里呢?哈哈,其实就在方法表指针地址的上面,鼠标滑轮滚一滚就到了

而后继续执行你的程序,给id赋值。

  

至此,你们经过以上的剖析,知道了引用类型对象在内存中长成什么样了

 

细心的同窗应该发现了,SBI永远是在MT的上面(偏移负4个字节,x32位系统)

那最后咱们这个对象的内存布局,从宏观上看应该是(以32位系统为例):

 

 

看到这里,小伙伴们不放按照我上面的步骤,动手试一下,会加深本身的理解。

不过我相信,有的小伙伴对上面的MT和SBI并无直观的印象,这2个家伙有啥用的,为啥CLR在建立引用类型的对象的时候会用到它呢,且看下面的代码

SBI

下面是你们常见的,锁机制的代码

 咱们先快速定位到p1对象的SBI,以下图

 

 

由上能够得出,CLR中的lock机制,实际上是经过对象的SBI来实现的,上锁则设置成1(其实这个1是,当前执行线程的id号,你能够试试上书lock代码外面包一层Task,你会发现可能不是1了,不要误解凡是上锁的地方要么1,要么0),lock结束则重置成0,这就是同步块索引的用处之一,没错,是之一,他尚未其余用途(一些你们日常挂在嘴边,可是不多去深挖的)。

看到这个案例,不知道你们有没有想起一个面试题,为啥lock不能锁值类型对象,其实本质缘由是:值类型对象是没有SBI的,从而CLR没法实现lock(下面说值类型的时候,会作详细的阐述),在代码的编译阶段vs就会给你报错了

MT

且看以下代码

细心的同窗在内存中,能够看到,p1和p2的MT指向的是相同的地址,这也是CLR优化的地方,由于二者的对象类型都是Person.

经过 LLDB从另外一个角度来细细看下Person对象

  你们也许对lldb陌生,不过应该对windbg不陌生,lldb是和windbg同样,能够抓dump,分析内存的一个组件,在core里面,咱们大部分状况会把咱们的app部署到linux,或者容器中,这个时候

windbg是无法用的(windows),附上lldb相关连接:

https://docs.microsoft.com/en-us/dotnet/framework/tools/sos-dll-sos-debugging-extension#commands
https://lldb.llvm.org/lldb-gdb.html

 

根据上述结果,咱们能够找到Person对象的MT地址,咱们看看具体里面有些什么,执行dumpmt -md MT的地址

从图中咱们得知,一个引用类型的对象的MT指针,所指向的内容包含:EEClass、token、size、以及很重要要的MethodDesc Table(方法表)

细心的同窗会发现,方法表除了包含Person类自己的方法,还有它的基类方法。方法表是程序运行时供CLR选择对应的方法进行调用的。

上述信息有一列JIT,它有3个状态,分别解释下:

PreJIT:该方法被NGEN(Native Image Generator) 编译了。

JIT:该方法,CLR在runtime的时候被JIT即时编译了。

NONE:该方法,暂时尚未被编译。(回到咱们上面的代码,咱们只对id作了set,并无get,因此get_Id()的JIT状态是NONE)。

接下来,咱们看下Person类的构造函数所对应的本机代码,咱们执行下:dumpmd 方法描述地址

 继续执行:u codeaddress

 可是当前的插件版本,彷佛不支持u命令,那咱们查下当前sos plugin支持的命令有哪些

 u命令不行,那咱们用它的全称去尝试下 clru 方法地址:

咱们当前看到的就是,Person类的构造函数,所对应的代码了。

咱们在看看Person类的构造函数,所对应的IL代码:

 值类型(Value Type)

  值类型对象分配的地方不是在Managed Heap(引用类型),而是在当前程序所在的执行线程Stack里(thread stack)。

 咱们先建立一个简单的结构体:

    public struct Line
    {
        public int Length { get; set; }
    }

 而后,老样子,开启F5调试,打开咱们的3大神器窗口:

 ps:细心的同窗有没有发现,这个截图里的地址,是16位长度的16进制来显示的显示的,上面讲引用类型的时候,截图是8位长度的16进制来显示的。

其实上面的环境是x86, 下面的是x64位系统:以64位系统举例,16进制的F,表示成二进制则是1111,那么想表达64位的话,16进制的长度就为64%4=16;同理32位系统,想要表达32位的话,16进制的长度就为32%4=8。

有的同窗在本身vs行试的时候,也许不是上图的16长度,由于我为了使用lldb,方便我剖析问题,我建立了一个.net core 2.0的console项目。在调试的时候,vs会默认启动你本机安装的dotnet.exe程序。个人电脑安装列表以下

 我装的都是64位的,若是你想vs能调试32位的话(像我当前的状况的话,你以32位环境调试的话,会直接crash的),你须要安装dotnet的32位版本的sdk才行,为何须要这样,详情戳这里

不过。。。。64位调试下,也没啥影响,咱们继续

 哈哈,会对比的同窗,看到这里,应该发现了2点不同了:

1. 值类型对象并无MT和SBI

2. 值类型对象的对象存储是从 高地址位--->低地址位

 boxing

  通过上面的剖析,咱们引出咱们常常遇到的一个问题“装箱(boxing)”,你们都知道装箱会带来性能问题(我的以为,看状况,毕竟如今硬件这么牛逼,某些场景下不必吹毛求疵),可是你们思考下,性能的问题,具体体如今哪里呢?

带着问题,咱们再改下咱们的代码:

 经过IL代码,很容易看出,咱们把obj装箱了,那咱们看看装箱后的对象obj长什么样:

 

 

 根据上面的例子,其实已经验证了,值类型对象通过boxing以后,CLR在内存中,其实建立了一个引用类型对象,而后把值对象的值copy过来,产生MT和SBI。因此装箱的性能损耗也显而易见了。

而且,值类型对象一旦boxing以后,新产生的obj,它的释放,则交由GC来控制。这也给GC间接增长了压力(仍是以前提到的那句话,如今硬件这么牛逼了,某些场景下,不要吹毛求疵,囧~~~~)

用lldb进一步验证boxing

咱们不妨用lldb来深刻验证下,新产生的对象,到底存不存在,咱们稍微调整下咱们的代码,方便咱们作验证:

 此时,咱们当前的内存里,应该是没有obj对象的(被注释了,固然没有了。。囧),l1也并无被装箱,而后咱们经过lldb的dumpheap -stat指令来看下,托管堆里的对象有哪些。

强调下哈,dumpheap指令看的是托管堆对象、托管堆对象、托管堆对象,重要的事情说三遍,因此值类型的对象,不该该也不可能出如今该指令下,向下看↓↓↓↓↓↓

 由上图咱们看到,其实并无任何和Line相关的对象信息,到当前位置,一切的现象都是十分正确的。

接下来,咱们改下代码,进行boxing,咱们再来对比下:

 

 上图能够获得,我圈起来,其实就是咱们的obj对象,它是由l1 boxing而来的,类型为Line,咱们继续执行下咱们上面执行过的指令,看看这个boxing而来的对象,内部到底长什么样!?

总结

  至此,对于.net的类型,是否是又多了一层认知,除了知道2者的传值方式的不一样、直接继承类的不一样、Compare的不一样,还有他内存分布的不一样

相信小伙伴们,之后再回答这类问题的时候,又多了一个关注点。

 

ps:文章中,有不少步骤并无细说,好比docker中怎么使用lldb,lldb指令的详解,3个vs窗口的使用等等,都是一笔带过,后面有时间再补起来,到时候会在文章中加link方便跳转。

文章中有些地方,本身也不是理解的很透,好比说汇编(大学没学好,基本上还给老师了,囧),有不足以及错误的地方欢迎你们讨论。

相关文章
相关标签/搜索