C# 底层运行机制

源代码——》托管模块——》程序集—JIT—》编程CPU指令

1.1 在.NET框架下,首先将源代码编译为托管模块

CLR编译过程:C#源码文件——(C#编译器编译)——》托管模块

托管模块是一个需要CLR环境才能执行的标准windows PE文件,包含IL和元数据以及PE表头和CLR表头。

iL: 又叫托管代码,是编译器编译原文件后产生的指令,CLR会在运行时将IL编译成本地CPU指令。

元数据实际上是一个数据表集合,用来描述托管模块中所定义和引用的内容。VS能够智能感知就得益于元数据的描述

内容: 1.描述了模块中定义的内容,比如类及其成员

2.指出了托管模块引用的内容,比如导入的类及其成员

3.清单manifest,描述了构成Assembly的文件,由Assembly中的文件实现的公共导出类型,与Assembly相关联的资源/数据文件

元数据总是嵌入到与代码相同的EXE/DLL中,始终与IL保持同步

用途:

1.消除了对头/库文件的依赖,直接从托管模块中读取

2.智能感知,从元数据中解析

3.代码验证,使用元数据确保代码只执行安全操作

4.正反序列化

5.垃圾收集器跟踪对象的生存期以及对象的类型

PE表头:标准Windows PE文件表头,包含文件类型(如GUI、CUI等),以及文件创建时间等信息,在32位还是64位上运行

CLR表头:包含标识托管模块的一些信息。如CLR版本号,托管模块入口点方法(main方法)以及MethodDef元数据等等

1.2托管模块组合为程序集

程序集:一个或多个托管模块/资源文件的逻辑分组,是最小的重用,安全性以及版本控制单元。

既可以生成单文件程序集,也可以生成多文件程序集,这由编译器工具决定。

CLR是和程序集一起工作的,而不是和托管模块

一般编译器会默认将生成的托管模块生成一个程序集,CLR直接打交道的是程序集(assembly),程序集包含一个或多个托管模块,以及资源文件的逻辑组合。组合过程如下:

左侧为一些托管模块,在经过一些工具的处理后,生成了一个PE文件,也就是程序集。程序集中有一个清单(manifest)数据块,用来描述组成程序集的所有文件。此外,程序集还包含它所引用的程序集的信息,这就使得程序集可以实现自描述。这样CLR就直接知道了程序集所需要的所有内容,因此程序集的部署比非托管组件要容易。

1.2 加载 CLR

CLRVer命令,查看机器上所有CLR版本

csc的 /plattform开关,决定生成什么样的程序集:AnyCPU,x86,x64,Itanium

程序要运行,首先确定及其安装了.NET框架.然后,加载并初始化CLR

1.4 程序集执行

(ILAsm命令,将IL编译成Assembly;ILDasm将Assembly编译成IL)

IL代码要通过及时编译器JITter转化成CPU指令

方法第一次调用:

( 在方法首次执行时,CLR检测出Main的代码引用的所有类型,于是CLR分配一个内部数据结构,用于管理对引用类型的访问

在这个内部结构中,每个方法都有一条对应的纪录以及地址

对此结构进行初始化时,CLR将每条纪录都设置为CLR内部包含的一个未文档化的函数,即 JITCompiler函数。

JITCompiler函数被调用时,找到相应方法的IL,编译成本地CPU指令,并保存到一个动态内存块中,将该内存地址存入内部结构中,最后JITCompiler函数会跳转到内存块中的代码

)

1. 当程序第一次运行时,会调用JITCompiler函数,它可以知道调用了那些方法,以及定义该方法的类。

2. 然后JITCompiler函数在元数据中搜索该IL代码的位置,验证后转换成本地CPU指令。将指令保存在动态分配的内存中

3. JITCompiler将被调用方法地址改为第2步的内存地址

4. 跳转到上述代码块上执行代码

5. 执行完成后返回

IL是基于堆栈的语言,而且是无类型的。

再次调用方法

在一个程序中,我们经常反复调用同一个方法,当再次调用该方法时就不需要重复进行验证了,可以直接调用内存块中已有的本地代码,完全跳过JITCompile函数的验证和编译过程。所以同一方法只有在第一次调用时会产生一些性能损失,后续调用就可以全速进行了。

关闭程序

由于编译器将本地代码保存在动态内存中,所以关闭程序时本地代码将发生丢失。当再次启动程序或者同时运行程序的两个实例时,JIT编译器将再次将IL代码编译为本地指令。

谈谈IL安全问题

IL是基于堆栈的。所有指令都是:将操作数压栈,结果则从栈中弹出

IL有安全验证机制,保证每一行IL代码是正确的,不会非法访问内存,每个托管EXE都在独自的AppDomain中运行。

不安全代码:允许C#直接操作内存字节,在COM互操作时使用,csc以/unsafe开关标记包含不安全代码,其中所有方法都使用unsafe关键字。

PEVerify命令检查程序集所有方法,指出其中的不安全代码方法

1.5 本地代码生成器NGEN.exe

NGEN.exe将IL预先编译到硬盘文件中,可以加快程序的启动速度,减小程序的工作集(所有加载该程序集的AppDomain不再copy其副本,因为该程序集已经与编译到文件中,是代码共享的)。

缺点是:

不能保护IL外泄

生成的文件可能失去同步

因为在文件中要计算首选基地址,而NGEN是静态计算好的,所以要修改基地址,速度会慢下来

较差的执行性能,NGEN生成的代码没有JIT好。

如果不能使用NGEN生成的文件,会自动加载JITCompiler。

2. 认识CLR

基本术语:

CLR :Common Language Runtime 公共语言运行期,有多种不同编程语言使用的运行库

托管模块:Managed Module,一个标准的MS Window可移植执行体文件(32位PE32或64位PE32+)

IL:Intermediate Language 中间语言,又叫托管代码(由CLR管理它的执行)

元数据:metadata,一系列特殊的数据表

程序集:Assembly,抽象的

JIT:just-in-time 即时编译,将IL编译成本地CPU指令(本地代码)

FCL:Framework Class Library,Framework 类库

CTS:Common Type System,通用类型系统,描述了类型的定义及其行为方式

CLI:Common Language Infrastructure,公共语言基础结构,这是MS提交给ECMA的一个标准,由CTS和其他Framwork组件构成 (CTS、CLS、 CR)

CLS:Common Language Specfication,公共语言规范,详细规定了一个最小特性集

跨语言的方法CTS

CTS

CTS的一些规定:

1.一个类型可以包含0个或多个成员

2.类型可视化以及类型成员的访问规则

3.定义了继承,虚方法,对象生成期的管理规则

4.所有类型最终都从预定义的System.Object继承

例如: 如果在C#中定义的类型及其方法,同样可以在VB中使用。

那么,就不能在C#中定义CLS外的任何public/protected特性,privated的类型及其成员不受限制

C#可以有仅大小写不同的两个方法——不符合CLS,所以不能是public的

(这儿说的真实纠结)

举例:

using System;

定义了两个不同方法A和a,编译器会有警告,说这样的语法不兼容CLS;

解决办法:

(1)如果去掉[assembly:CLSComplant(true)]声明,那么不会有这个警告;

(2)如果将a方法改为private,则不会有警告

接下来,我们在VB中,使用这个dll

如果我们使用方法1,去掉[assembly:CLSComplant(true)]声明

结果是:在c1.后面不会有A或a方法的智能感知,说明VB不能识别不符合CLS的语法

如果我们是使用方法2,解决问题

可以在VB钟只能感知A方法

.NET 是微软研发出来的扩语言解决方案。他的核心是CLR,这是微软发布的CLI规范的一个实现。

CLI 包括:CIL(公共中间语言) 、CTS (公共类型系统)

基于CTS公共类型系统,.net就可以把其下的各种语言中的数据类型翻译为公共数据类型,再将其翻译为公共中间语言,就可以实现跨语言的互通。本来程序就基本等于数据+流程逻辑,两部分都使用了公共规范进行约束后,实现互通性就有可能了。

CLI(Common Language Infrastructure)

CLI是CLR的一个子集,也就是.NET中最终对编译成MSIL代码的应用程序的运行环境进行管理的那一部分。

在CLR结构图中CLI位于下半部分,主要包括类加载器(Class Loader)

�实时编译器(IL To Native Compilers)和一个运行时环境的垃圾收集器(Garbage Col

lector)。

CLI是.Net和CLR的灵魂,CLI为IL代码提供运行的环境,你可以将使用任何语言编写的代码通过其特定的编译器转换为MSIL代码之后运行其上,甚至还可以自己写MSIL代码在CLI上面运行。

CIL(Common Intermediate Language)

通用中间语言,曾经被称为微软中间语言或MSIL

它是一种类似于JAVA字节码的语言。在微软语言平台中,不管程序员使用C#、VB.NET或者J#等语言编写的程序进行编译的时候,编译器将这几种语言编写的源代码编译为CIL(微软中间语言)语言,此时再通过JIL(Just In Time实时编译器)编译为针对各种不同CPU的指令(注意因为是实时的编译器,所以它运行的时候是只运行需要编译的CIL语言段

Eg: CIL语言:

利用ILDASMI工具界面如下:

点击默认构造函数.ctor:void()我们可以看到这个构造函数的CIL语言如下

.method public hidebysig specialname rtspecialname

//.method表示对方法

instance void .ctor() cil managed

{

// 代码大小 7 (0x7)

.maxstack 8

IL_0000: ldarg.0

IL_0001: call instance void [mscorlib]System.Object::.ctor()

IL_0006: ret

} // end of method Program::.ctor

.method private hidebysig static void  Main(string[] args) cil managed
{
.entrypoint //程序进入点
// 代码大小 15 (0xf)
.maxstack 1 //堆栈分配
.locals init ([0] string a)
IL_0000: nop
IL_0001: ldstr "Hello World!"//压入字符串,堆栈压操作
IL_0006: stloc.0 //从计算堆栈的顶部弹出当前值并将其存储到索引 0 处的局部变量列表中。
IL_0007: ldloc.0 //将索引 0 处的局部变量加载到计算堆栈上。
IL_0008: call void [mscorlib]System.Console::WriteLine(string)
//呼叫WriteLine函数打印Hello World
IL_000d: nop
IL_000e: ret //返回
} // end of method Program::Main

3 CLR垃圾收集

基本概念

对象的生成过程(newobj指令)

1:计算类型(包括基类)所有字段的字节总数

2: 字节总数再加上对象开销字段字节数(相加为:对象所需的字节数)。每个对象包含2个开销字段:类型对象指针以及同步块索引。WIN32中,各占32位,WIN64中,各占64位。

3:CLR检测托管堆中是否有足够的空间满足对象所需的字节数。如果满足,对象将被分配在NextObjPtr指针指示的地方,实例构造器被调用,(new操作)返回对象的内存地址。指针NextObjPtr越过对象所在的区域,指示下一个新建对象在托管堆中的地址。如果不满足,进行垃圾收

根:

每一个应用程序都有一组根Root。一个根是一个存储地址,包含一个指向类型对象的指针。

该指针有2种形式:(1)指向托管堆中的一个对象。(2)设为null。

根包括静态字段,方法参数,局部变量,CPU寄存器。

对象的代

托管堆中,对象的代大概为0代,1代,2代,相应的内存容量为256K,2M,10M。当然,垃圾收集器也会自动调整预算容量

终结操作和释放模式

终结操作(Finalize()方法)可以确保托管对象在释放内存的同时不会泄露本地资源,但是不能确定它在何时被调用。

释放模式(Dispose()方法):当对象不再被使用的时候显示的释放掉它所占有的资源

垃圾回收算法

垃圾收集算法的任务就是将活动的对象和已经死掉的对象分别处理啊,然后将死掉的对象的内存回收。

(1) 引用计数 (Reference Counting)

引用计数,顾名思义,就是每个对象上有个计数器,当添加了一个对它的引用时它的计数器就会加1,当不再使用这个引用时它的计数器就会递减1。当计数器为0的时候则认为该对象是垃圾,可以被回收了,如果该对象被回收则该对象所引用的所有对象的计数器会跟着递减。这样有可能会有很多对象的引用都减为0。

(2)跟踪 (trace)

使用跟踪对象的关系图,然后进行收集。使用跟踪方式的垃圾收集算法主要有以下几种:

1) 标记清扫(Mark -Sweep)

2) 标记压缩(mark -Compact)

3) 标记拷贝(Mark-Copy)

跟踪方式的其他问题

1.需要暂停当前程序的运行。 因为在垃圾收集过程中,如果当前程序还在运行,则会继续分配和使用内存,会带来更复杂的问题,为了避免这些问题大部分垃圾收集器的实现都会或多或少的暂停所有线程(只会暂停执行托管代码的线程)。这对于实时性很高的应用有可能是不可接受的。

2、有些垃圾收集器在某些阶段,垃圾收集的线程可以与应用程序的线程并发执行,但是垃圾收集线程也会占用系统资源,会降低应用程序的执行性能。

3、所有的线程都在同一个堆上分配,就有可能造成数据不一致的情况,这就需要锁定来做到线程的同步,这样会降低内存分配的效率,可以将内存划分为很多区域,给每个线程一个区域,做到不需要同步的情况。