UML软件工程组织

Java 理论与实践: 描绘线程安全性
Brian Goetz (brian@quiotix.com)
首席顾问,Quiotix Corp
2003年 11 月  来源:IBM 
7 月份我们的并发专家 Brian Goetz 将 Hashtable Vector 类描述为“有条件线程安全的”。一个类难道不是线程安全就是线程不安全的吗?不幸的是,线程安全并不是一个非真即假的命题,它的定义出人意料的困难。但是,正如 Brian 在本月的 Java 理论与实践中解释的,尽量在 Javadoc 中对类的线程安全性进行归类是非常重要的。请在附带的讨论论坛中与作者和其他读者分享您关于本文的心得(也可以通过单击本文顶部或底部的讨论来访问该论坛)。

在 Joshua Bloch 的那本出色的 Effective Java Programming Language Guide(参阅参考资料)一书中,第 52 项的标题为“Document Thread Safety”,在其中他恳请开发人员准确地记录下类对线程安全性有哪些保证。就像 Bloch 书中的大多数建议一样,这也是一个反复提到、但很少实现的非常好的建议(就像 Bloch 在其 Programming Puzzlers 谈话中说的“不要像我兄弟那样写代码”)。

有多少次您在 Javadoc 中查看一个类,并猜测“这个类是线程安全的吗?”。 由于缺少明确的记载,读者可能会对类的线程安全性做出不当的假设。也许他们将非线程安全的类假定为线程安全的(这真的很糟!),或者假设可以在调用一个对象的方法之前同步对象以得到线程安全性(这可能是正确的,也可能还不够,最差的情况是,可能只会提供虚幻的线程安全性)。不管在什么情况下,最好在文档中明确写明,在多个线程中共享类的实例时类的行为是怎样的。

看一个这种问题的一个例子,java.text.SimpleDateFormat 类不是线程安全的,但是在 1.4 JDK 之前这并没有记录在 Javadoc 中。有多少开发人员错误地创建了 SimpleDateFormat 的静态实例,并在多个线程中使用它,同时不知道他们的程序在大负荷下是否能正确运行?不要对您的客户或者同事做这样的事情!

在忘记之前写下来(要不就离开公司)
一定要在第一次编写类的时候记录线程安全性 -- 在编写它的时候访问类线程安全性需求和行为,要比在几个月后您(或者其他人)再回过头来看要容易得多。永远也不会比在编写它时更清楚地了解在一个实现中所发生的情况。此外,在编写类的时候记录线程安全性,可以使您对于线程安全性的最初想法得以保留,因为维护者希望看到这个记录成为类的说明的一部分。

如果线程安全性是类的一个二元属性就好了,您只需要记录类是线程安全还是线程不安全的。但是很不幸,它不是这么简单的。如果类不是线程安全的,是否可以在每次访问这个类的对象时通过同步使它成为线程安全的呢?是否有操作序列不能允许其他线程的介入,因而不仅需要对基本操作同步,而且对于复合操作也要同步呢?在包含需要自动执行的一组操作的方法之间是否有状态依赖关系呢?开发人员要在并发应用程序中使用一个类时需要掌握这些信息。

定义线程安全性
明确定义线程安全性出人意料地困难,大多数定义看上去完全是自我循环。快速搜索一下 Google,可以找到以下关于线程安全代码的典型的、但是没有多大帮助的定义(或者可以说是描述):

  • ...可以从多个编程线程中调用,无需线程之间不必要的交互。
  • ...可以同时被多个线程调用,不需要调用一方有任何操作。

有这样的定义,就不奇怪我们对于线程安全性会感到如此迷惑。这些定义比说“一个类在可以被多个线程安全调用时就是线程安全的”好不了多少,当然,它的意义就是如此,但是它不能帮助我们区分一个线程安全的类与一个线程不安全的类。安全的意义是什么呢?

实际上,所有线程安全的定义都有某种程序的循环,因为它必须符合类的规格说明 -- 这是对类的功能、其副作用、哪些状态是有效和无效的、不可变量、前置条件、后置条件等等的一种非正式的松散描述(由规格说明给出的对象状态约束只应用于外部可见的状态,即那些可以通过调用其公共方法和访问其公共字段看到的状态,而不应用于其私有字段中表示的内部状态)。

线程安全性
类要成为线程安全的,首先必须在单线程环境中有正确的行为。如果一个类实现正确(这是说它符合规格说明的另一种方式),那么没有一种对这个类的对象的操作序列(读或者写公共字段以及调用公共方法)可以让对象处于无效状态,观察到对象处于无效状态、或者违反类的任何不可变量、前置条件或者后置条件的情况。

此外,一个类要成为线程安全的,在被多个线程访问时,不管运行时环境执行这些线程有什么样的时序安排或者交错,它必须仍然有如上所述的正确行为,并且在调用的代码中没有任何额外的同步。其效果就是,在所有线程看来,对于线程安全对象的操作是以固定的、全局一致的顺序发生的。

正确性与线程安全性之间的关系非常类似于在描述 ACID(原子性、一致性、独立性和持久性)事务时使用的一致性与独立性之间的关系:从特定线程的角度看,由不同线程所执行的对象操作是先后(虽然顺序不定)而不是并行执行的。

方法之问的状态依赖
考虑下面的代码片段,它迭代一个 Vector 中的元素。尽管 Vector 的所有方法都是同步的,但是在多线程的环境中不做额外的同步就使用这段代码仍然是不安全的,因为如果另一个线程恰好在错误的时间里删除了一个元素,则 get() 会抛出一个 ArrayIndexOutOfBoundsException


    Vector v = new Vector();

    // contains race conditions -- may require external synchronization
    for (int i=0; i<v.size(); i++) {
      doSomething(v.get(i));
    }

这里发生的事情是: get(index) 的规格说明里有一条前置条件要求 index 必须是非负的并且小于 size()。但是,在多线程环境中,没有办法可以知道上一次查到的 size() 值是否仍然有效,因而不能确定 i<size(),除非在上一次调用了 size() 后独占地锁定 Vector

更明确地说,这一问题是由 get() 的前置条件是以 size() 的结果来定义的这一事实所带来的。只要看到这种必须使用一种方法的结果作为另一种讲法的输入条件的样式,它就是一个状态依赖,就必须保证至少在调用这两种方法期间元素的状态没有改变。一般来说,做到这一点的唯一方法在调用第一个方法之前是独占性地锁定对象,一直到调用了后一种方法以后。在上面的迭代 Vector 元素的例子中,您需要在迭代过程中同步 Vector 对象。

线程安全程度
如上面的例子所示,线程安全性不是一个非真即假的命题。Vector 的方法都是同步的,并且 Vector 明确地设计为在多线程环境中工作。但是它的线程安全性是有限制的,即在某些方法之间有状态依赖(类似地,如果在迭代过程中 Vector 被其他线程修改,那么由 Vector.iterator() 返回的 iterator 会抛出 ConcurrentModificationException)。

对于 Java 类中常见的线程安全性级别,没有一种分类系统可被广泛接受,不过重要的是在编写类时尽量记录下它们的线程安全行为。

Bloch 给出了描述五类线程安全性的分类方法:不可变、线程安全、有条件线程安全、线程兼容和线程对立。只要明确地记录下线程安全特性,那么您是否使用这种系统都没关系。这种系统有其局限性 -- 各类之间的界线不是百分之百地明确,而且有些情况它没照顾到 -- 但是这套系统是一个很好的起点。这种分类系统的核心是调用者是否可以或者必须用外部同步包围操作(或者一系列操作)。下面几节分别描述了线程安全性的这五种类别。

不可变
本栏目的普通读者听到我赞美不可变性的优点时不会感到意外。不可变的对象一定是线程安全的,并且永远也不需要额外的同步。因为一个不可变的对象只要构建正确,其外部可见状态永远也不会改变,永远也不会看到它处于不一致的状态。Java 类库中大多数基本数值类如IntegerString BigInteger 都是不可变的。

线程安全
线程安全的对象具有在上面“线程安全”一节中描述的属性 -- 由类的规格说明所规定的约束在对象被多个线程访问时仍然有效,不管运行时环境如何排列,线程都不需要任何额外的同步。这种线程安全性保证是很严格的 -- 许多类,如 Hashtable 或者 Vector 都不能满足这种严格的定义。

有条件的线程安全
我们在 7 月份的文件“并发集合类”中讨论了有条件的线程安全。有条件的线程安全类对于单独的操作可以是线程安全的,但是某些操作序列可能需要外部同步。条件线程安全的最常见的例子是遍历由 Hashtable 或者 Vector 或者返回的迭代器 -- 由这些类返回的 fail-fast 迭代器假定在迭代器进行遍历的时候底层集合不会有变化。为了保证其他线程不会在遍历的时候改变集合,进行迭代的线程应该确保它是独占性地访问集合以实现遍历的完整性。通常,独占性的访问是由对锁的同步保证的 -- 并且类的文档应该说明是哪个锁(通常是对象的内部监视器(intrinsic monitor))。

如果对一个有条件线程安全类进行记录,那么您应该不仅要记录它是有条件线程安全的,而且还要记录必须防止哪些操作序列的并发访问。用户可以合理地假设其他操作序列不需要任何额外的同步。

线程兼容
线程兼容类不是线程安全的,但是可以通过正确使用同步而在并发环境中安全地使用。这可能意味着用一个 synchronized 块包围每一个方法调用,或者创建一个包装器对象,其中每一个方法都是同步的(就像 Collections.synchronizedList() 一样)。也可能意味着用 synchronized 块包围某些操作序列。为了最大程度地利用线程兼容类,如果所有调用都使用同一个块,那么就不应该要求调用者对该块同步。这样做会使线程兼容的对象作为变量实例包含在其他线程安全的对象中,从而可以利用其所有者对象的同步。

许多常见的类是线程兼容的,如集合类 ArrayList HashMapjava.text.SimpleDateFormat、或者 JDBC 类 Connection ResultSet

线程对立
线程对立类是那些不管是否调用了外部同步都不能在并发使用时安全地呈现的类。线程对立很少见,当类修改静态数据,而静态数据会影响在其他线程中执行的其他类的行为,这时通常会出现线程对立。线程对立类的一个例子是调用 System.setOut() 的类。

其他线程安全记录考虑
线程安全类(以及线程安全性程度更低的的类) 可以允许或者不允许调用者锁定对象以进行独占性访问。Hashtable 类对所有的同步使用对象的内部监视器,但是 ConcurrentHashMap 类不是这样,事实上没有办法锁定一个 ConcurrentHashMap 对象以进行独占性访问。除了记录线程安全程序,还应该记录是否某些锁 -- 如对象的内部锁 -- 对类的行为有特殊的意义。

通过将类记录为线程安全的(假设它确实线程安全的),您就提供了两种有价值的服务:您告知类的维护者不要进行会影响其线程安全性的修改或者扩展,您还告知类的用户使用它时可以不使用外部同步。通过将类记录为线程兼容或者有条件线程安全的,您就告知了用户这个类可以通过正确使用同步而安全地在多线程中使用。通过将类记录为线程对立的,您就告知用户即使使用了外部同步,他们也不能在多线程中安全地使用这个类。不管是哪种情况,您都在潜在的严重问题出现之前防止了它们,而要查找和修复这些问题是很昂贵的

结束语
一个类的线程安全行为是其规格说明中的固有部分,应该成为其文档的一部分。因为(还)没有描述类的线程安全行为的声明式方式,所以必须用文字描述。虽然 Bloch 的描述类的线程安全程度的五层系统没有涵盖所有可能的情况,但是它是一个很好的起点。如果每一个类都将这种线程行为的程度加入到其 Javadoc 中,那么可以肯定的是我们大家都会受益。

参考资料

 

关于作者
Brian Goetz 在过去 15 年间一直是一位专业软件开发人员。他是 Quiotix 的首席顾问,该公司是一家位于加利福尼亚州洛斯拉图斯(Los Altos)的软件开发和咨询公司,他还为几个 JCP 专家组工作。请参阅流行的业界出版物中 Brian 的已经发表和即将发表的文章。可以通过 brian@quiotix.com 与 Brian 联系。
 

版权所有:UML软件工程组织