我们知道,在程序运行过程中,每个对象(object)都是对应了一块内存,这里的对象不仅仅指的是某个具体类型的实例(instance),也包括类型(type)本身。我想大家也很清楚CLR如何为我们创建一个类型的实例(instance)的:CLR计算即将被创建的Instance的size(所有的字段加上额外的成员所占的空间:TypeHandle和SyncBlockIndex);在当前AppDomain对应的managed heap中为之开辟一块连续的内存空间;初始化Instance的这两个额外的成员TypeHandle和SyncBlockIndex(TypeHandle是一个指针,指向Type的method table,SyncBlockIndex用于在多线程的条件下确保对该Instance操作的同步,它指向一块被称为Synchronization Block的内存块,我们对该Instance加锁, CLR会使instance的SyncBlockIndex指向某一个Synchronization Block,反之解锁会重置SyncBlockIndex);最后调用对应的constructor。
在面向对象的原则下,Instance的Field代表的是对象的状态(state), 而方法则体现的是对象的行为(behavior)。状态只能和具体的Instance绑定在一起,而属于同一类型的不同的Instance则具有一样的行为,所以行为是和Type绑定在一起的。同时Type定义了很多原数据的信息。这些基于Type的信息是如何保存的,今天我们就来简单地讨论这个问题。
一、 Sample
在开始介绍之前我们给出一个有趣例子。在讨论String interning的时候,我通过对具有相同字符序列的string进行加锁,证明了基于进程的string interning。今天我仍然沿用这种机制,不过进行加锁的对象不是string,而是Type对象。
上面是整个Solution的结构,为了把CustomType类型定义在一个和主程序不同的Assembly中,我添加了Artech.TypeInManagedHeap.ClassLibrary Project。CustomType是一个空的Class,没有定义任何的成员,因为我们需要的仅仅是CustomType这个Type本身:
在Main所在的Artech.TypeInManagedHeap.ConsoleApp Project,我定义了一个MarshalByRefType,他继承自MarshalByRefObject,因为我需要让它在不同的AppDomain中以By Reference进行传递。唯一的方法ExecuteWithTypeLocked中,先对传入的Type对象加锁,获得锁后做一些输出,随后进行10s的时间延迟。
在主程序中,我在两个新创建的AppDomain中创建了MarshalByRefType 实例,并在各自的线程中调用ExecuteWithTypeLocked方法。该程序的目的是证明在不同线程中被加锁的Type对象是否是同一个对象,如果是同一个对象,果是两个线程中的操作的执行间隔应该是10s,否则他们几乎在同一个时刻执行。
首先进行加锁的对象是System.Int32 Type(typeof(int)))我们来运行程序,看看输出结果:
输出的两个时间刚好相差10s,这充分说明了在不同的AppDomain中进行加锁的System.Int32 Type是同一个对象。
我们现在来对我们自定义的CustomType Type进行加锁,我只需修改Execute方法:
现在看看输出的结果:
两个操作输出了相同的时间,这说明了在两个不同AppDomain中进行加锁的CustomType Type对象并非同一个对象。
我们进一步作一些修改,在Main方法上运用LoaderOptimizationAttribute:
看看现在的输出又如何:
现在的时间间隔又变成了10s,也就是说,在这种情况下,CustomType Type虽然使用在不同的AppDomain中,但是它们实际是同一个对象。
二、Managed code的执行
我先不对上面出现的现象做出解释,我首先对在CLR下托管代码的执行过程做一个简单的介绍。我们就以上面的Sample为例,对于下面的4行code, 如果MarshalByRefType实现在另外一个Assembly中(假设叫做CustomAssembly.dll), 我们来看看CLR到底会为为我们做些什么。
CLR先创建了两个AppDomain:Artech.AppDomain1和Artech.AppDomain2。接着调用CreateInstanceAndUnwrap方法。发现一个MarshalByRefType类型,并且该类型并没有在已经加载的Assembly中定义,于是CLR会在一些特殊的目录或者GAC中试着找到定义了MarshalByRefType的CustomAssembly.dll,找到后加载该Assembly。注意该Assembly的加载是基于AppDomain的,也就是说,两个CustomAssembly.dll被分别加载到Artech.AppDomain1和Artech.AppDomain2中。
每个AppDomain都具有一段限于自己使用的、被隔离的托管堆。托管堆中又具有很多不同的划分,分别基于不同的目的用于存储不同的信息。其中最重要的是GC heap和Loader heap。GC heap用于Reference type实例的存储,每个实例的生命周期受GC的管理。GC以某种机制进行垃圾收集回收垃圾对象的内存。Loader heap在存储原数据相关的信息,也就是我们说的Type。每个Type在Loader heap中体现为一个MethodTable,MethodTable主要记录了这些metadata的信息,比如Type的base type,实现的interface, Type被定义的module, static field,以及所有的方法,而System.Type则可以看成是对MethodTable的封装,在程序执行过过程中一个Type对象对应着一个具体的MethodTable。
当基于Type的Meta data被成功加载在各自AppDomain的Loader heap中之后,CLR便按照开篇介绍的Instance创建的过程创建对象,Instance对应的managed heap就是GC heap。在初始化Instance额外成员TypeHandle过程中,就是把它指向在Loader heap 的MethodTable。通过TypeHandle这个指针就可以定位到具体的Type。
如果我们执行了Intance的某个方法,那么CLR根据TypeHandle找到对应的MethodTable,随后定位到具体的方法,通过JIT Compiler把IL指令变成基于处理器的machine instruction,并执行之,该machine instruction被保存,用于下一次执行。
通过上面的介绍,我们说Assembly的加载和Type在Loader heap的加载都是基于某个单独AppDomain,是被AppDomain隔离起来,不能被其他的AppDomain共享。这样充分的利用AppDomain提供的内存隔离机制,保证了托管程序的健壮性。但是,从另一方面讲,由于在不同的AppDomain使用的Type,都会在该AppDomain中加载对应的Assembly,并在AppDomain所在的Loader heap加载基于Type的metadata,这样对于一些不是很Common的Type来说没有问题,但是对一些我们经常使用的基本类型,比如int,Array,object等,则会带来Performance的损耗和内存的压力。于是出现了另一种加载机制:以中立域的方式加载。
在《再说string》中,我提到过,在CLR初始化过程中,会创建3个Domain:SystemDomain,SharedDomain和DefaultDomain。SharedDomain中加载的是一些AppDomain中性,能被各不同的AppDomain共享的信息。定义了一些基本类型,比如int,object,Array等的Assembly -MSCorLib.dll就是被加载到SharedDomain中供同一个进程的各个AppDomain共享。同理存储于SharedDomain的Loader heap的Type也是被各个AppDomain共享的。
MSCorLib.dll在初始化的时候被自动地加载到SharedDomain,而对于一般的Assembly,CLR为我们提供了一些方式实现这样的加载方式,比如在Main方法上运用LoaderOptimizationAttribute。
三、回到Sample
有了上面的理论基础,我们再一次回到我们第一部分的Sample,对于运行的现象就很容易理解了:
首先对System.Int32Type进行加锁,由于System.Int32是定义在MSCorLib.dll中,并且该Assembly一中立的方式加载到SharedDomain中,同一个进程中的各个AddDomain用到的System.Int32Type实际上是同一个对象。
后来我们又对我们自定义的CustomType进行加锁,这个Type对应的Assembly为Artech.TypeInManagedHeap.ClassLibrary.dll, 在某人的情况下两个Assembly被加载到我们新创建的两个AppDomain中,所以在AppDomain1和AppDomain2的CustomType Type是不同的对象。
最后我们在Main上运用了[LoaderOptimizationAttribute(LoaderOptimization.MultiDomain)],实际上就是指示CLR以中立的方式把Artech.TypeInManagedHeap.ClassLibrary.dll加载到SharedDomain中,所以这时候爱两个AppDomain中的CustomType Type是相同的对象。
四、一点补充
由于Type对象是基于Loader heap的,而非GC heap,所以Type的生命周期会保持到AppDomain被卸载,对于以中立的方式加载到SharedDomain的情况,Type对象的生命周期会延续要进程的结束。
另外,切忌对Type进行加锁,如果你对Type加锁,你实际上相当于对所有的该Type对应的instance加锁,粒度太大,极易形成死锁。
关于CLR如何创建对象,请参考:
《Drill Into .NET Framework Internals to See How the CLR Creates Runtime Objects》
By Hanu Kommalapati and Tom Christian