墨菲定律表明:凡是可能出错的事就一定会出错,写代码是可能出错的事,所以写的代码一定会出错。
之所以容易出错,主要是因为数据在不同的代码域中被修改,被读取,被存储所产生的可变性使我们很难预测,其结果往往超出预期。
Java 中,对象作为第一公民,即使数据在抽象层次上往往也是以对象对单位的,当我们谈及数据会出现在不同代码域中,不得不提及发布与逸出这两个术语。
发布与逸出
很多时候对象会被共享出去,即在超出当前作用域的代码中使用。例如,将一个指向该对象的引用保存到其他代码可以访问的地方,或者在某个非私有的方法中返回该引用,或者将引用传递到其他类的方法中。我们把这种现象发布(Publication)
。
多数情况下,我们都希望对象具有良好的封装性,其内部状态不被发布。但在某些情况下又需要发布对象,当某个不应该发布的对象象被发布时,这种情况就被称为逸出(Escape)
,被发布的对象可能会被滥用,这会破坏程序的封装性并使得其难以维护。
我们来看一个稍微简单一点的逸出例子。在这里,我们使用知名的土拨鼠算法(Harold Ramis, Bill Murray, et al. Groundhog Day, 1993)
来计算春季的第一天,并且为了提高性能我们缓存了土拨鼠给我们的答案,以提供给之后的调用。
/** @return the first day of spring this year */
public static Date startOfSpring() {
if (groundhogAnswer == null) groundhogAnswer = askGroundhog();
return groundhogAnswer;
}
private static Date groundhogAnswer = null;
看上去一切那么完美,土拨鼠的答案没任何问题,我们开始使用该方法安排一场派对,但是由于天气原因,这个派对可能需要延期一个月。
public static void partyPlanning() {
// let's have a party one month after spring starts!
Date partyDate = startOfSpring();
partyDate.setMonth(partyDate.getMonth() + 1);
// ... uh-oh. what just happened?
}
你发现了什么,糟糕的事情发生了,由于土拨鼠的答案发生了逸出,春季的第一天居然变成了我们开派对的那一天。
我们再来看一个稍微复杂的发布。我们定义了一个 EventListener
类并且在它的构造函数中为事件源注册了监听者。
public class UnsafeEventListener implements EventListener {
private Object obj;
public UnsafeEventListener(EventSource eventSource) {
obj = new Object();
eventSource.registerListener(this);
}
@Override
public void onEvent(Event e) {
// do something...
System.out.println("obj = " + obj);
}
}
以上我们显式地将 this
发布了出去。我们假设存在两个线程,一个线程 A 在访问 EventListener
的构造函数,另外一个线程 B 在访问 processEvent
函数,由于 JMM 的重排序和可见性问题,B 线程调用 onEvent
后可能导致 obj 的输出为 null,如果不小心使用了 obj 则可能导致 NullPorinterException。
public class MyEventSource implements EventSource {
private EventListener listener;
@Override
public void registerListener(EventListener listener) {
this.listener = listener;
}
@Override
public void processEvent(Event e) {
listener.onEvent(e);
}
}
即使,我们不考虑 JMM 的重排序和可见性问题,例如下面这样的代码一样会导致问题。
public class RecordingEventListener extends UnsafeEventListener {
private final ArrayList list;
public RecordingEventListener(EventSource eventSource) {
super(eventSource);
list = Collections.synchronizedList(new ArrayList());
}
@Override
public onEvent(Event e) {
list.add(e);
super.onEvent(e);
}
public Event[] getEvents() {
return (Event[]) list.toArray(new Event[0]);
}
}
由于 Java 规范要求子类构造函数需要先调用 super()
,时间监听函数还是会被事先注册。但此时对我们来说,构造函数并未完全初始化,当事件触发的时候,onEvent()
被回调,我们可以看到 list 并未初始化也就是 null。同样会抛出 NullPointerException。
同样的,对于下面这种隐式的 this
发布面临着同样的问题,这是因为内部类隐含了 this
引用。
public class ThisEscape {
public ThisEscape(EventSource source) {
source.registerListener(
new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
});
}
}
安全的发布
对于类似土拨鼠算法
的情况,我们需要在设计代码多思考,写出带防御性的代码。如下,我们在返回的时候采用防御式拷贝(Defensive Copying)
。
/** @return the first day of spring this year */
public static Date startOfSpring() {
if (groundhogAnswer == null) groundhogAnswer = askGroundhog();
return new Date(groundhogAnswer.getTime());
}
private static Date groundhogAnswer = null;
对于构造函数引用被发布的情况,我们应该避免,保证构造函数在初始化的时候 this
不被其他线程看见。例如我们可以通过下面这样的工厂方式。
public class SafeEventListener implements EventListener {
private final EventListener eventListener;
private SafeEventListener() {
eventListener = new EventListener() {
@Override
public void onEvent(Event e) {
// do something...
}
};
}
public static SafeEventListener newInstance(EventSource eventSource) {
SafeEventListener safeEventListener = new SafeEventListener();
eventSource.registerListener(safeEventListener);
return safeEventListener;
}
@Override
public void onEvent(Event e) {
// todo
}
}
要安全发布一个对象,我们使用了防御式拷贝。事实上,我们在努力地让该对象的内部引用不被读取,即让其成为不可变对象(Immutable Object)
,不可变对象对线程来说是安全的,拥有很多优点,因此能用的地方尽量使用不可变对象。正如,Martin Flower
在重构中说道的一样,不可变数据是强大代码的防腐剂,它能防止我们的代码味道变坏。
“ 不可变对象一定是线程安全的。
但是,使用可变对象(Mutable Object)
是无可避免的。所以在发布可变对象,我们确保对象的引用以及对象的状态对其他线程可见。除了一些取巧的方式,对于一个正确构造的对象还可以通过以下方式来安全的发布:
- 在静态初始化函数中初始化一个对象引用。
- 将对象的引用保存到 volatile 类型的域或者 AtomicReferance 对象中。
- 将对象的引用保存到某个正确构造对象的 final 类型域中。
- 将对象的引用保存到一个由锁保护的域中。
当然,如果对象发布后不被修改,那么对于其他在没有额外同步的情况下安全地访问这些对象的线程来说,发布是足够安全的。从技术的角度来看,这个对象是可变的,但发布以后不会再改变,那么我们把这种对象称作为事实不可变对象(Effectively Immutable Object)
。使用这样的对象,能减少我们对同步的依赖而提高性能。例如,Date 本身可变,但是如果把它当做不可变对象来用的话,在多线程的环境上,就可以省去对同步的依赖。假设我们拥有一个保存用户最近登录时间的列表。
public Map<String, Date> lastLogin = Collections.synchronizedMap(new HashMap<Date>());
我们假定 Date 在发布后不会被改变,那么使用 synchronizedMap
就足以满足同步性需求,并且在访问 Date 的时候不需要额外同步操作。
总结
总的来说,对象发布安不安全主要取决于它的可变性。
- 不可变对象可以通过任意机制发布。
- 事实不可变对象必须通过安全方式来发布。
- 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来。
值得一提的是,有种技术叫做 Copy-on-write
,它兼具了不可变对象和可变对象的优点。
参考
- Java Concurrency in Action - Brian Goetz
- https://www.cs.umd.edu/class/spring2013/cmsc433/Notes/09-CMSC433-Publication.pdf
- https://www.ibm.com/developerworks/library/j-jtp0618/index.html
- https://web.mit.edu/6.005/www/fa15/classes/09-immutability/