深入了解.net类在内存中的结构以及安全线程同步
一:类在内存中的结构
每当在堆中创建对象时,每个对象都会获得两个与它关联的附加开销字段。
第一个开销字段MethodTablePointer 包含类型的方法表的内存地址。
基本上,该指针使获得有关堆中的任何对象的类型信息成为可能。实际上,当您在内部调用 System.Object 的 GetType 方法时,该方法会按照对象的 MethodTablePointer 字段来确定该对象的类型。
第二个开销字段名为 SyncBlockIndex,它包含 SyncBlock 缓存的 32 位有符号的整数索引。
当构建某个对象时,该对象的 SyncBlockIndex 会被初始化为一个负值,以表明它根本不引用任何 SyncBlock。 然后,当调用一个方法以同步对该对象的访问时,CLR 会在它的缓存中查找一个可用的 SyncBlock,并且将该对象的 SyncBlockIndex 设置为引用该 SyncBlock。换言之,当对象需要同步字段时,SyncBlock 被即时与该对象相关联。当不再有其他要同步对象访问的线程时,该对象的 SyncBlockIndex 被重置为一个负数,并且 SyncBlock 可在将来用来与其他对象相关联。
有关该想法的具体表示,请参见下图。
在
该图的“CLR 数据结构”部分中,您可以看到对于系统了解的每个类型,都有一个数据结构;您还可以看到 SyncBlock
结构集。在该图的“托管堆”部分中,您可以看到创建了三个对象:ObjectA、ObjectB 和 ObjectC。每个对象的
MethodTablePointer 字段都引用类型的方法表。从该方法表中,可以知道每个对象的类型。因此,我们可以容易地看到 ObjectA
和 ObjectB 是 SomeType 类型的实例,而 ObjectC 是 AnotherType 类型的实例。
您将注意到,ObjectA 的 SyncBlockIndex 开销字段被设置为 0。这表明 SyncBlock #0 当前由 ObjectA 使用。
另一方面,ObjectB 的 SyncBlockIndex 字段被设置为 -1,这表明 ObjectB 不具有与它关联以供它使用的 SyncBlock。
最后,ObjectC 的 SyncBlockIndex 字段被设置为 2,这表明它使用的是 SyncBlock #2。
在此处我已经介绍的示例中,SyncBlock #1 未使用,并且可能在将来与某个对象相关联。
因此,从逻辑上讲,您会看到堆中的每个对象都具有一个与其相关联的
SyncBlock,并且可以使用它来进行快速的独占的线程同步。但是,从物理上讲,只有在需要 SyncBlock
结构时才会将它们与对象相关联;当不再需要它们时,则会解除它们与对象之间的关联。这说明内存使用很有效。顺便说一句,如有必要,则
SyncBlock 缓存能够创建更多的 SyncBlocks,因此如果要同时对很多对象进行同步,则无需担心系统会耗尽它们。
二:安全线程同步
在多线程编程中,lock/SyncLock
语句用于创建代码中一次只执行一个线程的关键部分或简要部分。此语句将锁定与您指定的对象相关联的唯一监视对象,如果其他线程已经锁定了该监视对象,则等
待。一旦它锁定了监视对象,任何其他线程都无法锁定该监视对象,除非您的线程解除锁定,解除锁定会在封闭块的结尾自动发生。一种常见的用法是锁定
this/Me 引用,这样,只有您的线程可以修改您在使用的对象 —
不过,更好的做法是锁定您即将修改的特定对象。锁定尽可能小的对象的好处是可以避免不必要的等待。
在 C# 中,加锁的做法是 lock(typeof(ClassName)),其中,ClassName 是某个类的名称;在 Microsoft Visual Basic .NET 中,加锁的做法是 SyncLock GetType(ClassName)。
GetType 和 typeof 返回对该类型的类型对象的引用。System.Type 类型的类型对象包含使您能够反映类型的方法,这意味着您可以找到它的字段和方法,甚至可以访问字段和调用方法。一旦您拥有对类型对象的引用,就可以创建该对象的一个实例。
因此,类型对象非常方便。但是,有些程序员喜欢“滥用”这种方式,借此来代替可以对其进行加锁的 static/Shared 对象。(遗憾的是,我们在 C# 文档和 Visual Basic .NET 文档中都提到了这种方法,暗示这是一种建议采用的做法。)在这种情况下,这些文档中的建议是错误的。这种做法是不 可接受的,更不用说建议采用了。
原因是这样的:由于一个类的所有实例都只有一个类型对象,因此从表面看,锁定类型对象相当于锁定类中包含的静态对象。只要您锁定类的所有实例,等到其他线程访问完任一实例的任何部分,然后锁定访问,这样您就可以安全地访问静态成员,而不会受到其他线程的干扰。
这种做法的确有效,至少在大多数情况下是这样的。但它也有一些问题:首先,获得类型对象实际上是一个很缓慢的过程(尽管大多数程序员会认为这个过程 非常快);其次,任何类中的其他线程、甚至在同一个应用程序域中运行的其他程序都可以访问该类型对象,因此,它们就有可能代替您锁定类型对象,完全阻止您 的执行,从而导致您挂起。
这里的基本问题是,您并未拥有该类型对象,并且您不知道还有谁可以访问它。总的来说,依靠锁定不是由您创建、并且您不知道还有谁可以访问的对象是一种很不好的做法。这样做很容易导致死锁。最安全的方式就是只锁定私有对象。
但除此之外,还有更严重的问题。由于在当前版本的 .NET 运行库中,类型对象有时会在应用程序域之间(但不是在进程之间)共享。(通常这没有问题,因为它们是不变的。)这意味着,运行在其他应用程序域(但在同一 进程)中的另一个应用程序有可能对您要锁定的类型对象进行加锁,并且始终不释放该类型对象,从而使您的应用程序发生死锁。并且,这样可以很容易地获得类型 对象的访问权限,因为该对象具有名称 — 该类型的完全限定名!请记住,lock/SyncLock 会一直阻塞(这是挂起的含蓄说法),直到它可以获得锁定为止。很显然,依靠锁定其他程序或组件可以锁定的对象不是一种很好的做法,并且会导致死锁。
即使该类型对象在您的应用程序域中是唯一的,这仍然是一种不好的做法,因为任何代码都可以访问公共类型的类型对象,从而导致死锁的发生。如果您在应 用程序中使用的组件不是您编写的,这种做法尤其成问题。(即使是 lock(this)/SyncLock Me 也可能有这个问题,因为其他人可能会锁定您。即使发生了这种事情,问题的根源也可能会比锁定类型对象而导致的死锁更容易发现,因为您的对象并不是跨应用程 序域的全局可用对象。)
那么,应该采用什么方法呢?非常简单:只要声明并创建一个对象作为锁,然后使用它而不是 类型对象来进行锁定。通常,为了复制问题代码的语义,您会希望此对象是 static/Shared — 当然,它其实应该是私有的!总之,您可以将以下问题代码:
// C#
lock(typeof(Foo)) { // BAD CODE! NO! NO! NO!
// statements;
}
' VB .NET
SyncLock GetType(MyClass) ' BAD CODE! NO! NO! NO!
' statements
End SyncLock
更改为以下正确代码:
// C#
lock(somePrivateStaticObject) { // Good code!
// statements;
}
' VB .NET
SyncLock GetType(somePrivateStaticObject) ' Good code!
' statements
End SyncLock
当然,您必须已经拥有一个要锁定的私有静态对象(如果您使用锁定来修改静态对象,实际上您可能已经有了一个!)或者必须创建一个。(使它成为私有对 象可以避免其他类锁定您的对象。)请不要尝试锁定不是引用(对象)类型的字段,例如 int/Integer。那样会出现编译器错误。如果您没有要锁定的私有静态对象,可能需要创建一个哑对象:
// C#
Class MyClass {
private static Object somePrivateStaticObject = new Object();
// methods of class go here--can lock somePrivateStaticObject
}
' VB .NET
Class MyClass
Private Shared somePrivateStaticObject As New Object
' methods of class go here--can lock somePrivateStaticObject
End Class
您需要单独分析每种情况,以确保不会出现问题,但通常上述技巧会奏效。
有两点需要注意:首先,类以外的任何代码都无法锁定 MyClass.somePrivateStaticObject,因此避免了许多死锁的可能。由于死锁属于那种最难找到根源的问题,因此,避免发生死锁的可能是一件很好的事情。
其次,您知道,您的应用程序中只有一份 MyClass.somePrivateStaticObject 的副本,并且系统上运行的其他每个应用程序也只有一个副本。因此,在同一个应用程序域中的应用程序之间没有相互影响。总之,不要锁定类型对象,因为您并不 知道哪里又出现问题了。锁定类型对象的过程很慢,并且可能发生死锁情况。这是一种很不好的编程习惯。相反,您应该在对象中锁定静态对象。