博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

.NET 引用类型的性能

Posted on 2012-11-27 23:30  淡如水wp  阅读(1726)  评论(2编辑  收藏  举报

 

一、引子

  假如有一个Point2D类表达一个二维空间--点,每个坐标都是一个short类型,整个对象有4个字节。如果存储100万个点,会用多少字节的空间?答案是取决于Point2D是值类型还是引用类型,如果是引用类型,100万个点将会存储100万个引用,这些引用在32位操作系统上就是40M左右,但这些对象本身还要占最少同样的空间,事实上,每个Point2D将会占12个字节的空间,这样算下来总的内存数在160M。但如果是值类型,没有一个多余字节的浪费,就是整整40M,只有引用类型时的1/4,不同就在于值类型的内存密度。
  存储成引用类型还有一个缺点是如果想在这个巨型的堆对象引用数组(非连续存储)内游走要比值类型困难的多,因为值类型是连续存储的。

  总之我们最好能清楚地知道CLR的内存布局以及值类型和引用类型的不同。

二、细节分析

  

上图是值类型与引用类型的Point2D数组在内存中的区别。
引用类型包括class,delegate,interface,arrays.string(System.String)。值类型包括enum和struct,int, float, decimal这些基本类型也是值类型。

值类型和引用类型在语义上的区别:

传递参数时:引用类型只传引用值,意思是当这个参数改变时,同时将改变传递给所有其他的引用。而值类型会拷贝一个复本传递过去,除非用ref或out声明,否则这个参数改变的不会影响到调用之外的值。
赋值时:引用类型只把引用值赋给目标,两个变量将引用同一个对象。而值类型会将所有内容赋给目标,两个变量将拥有同样的值但没有任何关系。
用==比较时:引用类型只比较引用值,如果两个变量引用的是同一个对象,则返回相同。而值类型比较内容,除非两个变量内的值完全相同才返回相同。

存储,内存分配,内存回收:

引用类型从托管堆上分配,托管堆区域由.NET的GC控制。从托管堆上分配一个对象只涉及到一个增量指针,所以性能上的代价很小。如果在多核机器上,如果多个进程存取同一个堆,则需要同步,但是代价还是很小,要比非托管的malloc代价小多了。
GC回收内存的方式是不确定的,一次完全GC的代价很高,但平均算下来,还是比非托管的成本低。
注意:有一种引用类型可以从栈内分配,那就是基本类型的数组,比如int型数组,可以在unsafe上下文中用stackalloc关键字从栈内分配,或者用fixed关键字将一个大小固定的数组嵌入自定义的结构体。其实用fixed和stackalloc创建的对象不是真正的数组,和从中分配的标准数组的内存布局是不一样的。
单独的值类型一般从正在执行线程的栈中分配。值类型可以嵌在引用类型中,在这种情况下就是在堆上分配,或者也可以通过装箱,将自己的值转移到堆上。从栈上给值类型分配空间的代价是相当低的,只需要修改栈指针(ESP),而且能立即分配几个对象。回收栈内存也很快,反向修改栈指针(ESP)就行。

下面这个函数是典型的从托管方法编译成32位机器码的开场和收场,函数内有4个本地变量,这4个本地变量在开场时立即分配,收场时立即回收。

 

int Calculation(int a, int b)
{
int x = a + b;
int y = a - b;
int z = b - a;
int w = 2 * b + 2 * a;
return x + y + z + w;
}
; parameters are passed on the stack in [esp+4] and [esp+8]
push ebp
mov ebp, esp
add esp, 16 ; allocates storage for four local variables
mov eax, dword ptr [ebp+8]
add eax, dword ptr [ebp+12]
mov dword ptr [ebp-4], eax
; ...similar manipulations for y, z, w
mov eax, dword ptr [ebp-4]
add eax, dword ptr [ebp-8]
add eax, dword ptr [ebp-12]
add eax, dword ptr [ebp-16] ; eax contains the return value
mov esp, ebp ; restores the stack frame, thus reclaiming the local storage space
pop ebp
ret 8 ; reclaims the storage for the two parameters


注意:C#中的new并不代表在堆中分配,其他托管语言也一样。因为也可以用new在栈上分配,比如一些struct。

栈和堆的不同:

.NET里处理堆和栈都差不多,栈和堆无非是在虚拟内存的地址范围不同,但地址范围不同也没什么大不了的,从堆上存取内存比在栈上也快不了,而主要是有以下几个考虑因素,在某些类中,从栈中取内存要快一些:

  1. 在栈中,同一时间分配意味着同一地点分配(意思是同时申请的内存是挨着很近的),反过来,一起分配的对象一起存取,顺序栈的性能往往在CPU缓存和操作系统分布系统上表现良好。
  2. 栈中的内存密度往往比堆中高(因为引用类型有头指针),高内存密度往往效率更高,因为CPU缓存中填充了更多的对象。
  3. 线程栈往往相当小,Windows中默认配认栈空间最大为1MB,大多数线程往往只用了一点点空间,在现代操作系统中,所有程序的线程的栈都可以填进CPU缓存,这样速度就相当快了,而堆很少能塞进CPU缓存。

这也不是说就应该把所有内存分配放到栈上,线程栈在windows上是有限制的,而且很容易就会用完。

深入引用类型的内部:

引用类型的内存结构相当复杂,这里用一个Employee的引用类型来举例说明:

public class Employee
{
private int _id;
private string _name;
private static CompanyPolicy _policy;
public virtual void Work() 
{   Console.WriteLine(“Zzzz...”)
; } public void TakeVacation(int days)
{   Console.WriteLine(“Zzzz...”)
; } public static void SetCompanyPolicy(CompanyPolicy policy)
{   _policy = policy
; } }

现在来看这个Employee引用类型实例在32位.NET上的内存结构:

_id和_name在内存中的顺序是不一定的(在值类型中可以用StructLayout属性控制),这个对象的开头是一个4个字节叫做同步对象索引(sync object index)或对象头字节(object head word),接下来的4个字节叫做类型对象指针(type object pointer)或函数表指针(method table pointer),这两块区域不能用.NET语言直接存取,它们为JIT和CLR服务,对象引用指针(object reference)指向函数表指针(method table pointer)的开头,所以对象头字节在这个对象地址(object head word)的偏移量是负的。
注意:在32位系统上,堆上的对象是4字节对齐的,意味着一个只有单字节成员的对象也仍然需要在堆中占12个字节,事实上,一个没有任何成员的空类实例化的时候也要占12个字节,64位系统不是这样的:首先函数表指针(method table pointer)占8个字节,对象头字节(object head word)也占8个字节; 第二,堆中的对象是以邻近的8字节对齐的,意味着单字节员的对象在64位堆中占24个字节。

 函数表(Method Table)

函数表指针指向一个叫做MT(Method Table)内部的CLR结构,这个MT又指向另一个叫做EEClass(EE=Excution Engine)的内部结构。MT和EEClass包括了调度虚函数,存取静态变量,运行时对象的类型判定,有效存取基本类型方法以及一些其他目的所需的信息。函数表包括了临界机制的运行时操作(比如虚函数调度)需要频繁存取的信息。EEClass包括了一些不需要频繁存取的信息,但一些运行时机制仍然要用(比如反射)。我们可以用!DumpMT和!DumpClass这两个SOS命令学习这两个数据结构。
注意:SOS(son of strike)命令是一个debugger扩展dll,帮助调试托管程序的,可以在VisualStuido的即时窗口里调用。

EEClass决定静态变量的存储位置,基本类型(如Int)在存储在堆中动态分配的位置上,自定义值类型和引用类型存储以间接引用的形式存储在堆上。存取一个静态变量,不需要找MT和EEClass,JIT编译器可以将静态变量的地址硬编码成机器码。静态变量数组的引用是固定的,所以在GC的时候,其存储地址不变,而且MT中的原始静态字段也不归GC管,以保证硬编码的内存地址能被定位。

public static void SetCompanyPolicy(CompanyPolicy policy)
{
_policy = policy;
}
mov ecx, dword ptr [ebp+8] ;copy parameter to ECX
mov dword ptr [0x3543320], ecx ;copy ECX to the static field location in the global pinned array

MT包括一组代码地址,包括类内所有方法的地址,包括继承下来的虚方法,如下图所示:

我们可以用!DumpMT检查MT的结构,-md 参数会输出函数的描述表,包括代码地址,每个函数的描述,JIT列会标明是PreJIT/JIT/NONE中的一个。PreJIT表示函数被NGEN编译过,JIT表示函数是JIT编译的,NONE表示没有被编译过。

0:000> r esi
esi=02774ec8
0:000> !do esi
Name: CompanyPolicy
MethodTable: 002a3828
EEClass: 002a1350
Size: 12(0xc) bytes
File: D:\Development\...\App.exe
Fields:
None
0:000> dd esi L1
02774ec8 002a3828
0:000> !dumpmt -md 002a3828
EEClass: 002a1350
Module: 002a2e7c
Name: CompanyPolicy
mdToken: 02000002
File: D:\Development\...\App.exe
BaseSize: 0xc
ComponentSize: 0x0
Slots in VTable: 5
Number of IFaces in IFaceMap: 0
--------------------------------------
MethodDesc Table
Entry MethodDe JIT Name
5b625450 5b3c3524 PreJIT System.Object.ToString()
5b6106b0 5b3c352c PreJIT System.Object.Equals(System.Object)
5b610270 5b3c354c PreJIT System.Object.GetHashCode()
5b610230 5b3c3560 PreJIT System.Object.Finalize()
002ac058 002a3820 NONE CompanyPolicy..ctor()

注意:这不像C++的虚函数表,CLR的函数表包括代码地址和所有函数(非虚的也在),函数在表里的顺序是不一定的,但依次是继承的虚函数,新的虚函数,非虚函数,静态函数。
函数表中的存储的代码地址是JIT编译器编译函数第一次调用时生成的,除非NGEN已经用过。不管怎么样,函数表的使用者不用操心编译的麻烦,当函数表创建时,被pre-JIT的指针填满,编译完成时,控制权交给新的函数。函数在JIT之前的函数描述是这样的:

0:000> !dumpmd 003737a8
Method Name: Employee.Sleep()
Class: 003712fc
MethodTable: 003737c8
mdToken: 06000003
Module: 00372e7c
IsJitted: no
CodeAddr: ffffffff
Transparency: Critical

JIT之后的函数描述是这样的:

0:007> !dumpmd 003737a8
Method Name: Employee.Sleep()
Class: 003712fc
MethodTable: 003737c8
mdToken: 06000003
Module: 00372e7c
IsJitted: yes
CodeAddr: 00490140
Transparency: Critical

真正的函数表包括更多信息,理解接下来的函数调度细节的其他字段是很难的,这就是为什么花很长时间看函数表的结构(用Employee的例子),假设Employee实现了3个接口:IComparable, IDisposable, 和ICloneable。

  1. 这个函数表的头部包括几个有意思的标记用来表示自己的布局,比如虚函数的数量,接口的数量。
  2. 这个函数表有一个指针指向他的基类的函数表,一个指针指向它的模块,一个指针指向它的EEClass。
  3. 实函数被一列接口函数表预处理了,这就是为什么函数表中有一个指针指向函数列表,偏移量在函数表开始处的40个字节里。

注意:如果看System.Object的函数表,会发现它的代码地址在一个单独的位置存放,此外,有太多虚函数的类将会有一些一级表指针,允许其子类部分重用。

在引用类型的实例上调用函数:

函数表可以用来调用任意对象实例上的函数,假设栈空间EBP-64包括一个和上个图一样的Employee对象的地址,那么我们可以用下面的指令调用Work这个虚函数:

mov ecx, dword ptr [ebp-64]
mov eax, dword ptr [ecx] ; the method table pointer
mov eax, dword ptr [eax+40] ; the pointer to the actual methods inside the method table
call dword ptr [eax+16] ; Work is the fifth slot (fourth if zero-based)

 第一条指令将引用从栈复制到ECX寄存器,第二条指令间接引用ECX寄存器来获得对象的函数表指针,第三条指令获取函数表中的函数列表的指针(偏移量固定在40),第四条指令间接引用内部的函数表(偏移量在16)来获取Work函数的代码地址,然后调用。为了理解为什么需要使用函数表调度虚函数,我们得考虑运行时怎么绑定,例如怎么多态地实现虚函数。
假设有一个另外的类叫Manager,从Employee继承并且重写了Work函数并且实现了另一个接口:

public class Manager : Employee, ISerializable
{
private List<Employee> _reports;
public override void Work() ...
//...implementation of ISerializable omitted for brevity
}

如果是下面的代码的话,编译器可能会使程序通过对象引用调用Manager.Work:

Employee employee = new Manager(...);
employee.Work();

在这种情况下,编译器用静态分析可能推断不了用哪一个类的函数。一般情况下,当有一个静态类型的Employee引用,编译器需要延迟绑定。其实JIT干的活就是在运行时决定实函数绑定到正确的引用上的。

 

Manager函数表布局,包括一个Work函数的槽,使”指向函数的指针“的偏移量变大了。

mov ecx, dword ptr [ebp-64]
mov eax, dword ptr [ecx]
mov eax, dword ptr [ecx+40] ;this accommodates for the Work method having a different
call dword ptr [eax+16] ;absolute offset from the beginning of the MT


调用非虚函数:

 

我们也可以用类似的指令序列来调用非虚函数。尽管非虚函数不需要用函数表调度函数(被调用的代码地址在JIT编译的时候就知道了),举个例子,如果栈空间EBP-64包括一个Employee对象的地址,下面的指令将会调用用参数5来TakeVacation函数:

mov edx, 5 ;parameter passing through register – custom calling convention
mov ecx, dword ptr [ebp-64] ;still required because ECX contains ‘this’ by convention
call dword ptr [0x004a1260]

这里需要把对象的地址加载到ECX寄存器中,但是这里不需要间接引用函数表以及包含函数表里的地址。JIT编译器仍然需要在调用后更新调用地址。
但是函数调度之上有一个严重的问题,那就是它允许函数调用一个空的对象引用,还可以调用这个对象的成员和虚函数,这就引起违规存取。其实这是C++实例函数的调用行为--下面的代码在C++环境里没什么危害,但是在C#里是就不那么容易了。

class Employee {
public: void Work() { } //empty non-virtual method
};
Employee* pEmployee = NULL;
pEmployee->Work(); //runs to completion

如果你看看用JIT编译器调用的非虚实例函数的实际指令序列,将包括一个附加的指令:

mov edx, 5 ;parameter passing through register – custom calling convention
mov ecx, dword ptr [ebp-64] ;still required because ECX contains ‘this’ by convention
cmp ecx, dword ptr [ecx]
call dword ptr [0x004a1260]

重新调用CMP指令从第一次操作数减去第二次的结果设置成CPU的标志位。上面的代码并没有将比较结果存在CPU的标志位上,那CMP指令如何帮助阻止调用一个空对象引用的函数呢?CMP指令试着存取ECX寄存器里的内存地址(包含了对象引用),如果这个对象引用是空的,那么这个内存存取将会失败,因为存取地址0是在Windows进程里是非法的。这个违规存取在CLR里被转换成了空引用异常被抛出。更好的选择是在调用函数之前检查引用是否为空 ,CMP指令只占2个字节就能检查无效地址。

注意:
调用虚函数的时候不需要一个类似CMP指令的东西,空引用检查是隐式的,因为标准虚函数要存取函数表指针,这就能保存对象指针是有效的。在最新的CLR版本中,JIT编译器可以智能的避免多余的检查,如果程序已经从一个虚函数返回,JIT就不执行CMP指令了。

我们说这么多讨论调用虚函数与非虚函数的调用的实现细节不是因为需要的内存或多余的指令,虚函数的主要优化手段是将内联函数,内联函数是相当简单的编译器花招,即用代码量换速度,凭着将调用小或简单的函数换成调用函数体,举个例子,下面的代码,它将相加函数替换成简单的操作:

int Add(int a, int b)
{
return a + b;
}
int c = Add(10, 12);


//假设C后面还要用
没有优化过的指令包括10条,3条用来生成参数和调用函数,2条生成函数框架,1条用来加,2条撤掉函数框架,1条从函数返回。优化过的指令只有1条,猜猜是什么?一个选项是ADD指令,但其实优化方案是编译器在编译时就将C的值赋成22了。
内联函数和非内联函数的性能区别很大,尤其是当函数像上面的代码一样的简单时,所以尽量考虑将属性内联,编译器生成自动的自动属性甚至更好因为相比存取一个代码段,它不包含任何逻辑。但是虚函数不能内联,因为内联需要编译器在编译时知道要调用哪个函数。当函数在运行时决定调用哪个函数时不能生成虚函数的内联代码。如果所有的函数都默认是虚的,那属性也会是虚的,
你可能想知道sealed关键字对函数调度的影响。举个例子,如果Manager类将Work函数声明成sealed,调用含有Manager静态类型的对象引用的Work可能被处理成一个非虚函数调用。

public class Manager : Employee
{
public override sealed void Work() ...
}
Manager manager = ...; //could be an instance of Manager, could be a derived type
manager.Work(); //direct dispatch should be possible!

但是,写代码的时候,sealed关键字不影响所有CLR版本上函数的调用,甚至知道一个类或一个函数是sealed也能有效地清除虚函数调用。

调用静态和接口函数:

还有两种类型的函数需要讨论,静态函数和接口函数。调度静态函数相当简单,不需要加载对象引用,就简单地调用函数就行,因为调用不通过函数表处理,JIT编译器用和非虚函数相同的招术:函数在JIT编译之后通过一个间接的内存空间调用.
接口函数则完全不同,乍一看调用接口函数和调用虚的实例函数不同,事实上,接口函数允许一种形式上的多态。这里不保证类实现不同接口的函数有相同的函数表布局。

class Manager : Employee, IComparable {
public override void Work() ...
public void TakeVacation(int days) ...
public static void SetCompanyPolicy(...) ...
public int CompareTo(object other) ...
}
class BigNumber : IComparable {
public long Part1, Part2;
public int CompareTo(object other) ...
}

上面的代码中,函数表的内存布局是不同的。
在之前的CLR版本中,这些信息是存储在全局(程序级)表里以接口ID索引的,当接口第一次加载时生成。函数表有一个特殊的入口(偏移量在12),指向全局接口表中适当的位置,然后全局接口表整个指回函数表.

mov ecx, dword ptr [ebp-64] ; object reference
mov eax, dword ptr [ecx] ; method table pointer
mov eax, dword ptr [eax+12] ; interface map pointer
mov eax, dword ptr [eax+48] ; compile time offset for this interface in the map
call dword ptr [eax] ; first method at EAX, second method at EAX+4, etc.

看起来挺复杂的,而且代价高,需要4次内存存取才能得到接口实现的代码地址然后调用它,而且有些接口可能代价更高。这就是为什么你从不用JIT编译器看上面的指令序列,甚至不开启优化选项。JIT使用一些小的招术提高内联函数效率,至少能满足一般大多数情况。
hot path分析:当JIT检测到同样的接口实现时,它将会优化代码。

mov ecx, dword ptr [ebp-64]
cmp dword ptr [ecx], 00385670 ; expected method table pointer
jne 00a188c0 ; cold path, shown below in pseudo-code
jmp 00a19548 ; hot path, could be inlined body here

cold path:

if (--wrongGuessesRemaining < 0) { ;starts at 100
back patch the call site to the code discussed below
} else {
standard interface dispatch as discussed above
}

频率分析:当JIT检测到hot path无效时,它会替换新的hot path:

start: if (obj->MTP == expectedMTP) {
direct jump to expected implementation
} else {
expectedMTP = obj->MTP;
goto start;
}

更多讨论可以参考Sasha Goldshtein’s 的文章 “JIT Optimizations” (http://www.codeproject.com/Articles/25801/JIT-Optimizations)
和Vance Morrison’s 的Blog (http://blogs.msdn.com/b/vancem/archive/2006/03/13/550529.aspx).

同步块索引和lock关键字:

在引用类型的头部的第二块嵌入字段是同步块索引(sync block index)也叫对象头字节(object header word)。不像函数表指针,这个字段有很多用处,包括同步,GC预订-保留,析构,哈希代码存储。这个字段里的少量字节决定哪些信息存储在这里面。
最复杂的目的是用CLR的监视器机制同步,暴露了一个lock关键字,主旨在于:少量的线程可能试图进入一个lock代码块保护的区域内,但是同时只能有一个线程能进入,以达到互斥:

class Counter
{
private int _i;
private object _syncObject = new object();
public int Increment()
{
lock (_syncObject)
{
return ++_i; //only one thread at a time can execute this statement
}
}
}

lock关键字不光是封装了Monitor, Enter, Monitor.Exit 的语法糖:

class Counter
{
private int _i;
private object _syncObject = new object();
public int Increment()
{
bool acquired = false;
try
{
Monitor.Enter(_syncObject, ref acquired);
return ++_i;
}
finally
{
if (acquired) Monitor.Exit(_syncObject);
}
}
}

为了保证互斥,同步机制可以与每个对象关联,因为给每个对象都创建一个同步对象是代价很高的,当对象第一次用作同步时关联动作才发生。CLR从叫做同步块表的全局数组里分配一个叫同步块的结构体。这个同步块包括一个指向自己的对象后向引用,和一些其他的东西,同步机制调用monitor,内部用的是Win32的事件。同步块的索引数存在对象的头字节里。

同步块长时间不用的话,GC会回收并卸载它的对象,将同步块的索引设置成一个无效索引。接着同步块就能和其他对象关联了。

!SyncBlk SOS命令可以查看当前的同步块的情况,比如,同步块被一个线程占有了,等着另一个线程。在CLR2.0里,当有竞争时才生成同步块,没有同步块时,CLR会用锁来同步状态。以下是一些例子:首先看一下对象的对象头字节还没有同步时的情况,但哈希码已经存储了,下面的例子里,指向Employee对象的EAX指针的哈希码是46104728.

0:000> dd eax-4 L2
023d438c 0ebf8098 002a3860
0:000> ? 0n46104728
Evaluate expression: 46104728 = 02bf8098
0:000> .formats 0ebf8098
Evaluate expression:
Hex: 0ebf8098
Binary: 00001110 10111111 10000000 10011000
0:000> .formats 02bf8098
Evaluate expression:
Hex: 02bf8098
Binary: 00000010 10111111 10000000 10011000

 

这里没有同步块索引,只有哈希码和2个设置成1的bit。其中之一表示对象头字节现在存储了哈希码。接下来我们从一个线程发出Monitor.Enter的调用

0:004> dd 02444390-4 L2
0244438c 08000001 00173868
0:000> .formats 08000001
Evaluate expression:
Hex: 08000001
Binary: 00001000 00000000 00000000 00000001
0:004> !syncblk
Index SyncBlock MonitorHeld Recursion Owning Thread Info SyncBlock Owner
1 0097db4c 3 1 0092c698 1790 0 02444390 Employee

对象把同步块赋值成#1,当另一个线程试图进入lock区域时,它进了Win32的wait,下面是线程的栈底

0:004> kb
ChildEBP RetAddr Args to Child
04c0f404 75120bdd 00000001 04c0f454 00000001 ntdll!NtWaitForMultipleObjects+0x15
04c0f4a0 76c61a2c 04c0f454 04c0f4c8 00000000 KERNELBASE!WaitForMultipleObjectsEx+0x100
04c0f4e8 670f5579 00000001 7efde000 00000000 KERNEL32!WaitForMultipleObjectsExImplementation+0xe0
04c0f538 670f52b3 00000000 ffffffff 00000001 clr!WaitForMultipleObjectsEx_SO_TOLERANT+0x3c
04c0f5cc 670f53a5 00000001 0097db60 00000000 clr!Thread::DoAppropriateWaitWorker+0x22f
04c0f638 670f544b 00000001 0097db60 00000000 clr!Thread::DoAppropriateWait+0x65
04c0f684 66f5c28a ffffffff 00000001 00000000 clr!CLREventBase::WaitEx+0x128
04c0f698 670fd055 ffffffff 00000001 00000000 clr!CLREventBase::Wait+0x1a
04c0f724 670fd154 00939428 ffffffff f2e05698 clr!AwareLock::EnterEpilogHelper+0xac
04c0f764 670fd24f 00939428 00939428 00050172 clr!AwareLock::EnterEpilog+0x48
CHAPTER 3 ■ TyPE InTERnAls
78
04c0f77c 670fce93 f2e05674 04c0f8b4 0097db4c clr!AwareLock::Enter+0x4a
04c0f7ec 670fd580 ffffffff f2e05968 04c0f8b4 clr!AwareLock::Contention+0x221
04c0f894 002e0259 02444390 00000000 00000000 clr!JITutil_MonReliableContention+0x8a
The synchronization object used is 25c, which is a handle to an event:
0:004> dd 04c0f454 L1
04c0f454 0000025c
0:004> !handle 25c f
Handle 25c
Type Event
Attributes 0
GrantedAccess 0x1f0003:
Delete,ReadControl,WriteDac,WriteOwner,Synch
QueryState,ModifyState
HandleCount 2
PointerCount 4
Name <none>
Object Specific Information
Event Type Auto Reset
Event is Waiting

最后,如果我们想看原始的同步块内存

0:004> dd 0097db4c
0097db4c 00000003 00000001 0092c698 00000001
0097db5c 80000001 0000025c 0000000d 00000000
0097db6c 00000000 00000000 00000000 02bf8098
0097db7c 00000000 00000003 00000000 00000001

在CLR2.0里,为了节省内存和时间做了一个特别的优化,如果对象没有关联的同步,就不创建同步块。CLR使用了一种thin锁,当对象第一次被锁定且不存在争用时,在对象的头字节对象当前线程的托管线程ID,例如:下面的对象是主线程锁定对象:

0:004> dd 02384390-4
0238438c 00000001 00423870 00000000 00000000

这里,线程的托管线程ID1是程序的主线程:

0:004> !Threads
ThreadCount: 2
UnstartedThread: 0
BackgroundThread: 1
PendingThread: 0
DeadThread: 0
Hosted Runtime: no
Lock
ID OSID ThreadOBJ State GC Mode GC Alloc Context Domain Count Apt Exception
0 1 12f0 0033ce80 2a020 Preemptive 02385114:00000000 00334850 2 MTA
2 2 23bc 00348eb8 2b220 Preemptive 00000000:00000000 00334850 0 MTA (Finalizer)

Thin锁的信息也在!DumpObj命令里可以看到,!DumpHeap -thinlock 命令可以输出所有当前在托管堆中的thin锁:

0:004> !dumpheap -thinlock
Address MT Size
02384390 00423870 12 ThinLock owner 1 (0033ce80) Recursive 0
02384758 5b70f98c 16 ThinLock owner 1 (0033ce80) Recursive 0
Found 2 objects.
0:004> !DumpObj 02384390
Name: Employee
MethodTable: 00423870
EEClass: 004213d4
Size: 12(0xc) bytes
File: D:\Development\...\App.exe
Fields:
MT Field Offset Type VT Attr Value Name
00423970 4000001 4 CompanyPolicy 0 static 00000000 _policy
ThinLock owner 1 (0033ce80), Recursive 0

当另一个线程试图锁这个对象时,它将会旋转一会儿等着thin锁释放。如果某段时间后锁还没有释放的话,它会转换成同步块,同步块索引存在对象头字节里,从那时起,线程就像Win32的同步机制一样。