双重检查锁
概述
本文将会介绍两种常见的实现双重检查锁的错误方式,主要目的是为了理解为什么这两种实现方式是错误的,最后给出正确的实现方式。
在开始介绍双重检查锁(double checked locking)之前,需要先复习一下 synchronized 和 volatile,如果你已经很熟悉了,可以直接跳过这两部分的介绍。
synchronized
该关键字有两方面的作用:
- 互斥性:一次只能有一个线程持有某个对象的 Monitor,一旦有一个线程进入了同步块,其他线程就无法进入由同一个 Monitor 保护的同步块,直到第一个线程退出同步块。
- 可见性:同步确保在同步块之前或期间由一个线程进行的内存写操作对其他在同一 Monitor 上进行同步的线程可见。在退出同步块后,释放 Monitor 的同时,会将缓存刷新到主内存,使得该线程进行的写操作对其他线程可见。其他线程进入同步块之前,需要获取 Monitor,这会使得本地处理器缓存失效,变量从主内存重新加载,这便保证了可见性。
注:同步问题的一个根本原因是 CPU cache,synchronized 关键词修饰的块退出时刷新 cache 内容到内存,进入时清理 cache,从内存读取最新内容,保证了可见性。
volatile
对 volatile 字段的读取总能看到任何其它线程对该 volatile 字段的最新写入,也就是说 volatile 字段不允许因缓存或重排序而看到“陈旧”的值,因此编译器和运行时禁止将它们分配到寄存器中。
volatile 字段写入后,会被刷新到主内存中,以便立即对其他线程可见。在读取 volatile 字段之前,先使缓存失效,然后从主内存中读取值最新值。
写 volatile 字段和释放 monitor 产生的内存效果相当,读 volatile 字段则和获取 monitor 产生的内存效果相当。Java 内存模型对 volatile 字段访问与其他字段访问(无论是否为 volatile)的重排序施加了更严格的限制,线程 A 向 volatile 字段 f 写入时可见的任何内容在线程 B 读取 f 时也将对 B 可见。
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
// v 是 volatile 类型的,v 写入之前的指令不可以重排
// 也就是说 x = 42; 不可以被重排到 v = true 之后。
x = 42;
v = true;
}
public void reader() {
if (v == true) {
//uses x - guaranteed to see 42.
}
}
}
这里需要注意的是,volatile 字段写入后,也会导致缓存被刷新到主内存,而不仅仅是该字段本身被刷新到内存中,因此这里 v == true
时,x 的值为 42。这可由 happens-before(hb) 规则推导出来。
以下是对 JMM happens-before 规则的引述:
...
- Each action in a thread happens before every action in that thread that comes later in the program's order.
- An unlock on a monitor happens before every subsequent lock on that same monitor.
- A write to a volatile field happens before every subsequent read of that same volatile.
- A call to start() on a thread happens before any actions in the started thread.
- All actions in a thread happen before any other thread successfully returns from a join() on that thread.
注1:program's order 和 source order 不是一回事。program order 是程序运行的一个总体 order,source order 中的某些语句在实际执行时可能会被重排。
注2:可见不代表最新,对于上面的代码,假设还其他方法可以修改 x,如果不同步 x 的访问,某个线程很有可能在 writer 方法之后修改了 x,这将会导致 reader 读取到的 x 不是最新值。
错误的双重检查锁1
// Broken multithreaded version
// "Double-Checked Locking" idiom
class Foo {
private Helper helper = null;
public Helper getHelper() {
if (helper == null)
synchronized(this) {
if (helper == null)
helper = new Helper();
}
return helper;
}
// other functions and members...
}
它无法正常工作的原因在于初始化 Helper 对象的写操作和对 helper 字段赋值的写操作可能会以无序的方式进行或感知。 因此,调用 getHelper() 的线程可能会看到 helper 对象的非空引用,但是却看到 helper 对象的字段仍然是构造函数中设置的默认值,而不是构造函数中设置的值。
注:无序的方式进行的根本原因是重排,假设重排后,对象的引用赋值操作在前,对象的实际构造在后。在赋值和构造之间,有 t2 调用了 getHelper(),那么 t2 看到的 helper 非空,但是由于非空就不会在进入同步代码块,因此不会从内存中读入最新的 helper 对象,这将导致 t2 看到的 helper 对象中的字段还是默认值。
如果编译器内联调用构造函数,那么如果编译器可以证明构造函数不会抛出异常或执行同步操作,初始化对象的写操作和对 helper 字段的写操作可以自由地进行重排序。
即使编译器不重排这些写操作,在多处理器系统中,处理器或内存系统也可能会对这些写操作进行重排,从而被运行在另一个处理器上的线程所感知到。
错误的双重检查锁2
// (Still) Broken multithreaded version
// "Double-Checked Locking" idiom
class Foo {
private Helper helper = null;
public Helper getHelper() {
if (helper == null) {
Helper h;
synchronized(this) {
h = helper;
if (h == null)
synchronized (this) {
h = new Helper();
} // release inner synchronization lock
helper = h;
}
}
return helper;
}
// other functions and members...
}
这段代码将 Helper 对象的构造放在了一个内部的 synchronized 块中。直观的想法是,在释放同步锁的地方应该有一个内存屏障,这样可以防止对 Helper 对象的初始化和对 helper 字段的赋值进行重排。
注:内存屏障导致 CPU cache 中的内容被刷新到内存。
不幸的是,这种直觉是完全错误的,同步的规则不是这样工作的。对于monitorexit(即释放同步锁)的规则是:在monitorexit 之前的操作必须在释放锁之前执行。然而并没有规则规定在 monitorexit 之后的操作不能在释放锁之前执行。
编译器完全可以将 helper = h;
的赋值操作移到 synchronized 块的内部,这种情况下我们回到了之前的状态。许多处理器提供执行这种单向内存屏障的指令。将语义更改为要求释放锁是一个完整的内存屏障会带来性能损失。
以上双重检查锁的例子大部分来自 double checked locking,有能力的同学可以直接去围观。
正确的版本
class Foo {
private volatile Helper helper = null;
public Helper getHelper() {
if (helper == null) {
synchronized(this) {
if (helper == null)
helper = new Helper();
}
}
return helper;
}
}
温馨提示:反馈需要登录