这是一篇摘抄的文章
有一些内容对我很有帮助 、有一些内容解释很清晰 所以我拿过来了。
第一遍用了5天时间,第二遍看的时候决定自己复制一份出来于是有了这儿博客。
什么是.NET?什么是.NET Framework?本文将从上往下,循序渐进的介绍一系列相关.NET的概念,先从类型系统开始讲起,我将通过跨语言操作这个例子来逐渐引入一系列.NET的相关概念,这主要包括:CLS、CTS(CLI)、FCL、Windows下CLR的相关核心组成、Windows下托管程序运行概念、什么是.NET Framework,.NET Core,.NET Standard及一些VS编译器相关杂项和相关阅读链接。完整的从上读到下则你可以理解个大概的.NET体系。
.NET和C#是什么关系
语言,是人们进行沟通表达的主要方式。编程语言,是人与机器沟通的表达方式。不同的编程语言,其侧重点不同。
微软公司是全球最大的电脑软件提供商,为了占据开发者市场,进而在2002年推出了Visual Studio(简称VS,是微软提供给开发者的工具集) .NET 1.0版本的开发者平台。而为了吸引更多的开发者涌入平台,微软还在2002年宣布推出一个特性强大并且与.NET平台无缝集成的编程语言,即C# 1.0正式版。
只要是.NET支持的编程语言,开发者就可以通过.NET平台提供的工具服务和框架支持便捷的开发应用程序。
C#就是为宣传.NET而创立的,它直接集成于Visual Studio .NET中,VB也在.NET 1.0发布后对其进行支持, 所以这两门语言与.NET平台耦合度很高,并且.NET上的技术大多都是以C#编程语言为示例,所以经常就.NET和C#混为一谈(实质上它们是相辅相成的两个概念)。
跨语言和跨平台是什么
跨语言:即只要是面向.NET平台的编程语言((C#、Visual Basic、C++/CLI、Eiffel、F#、IronPython、IronRuby、PowerBuilder、Visual COBOL 以及 Windows PowerShell)),用其中一种语言编写的类型可以无缝地用在另一种语言编写的应用程序中的互操作性。
跨平台:一次编译,不需要任何代码修改,应用程序就可以运行在任意有.NET框架实现的平台上,即代码不依赖于操作系统,也不依赖硬件环境。
什么是跨语言互操作,什么是CLS
每门语言在最初被设计时都有其在功能和语法上的定位,让不同的人使用擅长的语言去干合适的事,这在团队协作时尤为重要。
.NET平台上的跨语言是通过CLS这个概念来实现的,它是一组语言互操作的标准规范,它就是公共语言规范 - Common Language Specification ,简称CLS;
CLS从类型、命名、事件、属性、数组等方面对语言进行了共性的定义及规范。这些东西被提交给欧洲计算机制造联合会ECMA,称为:共同语言基础设施。
什么是CTS?
假设你已经围绕着封装 继承 多态 这3个特性设计出了多款面向对象的语言,你发现大家都是面向对象,都能很好的将现实中的对象模型表达出来。除了语法和功能擅长不同,语言的定义和设计结构其实都差不多一回事。
现实中你看到了一辆小汽车,这辆车里坐着两个人,那么如何用这门语言来表达这样的一个概念和场面?
首先要为这门语言横向定义一个“类型”的概念。接下来在程序中就可以这样表示:有一个汽车类型,有一个人类型,在一个汽车类型的对象内包含着两个人类型的对象,因为要表达出这个模型,你又引入了“对象”的概念 。而现在,你又看到,汽车里面的人做出了开车的这样一个动作,由此你又引入了“动作指令”这样一个概念。
接着,你又恍然大悟总结出一个定理,无论是什么样的“类型”,都只会存在这样一个特征,即活着的 带生命特征的(如人) 和 死的 没有生命特征的(如汽车) 这两者中的一个。最后,随着思想模型的成熟,你发现,这个“类型”就相当于一个富有主体特征的一组指令的集合。
好,然后你开始照葫芦画瓢。
总结出了一门语言很多必要的东西如两种主要类别:值类别和引用类别,五个主要类型:类、接口、委托、结构、枚举,我还规定了,一个类型可以包含字段、属性、方法、事件等成员,我还指定了每种类型的可见性规则和类型成员的访问规则,等等等等,只要按照我这个体系来设计语言,设计出来的语言它能够拥有很多不错的特性,比如跨语言,跨平台等,C#和VB.net之所以能够这样就是因为这两门语言的设计符合我这个体系。
那么,什么是CTS呢?
当你需要设计面向.Net的语言时所需要遵循一个体系(.Net平台下的语言都支持的一个体系)这个体系就是CTS(Common Type System 公共类型系统),它包括但不限于:
- 建立用于跨语言执行的框架。
- 提供面向对象的模型,支持在 .NET 实现上实现各种语言。
- 定义处理类型时所有语言都必须遵守的一组规则(CLS)。
- 提供包含应用程序开发中使用的基本基元数据类型(如 Boolean、Byte、Char 等)的库
上文的CLS是CTS(Common Type System 公共类型系统)这个体系中的子集。
一个编程语言,如果它能够支持CTS,那么我们就称它为面向.NET平台的语言。
微软已经将CTS和.NET的一些其它组件,提交给ECMA以成为公开的标准,最后形成的标准称为CLI(Common Language Infrastructure)公共语言基础结构。
所以有的时候你见到的书籍或文章有的只提起CTS,有的只提起CLI,请不要奇怪,你可以宽泛的把他们理解成一个意思,CLI是微软将CTS等内容提交给国际组织计算机制造联合会ECMA的一个工业标准。
什么是类库?
在CTS中有一条就是要求基元数据类型的类库。我们先搞清什么是类库?类库就是类的逻辑集合,你开发工作中你用过或自己编写过很多工具类,比如搞Web的经常要用到的 JsonHelper、XmlHelper、HttpHelper等等,这些类通常都会在命名为Tool、Utility等这样的项目中。 像这些类的集合我们可以在逻辑上称之为 "类库",比如这些Helper我们统称为工具类库。
基础类库BCL?
控制台中你直接就可以用ConSole类来输出信息,或者using System.IO 即可通过File类对文件进行读取或写入操作,这些类都是微软帮你写好的,不用你自己去编写,它帮你编写了一个面向.NET的开发语言中使用的基本的功能,这部分类,我们称之为BCL(Base Class Library), 基础类库,它们大多都包含在System命名空间下。
基础类库BCL包含:基本数据类型,文件操作,集合,自定义属性,格式设置,安全属性,I/O流,字符串操作,事件日志等的类型
框架类库FCL
Framework Class Library ,.NET框架类库,我上述所表达的BCL就是FCL中的一个基础部分,FCL中大部分类都是通过C#来编写的。
在FCL中,除了最基础的那部分BCL之外,还包含我们常见的 如 : 用于网站开发技术的 ASP.NET类库,该子类包含webform/webpage/mvc,用于桌面开发的 WPF类库、WinForm类库,用于通信交互的WCF、asp.net web api、Web Service类库等等
什么是基元类型?
每门语言都会定义一些基础的类型,比如C#通过 int 来定义整型,用 string 来定义 字符串 ,用 object 来定义 根类。
System.Object的意义
CTS定义的一个非常重要的规则,就是类与类之间只能单继承,System.Object类是所有类型的根,任何类都是显式或隐式的继承于System.Object。
计算机是如何运行程序的?
什么是.NET的跨平台,并解释为什么能够跨语言。不过要想知道什么是跨平台,首先你得知道一个程序是如何在本机上运行的。
什么是CPU
CPU,全称Central Processing Unit,叫做中央处理器,它是一块超大规模的集成电路,
CPU是一台计算机的运算核心和控制核心,CPU从存储器或高速缓冲存储器中取出指令,放入指令寄存器,并对指令译码,执行指令。
我们运行一个程序,CPU就会不断的读取程序中的指令并执行,直到关闭程序。事实上,从电脑开机开始,CPU就一直在不断的执行指令直到电脑关机。
什么是高级编程语言
在计算机角度,每一种CPU类型都有自己可以识别的一套指令集,计算机不管你这个程序是用什么语言来编写的,其最终只认其CPU能够识别的二进制指令集。
在早期计算机刚发展的时代,人们都是直接输入01010101这样的没有语义的二进制指令来让计算机工作的,可读性几乎没有,没人愿意直接编写那些没有可读性、繁琐、费时,易出差错的二进制01代码,所以后来才出现了编程语言。
编程语言的诞生,使得人们编写的代码有了可读性,有了语义,与直接用01相比,更有利于记忆。
而前面说了,计算机最终只识别二进制的指令,那么,我们用编程语言编写出来的代码就必须要转换成供机器识别的指令。
一门编程语言所编写的代码文件转换成能让本机识别的指令,这中间是需要一个翻译的过程。这个翻译过程是需要工具来完成,我们把它叫做 编译器。
不同厂商的CPU有着不同的指令集,为了克服面向CPU的指令集的难读、难编、难记和易出错的缺点,后来就出现了面向特定CPU的特定汇编语言
不同CPU架构上的汇编语言指令不同,而为了统一一套写法,同时又不失汇编的表达能力,C语言就诞生了。
C语言写的代码文件,会被C编译器先转换成对应平台的汇编指令,再转成机器码,最后将这些过程中产生的中间模块链接成一个可以被操作系统执行的程序。
汇编语言和C语言比较,我们就不需要去阅读特定CPU的汇编码,我只需要写通用的C源码就可以实现程序的编写,我们用将更偏机器实现的汇编语言称为低级语言,与汇编相比,C语言就称之为高级语言。
C#,我们在编码的时候都不需要过于偏向特定平台的实现,翻译过程也基本遵循这个过程。它的编译模型和C语言类似,都是属于这种间接转换的中间步骤,故而能够跨平台。
所以就类似于C/C#等这样的高级语言来说是不区分平台的,而在于其背后支持的这个 翻译原理 是否能支持其它平台。
什么是CLR,.NET虚拟机?
.NET不仅提供了自动内存管理的支持,他还提供了一些列的如类型安全、应用程序域、异常机制等支持,这些 都被统称为CLR公共语言运行库。
CLR是.NET类型系统的基础,所有的.NET技术都是建立在此之上,熟悉它可以帮助我们更好的理解框架组件的核心、原理。
在我们执行托管代码之前,总会先运行这些运行库代码,通过运行库的代码调用,从而构成了一个用来支持托管程序的运行环境,进而完成诸如不需要开发人员手动管理内存,一套代码即可在各大平台跑的这样的操作。
这套环境及体系之完善,以至于就像一个小型的系统一样,所以通常形象的称CLR为".NET虚拟机"。
如果以进程为最低端,进程的上面就是.NET虚拟机(CLR),而虚拟机的上面才是我们的托管代码。换句话说,托管程序实际上是寄宿于.NET虚拟机中。
什么是程序集
编译器,即将源代码文件给翻译成一个计算机可识别的二进制程序。而在.NET Framework目录文件夹中就附带的有 用于C#语言的命令行形式的编译器csc.exe 和 用于VB语言的命令行形式的编译器vbc.exe。
通过编译器可以将后缀为.cs(C#)和.vb(VB)类型的文件编译成程序集。
程序集是一个抽象的概念,不同的编译选项会产生不同形式的程序集。以文件个数来区分的话,那么就分 单文件程序集(即一个文件)和多文件程序集(多个文件)。
而不论是单文件程序集还是多文件程序集,其总有一个核心文件,就是表现为后缀为.dll或.exe格式的文件。
它们都是标准的PE格式的文件,主要由4部分构成:
-
- 1.PE头,即Windows系统上的可移植可执行文件的标准格式
- 2.CLR头,它是托管模块特有的,它主要包括
- 1)程序入口方法
- 2)CLR版本号等一些标志
- 3)一个可选的强名称数字签名
- 4)元数据表,主要用来记录了在源代码中定义和引用的所有的类型成员(如方法、字段、属性、参数、事件...)的位置和其标志Flag(各种修饰符)
正是因为元数据表的存在,VS才能智能提示,反射才能获取MemberInfo,CLR扫描元数据表即可获得该程序集的相关重要信息,所以元数据表使得程序集拥有了自我描述的这一特性。clr2中,元数据表大概40多个,其核心按照用途分为3类:- 1.即用于记录在源代码中所定义的类型的定义表:ModuleDef、TypeDef、MethodDef、ParamDef、FieldDef、PropertyDef、EventDef,
- 2.引用了其它程序集中的类型成员的引用表:MemberRef、AssemblyRef、ModuleRef、TypeRef
- 3. 用于描述一些杂项(如版本、发布者、语言文化、多文件程序集中的一些资源文件等)的清单表:AssemblyDef、FileDef、ManifestResourceDef、ExportedTypeDef
- 3.IL代码(也称MSIL,后来被改名为CIL:Common Intermediate Language通用中间语言),是介于源代码和本机机器指令中间的代码,将通过CLR在不同的平台产生不同的二进制机器码。
- 4.一些资源文件
用csc.exe进行编译
.NET程序执行原理
现在我们已经有了一个demo.exe的可执行程序,它是如何被我们运行的?。
C#源码被编译成程序集,程序集内主要是由一些元数据表和IL代码构成,我们双击执行该exe,Windows加载器将该exe(PE格式文件)给映射到虚拟内存中,程序集的相关信息都会被加载至内存中,并查看PE文件的入口点(EntryPoint)并跳转至指定的mscoree.dll中的_CorExeMain函数,该函数会执行一系列相关dll来构造CLR环境,当CLR预热后调用该程序集的入口方法Main(),接下来由CLR来执行托管代码(IL代码)。
JIT编译
前面说了,计算机最终只识别二进制的机器码,在CLR下有一个用来将IL代码转换成机器码的引擎,称为Just In Time Compiler,简称JIT,CLR总是先将IL代码按需通过该引擎编译成机器指令再让CPU执行,在这期间CLR会验证代码和元数据是否类型安全(在对象上只调用正确定义的操作、标识与声称的要求一致、对类型的引用严格符合所引用的类型),被编译过的代码无需JIT再次编译,而被编译好的机器指令是被存在内存当中,当程序关闭后再打开仍要重新JIT编译。
AOT编译
CLR的内嵌编译器是即时性的,这样的一个很明显的好处就是可以根据当时本机情况生成更有利于本机的优化代码,但同样的,每次在对代码编译时都需要一个预热的操作,它需要一个运行时环境来支持,这之间还是有消耗的。
而与即时编译所对应的,就是提前编译了,英文为Ahead of Time Compilation,简称AOT,也称之为静态编译。
在.NET中,使用Ngen.exe或者开源的.NET Native可以提前将代码编译成本机指令。
Ngen是将IL代码提前给全部编译成本机代码并安装在本机的本机映像缓存中,故而可以减少程序因JIT预热的时间,但同样的也会有很多注意事项,比如因JIT的丧失而带来的一些特性就没有了,如类型验证。Ngen仅是尽可能代码提前编译,程序的运行仍需要完整的CLR来支持。
.NET Native在将IL转换为本机代码的时候,会尝试消除所有元数据将依靠反射和元数据的代码替换为静态本机代码,并且将完整的CLR替换为主要包含垃圾回收器的重构运行时mrt100_app.dll。
程序集的规则
程序集的加载方式
对于自身程序集内定义的类型,我们可以直接从自身程序集中的元数据中获取,对于在其它程序集中定义的类型,CLR会通过一组规则来在磁盘中找到该程序集并加载在内存。
CLR在查找引用的程序集的位置时候,第一个判断条件是 判断该程序集是否被签名。
什么是签名?
强名称程序集
就比如大家都叫张三,姓名都一样,喊一声张三不知道到底在叫谁。这时候我们就必须扩展一下这个名字以让它具有唯一性。
我们可以通过sn.exe或VS对项目右键属性在签名选项卡中采取RSA算法对程序集进行数字签名(加密:公钥加密,私钥解密。签名:私钥签名,公钥验证签名),会将构成程序集的所有文件通过哈希算法生成哈希值,然后通过非对称加密算法用私钥签名,最后公布公钥生成一串token,最终将生成一个由程序集名称、版本号、语言文化、公钥组成的唯一标识,它相当于一个强化的名称,即强名称程序集。
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
我们日常在VS中的项目默认都没有被签名,所以就是弱名称程序集。强名称程序集是具有唯一标识性的程序集,并且可以通过对比哈希值来比较程序集是否被篡改,不过仍然有很多手段和软件可以去掉程序集的签名。
需要值得注意的一点是:当你试图在已生成好的强名称程序集中引用弱名称程序集,那么你必须对弱名称程序集进行签名并在强名称程序集中重新注册。
之所以这样是因为一个程序集是否被篡改还要考虑到该程序集所引用的那些程序集,根据CLR搜索程序集的规则(下文会介绍),没有被签名的程序集可以被随意替换,所以考虑到安全性,强名称程序集必须引用强名称程序集,否则就会报错:需要强名称程序集。
程序集搜索规则
事实上,按照存储位置来说,程序集分为共享(全局)程序集和私有程序集。
CLR查找程序集的时候,会先判断该程序集是否被强签名,如果强签名了那么就会去共享程序集的存储位置(后文的GAC)去找,如果没找到或者该程序集没有被强签名,那么就从该程序集的同一目录下去寻找。
强名称程序集是先找到与程序集名称(VS中对项目右键属性应用程序->程序集名称)相等的文件名称,然后 按照唯一标识再来确认,确认后CLR加载程序集,同时会通过公钥效验该签名来验证程序集是否被篡改(如果想跳过验证可查阅https://docs.microsoft.com/zh-cn/dotnet/framework/app-domains/how-to-disable-the-strong-name-bypass-feature),如果强名称程序集被篡改则报错。
而弱名称程序集则直接按照与程序集名称相等的文件名称来找,如果还是没有找到就以该程序集名称为目录的文件夹下去找。总之,如果最终结果就是没找到那就会报System.IO.FileNotFoundException异常,即尝试访问磁盘上不存在的文件失败时引发的异常。
注意:此处文件名称和程序集名称是两个概念,不要模棱两可,文件CLR头内嵌程序集名称。
项目的依赖顺序
项目间的生成是有序生成的,它取决于项目间的依赖顺序。
比如Web项目引用BLL项目,BLL项目引用了DAL项目。那么当我生成Web项目的时候,因为我要注册Bll程序集,所以我要先生成Bll程序集,而BLL程序集又引用了Dal,所以又要先生成Dal程序集,所以程序集生成顺序就是Dal=>BLL=>Web,项目越多编译的时间就越久。
程序集之间的依赖顺序决定了编译顺序,所以在设计项目间的分层划分时不仅要体现出层级职责,还要考虑到依赖顺序。代码存放在哪个项目要有讲究,不允许出现互相引用的情况,比如A项目中的代码引用B,B项目中的代码又引用A。
为什么Newtonsoft.Json版本不一致?
而除了注意编译顺序外,我们还要注意程序集间的版本问题,版本间的错乱会导致程序的异常。
举个经典的例子:Newtonsoft.Json的版本警告,大多数人都知道通过版本重定向来解决这个问题,但很少有人会琢磨为什么会出现这个问题,找了一圈文章,没找到一个解释的。
比如:
A程序集引用了 C盘:\Newtonsoft.Json 6.0程序集
B程序集引用了 从Nuget下载下来的Newtonsoft.Json 10.0程序集
此时A引用B,就会报:发现同一依赖程序集的不同版本间存在无法解决的冲突 这一警告。
A:引用Newtonsoft.Json 6.0 Func() { var obj= Newtonsoft.Json.Obj; B.JsonObj(); } B: 引用Newtonsoft.Json 10.0 JsonObj() { return Newtonsoft.Json.Obj; }
A程序集中的Func方法调用了B程序集中的JsonObj方法,JsonObj方法又调用了Newtonsoft.Json 10.0程序集中的对象,那么当执行Func方法时程序就会异常,报System.IO.FileNotFoundException: 未能加载文件或程序集Newtonsoft.Json 10.0的错误。
这是为什么?
1.这是因为依赖顺序引起的。A引用了B,首先会先生成B,而B引用了 Newtonsoft.Json 10.0,那么VS就会将源引用文件(Newtonsoft.Json 10.0)复制到B程序集同一目录(bin/Debug)下,名为Newtonsoft.Json.dll文件,其内嵌程序集版本为10.0。
2.然后A引用了B,所以会将B程序集和B程序集的依赖项(Newtonsoft.Json.dll)给复制到A的程序集目录下,而A又引用了C盘的Newtonsoft.Json 6.0程序集文件,所以又将C:\Newtonsoft.Json.dll文件给复制到自己程序集目录下。因为两个Newtonsoft.Json.dll重名,所以直接覆盖了前者,那么只保留了Newtonsoft.Json 6.0。
3.当我们调用Func方法中的B.Convert()时候,CLR会搜索B程序集,找到后再调用 return Newtonsoft.Json.Obj 这行代码,而这行代码又用到了Newtonsoft.Json程序集,接下来CLR搜索Newtonsoft.Json.dll,文件名称满足,接下来CLR判断其标识,发现版本号是6.0,与B程序集清单里注册的10.0版本不符,故而才会报出异常:未能加载文件或程序集Newtonsoft.Json 10.0。
以上就是为何Newtonsoft.Json版本不一致会导致错误的原因,其也诠释了CLR搜索程序集的一个过程。
那么,如果我执意如此,有什么好的解决方法能让程序顺利执行呢?有,有2个方法。
第一种:通过bindingRedirect节点重定向,即当找到10.0的版本时,给定向到6.0版本
<runtime> <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1"> <dependentAssembly> <assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" /> <bindingRedirect oldVersion="10.0.0.0" newVersion="6.0.0.0" /> </dependentAssembly> </assemblyBinding> </runtime>
如何在编译时加载两个相同的程序集?
注意:我看过有的文章里写的一个AppDomain只能加载一个相同的程序集,很多人都以为不能同时加载2个不同版本的程序集,实际上CLR是可以同时加载Newtonsoft.Json 6.0和Newtonsoft.Json 10.0的。
第二种:对每个版本指定codeBase路径,然后分别放上不同版本的程序集,这样就可以加载两个相同的程序集。
<runtime> <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1"> <dependentAssembly> <assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" /> <codeBase version="6.0.0.0" href="D:\6.0\Newtonsoft.Json.dll" /> </dependentAssembly> <dependentAssembly> <assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" /> <codeBase version="10.0.0.0" href="D:\10.0\Newtonsoft.Json.dll" /> </dependentAssembly> </assemblyBinding> </runtime>
项目间的生成是有序生成的,它取决于项目间的依赖顺序。
比如Web项目引用BLL项目,BLL项目引用了DAL项目。那么当我生成Web项目的时候,因为我要注册Bll程序集,所以我要先生成Bll程序集,而BLL程序集又引用了Dal,所以又要先生成Dal程序集,所以程序集生成顺序就是Dal=>BLL=>Web,项目越多编译的时间就越久。
项目间的生成是有序生成的,它取决于项目间的依赖顺序。
比如Web项目引用BLL项目,BLL项目引用了DAL项目。那么当我生成Web项目的时候,因为我要注册Bll程序集,所以我要先生成Bll程序集,而BLL程序集又引用了Dal,所以又要先生成Dal程序集,所以程序集生成顺序就是Dal=>BLL=>Web,项目越多编译的时间就越久。
程序集之间的依赖顺序决定了编译顺序,所以在设计项目间的分层划分时不仅要体现出层级职责,还要考虑到依赖顺序。代码存放在哪个项目要有讲究,不允许出现互相引用的情况,比如A项目中的代码引用B,B项目中的代码又引用A。
程序集之间的依赖顺序决定了编译顺序,所以在设计项目间的分层划分时不仅要体现出层级职责,还要考虑到依赖顺序。代码存放在哪个项目要有讲究,不允许出现互相引用的情况,比如A项目中的代码引用B,B项目中的代码又引用A。
内存
应用程序域把资源给隔离开,这个资源,主要指内存。那么什么是内存呢?
要知道,程序运行的过程就是电脑不断通过CPU进行计算的过程,这个过程需要读取并产生运算的数据,为此我们需要一个拥有足够容量能够快速与CPU交互的存储容器,这就是内存了。对于内存大小,32位处理器,寻址空间最大为2的32次方byte,也就是4G内存,除去操作系统所占用的公有部分,进程大概能占用2G内存,而如果是64位处理器,则是8T。
而在.NET中,内存区域分为堆栈和托管堆。
堆栈和堆的区别
堆和堆栈就内存而言只不过是地址范围的区别。不过堆栈的数据结构和其存储定义让其在时间和空间上都紧密的存储,这样能带来更高的内存密度,能在CPU缓存和分页系统表现的更好。故而访问堆栈的速度总体来说比访问堆要快点。
线程堆栈
操作系统会为每条线程分配一定的空间,Windwos为1M,这称之为线程堆栈。在CLR中的栈主要用来执行线程方法时,保存临时的局部变量和函数所需的参数及返回的值等,在栈上的成员不受GC管理器的控制,它们由操作系统负责分配,当线程走出方法后,该栈上成员采用后进先出的顺序由操作系统负责释放,执行效率高。
而托管堆则没有固定容量限制,它取决于操作系统允许进程分配的内存大小和程序本身对内存的使用情况,托管堆主要用来存放对象实例,不需要我们人工去分配和释放,其由GC管理器托管。
为什么值类型存储在栈上
不同的类型拥有不同的编译时规则和运行时内存分配行为,我们应知道,C# 是一种强类型语言,每个变量和常量都有一个类型,在.NET中,每种类型又被定义为值类型或引用类型。
使用 struct、enum 关键字直接派生于System.ValueType定义的类型是值类型,使用 class、interface、delagate 关键字派生于System.Object定义的类型是引用类型。
对于在一个方法中产生的值类型成员,将其值分配在栈中。这样做的原因是因为值类型的值其占用固定内存的大小。
C#中int关键字对应BCL中的Int32,short对应Int16。Int32为2的32位,如果把32个二进制数排列开来,我们要求既能表达正数也能表达负数,所以得需要其中1位来表达正负,首位是0则为+,首位是1则为-,那么我们能表示数据的数就只有31位了,而0是介于-1和1之间的整数,所以对应的Int32能表现的就是2的31次方到2的31次方-1,即2147483647和-2147483648这个整数段。
1个字节=8位,32位就是4个字节,像这种以Int32为代表的值类型,本身就是固定的内存占用大小,所以将值类型放在内存连续分配的栈中。
托管堆模型
而引用类型相比值类型就有点特殊,newobj创建一个引用类型,因其类型内的引用对象可以指向任何类型,故而无法准确得知其固定大小,所以像对于引用类型这种无法预知的容易产生内存碎片的动态内存,我们把它放到托管堆中存储。
托管堆由GC托管,其分配的核心在于堆中维护着一个nextObjPtr指针,我们每次实例(new)一个对象的时候,CLR将对象存入堆中,并在栈中存放该对象的起始地址,然后该指针都会根据该对象的大小来计算下一个对象的起始地址。不同于值类型直接在栈中存放值,引用类型则还需要在栈中存放一个代表(指向)堆中对象的值(地址)。
而托管堆又可以因存储规则的不同将其分类,托管堆可以被分为3类:
- 1.用于托管对象实例化的垃圾回收堆,又以存储对象大小分为小对象(<85000byte)的GC堆(SOH,Small Object Heap)和用于存储大对象实例的(>=85000byte)大对象堆(LOG,Larage Object Heap)。
- 2.用于存储CLR组件和类型系统的加载(Loader)堆,其中又以使用频率分为经常访问的高频堆(里面包含有MethodTables方法表, MeghodDescs方法描述, FieldDescs方法描述和InterfaceMaps接口图),和较低的低频堆,和Stub堆(辅助代码,如JIT编译后修改机器代码指令地址环节)。
- 3.用于存储JIT代码的堆及其它杂项的堆。
加载程序集就是将程序集中的信息给映射在加载堆,对产生的实例对象存放至垃圾回收堆。前文说过应用程序域是指通过CLR管理而建立起的逻辑上的内存边界,那么每个域都有其自己的加载堆,只有卸载应用程序域的时候,才会回收该域对应的加载堆。
而加载堆中的高频堆包含的有一个非常重要的数据结构表---方法表,每个类型都仅有一份方法表(MethodTables),它是对象的第一个实例创建前的类加载活动的结果,它主要包含了我们所关注的3部分信息:
- 1包含指向EEClass的一个指针。EEClass是一个非常重要的数据结构,当类加载器加载到该类型时会从元数据中创建出EEClass,EEClass里主要存放着与类型相关的表达信息。
- 2包含指向各自方法的方法描述器(MethodDesc)的指针逻辑组成的线性表信息:继承的虚函数, 新虚函数, 实例方法, 静态方法。
- 3包含指向静态字段的指针。
那么,实例一个对象,CLR是如何将该对象所对应的类型行为及信息的内存位置(加载堆)关联起来的呢?
原来,在托管堆上的每个对象都有2个额外的供于CLR使用的成员,我们是访问不到的,其中一个就是类型对象指针,它指向位于加载堆中的方法表从而让类型的状态和行为关联了起来, 类型指针的这部分概念我们可以想象成obj.GetType()方法获得的运行时对象类型的实例。而另一个成员就是同步块索引,其主要用于2点:1.关联内置SyncBlock数组的项从而完成互斥锁等目的。 2.是对象Hash值计算的输入参数之一。
可以看到对于方法中申明的值类型变量,其在栈中作为一块值表示,我们可以直接通过c#运算符sizeof来获得值类型所占byte大小。而方法中申明的引用类型变量,其在托管堆中存放着对象实例(对象实例至少会包含上述两个固定成员以及实例数据,可能),在栈中存放着指向该实例的地址。
当我new一个引用对象的时候,会先分配同步块索引(也叫对象头字节),然后是类型指针,最后是类型实例数据(静态字段的指针存在于方法表中)。会先分配对象的字段成员,然后分配对象父类的字段成员,接着再执行父类的构造函数,最后才是本对象的构造函数。这个多态的过程,对于CLR来说就是一系列指令的集合,所以不能纠结new一个子类对象是否会也会new一个父类对象这样的问题。而也正是因为引用类型的这样一个特征,我们虽然可以估计一个实例大概占用多少内存,但对于具体占用的大小,我们需要专门的工具来测量。
对于引用类型,u2=u1,我们在赋值的时候,实际上赋的是地址,那么我改动数据实际上是改动该地址指向的数据,这样一来,因为u2和u1都指向同一块区域,所以我对u1的改动会影响到u2,对u2的改动会影响到u1。如果我想互不影响,那么我可以继承IClone接口来实现内存克隆,已有的CLR实现是浅克隆方法,但也只能克隆值类型和String(string是个特殊的引用类型,对于string的更改,其会产生一个新实例对象),如果对包含其它引用类型的这部分,我们可以自己通过其它手段实现深克隆,如序列化、反射等方式来完成。而如果引用类型中包含有值类型字段,那么该字段仍然分配在堆上。
对于值类型,a=b,我们在赋值的时候,实际上是新建了个值,那么我改动a的值那就只会改动a的值,改动b的值就只会改动b的值。而如果值类型(如struct)中包含的有引用类型,那么仍是同样的规则,引用类型的那部分实例在托管堆中,地址在栈上。
我如果将值类型放到引用类型中(如:object a=3),会在栈中生成一个地址,在堆中生成该值类型的值对象,还会再生成这类型指针和同步块索引两个字段,这也就是常说装箱,反过来就是拆箱。每一次的这样的操作,都会涉及到内存的分布、拷贝,可见,装箱和拆箱是有性能损耗,因此应该减少值类型和引用类型之间转换的次数。
但对于引用类型间的子类父类的转换,仅是指令的执行消耗,几尽没有开销。
GC管理器
值得注意的是,当我new完一个对象不再使用的时候,这个对象在堆中所占用的内存如何处理?
在非托管世界中,可以通过代码手动进行释放,但在.NET中,堆完全由CLR托管,也就是说GC堆是如何具体来释放的呢?
当GC堆需要进行清理的时候,GC收集器就会通过一定的算法来清理堆中的对象,并且版本不同算法也不同。最主要的则为Mark-Compact标记-压缩算法。
这个算法的大概含义就是,通过一个图的数据结构来收集对象的根,这个根就是引用地址,可以理解为指向托管堆的这根关系线。当触发这个算法时,会检查图中的每个根是否可达,如果可达就对其标记,然后在堆上找到剩余没有标记(也就是不可达)的对象进行删除,这样,那些不在使用的堆中对象就删除了。
前面说了,因为nextObjPtr的缘故,在堆中分配的对象都是连续分配的,因为未被标记而被删除,那么经过删除后的堆就会显得支零破碎,那么为了避免空间碎片化,所以需要一个操作来让堆中的对象再变得紧凑、连续,而这样一个操作就叫做:Compact压缩。
而对堆中的分散的对象进行挪动后,还会修改这些被挪动对象的指向地址,从而得以正确的访问,最后重新更新一下nextObjPtr指针,周而复始。
而为了优化内存结构,减少在图中搜索的成本,GC机制又为每个托管堆对象定义了一个属性,将每个对象分成了3个等级,这个属性就叫做:代,0代、1代、2代。
每当new一个对象的时候,该对象都会被定义为第0代,当GC开始回收的时候,先从0代回收,在这一次回收动作之后,0代中没有被回收的对象则会被定义成第1代。当回收第1代的时候,第1代中没有被清理掉的对象就会被定义到第2代。
CLR初始化时会为0/1/2这三代选择一个预算的容量。0代通常以256 KB-4 MB之间的预算开始,1代的典型起始预算为512 KB-4 MB,2代不受限制,最大可扩展至操作系统进程的整个内存空间。
比如第0代为256K,第1代为2MB。我们不停的new对象,直到这些对象达到256k的时候,GC会进行一次垃圾回收,假设这次回收中回收了156k的不可达对象,剩余100k的对象没有被回收,那么这100k的对象就被定义为第1代。现在就变成了第0代里面什么都没有,第1代里放的有100k的对象。这样周而复始,GC清除的永远都只有第0代对象,除非当第一代中的对象累积达到了定义的2MB的时候,才会连同清理第1代,然后第1代中活着的部分再升级成第二代...
第二代的容量是没有限制,但是它有动态的阈值(因为等到整个内存空间已满以执行垃圾回收是没有意义的),当达到第二代的阈值后会触发一次0/1/2代完整的垃圾收集。
也就是说,代数越长说明这个对象经历了回收的次数也就越多,那么也就意味着该对象是不容易被清除的。
这种分代的思想来将对象分割成新老对象,进而配对不同的清除条件,这种巧妙的思想避免了直接清理整个堆的尴尬。
GC堆回收
那么除了通过new对象而达到代的阈(临界)值时,还有什么能够导致垃圾堆进行垃圾回收呢? 还可能windows报告内存不足、CLR卸载AppDomain、CLR关闭等其它特殊情况。
或者,我们还可以自己通过代码调用。
.NET有GC来帮助开发人员管理内存,并且版本也在不断迭代。GC帮我们托管内存,但仍然提供了System.GC类让开发人员能够轻微的协助管理。 这其中有一个可以清理内存的方法(并没有提供清理某个对象的方法):GC.Collect方法,可以对所有或指定代进行即时垃圾回收(如果想调试,需在release模式下才有效果)。这个方法尽量别用,因为它会扰乱代与代间的秩序,从而让低代的垃圾对象跑到生命周期长的高代中。
GC还提供了,判断当前对象所处代数、判断指定代数经历了多少次垃圾回收、获取已在托管堆中分配的字节数这样的三个方法,我们可以从这3个方法简单的了解托管堆的情况。
托管世界的内存不需要我们打理,我们无法从代码中得知具体的托管对象的大小,你如果想追求对内存最细微的控制,显然C#并不适合你,不过类似于有关内存把控的这部分功能模块,我们可以通过非托管语言来编写,然后通过.NET平台的P/Invoke或COM技术(微软为CLR定义了COM接口并在注册表中注册)来调用。
像FCL中的源码,很多涉及到操作系统的诸如 文件句柄、网络连接等外部extren的底层方法都是非托管语言编写的,对于这些非托管模块所占用的资源,我们可以通过隐式调用析构函数(Finalize)或者显式调用的Dispose方法通过在方法内部写上非托管提供的释放方法来进行释放。
像文中示例的socket就将释放资源的方法写入Dispose中,析构函数和Close方法均调用Dispose方法以此完成释放。事实上,在FCL中的使用了非托管资源的类大多都遵循IDispose模式。而如果你没有释放非托管资源直接退出程序,那么操作系统会帮你释放该程序所占的内存的。
垃圾回收对性能的影响
还有一点,垃圾回收是对性能有影响的。
GC虽然有很多优化策略,但总之,只要当它开始回收垃圾的时候,为了防止线程在CLR检查期间对对象更改状态,所以CLR会暂停进程中的几乎所有线程(所以线程太多也会影响GC时间),而暂停的时间就是应用程序卡死的时间,为此,对于具体的处理细节,GC提供了2种配置模式让我们选择。
第一种为:单CPU的工作站模式,专为单CPU处理器定做。这种模式会采用一系列策略来尽可能减少GC回收中的暂停时间。
而工作站模式又分为并发(或后台)与不并发两种,并发模式表现为响应时间快速,不并发模式表现为高吞吐量。
第二种为:多CPU的服务器模式,它会为每个CPU都运行一个GC回收线程,通过并行算法来使线程能真正同时工作,从而获得性能的提升。
我们可以通过在Config文件中更改配置来修改GC模式,如果没有进行配置,那么应用程序总是默认为单CPU的工作站的并发模式,并且如果机器为单CPU的话,那么配置服务器模式则无效。
如果在工作站模式中想禁用并发模式,则应该在config中运行时节点添加 <gcConcurrent enabled="false" />
如果想更改至服务器模式,则可以添加 <gcServer enabled="true" />。
<configuration>
<runtime>
<!--<gcConcurrent enabled="true|false"/>-->
<!--<gcServer enabled="true|false"/>-->
</runtime>
</configuration>
.NET程序执行图
至此,.NET Framework上的三个重要概念,程序集、应用程序域、内存在本文讲的差不多了,我画了一张图简单的概述.NET程序的一个执行流程:
.NET是什么
我在前文对.NET系统概述时,有的直接称.NET,有的称.NET Framework。那么准确来说什么是.NET?什么又是.NET Framework呢?
.NET是一个微软搭造的开发者平台,它主要包括:
- 1.支持(面向)该平台的编程语言(如C#、Visual Basic、C++/CLI、F#、IronPython、IronRuby...),
- 2.用于该平台下开发人员的技术框架体系(.NET Framework、.NET Core、Mono、UWP等),
- 1.定义了通用类型系统,庞大的CTS体系
- 2.用于支撑.NET下的语言运行时的环境:CLR
- 3..NET体系技术的框架库FCL
- 3.用于支持开发人员开发的软件工具(即SDK,如VS2017、VS Code等)
.NET Framework是什么
事实上,像我上面讲的那些诸如程序集、GC、AppDomain这样的为CLR的一些概念组成,实质上指的是.NET Framework CLR。
.NET平台是微软为了占据开发市场而成立的,不是无利益驱动的纯技术平台的那种东西。基于该平台下的技术框架也因为 商业间的利益 从而和微软自身的Windows操作系统所绑定。所以虽然平台雄心和口号很大,但很多框架类库技术都是以Windows系统为蓝本,这样就导致,虽然.NET各方面都挺好,但是用.NET就必须用微软的东西,直接形成了技术-商业的绑定。
.NET Framework就是.NET 技术框架组成在Windows系统下的具体的实现,和Windows系统高度耦合,上文介绍的.NET系统,就是指.NET Framework。
.NET Core是什么
有丑才有美,有低才有高,概念是比较中诞生的。.NET Core就是如此,它是其它操作系统的.NET Framework翻版实现。
操作系统不止Windows,还有Mac和类Linux等系统, .NET的实现 如果按操作系统来横向分割的话,可以分为 Windows系统下的 .NET Framework 和 兼容多个操作系统的 .NET Core。
我们知道,一个.NET程序运行核心在于.NET CLR,为了能让.NET程序在其它平台上运行,一些非官方社区和组织为此开发了在其它平台下的.NET实现(最为代表的是mono,其团队后来又被微软给合并了 ),但因为不是官方,所以在一些方面多少有些缺陷(如FCL),后来微软官方推出了.NET Core,其开源在Github中,并被收录在NET基金会(.NET Foundation,由微软公司成立与赞助的独立自由软件组织,其目前收录包括.NET编译器平台("Roslyn")以及ASP.NET项目系列,.NET Core,Xamarin Forms以及其它流行的.NET开源框架),旨在真正的 .NET跨平台。
.NET Core是.NET 技术框架组成在Windows.macOS.Linux系统下的具体的实现。
.NET Core是一个开源的项目,其由 Microsoft 和 GitHub 上的 .NET 社区共同维护,但 这份工作仍然是巨大的,因为在早期对.NET上的定义及最初的实现一直是以Windows系统为参照及载体,一些.NET机制实际上与Windows系统耦合度非常高,有些属于.NET自己体系内的概念,有些则属于Windows系统api的封装。 那么从Windows转到其它平台上,不仅要实现相应的CLR,还要舍弃或重写一部分BCL,因而,.NET Core在概念和在项目中的行为与我们平常有些不同。
比如,NET Core不支持AppDomains、远程处理、代码访问安全性 (CAS) 和安全透明度,任何有关该概念的库代码都应该被替换。
这部分代码它不仅指你项目中的代码,还指你项目中using的那些程序集代码,所以你会在github上看到很多开源项目都在跟进对.NET Core的支持,并且很多开发者也尝试学习.NET Core,这也是一种趋势。