在软件工程中,双重检查锁(double-checked locking[1])是一种设计模式,通过在加锁前检查锁标志(criterion),可以减少加锁的开销。只有当标志显示需要的时候才会加锁。
这个模式在某些软硬件混合实现的场景下可能是不安全的,因此有时也被认为是一种“反模式”[2]。
该模式通常被用于多线程下延迟初始化的场景,尤其是单例模式。延迟初始化是指在某个资源首次被访问时才进行初始化(例如建立网络链接、加载某些数据等场景)。
以下是几种常见语言使用双重检查来单例模式的例子。Java的实现会着重展开讲。
== C++11 ==
在C++中不需要双重检查来实现单例模式。
If control enters the declaration concurrently while the variable is being initialized, the concurrent execution shall wait for completion of the initialization.
如果控制流并发执行到声明语句,且该变量正在初始化,并发的执行流应当等初始化完成才能继续。
— § 6.7 [stmt.dcl] p4 (C++11标准6.7节第四段)
Singleton& GetInstance() {
static Singleton s; //静态初始化
return s;
}
如果希望用双重检查惯用法(idiom)来实现以上逻辑(比如Visual Studio低于2015的版本没有实现前面引用的C++11标准对并发初始化的要求[3]),需要使用acquire fence和release fence[4](注:内存屏障,防止编译器和CPU的指令重排):
#include
#include
class Singleton {
public:
Singleton* GetInstance();
private:
Singleton() = default;
static std::atomic s_instance;
static std::mutex s_mutex;
};
Singleton* Singleton::GetInstance() {
Singleton* p = s_instance.load(std::memory_order_acquire);
if (p == nullptr) {
std::lock_guard lock(s_mutex);
p = s_instance.load(std::memory_order_relaxed);
if (p == nullptr) {
p = new Singleton();
s_instance.store(p, std::memory_order_release);
}
}
return p;
}
== Golang ==
package main
import "sync"
var arrMu sync.Mutex
var arr *[]int
// getArr 获取 arr, 在需要时延迟初始化,双重检查
// 避免锁住整个函数,并且保证arr只会被初始化一次
func getArr() *[]int {
up := (*unsafe.Pointer)(unsafe.Pointer(&arr))
if atomic.LoadPointer(up) != nil { // 1st check
return arr
}
arrMu.Lock()
defer arrMu.Unlock()
if arr != nil { // 2nd check
return arr
}
a := &[]int{0, 1, 2}
atomic.StorePointer(up, unsafe.Pointer(a))
return arr
}
func main() {
// 双重可以保证两个goroutine同时调用getArr()
// 不会导致重复初始化
go getArr()
go getArr()
}
== Java ==
针对单例的实现,很多地方[2]都会给出这样一个例子:
// 单线程版
class Foo {
private Helper helper;
public Helper getHelper() {
if (helper == null) {
helper = new Helper();
}
return helper;
}
// 其他方法和成员...
}
这个实现的问题是无法满足多线程的场景。当两个线程同时调用 getHelper() 时必须加锁,否则他们可能会各创建一个对象,也可能有一个会拿到没有完全初始化完成的对象。
加锁需要的同步开销很大,如下例所示:
// 正确但开销很大的多线程版本
class Foo {
private Helper helper;
public synchronized Helper getHelper() {
if (helper == null) {
helper = new Helper();
}
return helper;
}
// 其他方法和成员...
}
然而,首次调用 getHelper() 就会创建单例对象,并且只有少数几个在当时尝试去调用的线程之间需要同步,之后所有的调用只需要返回该成员变量的引用即可。给一个方法加上 synchronized 关键字有时可能会导致100倍甚至更高的性能损耗[5],每次调用该方法加锁、解锁的开销似乎不太有必要:一旦初始化完成,加锁、解锁就显得毫无必要了。许多程序员尝试用如下方法优化这个场景:
检查变量是否被初始化(不加锁)。如果已初始化,立即返回。
加锁。
再次检查该变量是否初始化:如果另一个线程之前已经加过锁,它可能已经完成了初始化,在这种情况下直接返回初始化的对象引用即可。
否则需要初始化并返回
// 有问题的多线程版本
// "双重检查" 惯用法
class Foo {
private Helper helper;
public Helper getHelper() {
if (helper == null) {
synchronized (this) {
if (helper == null) {
helper = new Helper();
}
}
}
return helper;
}
// 其他方法和成员...
}
乍一看,这好像是个高效的算法。然而,这种技巧有很多小问题,通常需要避免。例如,考虑这种场景,按以下顺序发生了一系列事件:
线程A发现该变量未初始化,因此加锁并尝试初始化该变量。
由于某些语言的语义,编译器生成的代码允许将共享变量指向一个部分初始化的对象,此时A尚未完成对该对象的初始化。例如,在Java中,如果构造函数被内联(inline),共享变量可能会立即被更新,指向该对象新分配的地址,然后才执行被内联的构造函数[6]。
线程B发现该变量已经“被初始化了”(至少看起来是),返回该对象。由于线程B认为变量已经初始化,它不会加锁。在A对该对象的初始化完成、并对B可见之前(可能是A还没完成初始化,或者因为缓存一致性的问题涉及到的内存变动尚未同步到B),如果B使用该对象,程序可能就会崩溃。
在J2SE 1.4(或更早版本)使用多重检查锁的危险是,它往往执行正常,而想要区分正确的实现和有一点小问题的实现往往很困难。由于编译器的实现、调度器对线程的交错调度策略和并发系统的其他特性,如上不正确实现双重检查锁导致的异常可能是间歇出现的,而且很难复现。
在J2SE 5.0里这个问题被修复了,volatile关键字可以保证在多线程环境下正确处理单例对象。新的惯用法如下:
// 在 Java 1.5及之后版本有效,基于volatile的acquire/release语义
// 在 Java 1.4及更早版本volatile的语义存在问题
class Foo {
private volatile Helper helper;
public Helper getHelper() {
Helper localRef = helper;
if (localRef == null) {
synchronized (this) {
localRef = helper;
if (localRef == null) {
helper = localRef = new Helper();
}
}
}
return localRef;
}
// 其他方法和成员...
}
注意这个局部变量 "localRef",似乎看起来没必要,实际作用是,当 helper 被初始化以后(大多数情况下),这个 volatile 字段只需要被访问一次(最后return的是 localRef 的值,而不是 helper),这最高可以使该方法的整体性能提高25%[7](注:volatile的内存屏障语义,会导致每次读、写的时候对应缓存失效)。
Java 9 引入了 VarHandle 类,可以使用 "relaxed" 级别的原子操作来访问变量,在使用弱内存模型(weak memory model)的机器上(注:指CPU)读操作会更快,但代价是更复杂的机制和不保证顺序一致性(sequencial consistency,访问该变量不再是synchronization order)[8]。
// 在Java 9有效,基于VarHandle的acquire/release语义
class Foo {
private volatile Helper helper;
public Helper getHelper() {
Helper localRef = getHelperAcquire();
if (localRef == null) {
synchronized (this) {
localRef = getHelperAcquire();
if (localRef == null) {
localRef = new Helper();
setHelperRelease(localRef);
}
}
}
return localRef;
}
private static final VarHandle HELPER;
private Helper getHelperAcquire() {
return (Helper) HELPER.getAcquire(this);
}
private void setHelperRelease(Helper value) {
HELPER.setRelease(this, value);
}
static {
try {
MethodHandles.Lookup lookup = MethodHandles.lookup();
HELPER = lookup.findVarHandle(Foo.class, "helper", Helper.class);
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
// 其他方法和成员...
}
如果 helper 对象是静态(static,每个class加载器一个),另一种实现方案是 "Initialization-on-demand holder" 惯用法[9]:
// Java正确的延迟初始化
class Foo {
private static class HelperHolder {
public static final Helper helper = new Helper();
}
public static Helper getHelper() {
return HelperHolder.helper;
}
}
内嵌类在被引用的时候才会被加载,这保证了以上实现的正确性。
Java 5 中 final 的语义可以在不使用 volatile 的情况下安全发布 helper 对象[11]:
public class FinalWrapper {
public final T value;
public FinalWrapper(T value) {
this.value = value;
}
}
public class Foo {
private FinalWrapper helperWrapper;
public Helper getHelper() {
FinalWrapper tempWrapper = helperWrapper;
if (tempWrapper == null) {
synchronized (this) {
if (helperWrapper == null) {
helperWrapper = new FinalWrapper(new Helper());
}
tempWrapper = helperWrapper;
}
}
return tempWrapper.value;
}
}
局部变量 tempWrapper 是必须的:如果在两个 null 检查中都使用 helperWrapper ,return语句可能失败,因为Java内存模型允许读乱序[12]。这个实现的性能不一定比 volatile 版本更好。
== C# ==
在 .NET 中实现双重检查很容易。常用的模式是在单例实现中加入双重检查:
public class MySingleton {
private static object myLock = new object();
private static volatile MySingleton mySingleton = null;
// 在.NET 2.0 及以上版本'volatile'不是必须的
private MySingleton() {
}
public static MySingleton GetInstance() {
if (mySingleton == null) { // 1st check
lock (myLock) {
if (mySingleton == null) { // 2nd check
mySingleton = new MySingleton();
// .NET 1.1中,volatile隐含了write-release 语义
// 会在构造函数调用和复制之间加上必要的内存屏障
// 加锁的屏障不够,因为在释放锁之前对象就可见了
// .NET 2.0及以后的版本锁就够了,不需要volatile
}
}
}
// 在.NET 1.1中,加锁的屏障不够,因为不是所有线程都会加锁
// 在校验和读取mySingleton之间需要read-acquire语义的内存
// 因为mySingleton是volatile的,所以被自动加上了这个屏障
// 在.NET 2.0 及后续版本, 'volatile' 就不是必要的了
return mySingleton;
}
}
在这个例子中,"lock hint" 是 mySingleton 对象,当它被初始化完成以后就不在是null了。
在 .NET 4.0中引入的 Lazy
类内部默认使用双重检查锁(ExecutionAndPublication 模式)来保存初始化时抛出的异常,或传给它的函数执行的结果[13]:
public class MySingleton
{
private static readonly Lazy _mySingleton = new Lazy(() => new MySingleton());
private MySingleton() { }
public static MySingleton Instance
{
get
{
return _mySingleton.Value;
}
}
}
== 其他参考 ==
Test and Test-and-set 惯用法,用于一种low-level锁机制
https://en.wikipedia.org/wiki/Test_and_Test-and-set
Initialization-on-demand holder惯用法,在java中用于线程安全的替代做法
https://en.wikipedia.org/wiki/Initialization-on-demand_holder_idiom
引用
Schmidt, D et al. Pattern-Oriented Software Architecture Vol 2, 2000 pp353-363
David Bacon et al. The "Double-Checked Locking is Broken" Declaration.
"Support for C++11-14-17 Features (Modern C++)".
Double-Checked Locking is Fixed In C++11
Boehm, Hans-J (Jun 2005). "Threads cannot be implemented as a library" (PDF). ACM SIGPLAN Notices. 40 (6): 261–268. doi:10.1145/1064978.1065042.
Haggar, Peter (1 May 2002). "Double-checked locking and the Singleton pattern". IBM.
Joshua Bloch "Effective Java, Second Edition", p. 283-284
"Chapter 17. Threads and Locks". docs.oracle.com. Retrieved 2018-07-28.
Brian Goetz et al. Java Concurrency in Practice, 2006 pp348
Goetz, Brian; et al. "Java Concurrency in Practice – listings on website". Retrieved 21 October 2014.
[1] Javamemorymodel-discussion mailing list
[2] Manson, Jeremy (2008-12-14). "Date-Race-Ful Lazy Initialization for Performance – Java Concurrency (&c)". Retrieved 3 December 2016.
Albahari, Joseph (2010). "Threading in C#: Using Threads". C# 4.0 in a Nutshell. O'Reilly Media. ISBN 978-0-596-80095-6. Lazy