本篇是整理蒋金楠对CLR 内存管理的博文,蒋大神的博文中将CLR 的内存分管理分为三个逻辑部分,博文中详细讲述了哪些程序集要加载到系统程序域,哪些要加载到共享程序域,以及我们写的代码会被加载到默认程序域。下面是我整理后的思路,目的是加强一下对CLR 内存管理的概念。
程序集与应用程序域
程序集是一个托管应用的基本的部署单元。一个程序集是子描述的(通过元数据),能够实施版本策略和部署策略。从结构组成来看,一个程序集主要由三个部署组成:IL指令、元数据和资源。
应用程序域从功能上看是,通过应用程序域实现的隔离机制为托管代码的执行提供了一个安全的边界。
从与程序集的关系来讲,我们可以将应用程序域看成是加载程序集的容器。只有相关的程序集被CLR加载到相应的应用程序域中,才谈得上代码的执行。
应用程序域的隔离,归根结底是内存的隔离。一个应用程序域中创建的对象,不能在另一个应用程序域中使用。这中间需要有一个跨应用程序域传递的机制,我们称之为”封送“。具体来讲分为两种:
-
按值封送(MBV),主要采用序列化的方式
-
按引用封送(MBR),典型方式是 .Net Remoting
系统程序域、共享程序域、默认程序域
当托管应用被启动后,在执行第一句代码之前,CLR会先后为我们创建三个应用程序域:系统程序域、共享程序域、和默认程序域,它们分别具有不同的作用。
-
系统程序域:第一个被创建,是其他两个应用程序域的创建者。在该程序域初始化过程中,由它将msCorLib.dll 这个程序集加载到共享程序域中,驻留字符串也被保存在系统程序域中。系统程序域的一个主要任务就是追踪其他所有应用程序域的状态
-
共享程序域:保存以”中立域“加载的程序集容器。中立域方式加载的程序集可以被其他程序域使用。
-
默认程序域:我们的托管程序最终就运行在该程序域上。通过AppDomain表示。
字符串的驻留
字符串的驻留是基于整个进程的,而不是仅仅基于某个应用程序域。字符串对象直接被保存到系统程序域中。从某种意义上讲,在字符串驻留机制下,字符串也是以“中立域”的方式被加载的,被驻留的字符串能够被同一个进程下所有应用程序域所共享。
程序集的加载方式
CLR 在启动托管应用的时候,以中立域的方式加载msCorLib.dll这个程序集,但是这不是程序集默认采用的加载方式。默认情况下,程序集被加载到当前的应用程序域中,该程序集独占使用。
可以看下面的例子,自己定义的Foo类不能被其他程序域访问,但是换成int 类型就不同了。
自己的程序采用中立域的方式加载
对于控制台应用,你只需要在Main方法上应用LoaderOptimizationAttribute 特性,并指定LoaderOptimization 为MultiDomain即可。
类型和实例:
对类型和实例的内存分配时如何进行的呢?对象是状态和行为的组合体,所以从.net framework 的角度来看类型,它只是具有两种类型的成员—— 字段和方法(实际还有嵌套类型),前者表示状态,后者表示行为。
类型是对元数据的描述,而实例则是符合该元数据描述的单个个体。
同一类型下的所有实例具有相同的行为,它们通过状态值的不同得以区分。
所以内存中的实例(这里所说的实例是引用类型的实例)表示的是字段值,而内存中的类型表示的则是类型成员结构的元数据。
当我们创建一个对象的时候,CLR会在GC堆(heap)中开辟一块连续的内存空间保存字段值。那么类型信息又是保存在哪块内存上的呢?
类型信息保存在“另一堆“上,我们称之为加载器堆(loader heap)。每一个应用程序域都具有各自的加载器堆,即包括我们创建的普通应用程序域,也包括三个特殊应用程序域:系统程序域、共享程序域或默认程序域。
如果说GC堆是实例的容器,那么基于应用程序域的加载器堆就是类型的容器。CLR采用”按需加载(这里指的是类型,不是程序集)、及时编译“的运行机制。
当某个类型被第一次使用的时候,CLR视图加载该类型。
如果该类型对应的程序没有独自地加载到本应用程序域中,或者没有通过中立域的形式加载到共享程序域中,它会按照相应的方式加载程序集(假设会按独占的方式加载)。然后,将使用到的这个类型加载到本应用程序域的加载器中。
加载器堆维护着自应用程序域创建以来使用过的所有类型记录,它们对应着一个特殊的对象——方法表(method table)。当程序第一次执行到某个方法的时候,CLR会定位到方法表中该条目,获取相关信息进行JIT编译。所以如果某个类型在加载器堆中的方法表的某个条目至少被执行一次,它就会指向一段JIT 编译后的机器指令。
实例内存分配不仅限于GC堆
CLR的GC堆用于盛放实例,加载器堆用于盛放类型。但CLR还不止这两个堆,它还有两个堆,一个是存放JIT编译后机器指令的JIT堆(JITheap),另一个则是专门用于”大对象“的大对象堆(LOH:large object Heap)。
当我们实例化一个对象的时候,如果该对象大于或者等于85000字节,CLR认为他是大对象,并被放到LOH中,否则放到GC堆中。这里有一点要注意,GC不仅限于堆GC堆中对象的回收,LOH中的对象也受GC管理。
实例对类型的引用
实例是类型的实例,实例和它所对应的类型需要维持一种联系。反应在内存中,就是GC堆和LOH中的对象有一个引用了 加载器堆中该类型的方法表。
实例对类型的引用通过一个特殊的对象来维系——TypeHandle。
举个例子,在如下一段简单对象实例化代码中,我先后实例化了四个对象:字符串”ABC“,System.Object 对象,自定义Bar对象和具有85000个元素的字节数组。
string strInstance = "ABC";
object objectInstance = new object();
Bar barInstance = new Bar()
byte[] largeObjInstance = new byte[85000];
上面的程序执行后,围绕着实例化的四个对象和类型信息,在内存中将会具有如下一个关系。
LOH中的对象如何被回收
CLR采用基于”代龄(Generation)“的垃圾回收机制。代龄,这个词充分体现了设计者用于表现”不同对象具有不同生命周期“的意思。所有对象分为三代,代表三个不同的连续内存块,”辈分“越高,时间越久;”辈分“越低,被扫荡(GC回收)的频率越高。
对于LOH和GC堆中的对象,除了大小还有其他的什么不同之处吗?
将大对象放在LOH中,目的在于对其实施特殊的回收机制。关于垃圾回收,应该有这样的认识:回收的成本是和对象的大小基本成”正向“关系,对象越大,回收成本就越大。所以我们不能对大对象频繁的实施垃圾回收,实际上CLR是将LOH对象当成最高代龄的对象。
也就是说,针对LOH的回收工作是和GC堆中G2一并进行的。换句话说,当G2或者LOH的剩余空间低于某个限度,针对它们的垃圾回收便被触发。