C#内存管理CLR深入讲解(上篇)
半年之前,PM让我在部门内部进行一次关于“内存泄露”的专题分享,我为此准备了一份PPT。今天无意中将其翻出来,觉得里面提到的关于CLR下关于内存管理部分的内存还有点意思。为此,今天按照PPT的内容写了一篇文章。本篇文章不会在讨论那些我们熟悉的话题,比如“值类型引用类型具有怎样的区别?”、“垃圾回收分为几个步骤?”、“Finalizer和Dispose有何不同”、等等,而是讨论一些不同的内容。整篇文章分上下两篇,上篇主要谈论的是“程序集(Assembly)和应用程序域(AppDomain)”。也许有的地方说的不是很正确,希望读者不吝赐教。
一、程序集与应用程序域
何谓程序集(Assembly)?它是一个托管应用的基本的部署单元。一个程序集是自描述的(通过元数据)、能够实施版本策略和部署策略。我倾向于这样的方式来定义程序集:“Assembly is a reusable, versionable, and self-describing building block of a CLR application.”从结构组成来看,一个程序集主要由三个部署组成:IL指令、元数据和资源。程序集的结构组成如下图所示。
那么什么又是应用程序域呢?从功能上讲,通过应用程序域实现的隔离机制为托管代码的执行提供了一个安全的边界。从与程序集的关系来讲,我们可以将应用程序域看成是加载程序集的容器。只有相关的程序集被CLR加载到相应的应用程序域中,才谈得上代码的执行。
基于应用程序域的隔离,归根结底是内存的隔离。一个基本的反映就是:在一个应用程序域中创建的对象,不能直接在另一个应用程序域中使用。这中间需要有一个基本的跨应用程序域传递的机制,我们将这种机制称之为“封送(Marshaling)”。具体来讲,又具有两种不同的封送方式:按值封送(MBV:Marshaling By Value )和按引用封送(MBR:Marshaling By Reference)。MBV主要采用序列化的方式,而MBR最典型的就是.ENT Remoting。
二、系统程序域、共享程序域和默认程序域
当托管应用被启动后,在执行第一句代码之前,CLR会先后为我们创建三个应用程序域:系统程序域(System Domain)、共享程序域(Shared Domain)和默认程序域(Default Domain),它们分别具有不同的作用。
- 系统程序域:系统程序域是第一个被创建的应用程序域,同时也是其他两个应用程序域的创建者。在该程序域初始化过程中,由它将msCorLib.dll这个程序集(这是一个很重要的程序集,.NET类型系统最基本的类型定义其中)加载到共享程序域中。此外,驻留的字符串也被保存在此系统程序域中。系统程序域的一个主要的任务是追踪其他所有应用程序域的状态,并负责加载和卸载它们;
- 共享程序域:共享程序域主要用于保存以“中立域(Domain-neutral Domain )”加载的程序集容器。所谓“中立域 ”方式加载的程序集,就是说程序集并不被加载到当前的程序域中并被该程序域专用,而是加载到一个公共的程序域中被所有程序域共享。
- 默认程序域:我们的托管程序最终就运行在该程序域中,默认程序域可以通过System.AppDomain表示。
三、字符串的驻留
上面的文字描述实际上透露一些重要的信息,其中一个就是字符串的驻留(String Interning)。关于字符串的驻留,我想大家都不陌生,所以在这里我就不作重复的介绍了。在这里,我只想讨论一个问题:字符串的驻留是基于整个进程的,而不是仅仅基于某个应用程序域。
从上面的描述我们知道,字符串对象和一般的引用类型对象具有很大的不同:字符串对象直接被保存到系统程序域中,而一般的引用类型对象我们都是最终保存在GC堆中。从某种意义上讲,在字符串驻留机制下,字符串也是以“中立域”的方式被加载的,被驻留的字符串能够被同一个进程下所有应用程序域所共享。
那么,我们是否可以通过一些比较直观的方式来验证这一点。但是,我们不能直接编写程序来比较两个应用程序域中字符串是否是相同的引用,但是我们有一些间接的机制。我个人喜欢采用的方式是:加锁。我们在运行于不同的应用程序域的代码中对两个字符串变量进行加锁,如果程序运行的结果和对相同的对象加锁一样,那么就可以证明被枷锁的两个对象实际上是同一个对象。
为了便于演示,我写一个如下一个AppDomainContext,表示某个AppDomain对应的执行上下文。AppDomainContext具有一个只读的类型为AppDomain的属性,该属性通过构造函数执行,最终在静态方法NewContext被创建。我们调用Invoke方法让指定的方法对应的应用程序域中执行。
public class AppDomainContext { public AppDomain AppDomain { get; private set; } private AppDomainContext(AppDomain appDomain) { this.AppDomain = appDomain; } public static AppDomainContext NewContext(string friendlyName) { return new AppDomainContext(AppDomain.CreateDomain(friendlyName)); } public void Invoke<T>(Action<T> action) where T : MarshalByRefObject { T instance = (T)this.AppDomain.CreateInstanceAndUnwrap(typeof(T).Assembly.FullName, typeof(T).FullName); action.Invoke(instance); } }
我们接着在定义一个辅助类ObjectLock方便进行加锁,以及确认对象是否被所住。ObjectLock比如继承自MarshalByRefObject,因为我们需要该对象以MBR的方式进行传递。在Lock方法中对指定的对象进行加锁,并指定加锁的时间。在CheckLock中通过时间间隔判断指定的对象是否已经被锁住,相应的结果会在控制台中被输出。为了让大家能够确定相应的操作是在哪个应用程序域中执行的,在枷锁和检查锁定的时候将应用程序域的名称(AppDomain.FriendlyName属性)打印出来。
public class ObjectLock : MarshalByRefObject { public void Lock(object objectToLock, int millisecondsTimeout) { lock (objectToLock) { Console.WriteLine("[{0}] Successfully lock the object.", AppDomain.CurrentDomain.FriendlyName); Thread.Sleep(millisecondsTimeout); } } public void CheckLock(object objectToLock) { if (Monitor.TryEnter(objectToLock, 10)) { Console.WriteLine("[{0}] The object is not locked.", AppDomain.CurrentDomain.FriendlyName); } else { Console.WriteLine("[{0}] The object is locked .", AppDomain.CurrentDomain.FriendlyName); } } }
然后我再一个控制台应用中的Main方法中,编写了如下简单的代码。通过AppDomainContext在一个的应用程序域(Foo)中锁定一个值为“Hello World!”的字符串,并在另一个应用程序域(Bar)中确认同值得字符串是否已经被锁定。结果表示在应用程序域Bar中指定的字符串已经被锁定,从而证明了应用程序域Foo和Bar中两个值为“Hello World!”的字符串对象实际上是同一个。
static void Main(string[] args) { Action<ObjectLock> lockObj = objLock => objLock.Lock("Hello World!", 2000); Action<ObjectLock> checkLock = objLock => objLock.CheckLock("Hello World!"); Thread lockObjThread = new Thread(() => AppDomainContext.NewContext("Foo").Invoke<ObjectLock>(lockObj)); Thread checkLockThread = new Thread(() => AppDomainContext.NewContext("Bar").Invoke<ObjectLock>(checkLock)); lockObjThread.Start(); Thread.Sleep(500); checkLockThread.Start(); }
输出结果:
1: [Foo] Successfully lock the object. 2: [Bar] The object is locked.
上面的介绍同时说明一个问题:千万不要对一个字符串对象加锁。
四、程序集加载的方式
虽然我们说CLR在启动托管应用的时候,以中立域的方式加载msCorLib.dll这个程序集,但是这不是程序集默认采用的加载方式。在默认的情况下,程序集被加载到当前的程序域中,供该程序集独占使用。我个人将这两种不同的程序集加载方式称为:独占加载(Exclusive Loading )和共享加载(Shared Loading)。如右图所示:如果某个类型被定义在程序集中Foo.Dll,当AppDomain1和AppDomain2需要使用该类型的时候,它们会分别以独占的方式加载程序集Foo.Dll。但是,如果它们使用一些基元类型,比如System.Object、System.Int32、System.DateTime等,则不会加载定义它们的msCorLib.dll程序集,而是直接使用已经被以中立域方式加载到共享程序域中的msCorLib.dll。
我们同样可以借助上面定义的AppDomainContext来证明这一点。在这之前我需要说明一点:程序集的加载包括对定义在程序集中类型系统的加载,我们可以通过类型对象的加锁情况来推断程序集的加载方式。为此我在上面创建的解决方案中添加了一个类库项目Lib,ConsoleApp引用Lib项目,并在Lib中定义了一个空的Foo类型。
namespace Artech.MemAllocation { public class Foo {} }
然后我们修改之前的程序,将对字符串加锁替换在对Foo类型(typeof(Foo))加锁。从输出结果我们可以看出,在Bar程序域中使用的Foo类型并没有被锁住,从而证明两个程序域(Foo和Bar)使用的同一个类型并不是Type对象,因为对应的程序集是以独占的方式加载的。
static void Main(string[] args) { Action<ObjectLock> lockObj = objLock => objLock.Lock(typeof(Foo), 2000); Action<ObjectLock> checkLock = objLock => objLock.CheckLock(typeof(Foo)); Thread lockObjThread = new Thread(() => AppDomainContext.NewContext("Foo").Invoke<ObjectLock>(lockObj)); Thread checkLockThread = new Thread(() => AppDomainContext.NewContext("Bar").Invoke<ObjectLock>(checkLock)); lockObjThread.Start(); Thread.Sleep(500); checkLockThread.Start(); }
输出结果:
[Foo] Successfully lock the object. [Bar] The object is not locked.
但是,如果我们将加锁和锁定检验的typeof(Foo)替换成typeof(int),结果就完全不一样了。不同的结果说明了msCorLib.dll采用了不同于上面的程序集加载方式,以中立域方法的加载方式决定在任何应用程序域中使用的类型都是同一个Type对象。
static void Main(string[] args) { Action<ObjectLock> lockObj = objLock => objLock.Lock(typeof(int), 2000); Action<ObjectLock> checkLock = objLock => objLock.CheckLock(typeof(int)); Thread lockObjThread = new Thread(() => AppDomainContext.NewContext("Foo").Invoke<ObjectLock>(lockObj)); Thread checkLockThread = new Thread(() => AppDomainContext.NewContext("Bar").Invoke<ObjectLock>(checkLock)); lockObjThread.Start(); Thread.Sleep(500); checkLockThread.Start(); }
输出结果:
[Foo] Successfully lock the object. [Bar] The object is locked.
五、我们自己的程序集也可以采用中立域的方式加载吗?
我想到这里有人会问一个问题:“我们自定义的程序集可以像msCorLib.dll一样以中立域的方式共享加载吗?”。对于控制台应用,你只需要在Main方法上应用LoaderOptimizationAttribute特性,并指定LoaderOptimization为MultiDomain即可。比如,还是采用对Foo类型Foo类型(typeof(Foo))对象加锁,这次我们在Main方法上应用了这样的特性:[LoaderOptimization(LoaderOptimization.MultiDomain)]。输出的结果就与对Int32类型对象加锁一样。
[LoaderOptimization(LoaderOptimization.MultiDomain)] static void Main(string[] args) { Action<ObjectLock> lockObj = objLock => objLock.Lock(typeof(Foo), 2000); Action<ObjectLock> checkLock = objLock => objLock.CheckLock(typeof(Foo)); Thread lockObjThread = new Thread(() => AppDomainContext.NewContext("Foo").Invoke<ObjectLock>(lockObj)); Thread checkLockThread = new Thread(() => AppDomainContext.NewContext("Bar").Invoke<ObjectLock>(checkLock)); lockObjThread.Start(); Thread.Sleep(500); checkLockThread.Start(); }
输出结果:
[Foo] Successfully lock the object. [Bar] The object is locked.
又一个关于加锁的注意:谨慎地对Type对象进行加锁。
关于CLR内存管理一些深层次的讨论[上篇]
关于CLR内存管理一些深层次的讨论[下篇]
到此这篇关于C#内存管理CLR深入讲解的文章就介绍到这了。希望对大家的学习有所帮助,也希望大家多多支持我们。