内存和类型安全
GC一个不怎么明显但是影响深远的功能就是内存安全。内存安全的意思很简单:只有程序只访问其分配(且没有被释放)的内存就是内存安全的。这意味着你不会有指向任意位置(精确来说就是过早释放的内存)的“野”(悬着的)指针。内存安全当然是我们希望所有程序都有的功能。野指针一般都是bug,而且跟踪它们相当困难。
GC 必须 保障内存安全
你可以很快看到GC对于内存安全的好处,因为其移除了用户过早释放内存(也就无法访问到没有正确分配的内存)的可能性。但另一个不怎么明显的因素是,如果你需要保证内存安全(即让程序员 不可能 创建一个内存不安全的程序),在实际操作中无法避免垃圾回收器。这是因为通常程序都需要堆(动态)内存分配系统,而这样对象的生命周期是任意的(与栈分配或者静态分配的内存不同,它们都是高度遵守分配协议的)。在这样不受控的环境下,通过分析程序来预测某个显式释放内存语句是否正确是不可能的。实际上,唯一判断释放语句是否正确只能在运行时做。这正是GC所做的事情(通过检查内存是否仍然有效)。因此,对于任何一个需要堆分配内存的程序来说,如果要保证内存安全,那必须使用GC。
虽然GC是保障内存安全的必要手段,但还是不够。GC无法阻止程序在数组里做越界索引或者在对象的结尾之后访问字段(可以通过对象基址和偏移量计算字段的地址做到)。当然,如果我们防范了这些情形,那么我们的确使程序员无法创建内存不安全的程序。
虽然 [通用中间语言]cil-spec 提供了存取任意内存位置的指令(即违背了内存安全原则),但它也有下列内存安全的指令集,并且CLR强烈建议使用它们:
- 字段访问指令集(LDFLD, STFLD, LDFLDA),根据名字读写字段地址。
- 数组访问指令集(LDELEM, STELEM, LDELEMA),根据索引读写一个数组元素地址。所有数组都带有指示其长度的标签,它用来在每次存取时做越界检查。
通过在用户代码中使用这些指令集,而不是底层(且不安全的)内存读写 指令集,还可以规避其他不安全 [CIL][cil-spec] 的操作(如那些允许跳转到任意且可能是非法的地址),这些都是构建一个内存安全系统所必须的。但CLR不只做这个,它支持更严谨的规则:类型安全。
类型安全是指每次内存分配都跟一个类型关联。所有操作内存的指令从理念上都与类型关联。类型安全要求读写指定内存只能使用与其关联的类型有效的指令集。这不仅保障了内存安全(没有野指针),也对每个类型加了一层额外的保护。
这些类型相关的保障中有一个重要的性质就是类型的可见性要求(特别是对于字段来说)也被强制保证了。因此,如果一个字段被声明为私有(即只能被类型本身定义的函数可见),那么这个私密性要求会被所有类型安全的代码所遵守。比如说,某个类可能定义了一个名为count的字段来记录其名为table的集合里的元素个数。假设table和count字段都是私有的,而且只有更新这两个字段的代码同时更新两个,那么table集合里元素的个数和count字段的值同步这一点有了强有力的保证。无论是否了解类型安全,程序员都是使用类型安全的概念来推理程序逻辑的。CLR将类型安全从编程语言/编译器之间的简单约定,上升到可以在运行时遵守的规范了。
可验证代码 - 强制内存和类型安全
从理念上来说,为了保证类型安全,程序执行的每个指令都需要检查其是否符合内存关联的类型要求。虽然可以在运行时做这个检查,但性能会非常慢。所以CLR采用 [CIL][cil-spec] 验证的概念,即根据[CIL][cil-spec] 静态分析程序来确认大部分指令集是类型安全的。运行时只用来补充静态分析不能检查的地方。实际上,运行时的检查次数很少。它们包括下面这些指令:
- 将一个基类的指针强制转换为派生类型(反过来的转换可以放在静态分析里)。
- 数组越界检查(如同内存安全一样的道理)。
- 将指针数组里的元素替换成一个新(指针)值。这点是因为CLR数组的自由转换规则(在后文分析)。
这些检查对CLR提了如下这些要求:
- GC里所有的内存都要关联类型(这样强制转换操作才能实现)。类型信息必须对运行时可见,而且要丰富到可以判断强制转换是否有效(即运行时需要知道类型的继承层次)。实际上,每个对象在GC堆的第一个字段就指向关联类型在运行时的数据结构对象。
- 所有的数组都必须包含其大小(用来做越界检查)。
- 数组必须知道其元素的完整类型信息。
幸运的是,有些开销很大的要求(给堆上的内存打标签)也是支持垃圾回收所必要的(GC需要知道正在扫描的对象所有字段信息),因此支持类型安全的额外成本实际上不高。
因此,按照[CIL][cil-spec]验证代码加上少量的运行时检查,CLR可以保证类型安全(和内存安全)。尽管如此,在编程弹性上,额外的安全带来严格的代价。CLR有直接的内存读写指令,为了保证代码可验证性,这些指令的使用范围很有限。如所有的指针运行都会使代码无法通过验证,因此很多C和C++的典型用法都不能在要通过验证的代码里使用;你必须使用数组。虽然这样让编码有点不舒服,但也不是很差(数组也很有用),而且好处是现成的(更少的“诡异”的bug)。
CLR强烈建议使用可验证的,类型安全的代码。即使这样,有时还是要用到无法验证的代码(主要是跟非托管代码交互)。CLR运行这样,但是最佳实践是尽量限制(类型)不安全的代码的使用。一般的程序只有极少部分的不安全代码,而其它的是类型安全代码。
网友评论