你必须知道的.NET_CLR


主要讲.NET底层的一些语言机制,载体为C#。写得比较深入,是本好书。


LOH堆 (Large Object Heap) ,用于分配大对象实例。LOH堆不会被压缩,而且只在完全GC回收时被回收
并非所有的值类型都创建在线程的堆栈上,当值类型作为类的字段、或作为堆上实例成员的一部分、或发生装箱时,该值类型都在托管堆上
引用类型的实例分配在托管堆上,而线程栈却是对象生命期开始的地方
托管堆根据存储信息的不同划分为多个区域:
垃圾回收堆(GC Heap):用于存储对象实例,受GC管理
加载堆(Loader Heap):存储元数据信息,也就是Type对象,每个Type在Loader Heap上体现为一个Method Table,而Method Table中则记录了存储的元数据信息。Loader Heap不受GC控制,其生命周期从创建到AppDomain卸载
High-Frequency Heap
Low-Frequency Heap
Stub Heap
每个.NET对象创建时都包含的附加成员:
TypeHandle,类型句柄,指向对应实例的方法表,占用一个机器字的内存空间
SyncBlockIndex,用于线程同步,指向一块被称为Synchronization Block的内存块,用于管理对象同步,占用一个机器字的内存空间
托管堆会维护一个NextObjPtr指针,用于标识下一个新建对象分配时在托管堆中所处的位置。CLR初始化时,NextObjPtr位于托管堆的基地址
声明一个引用类型的变量,实质是保存在线程堆栈上的一个引用(指针),占用一个机器字的空间,该引用指向堆中对象的实际有效地址
CLR new对象时,将按照类型的继承层次进行搜索,计算类型及其所有父类的字段并返回字节总数。实际对象所占的字节总数还要加上对象附加成员(TypeHandle, SyncBlockIndex)所需的字节总数。堆上的内存块总是按照4Byte的倍数进行分配
GC使用了非常高效的算法来满足内存分配需求,NextObjPtr指针向前推进给定个字节,并清零原NextObjPtr指针和当前NextObjPtr指针之间的字节,然后返回NextObjPtr指针地址即可,该地址正是新创建对象的托管堆地址,而此时的NextObjPtr仍指向下一个新建对象的位置
实例字段的存储是有顺序的,由上到下依次排列,父类在前子类在后
如果试图分配所需空间而发现内存不足时,GC将启动垃圾回收操作来回收垃圾对象所占的内存
第一次构造某类型时,会将其Type对象提交到Load Heap上
对象构造时会初始化附加成员TypeHandle和SyncBlockIndex/TypeHandle指针指向LoaderHeap上的MethodTable,CLR将根据TypeHandle来定位具体的Type,将SyncBlockIndex指针指向Synchronization Block的内存块,用于在多线程环境下对实例对象的同步操作
当值类型嵌套引用类型时,引用类型变量作为值类型的成员变量,在堆栈上保存该成员的引用,而实际的引用类型仍然保存在GC堆上;对于引用类型嵌套值类型的情况,该值类型字段将作为引用类型实例的一部分保存在GC堆上。值类型总是分配在声明它的地方
通过对象实例调用某个方法时,CLR根据TypeHandle可以找到对应的MethodTable,进而可以定位到具体的方法,再通过JIT Compiler将IL指令编译为本地CPU指令,该指令将保存在一个动态内存中,然后在该内存地址上执行该方法,同时该CPU指令被保存起来用于下一次的执行
MethodTable中,包含一个Method Slot Table,成为方法槽表,该表是一个基于方法实现的线性链表,并按照“继承的虚方法”→“引入的虚方法”→“实例方法”→“静态方法”的顺序排列。方法表在创建时,将按照继承层次向上搜索父类,直到System.Object类型,如果子类覆写了父类方法,则将会以子类方法覆盖父类虚方法
静态字段也保存在方法表中,位于方法表的槽数组后,其生命周期从创建AppDomain卸载。静态字段只能由静态构造函数进行初始化,静态构造函数确保在类型任何对象创建前,或者在任何静态字段或方法被引用前执行
方法表的创建是类第一次加载到AppDomain时完成的,对象创建时只是将其附加成员TypeHandle指向方法列表在Loader Heap上的地址,将对象与其动态方法列表相关联起来。方法表是先于对象而存在的
.NET重载决议按照‘关于对象原则“和”执行就近原则“。继承体系中不同的引用指针类型决定了不同对象在方法表中不同的访问权限。对应同名字段或者方法,编译器将按照其查找顺序来引用
抽象类适合于有族层概念的类间关系,而接口最适合为不同的类提供通用功能
接口着重于CAN-DO关系类型,而抽象类则偏重于IS-A式的关系
接口多定义对象的行为;抽象类多定义对象的属性
值类型的密封的,只能实现接口,而不能继承类
UML图中的依赖关系:若Class2被修改,则Class1会受到影响,则Class1依赖于Class2
Adapter模式分为类的Adapter模式和对象的Adapter模式。类的Adapter模式通过引入新的类型继承原有类型,同时实现新加入的接口方法,缺点是耦合度高,需要引入过多的新类型。对象的Adapter模式通过聚合而非继承的方式来实现对原有系统的扩展,松散耦合,较少的新类型
继承关系中,我们更多的是关注共性而不是特性,因为共性是层次复用的基础,而特性是系统扩展的基点
从宏观来看,继承多关于与共性,而多态多着眼于差异
只读字段只能在构造函数中被赋值,其他方法不能改变只读字段
多态分为基类继承式多态和接口实现式多态
严格来讲,.NET中并不存在静态绑定。所有的.NET源文件都首先被编译为IL代码和元数据,在方法执行时,IL代码才被JIT编译器即时转换为本地CPU指令。JIT编译发生于运行时,因此也就不存在完全在编译期建立的关联关系,静态绑定的概念也就无从谈起
.NET中的很多接口都以-able为命名后缀
接口应该被实现为具有单一功能的小接口,而不要实现为具有多个功能胖接口,类对于类的依赖应建立在最小的接口之上
程序设计已经走过了几十年的发展,如果纯粹的陶醉在历史中,除了脑子不好,没有其他的解释
对程序设计人员来说,学习和熟悉类库是突破设计水平的必经之路,因为其中数以万计的类帮助我们完成了程序设计绝大部分的基础性工作,重要的是我们要知道如何去使用
可以用过using MyClassName = NameSpace.Class给类创建别名
值类型通常分配在线程的堆栈上,并且不包含任何指向实例数据的指针。值类型的实例要么在堆栈上,要么内联在结构中
引用类型实例分配在托管堆上,变量保存了实例数据的内存引用
值类型变量作为局部变量时,该实例被创建在堆栈上;而如果值类型变量作为类型的成员变量时,它将作为类型实例数据的一部分,同该类型的其他字段都保存在托管堆上
引用类型变量数据将根据其实例大小区别保存在GC堆或LOH(Large Object Heap)堆上
类的私有字段如果为值类型,那它作为引用类型实例的一部分,也分配在托管堆上
引用类型嵌套在值类型时,该引用类型将作为值类型的成员变量,堆栈上将保存该成员的引用,而成员的实际数据还是保存在托管堆中
sizeof()运算符用于获取值类型的大小,但是不适用于引用类型
值类型不具有多态性
在可能会引起装箱与拆箱操作的集合或者队列中,值类型不是很好的选择,因为会引起对值类型的装箱操作,导致额外内存的分配
System.ValueType重载了System.Object的Equals方法,用反射的手段实现对值类型的按位判等
static bool Equals(object objA, object objB)考虑了null的比较后转而调用virtual bool Equals(object obj)
operator ==默认为引用地址比较。==和Equals方法的主要区别在于多态表现上,==是被重载,而Equals是被重写
所有的类型转换(explicit or implicit)实现都必须是static的(想通过扩展方法对已有类型注入自定义类型转换是不可能的)
从CLR的角度,ref和out都是编译器传递实例指针,在表现行为上是相同的。ref和out编译之后的IL代码是完全相同的
ref要求传递之前的参数必须首先显式初始化,而out不需要。ref的参数必须是一个实际的对象,不能指向null,而out的参数可以接受指向null的对象,而在调用方法内部必须完成对象的实例化
微软强烈建议不要通过GC.Collect方法来强制执行垃圾收集,因为那会妨碍GC本身的工作方式,通过Collect会使对象代龄不断提升,扰乱应用程序的内存使用。只有在明确知道有大量对象停止引用时,才考虑使用GC.Collect方法来调用收集器
CLR提供了两种收集器:工作站垃圾收集器(Workstation GC)和服务器垃圾收集器(Server GC),分别为不同的处理机而设计,默认情况为工作站收集器。工作站收集器主要引用于单处理器系统,工作站收集器尽可能地通过减少垃圾回收过程中程序的暂停次数来提供性能;服务器收集器专为多处理器的服务器系统而设计,采用并行算法,每个CPU都具有一个GC线程。在CLR加载到进程时,可以通过CorBindToRuntimeEx函数来选择执行那种收集器,选择合适的收集器是有效、高效管理的关键
垃圾收集器将托管堆中的对象分为三代,分别为0、1、2.在CLR初始化时,会选择为三代设计不同的阈值容量,GC收集器也会运行时自动调节其阈值容量来提升执行效率。
CLR初始化后,首先被添加到托管堆中的对象都被定为第0代。当有垃圾回收执行时,未被回收的对象代龄将提升一级,变成第1代对象,而后新建的对象仍为第0代对象。代龄越小,表示对象越新,通常情况下其生命周期也越短,因此垃圾收集器总是首先收集第0代的不可达对象内存。
随着对象的不断创建,垃圾收集再次启动时则只会检查0代对象,并回收0代垃圾对象。而1代对象由于未达到预定的1代容量阈值,则不会进行垃圾挥回收操作,从而有效的提高了垃圾收集的效率,这就是代龄机制在垃圾回收中的性能优化作用。
当第0代对象释放的内存不足以创建新的对象,同时第1代对象的体积也超出了容量的阈值时,垃圾收集器将同时对0代和1代对象进行垃圾回收。之后未被回收的1代对象将升级为2代对象,未被回收的0代对象升级为1代对象,而后新建的对象依然为0代对象
通过GC.Collect方法可以指定对从第0代到指定代的对象进行回收,通过GC.MaxGeneration来获取框架版本支持的最大代龄
CLR提供了一种分代式、标记清除型GC,利用标记清除算法来对不同代龄的对象进行垃圾收集和内存紧缩,保证了运算效率和执行优化
.NET中,非托管资源的清理,主要有两种方式:Finalize方法和Dispose方法。Finalize方式,又称为终止化操作,通过对自定义类型实现一个Finalize方法来释放非托管资源;Dispose模式指的是在类中实现IDisposable接口,该接口中的Dispose方法定义显式释放由对象引用的所有非托管资源。因此,Dispose方法提供了更加精确的控制方式,在使用上更加灵活
重写的Finalize方法中,可以通过GC.SuppressFinalize来免除终结
Finalize方式的弊端:
终止化操作的时间无法控制,执行顺序也不能保证。因此,在资源清理上不够灵活,也可能由于执行顺序的不确定而访问已经执行了清理的对象
Finalize方法会极大的损伤性能。GC使用一个终止化队列的内部结构来跟踪具有Finalize方法的对象
重写了Finalize方法的类型对象,其引用类型对象的代龄将被提升,从而提升内存压力
Finalize方法在某些情况下可能不被执行,例如可能某个终结器被无限期阻止,则其他终结器得不到调用。因此,应该确保重写的Finalize方法尽快被执行
Finalize方法的一些规则:
在C#中无法显示的重写Finalize方法,只能通过析构函数语法形式来实现
struct不允许定义虚构函数,只有class中才可以,并且只能有一个
Finalize方法不能被继承或重载
析构函数不能加任何修饰符,不能带参数,也不能被显式调用
执行垃圾回收之前系统会自动执行终止化操作
Dispose方法中,应该使用GC.SuppressFinalize防止GC调用Finalize方法
最佳资源清理策略,应该是同事实现Finalize方式和Dispose方式。一方面,Dispose方法可以克服Finalize方法在性能上的诸多弊端;另一方面,Finalize方法又能确保没有显式调用Dispose方式时,也自行回收使用的所有资源。任何重写了Finalize方法的类型都应该实现Dispose方法,来实现更加灵活的资源清理控制
using语句简化了资源清理代码的实现,并且能够确保Dispose方法得到调用,值得推荐
对于非托管资源的清理,Finalize由GC自行调用,而Dispose由开发者强制执行调用
尽量避免使用Finalize方式来清理资源,必须实现Finalize时,也应一并实现Dispose方法来提供显式调用的控制权限
强烈建议不要重写Finalize方法,同时强烈建议在任何有非托管资源访问的类中同时实现终止化操作和Dispose模式
对象使用完毕应该立即释放其资源,最好显式调用Dispose方法来实现
Finalize方式存在执行时间不确定,运行顺序不确定,同时对垃圾回收的性能有极大的损伤。强烈建议以Dispose模式来替代Finalize方式,在带来性能提升的同时,实现了更加灵活的控制权
推荐使用泛型集合来代替非泛型集合,这种性能差别对值类型的影响较大
容器初始化时最好指定大小。集合动态增加的过程是一个内存重新分配和集合元素复制的过程,对性能造成一定的影响,有必要在容器初始化时指定一个适当的容量
CLR为string类型实现了特殊的”字符串驻留机制“。相同的字符串可能共享内存空间。字符串驻留是进程级的,垃圾回收不能释放CLR内部哈希维护的字符串对象,只有进程结束时才释放。这为string类型的性能提升和内存优化提供了良好的基础
建议使用String.Compare方法进行比较,尤其是非大小写敏感的字符串比较,在性能上更加有效
推荐多线程编程中使用线程池,一个进程对应一个ThreadPool,可以被多个AppDomain共享
在特殊情况下,以struct来实现对轻量数据的封装是较好的选择,但绝大部分情况下,class都具有不可替代的地位
const是编译时常量,readonly是运行时常量,所以const高效,readonly灵活。在实际的应用中,推荐以static readonly来代替const,以解决const可能引起的程序集引用不一致问题,还能带来较多灵活性控制
尽量使用一维零基数组,CLR对一维零基数组采用特殊的IL直接操作指令,访问时无需进行额外的偏移计算,并且JIT也只需进行一次访问范围检查,所以在各种数组中性能最好
采用FxCop工具,检查你的代码
在有重载的的情况下,base将指向直接继承的父类成员的方法;没有重载的情况下,base指向任何上级父类的公有或者受保护方法
不可同时将this和base作用在一个构造函数上
using创建别名的用法为:using alias = namespace | type
当同一cs文件中引入的不同命名空间中包括了相同名称的类型,为避免出现名称冲突可以通过using设定别名来解决
using(…) { }中初始化的对象,也可以在using语句之前声明
class表现为行为,而struct常用于存储数据
struct只能声明带参数构造函数,且不能声明析构函数。struct没有自定义的默认无参构造函数,默认无参构造器只是简单的把所有值初始化为它们的0等价值
Attribute实例是在编译期进行初始化,而不是运行期
C#允许以指定的前缀来表示特性所应用的目标元素,显式处理可以消除可能带来的二义性:[assembly: MyAttribute(1)]、[moduel: MyAttribute(2)]
CLR允许接口可以包含事件、属性、索引器、静态方法、静态字段、静态构造函数以及常数。但C#接口中不能包含任何静态成员
抽象类是一个不完全的类,着重族的概念
“接口不变”是应该考虑的重要因素。所以,在由接口增加扩展时,应该增加新的接口,而不能更改现有接口
尽量将接口设计成功能单一的功能块
如果预计要创建组件的多个版本,则创建抽象类。抽象类提供简单易行的方法来控制组件版本。通过更新积累,所有继承类都随更改自动更新。另一方面,接口一旦创建就不能更改。如果需要接口的新版本,必须创建一个全新的接口
如果创建的功能将在大范围的全异对象间使用,则使用接口。抽象类应主要用于关系密切的对象,而接口最适合为不相关的类提供通用功能
如果要设计小而简练的功能块,则使用接口。如果要设计大的功能单元,则使用抽象类
如果要在组件的所有实现间提供通用的已实现的功能,则使用抽象类。抽象类允许部分实现类,而接口不包含任何成员的实现
虚方法就是以virtual关键字修饰并在一个或多个派生类中实现的方法,子类重写的虚方法则以override关键字标记。抽象方法就是以abstract关键字修饰的方法,抽象方法可以看作是没有实现体的虚方法,并且必须在派生类中被覆写,如果一个类包括抽象方法,则该类就是一个抽象类。因此,抽象方法其实隐含为虚方法,只是在声明和调用语法上有所不同
函数重载决议时优先匹配的方法是一般方法,而非泛型方法
重写定义了泛型参数的虚方法时,子类实现不能重复在基类泛型方法中的约束
Object.MemberwiseClone方法执行对象的浅拷贝
Object.GetType方法为非虚函数
对Object.Equals和GetHashCode方法的覆写要保持统一,因为两个对象的值相等,其哈希码也应该相等
静态Equals的执行取决于==操作符和Equals虚方法,决议静态Equals方法的执行,就要在自定义类型中覆写Equals方法和重载==操作符。.NET提供了Equals静态方法可以解决两个值为null对系那个的判等问题,而使用objA.Equals(objB)来判断两个null对象会抛出NullReferenceException异常
Object.ReferenceEquals内部实际调用的是operator ==。默认情况下,Equals方法和ReferenceEquals方法是一样的
对于自定义值类型,如果重载Equals方法,则应该保持和==在语义上的一致,以返回值相等结果。而对于引用类型,如果以覆写来处理值相等规则时,则不应该再重载==运算符,因为保持其缺省语义为判断引用相等才是恰当的处理规则
Equals虚方法与==操作符的主要区别在于多态表现:Equals通过血方法覆写来实现,而==操作符则通过运算符重载来实现
对于值类型,基类System.ValueType通过反射机制覆写了Equals方法来比较两个对象的值相等,但是这种方式并不高效,更明智的办法是在自定义值类型时有针对性的覆写Equals方法,来提供更灵活、高效的处理机制
对于引用类型,覆写Equals方法意味着要改变System.Object类型提供的引用相等语义
禁止从Equals方法或者==操作符抛出异常
ReferenceEquals方法比较两个值类型一定返回false
ReferenceEquals方法比较两个System.String类型的唯一性时,要注意String类型的字符串驻留机制
值类型最好重新实现Equals方法和重载==操作符,因为默认情况下实现的是引用相等
String对象从应用角度体现了值类型语义,而从内存角度实现为引用类型存储,位于托管堆
String对象的恒定性保证对String对象的任何操作不会改变原字符串,意味着操作字符串不会出现县城同步问题
CLR内部维护了一个哈希表来管理其创建的大部分string对象。Key为string本身,而Value为分配给对应的string的内存地址。
对于动态生成的字符串,因为没有添加到CLR内部维护的哈希表而使字符串驻留机制失效。当我们需要高效的比较两个字符串是否相等时,可以手工启用字符串驻留机制:public static string Intern(string str); public static string IsInterned(string str)
字符串驻留是进程级的,可以跨应用程序域而存在。垃圾收集不能释放哈希表中引用的字符串对象,只有进程结束这些对象才会被释放
所有枚举类型都隐式而且只能隐式的继承自System.Enum类型,System.Enum类型是继承自System.ValueType类型的唯一不为值类型的引用类型
枚举类型是值类型,分配与线程的堆栈上,自动继承于Enum类型,但是本身不能被继承;Enum类型是引用类型,分配与托管堆上,Enum类型本身不是枚举类型,但是提供了操作枚举类型的共用方法
枚举可以进行自增自减操作:e++; e–
Enum.IsDefined方法不能应对位枚举成员,结果将总是返回false
委托类型为非void类型时,多播委托将返回最后一个调用的方法的执行结果
委托在本质上仍然是一个类
异常是对程序接口隐含假设的一种违反
因Exception实现了ISerializable接口,自定义异常类新增字段时需要重写GetObjectData方法以填充SerializationInfo实现异常的可序列化
.NET提供了两个直接继承于Exception的重要子类。ApplicationException类型是框架为应用程序预留的基类型,自定义异常可以选择从其继承;SystemException为系统异常基类,CLR自身抛出的异常都继承自SystemException
虽然异常机制提高了自定义特定异常的方法,但是大部分时候我们应该优先考虑.NET的系统异常,而不是实现自定义异常
要想使自定义异常能应用于跨应用程序域,应该使异常可序列化,给异常类实现ISerializable接口是个好的选择
如果自定义异常没有必要实现子类层次结构,那么异常类应该定义为密封类(sealed),以保证其安全性
尽可能以逻辑流程控制来代替异常
异常是对程序接口隐含假设的一种违反,而这种假设常常和错误没有关系,反倒更多的是规则与约定
微软提供的Enterprise Library异常处理应用程序块(EHAB)来实现更灵活、可扩展、可定制的异常处理框架,力图体现对异常处理的最新实践方式
泛型约束:
class ClassC