测试和调试多线程程序是极其困难的,因为并发性方面的危险常常不是以一致的方式显现出来,甚至有时未必会显现这种危险性。就线程问题的本质而言,大多数这些问题是无法预料的,甚至在某些平台上(如单处理器系统),或者低于一定的负载,问题可能根本就不出现。由于测试多线程程序的正确性是如此困难,以及查找错误是如此费时,因此从一开始开发应用程序就要在心中牢记线程的安全性,这一点就显得尤为重要。
在本文中,我们将研究一个特殊的线程安全方面的问题 ― 在构造过程中,允许
this
引用逃脱(我们称之为
逃脱的引用问题) ― 该问题引起了一些未曾期望的结果。然后,为了编写出线程安全的构造函数,我们给出一些准则。
分析程序来找出线程安全的违例是非常困难的,这需要专门的经验。幸运的是(也许会感到吃惊),从一开始创建线程安全的类并不是那样的困难,尽管这需要一种其它专门的技巧:规程。大多数并发性错误是来自程序员以方便、改善性能或只是一时的懒惰为名企图违规而造成的。如许多其它并发性问题一样,在编写构造函数时,遵循一些简单的规则就可以避免这个逃脱的引用问题。
大多数并发性危险归根结底是由某类 数据争用引起的。 在多个线程或进程正在读取和写入一个共享数据项时,会发生数据争用或进入 争用状态 ,最终结果取决于这些线程的调度次序。清单 1 给出了一个简单的数据争用的示例,其中程序可以打印 0 或者 1,这取决于对线程的调度。
清单 1. 简单的数据争用
public class DataRace {
static int a = 0;
public static void main() {
new MyThread().start();
a = 1;
}
public static class MyThread extends Thread {
public void run() {
public void run() {
System.out.println(a);
}
}
}
|
可以立即调度第二个线程,打印
a 的初始值 0。另一种情形,第二个线程可能
不立即运行,则导致打印值
1。这个程序的输出取决于您正在使用的 JDK、底层操作系统的调度程序或者随机计时构件。重复运行该程序,会得到不同的结果。
在清单 1 中,除了这个明显的争用 ― 第二个线程是在第一个线程将
a 置为 1
之前还是之后开始执行 ― 之外,实际上还有另一种数据争用。第二种争用是一种可见性方面的争用:两个线程没有使用同步,而同步能保证线程之间数据更改的可见性。因为没有同步,如果在第一个线程对
a 赋值完成之后,运行第二个线程,则第二个线程可能或
不可能立即看见第一个线程所做的更改。第二个线程可能看到
a 仍然为 0,即使第一个线程已经将值 1 赋给了
a。这种第二类的数据争用(在没有正确同步的情况下,两个线程正在访问同一变量)是一种复杂的问题,但幸运的是,
每当读取一个其它线程可能已写过的变量,或者
写一个接下来可能会被其它线程读取的变量时,使用同步就可以避免这类数据争用。在这里,
我们不想进一步探讨这类数据争用,关于这类复杂问题,您可以参阅侧栏
“用
Java Memory Model 同步”,也可以参阅
参考资料以获取更多有关这类复杂问题的详细信息。
一种可以将数据争用引入类中的错误是,在构造函数完成之前,使
this 引用暴露给另一个线程。有时这个引用是显式的,
(譬如,直接将
this
存储在静态字段或集合),但还有一些时候它可以是隐式的(譬如,
当将一个引用公布给构造函数中的非静态内部类的实例时)。构造函数不是一般的方法
― 它们有特殊的用于初始化安全的语义。在构造函数完成之后,
可以认为对象是处于一种可预测和一致的状态,将引用公布给一个还未完成构造的对象是危险的。清单
2 显示了将这类争用条件引入构造函数的示例。这个示例看上去可能没有危害性,但它可以引发严重的并发性问题。
清单 2. 可能发生的数据争用
public class EventListener {
public EventListener(EventSource eventSource) {
// do our initialization
...
// register ourselves with the event source
eventSource.registerListener(this);
}
public onEvent(Event e) {
// handle the event
}
}
|
乍一看,
EventListener
类似乎没有危害性。构造函数最后完成的工作是注册侦听器,这会将引用公布给新对象,这时其它线程可能会看到这个引用。
但即使不考虑所有 Java 内存模型(JMM)问题(譬如,多个线程所看见同一数据的不一致性以及内存访问重排序的不同),该代码
仍然有将还未完成构造的
EventListener
对象暴露给其它线程的危险。考虑清单 3 中这种情况,当创建
EventListener 的一个子类时,会发生什么:
清单 3. 创建 EventListener 的一个子类时,问题产生了
public class RecordingEventListener extends EventListener {
private final ArrayList list;
public RecordingEventListener(EventSource eventSource) {
super(eventSource);
list = Collections.synchronizedList(new ArrayList());
}
public onEvent(Event e) {
list.add(e);
super.onEvent(e);
}
public Event[] getEvents() {
return (Event[]) list.toArray(new Event[0]);
}
}
|
由于 Java 语言规范要求对
super() 的调用应该是子类构造函数中的第一条语句,
所以在完成子类字段的初始化之前,还未构造完的事件侦听器已经对事件源进行了注册。现在,
list
字段存在数据争用。如果事件侦听器决定从注册调用内发送一个事件,
或者我们很不幸,在这不恰当的时刻,一个事件到来了,则会调用
RecordingEventListener.onEvent() ,而
这时
list 仍然是
null
值,结果会抛出
NullPointerException 异常。就象
onEvent() 这样的类方法一样,在编码时,应该避免使用还未初始化完的 final 字段。
清单 2 中的问题在于,
EventListener
在构造完成之前会向正在构造的对象公布一个引用。虽然看上去
几乎已经完整地构造了对象,所以
将
this 传递给事件源好象是安全的,但这种表像是具有欺骗性的。公布来自构造函数内的
this 引用(如清单 2 所示)就象放置了一颗随时会爆炸的定时炸弹。
在根本不使用
this 引用情况下,也有可能会造成逃脱的引用问题。非静态内部类维护着其父类的
this 引用的隐式副本,所以创建匿名的内部类实例,并将其传递给从当前线程外部可以看见的对象,
会存在与暴露
this 引用本身所具有的所有相同的风险。
考虑清单 4,它与清单 2 有同样的根本问题,但这里没有显式地使用
this 引用:
清单 4. 使用匿名内部类时,不正确地公布了“this”
public class EventListener2 {
public EventListener2(EventSource eventSource) {
eventSource.registerListener(
new EventListener() {
public onEvent(Event e) {
eventReceived(e);
}
});
}
public onEvent(Event e) {
}
}
|
EventListener2 类和其类似代码
清单
2 中的
EventListener 有同样的弊端:公布了对正在构造
的对象的引用 ― 在这种情况下,是间接的 ― 另一个线程可以看见这个引用。
如果打算创建
EventListener2 的子类,将会碰到同样的问题,即在子类构造函数完成
之前会调用子类方法。
清单 4 问题的一个特例是,从构造函数内启动了一个线程,由于
当一个对象拥有一个线程时,通常这个线程要么是一个内部类,要么我们将
this 引用传递给其构造函数(或者该类本身继承了
Thread
类)。如果一个对象将拥有一个线程,那么,如果对象如
Thread 那样提供一个
start() 方法,并从
start() 方法而不是从构造函数启动线程,那是最好的。虽然通过接口,这会暴露类的一些实现细节(譬如,
可能存在拥有一个线程),这往往是我们不期望的,但在这种情况下,从构造函数启动线程的害处要比隐藏实现所带来的好处多。
在构造期间,不是所有对
this 引用的引用都是有害的,只有那些公布引用,使其它线程看到该引用的引用才是有害的。确定与其它对象共享
this 引用是否安全,需要详细了解对象的可见性以及对象将如何利用引用。关于让
this 引用在构造期间逃脱,清单 5 列出了一些安全和不安全做法的示例:
清单 5. 在构造期间,安全和不安全地使用“this”引用
public class Safe {
private Object me;
private Set set = new HashSet();
private Thread thread;
public void run() {
// Safe because "me" is not visible from any other thread
me = this;
// Safe because "set" is not visible from any other thread
set.add(this);
// Safe because MyThread won't start until construction is complete
// and the constructor doesn't publish the reference
thread = new MyThread(this);
}
public void run() {
thread.start();
}
private class MyThread(Object o) {
private Object theObject;
public MyThread(Object o) {
this.theObject = o;
}
...
}
}
public class Unsafe {
public static Unsafe anInstance;
public static Set set = new HashSet();
private Set mySet = new HashSet();
public void run() {
// Unsafe because anInstance is globally visible
anInstance = this;
// Unsafe because SomeOtherClass.anInstance is globally visible
SomeOtherClass.anInstance = this;
// Unsafe because SomeOtherClass might save the "this" reference
// where another thread could see it
SomeOtherClass.registerObject(this);
// Unsafe because set is globally visible
set.add(this);
// Unsafe because we are publishing a reference to mySet
mySet.add(this);
SomeOtherClass.someMethod(mySet);
// Unsafe because the "this" object will be visible from the new
// thread before the constructor completes
thread = new MyThread(this);
thread.start();
}
public void run() {
// Unsafe because "c" may be visible from other threads
c.add(this);
}
}
|
正如您所见,在
Unsafe 类中许多不安全的构造与
Safe 类中安全的构造有很多相似之处。确定
this
引用对其它线程是否可见是一项很棘手的工作。最好的策略是,在构造函数中完全避免使用
this
引用(直接或间接)。然而,实际上这种完全避免使用 this 引用的可能并不总是存在。但只要记住,在
构造函数中谨慎使用
this 引用和创建非静态内部类的实例。
当我们考虑同步的影响时,上面所详细讲述的关于线程安全构造的那些做法就变得更为重要。例如,
当线程 A 启动线程 B 时,Java 语言规范(Java Language
Specification (JLS))保证:当线程
A 启动线程 B 时,所有那些对线程 A 可见的变量对线程 B 也是可见的,这非常类似
Thread.start()
中隐式同步。如果从构造函数中启动一个线程,并且正在构造的对象还没有完成,则我们
会丢失这些可见性的保证。
因为 JMM 有一些令人迷惑的方面,Java Community Process JSR 133
正对其进行修订,这将(尤其)会更改
volatile
和
final 的语义,使它们更符合其字面上含义。例如,在当前
JMM 语义下,一个线程在其生存期中可能看到
final
字段有多个值。新的内存模型语义将防止这种情形,但只有在正确定义了构造函数的情况下 ― 这意味着在构造期间不允许
this 引用逃脱。
对其它线程能够看见的还未完成构造的对象进行引用显然不是我们所期望的。归根结底,我们如何
正确辨别完全构造好的对象和尚未构造好的对象呢?但通过公布来自构造函数内的
this 引用 ― 直接或间接地通过内部类 ― 我们这样做时,会导致无法预料的后果。为了防止这种危险,在
创建内部类的实例或从构造函数启动线程时,尽量避免使用
this 。如果
无法在构造函数中避免直接或间接使用
this ,则确保不让其它线程看见
this 引用。
- 您可以参阅本文在 developerWorks 全球站点上的
英文原文.
- 请单击文章顶部或底部的
“讨论”参与本文的
论坛。
- Doug Lea 所著的
Concurrent Programming in Java, Second Edition
(Addison-Wesley,1999 年)是一本极棒的有关
Java 应用程序中多线程编程的深奥问题的书籍。
-
Synchronization and the Java Memory Model
摘自 Doug Lea 的一本有关专门介绍
synchronized真实内涵的书。
- “
Double-checked locking: Clever, but broken”(
JavaWorld,2001
年 2 月)和“
Can double-checked locking be fixed?”(
JavaWorld,2001
年 5 月)研究了 JMM 以及在某些情况下同步失败所带来的意外后果。
- 在“
Double-checked locking and the Singleton pattern”(
developerWorks,2002
年 5 月)中,Peter Haggar 一步一步给我们解释了,当同步失败时,一些奇怪的现象是如何发生的。
-
Semantics of Multithreaded
Java(PDF)详细讲述了 Java Memory Model 中所提议做的一些更改,从而产生了 JSR 133。
- 在“
Writing multithreaded Java applications”(
developerWorks,2001
年 2 月)中,Alex Roetter 对线程、同步和 Java 类中的锁做了基本概述。
- 请阅读 Brian Goetz 的
Java
理论和实践
专栏。
- 在
developerWorks
Java 技术专区可以找到其它 Java 技术内容。
Brian Goetz 是一名软件顾问,在过去 15 年间一直从事专业软件开发。他是 Quiotix(一家位于加利福尼亚州洛斯拉图斯的软件开发和咨询公司)的首席顾问。请在流行的业界出版物中查阅 Brian 已出版和即将出版的文章。可以通过 brian@quiotix.com与 Brian 联系。