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

时间:2019-10-17
本文章向大家介绍【随笔】CLR:.net的类型,内部到底长啥样?,主要包括【随笔】CLR:.net的类型,内部到底长啥样?使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

前言

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

引用类型(Reference Type)

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

“不为人知”的开销

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

大家看到这2个名词的时候,似乎觉得既熟悉又陌生,不过其实也没那么玄奥,让我们往下看↓(下面的截图中的地址,可能前后对不上,因为我本地重启过

  让我们先创建一个简单的Person类:

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

  然后打个断点:

 接下来我们按F5开启调试,并且打开3个神器窗口

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

我们可以通过内存窗口,看看我们创建的对象,在内存中到底是怎么布局的。

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

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

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

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

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

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

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

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

SBI

下面是大家常见的,锁机制的代码

 我们先快速定位到p1对象的SBI,如下图

由上可以得出,CLR中的lock机制,其实是通过对象的SBI来实现的,上锁则设置成1,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方便跳转。

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

原文地址:https://www.cnblogs.com/eastpig/p/11677748.html