级别: 初级 Brian Goetz (brian@quiotix.com), 首席顾问, Quiotix 公司
2002 年 8 月 21 日 Java 语言提供了灵活的、看上去很简单的线程功能,使得您很容易在您的应用程序中使用多线程。然而,Java应用程序中的并发编程比看上去要复杂:在 Java 程序中,有一些微妙(也许并不是那么微妙)方式会造成数据争用(data race)以及并发问题。在这篇
Java 理论和实践中,Brian探讨了一个常见的线程方面的危险:在构造过程中,允许
this 引用逃脱(escape)。这个看上去没有什么危害的做法可以在 Java 程序中造成无法可预料和不期望的结果。
测试和调试多线程程序是极其困难的,因为并发性方面的危险常常不是以一致的方式显现出来,甚至有时未必会显现这种危险性。就线程问题的本质而言,大多数这些问题是无法预料的,甚至在某些平台上(如单处理器系统),或者低于一定的负载,问题可能根本就不出现。由于测试多线程程序的正确性是如此困难,以及查找错误是如此费时,因此从一开始开发应用程序就要在心中牢记线程的安全性,这一点就显得尤为重要。
在本文中,我们将研究一个特殊的线程安全方面的问题 ― 在构造过程中,允许
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 同步”,也可以参阅
参考资料以获取更多有关这类复杂问题的详细信息。
 |
用 Java Memory Model 同步
在 Java 编程中的
synchronized 关键字是强制执行
互斥:它确保在给定的时间内只有一个线程执行给定的代码块。但
在具有弱内存(weak memory)模型(即,没有必要提供高速缓存一致性的平台)的多处理器系统上,
同步 ― 或者说没有同步 ― 还有其它一些微妙的结果。
同步确保其它线程以一种可预知的方式
看见某个线程所做的更改。在某些
体系结构上,由于没有同步机制,不同线程实际执行的内存操作次序与它们所看见的执行次序不一致。
这种现象令人感到迷惑,但很正常 ― 这关键是为了寻求在这些平台上的高性能。如果只要您
遵循这样的规则 ― 每当您读取一个可能已被其它线程写过的变量时,或者写一个接下来可能会被
其它线程读取的变量时,就执行同步 ― 那么您不会有任何问题。请参阅
参考资料以了解更多信息。
|
|
在构造期间,不要公布“this”引用
一种可以将数据争用引入类中的错误是,在构造函数完成之前,使
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 引用的隐式副本,所以创建匿名的内部类实例,并将其传递给从当前线程外部可以看见的对象,
会存在与暴露
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 引用。
参考资料
关于作者
对本文的评价
|