双重检查锁定模式(Double checked locking)是软件设计的小技巧,第一重检查跳过大多数不需要竞争的情况,从而减少并发系统中的竞争开销。它经常被用在在“惰性初始化” (lazy initialization) 中,例如实现一个线程安全的单例。如下示例:
public class Singleton { private static volatile UUID uuid; public static UUID getInstance() { if (uuid == null) { synchronized (Singleton.class) { if (uuid == null) { uuid = UUID.randomUUID(); } } } return uuid; } }
(注意上面的 synchronized
和 volatile
)
一般情况下,我们如果要实现单例,会这么写:
public class Singleton { private static UUID uuid; public static UUID getInstance() { if (uuid == null) { // ① uuid = UUID.randomUUID(); // ② } return uuid; // ③ } }
这个版本的问题是:如果多线程运行,则在 ① 处判断时会有多个线程为真,从而导致语句 ② 被执行多次。
很简单的思路是在方法上加上 synchronized 来强制同步:
public class Singleton { private static UUID uuid; public synchronized static UUID getInstance() { if (uuid == null) { uuid = UUID.randomUUID(); } return uuid; } }
这个版本是正确的,只是在高并发的情况下,尽管已经初始化完毕,也要竞争锁,效率低。
鉴于版本二性能不好,我们争取将锁放在 uuid == null
的 if 语句之内:
public class Singleton { private static UUID uuid; public static UUID getInstance() { if (uuid == null) synchronized (Singleton.class) { if (uuid == null) { uuid = UUID.randomUUID(); } } } return uuid; } }
这个版本看似无可挑剔,而且绝大多数情况下测试会通过,但它是错误的。
这其中的理由很复杂,并且需要很强的底层知识才能完全理解(如 java 内存模型,指令重排等等)。我们只需要记住,Java 1.5 之后,为对象加上 volatile
关键词即可。
如果只是需要初始化单例,可以使用下面这种形式:
public class Singleton { private static class Holder { private static UUID uuid = UUID.randomUUID(); } public static UUID getInstance() { return Holder.uuid; } }
内部静态类 Holder
只有在初次被使用时才会被加载,而只有 getInstance
方法才会使用它。这种方法的正确性是由 Java 类加载器保证的,在加载类的时候只会是单线程的。
只不过这种方法比较局限,只适合初始化单例。而 double-checked locking 使用范围更广,事实上它在 Java 源码里还有很多使用,如 ConcurrentHashMap 的初始化就使用了类似的技巧。