本文讨论:
| 本文使用下列技术: |
![*](http://img.microsoft.com/library/gallery/templates/MNP2.Common/images/3squares.gif)
本页内容
![]() | 精确的代价 |
![]() | 例外规则 |
![]() | 构造函数锁 |
![]() | 静态反射 |
![]() | .NET Framework 2.0 中的静态类 |
![]() | 静态局部变量 |
![]() | 静态假象 |
![]() | 静态小结 |
当您在一个基于 .NET 的应用程序中操作时,经常会碰到带有 Shared 方法或静态字段的类型。因为这些抽象具有特殊的行为,所以要询问一些有关实施的重要问题。运行库在何时初始化静态字段?该方法线程是否安全?该类是否会导致瓶颈?
在本文中,我将为您介绍应该了解的静态内容的七个特征。这将涉及静态构造函数的一些细节信息,以及 C# 和 Visual Basic® 编译器如何与运行库协同工作,以在后台实施附加安全。在本文结尾,您还会看到在应用程序中使用静态成员和静态类的最佳做法。
精确的代价
类型构造函数也称为类型初始值设定项。它们可以通过将静态或共享字段初始化为预定义值或计算值的方式,来初始化类型的状态。类型在两种情况下会获取类型初始值设定项。第一种情况是,开发人员在 Visual Basic 的 New 子例程上使用 Shared 关键字,或者添加与 C# 中的类型具有相同名称的静态、无参数方法,来显式添加类型构造函数。第二种情况是,类型具有一个用于静态字段的初始值设定项,在这种情况下,编译器会在后台添加类型构造函数。图 1 显示了 Visual Basic .NET 的这两种情况。
如果您编译图 1中的类,然后通过 FxCop 工具运行得到的程序集时,就会发现它会生成一个关于 ExplicitConstructor 类的严重警告。警告消息为“Do not declare explicit static constructors”。该规则说明告诉您,显式静态构造函数会导致代码性能变差。FxCop 建议在声明静态字段的位置对其进行初始化。但咱们不会只在表面上采用这个推荐,而是要深入研究一下使用反汇编程序或反编译器的程序集(如 ILDASM),以便真正理解出现性能差异的原因。
在 Microsoft® 中间语言 (MSIL) 中,类型构造函数的名称为 .cctor(class constructor 的缩写)。ExplicitConstructor 和 ImplicitConstructor 的 .cctor 方法如图 2 所示。
图 2 中的 MSIL 没有提供有关性能差异的任何提示。该编译器为 ImplicitConstructor 中的隐式类型构造函数和 ExplicitConstructor 中的显式类型构造函数提供了几乎完全相同的代码。如果您再深入了解一下 ILDASM,就会看到两个类的元数据之间存在差别:
.class private auto ansi ExplicitConstructor extends [mscorlib]System.Object { } // end of class ExplicitConstructor .class private auto ansi beforefieldinit ImplicitConstructor extends [mscorlib]System.Object { } // end of class ImplicitConstructor
请注意,ImplicitConstructor 具有一个附加的元数据标志,名为 beforefieldinit。此标志使得运行库能够在任何时候执行类型构造函数方法,只要该方法在第一次访问该类型的静态字段之前执行即可。换句话说,beforefieldinit 为运行库提供了一个执行主动优化的许可。如果没有 beforefieldinit,运行库就必须在某个精确时间运行类型构造函数,即,恰好在第一次访问该类型的静态或实例字段和方法之前。当存在显式类型构造函数时,编译器不会用 beforefieldinit 标记该类型,精确的计时限制会导致 FxCop 所暗示的性能下降。此规则在 C# 和 Visual Basic 中都存在。您可以使用图 3 中的代码来放大这种性能差异。
您不需要使用一个高精度的计时器即可看到速度的差异。在我的 2.8GHz Pentium 4 上,第一个循环(使用 ExplicitConstructor)的执行时间大约是第二个循环(使用 ImplicitConstructor)的八倍。运行库为了在精确时间运行类型初始值设定项而执行的检查会增加循环内的开销,而 beforefieldinit 则放松了这些规则的限制,并允许运行库在循环之外进行这些检查。
图 3中的代码是最坏的情况。您在使用显式类型构造函数时需要进行权衡。很少有类会真正需要类型构造函数在一个精确的时间执行,因此在大多数情况下,允许编译器添加 beforefieldinit 标志并避免显式类型构造函数就非常有意义。
例外规则
类型构造函数的另一个独特行为是运行库管理类型构造函数中的异常的方式。在引发异常时,运行库将开始查找其筛选器指定它可以处理该异常的最近的 catch 子句。现在,以图 4 中的 C# 控制台模式程序为例。
该程序将产生如图 5 所示的结果。有两个行为需要注意。第一,该程序不会为类型是 ApplicationException 的异常寻找 catch 子句,即使这种类型的异常是从静态构造函数引发的也如此。运行库将停止任何尝试保留类型构造函数的异常,并将该异常包装到一个新的 TypeInitializationException 对象内。然后,该类型构造函数中引发的原始异常会位于 TypeInitializationException 对象的 InnerException 属性中。
可能发生这种特殊行为的一个原因是,第二次尝试访问该类型的静态属性时,运行库不再尝试调用该类型构造函数,而是引发在第一个迭代中观察到的同一个异常。运行库不会给类型构造函数第二次机会。如果异常是从静态字段初始值设定项引发的,也存在同样的规则。正如您在前面看到的那样,静态字段初始值设定项的代码在隐式类型构造函数的内部执行。
TypeInitializationException 在应用程序中可能是一个致命错误,因为它会生成类型无用。如果有可能从错误中恢复,您应该计划捕获类型构造函数中的任何异常,如果错误无法调和,则应该允许应用程序终止。
构造函数锁
类型构造函数还有一个奇怪的行为。这种行为是由公共语言架构(Common Language Infrastructure,CLI)规范导致的,该规范确保一个类型构造函数只执行一次,除非用户代码进行显式调用。要在多线程环境中强制此保证,需要一个锁来同步线程。运行库线程必须在调用类型构造函数之前获得此锁。
采用锁的程序必须非常小心,以免产生死锁的情况。要了解它是如何发生的,请尝试使用图 6 中不好的代码将运行库置于死锁状态。此代码会将线程 A 发送到 Static1 的类型构造函数中,将线程 B 发送到 Static2 的类型构造函数中,然后将两个线程都置于睡眠状态。线程 A 将唤醒,并需要访问 Static2,而 B 已经将其锁定。线程 B 将唤醒,并需要访问 Static1,而 A 已经将其锁定。线程 A 需要线程 B 持有的锁,而线程 B 需要线程 A 持有的锁。这就是一个典型的死锁情况。
图 6 中的应用程序不会导致死锁,而是会生成图 7 所示的结果。它证明了该 CLI 规范还同时保证了运行库不允许类型构造函数产生死锁的情形,除非用户代码显式采用了附加锁。
![](https://i-blog.csdnimg.cn/blog_migrate/a09da616c935686e76b6cf91526d80c6.png)
图 7 CLI 和死锁避免
在图 7 所示的程序运行中,运行库通过允许 Static2 在 Static1 的类型构造函数完成执行之前访问 Static1 的静态 Message 属性,从而避免了死锁。Static1 中的消息应该是“Hello From Static1”,而您看到的消息却是“空白”。此程序还演示了静态字段初始值设定项如何在显式类型构造函数中的代码之前执行。
您可能认识到了,这个问题与 C++ 静态初始化失败很相似。这里的经验是避免触及类型构造函数中另一个类型的静态成员。虽然看到前面一种情形的机会很少,但是因为可用的诊断很少,所以结果很难进行调试和跟踪。
静态反射
在运行时检查类型或对象实例的元数据的能力是一个非常强大的功能。反射使您能够构建诸如对象浏览器之类的工具,还使您能够使用在运行时插入类型的功能来创建可扩展应用程序。在类型的静态成员上使用反射技术与在对象的实例成员上进行反射有一点小小的差别。例如,图 8 中的代码将从类型 Static1 中获取静态属性 Message 的值。它还会使用 Static2 的 Type 引用来尝试获取同一个属性值。
当代码使用 GetProperties 提取 Static1 类型的属性时,它会传递绑定的标志,以表示它想要具有公共可见性的静态成员。这些标志就足以获得所需的 PropertyInfo 对象数组中 Message 属性的相关元数据了。接下来,会调用数组中第一个 PropertyInfo 对象的 GetValue 方法(在这里是 Message 属性的 PropertyInfo)。通常情况下,您需要将对象的实例作为第一个参数传递给 GetValue,但是因为这是一个静态成员,所以没有对象可传递。在使用静态属性时,您可以使用实例的一个空引用来代替。
最后,尝试使用 Static2 的 Type 对象来获取 Message 的值。静态成员不会被继承,但是 IntelliSense® 和 C# 编译器会产生继承的假象,我将稍后讨论这一点。在这种情况下,要获得 Message 属性,您需要一个额外的绑定标志 FlattenHierarchy,该标志会告诉运行库在静态成员的搜索中包括基类型。
.NET Framework 2.0 中的静态类
目前使用的类有几个设计缺陷。即使这些类没有实例成员,您仍然可以使用 C# 或 Visual Basic 中的 new 运算符来创建这些类的实例。另外,即使您从来没有打算让这些类作为基类使用,但还是有些类会从这些类继承。
对于继承问题的解决方案是,在 C# 中应用 sealed 关键字,或在 Visual Basic 中应用 NotInheritable 关键字。为了防止从类外部的代码创建这些类(虽然它仍然会被该类的成员实例化),您可以在这些类中添加私有的默认构造函数。使用 C# 2.0 时,对这两个问题的解决方案是在类级别应用 static 关键字,如图 9 所示。
这种新语法使编译器能够强制一些新规则。您不能声明 Static1 类型的变量,因为它被标记为静态类。您也不能从 Static1 派生,或者向该类添加任何非静态成员。还要注意,从任何非对象类派生静态类都是错误的。
当您升级到 .NET Framework 2.0 时,应该复查您的 C# 类,以确保没有任何实例方法。这些类将成为在类声明上使用 static 关键字的备选。本文前面所概述的附加编译时间检查将使得这种复查值得付出努力。在当前的 beta 版本中,Visual Basic 不支持类声明中的 Shared 关键字。但是 Visual Basic 仍然有一些 C# 不可用的技巧。
静态局部变量
Visual Basic 中一个鲜为人知的功能是支持 Sub 或 Function 中的静态变量。静态局部变量会在方法调用之间保留它们的值。从这点来看,它们的行为就好像一个类级别变量,但是它们的可见性只限制为单个方法。
静态局部变量在 .NET 以前的 Visual Basic 版本中就已经推出,因此存在此功能大概就是为了简化旧式代码的移植。尽管 C 和 C++ 编程人员都非常熟悉静态局部变量,但是 C# 没有向前发展此功能。静态局部变量之所以非常令人感兴趣,是因为公共语言运行库 (CLR) 不支持方法内的静态变量。不管怎样,图 10 中的代码都会编译、执行,并通过输出“Count = 1”到“Count = 10”来产生预期的结果。
因为 CLR 不支持静态局部变量,所以 Visual Basic 编译器必须执行一些额外的工作才能使该程序成功。该编译器会使用两个特殊名称向该类添加两个变量,如图 11 的 ILDASM 中所看到的那样。此示例中的第一个字段是一个整数字段,其名称为 $STATIC$GetMessage$200E$i。此字段将在 GetMessage 中保留静态局部变量 i 的值。第二个字段的类型为 StaticLocalInitFlag,它在针对 Foo 类的每个实例第一次执行方法时协助正确初始化变量 i。要注意的重要一点是,这些字段不是共享字段,并且 Foo 的每个实例都有一个要初始化和使用的新局部变量 i。
静态局部变量的初始化需要一些操作。如果您在 MSIL 中查找 GetMessage 方法,就会发现 62 个指令(较之于不只使用类级别字段的类似方法的 13 个指令)。这些指令大多数是为了检查 i 的初始化,以及使用锁执行线程安全的初始化。
由于运行库在使用静态局部变量时有开销,因此您最好避免在当前设计中使用该功能。如果您已经使用静态局部变量将代码从 Visual Basic 6.0 移植到 Visual Basic .NET,则肯定应该考虑将该变量重新创建为一个类级别变量,这个任务应该相对比较简单。
静态假象
静态局部变量不是仅有的编译器技巧。我在前面已经提到过,CLR 不支持静态成员的继承。不管怎样,Visual Basic 和 C# 编译器都允许您通过派生的类型名称来触及基类型的静态成员。另外,Visual Basic .NET 允许您通过类型的实例、甚至派生类型的实例来触及静态成员,如图 12 中的代码所示。
针对图 12 中的 Main 方法研究 MSIL 时发现,Visual Basic 编译器会将带有 Message 属性的两个语句映射到一个 Static1::get_Message 调用中,同时完全包围了 Static2 类。即使您不使用 Static2 的实例,该编译器仍然会创建这个新对象,以便不会遗漏调用该构造函数的任何副作用。
不鼓励通过实例变量来使用静态成员。实际上,目前 Visual Studio?2005 中的 Visual Basic Beta 版会生成编译器警告“Access of shared member through an instance”。在 C# 中,如果您通过实例变量使用类的静态成员,会导致编译器错误;但是,您仍然可以通过 Visual Basic 编译器使用的这种技巧,通过派生的类型类名来触及静态成员。
静态小结
在设计时和运行时向类型添加静态成员时,需要权衡利弊。在设计时,您需要仔细选择哪些成员要标记为静态,并避免丢失面向对象的设计。不要尝试为不相关的静态方法创建一个转储平台,来将各种不同的功能组合到一个类中。如果类只包含静态成员,则要记得将该类标记为静态,或者密封该类 (NotInheritable),并且还要为该类提供一个私有构造函数,以避免实例创建。
您还应该记得,在方法调用之间保留状态的静态类在默认情况下应该是线程安全的。使一个类线程安全需要在实现和测试该类时非常小心。在继续此方法之前,请自问一下这个额外的开销是否一定需要。
线程安全在运行时还具有性能牵连。在本文中,您已经看到了在类具有一个静态构造函数时所导致的性能结果。在您编写可重用库的代码时,评估性能牵连就显得尤其重要。您无法知道人们何时会在最坏的情况下使用您的类,正如本文开头所述的那个情况一样。
最后,请对您的静态类进行很好的记录。使用静态方法(如 System.IO.FileClass 中的静态方法 Open 和 Copy)执行常见操作的捷径通常很好理解。一旦您实现了在方法调用之间保持状态的重要类,该类的使用者就会希望了解有关线程安全和性能的详细信息。对类的描述越多,您在以后排除问题时所花的时间就越少。
K. Scott Allen 是 Medisolv Inc. 的首席软件架构师,该公司位于哥伦比亚,MD。Scott 还是 .NET 社区站点 OdeToCode.com 的创始人。您可以通过 scott@OdeToCode.com 与他联系,还可以阅读他的网络日记。