双重检查锁Double Checked Locking Pattern的非原子操作下的危险性
Double Checked Locking Pattern
即双重检查锁模式。
双重检查锁模式是一种软件设计模式(常常用于单例模式懒汉式中),用于减少获取锁的开销。程序首先检查锁定条件,并且仅当检查表明需要锁时才才获取锁。
延迟初始化就是我们常说的懒加载是一种常用的策略,用于延迟对象初始化,直到它第一次被访问。在多线程环境中,初始化通常不是线程安全的,因此需要锁来保护临界区。由于只有第一次访问需要锁定,因此使用双重检查锁来避免后续访问的锁定开销。然而,在许多语言和硬件上,设计可能是不安全的。
Double-Checked Locking双重检查锁由来
在单线程中
如果我们正在编写单线程代码,我们可以像这样编写一个惰性初始化:
此代码适用于单个线程,但如果代码在多线程环境中运行,则两个或多个线程可能会同时找到它helper,null并创建对象的多个副本Helper。这甚至会导致某些语言(例如 C++)中的内存泄漏。
使用synchronized锁
为了解决这个问题,我们可以简单地给这个临界区加一个锁,如下所示,这样每次只有一个线程可以进入这个临界区。
但是,我们只需要为第一个线程访问而同步这部分代码。创建对象后,后面的线程就没有必要再次获取锁了。它们将对性能产生巨大影响。Always-synchronized solution is slow。
我们需要的是只有第一个线程会进入同步部分并创建对象。一旦helper初始化,所有后续访问都可以直接运行而无需同步。
直观地说,我们可以提出以下步骤来完成这项工作:
- 在锁之前检查对象是否已经初始化。如果已经创建好了,则立即返回对象。
- 获取锁之后再次检查对象是否已初始化。如果另一个线程之前已经抢到了锁,创建了对象,那么当前线程就可以看到对象被创建,并返回该对象。
- 否则,当前线程将创建对象并返回。
上述,我们将获得以下代码:
这种策略称为双重锁模式。
Double-checked locking is broken
但是helper = new Helper()不是原子操作,它由分配空间、初始化对象字段和分配地址的多条指令组成helper。
为了显示那里真正发生了什么,我们helper = new Helper()用一些伪代码进行扩展。
为了提高整体性能,一些编译器、内存系统或处理器可能会重新排序指令,我们之前文章《java多线程基础篇》中有提到了这种指令重排序。
因为初始化字段helper = ptr;的指令和其它指令之间没有数据依赖关系。
所以重排序后helper = ptr可能就会提前执行;
那么其它线程可能会获取到一个null的helper对象。
原子操作
原子操作要么完全发生,要么根本不发生。没有中间状态,因此原子操作在完成操作之前是不可见的。
在前面的分析中,我们已经看到h = new Helper()可以交错,因为它不是原子操作。如果此操作是原子操作,则双重检查锁将起作用。
解决方案
1.使用volatile
从 JDK 5 开始,我们可以通过将任何变量声明为 volatile 变量来对任何变量进行原子读写。每次读取 volatile 都会使缓存值无效并从主内存中加载它。volatile 的每次写入都会更新缓存中的值,然后将缓存的值刷新到主内存,就是缓存一致性协议。
Java 中的“volatile”还提供了排序保证,这与atomic_thread_fenceC++ 中提供的保证相同:
- 从 volatile 变量读取后其他变量的读/写操作不能在从 volatile 变量读取之前重新排序。
- 在写入 volatile变量之前对其他变量的读/写操作不能在写入 volatile 变量之后重新排序。
有了这个新特性,双重检查锁定问题就可以通过简单地声明helper为 volatile 变量来解决。
但是,由于 volatile 变量的所有读写操作都会触发缓存一致性协议并访问主存,因此可能会非常慢。可以使用局部变量进行改进,以减少访问 volatile 变量的次数。
Oracle Java文档:Atomic Access原子访问中long指定大多数原始变量(除了并且double因为它们是 64 位)的读写操作是原子的。
2静态单例
如果helper是静态的,即类的所有实例Foo共享同一个实例,在单独的类的静态字段中helper定义就可以解决问题。
这被称为Initialization on Demand Holder(IoDH 持有者按需初始化),它被认为是所有 Java 版本的安全高效的并发延迟初始化。
在软件开发中,Initialization on Demand Holder设计模式描述了所谓的惰性初始化单例的实现选项,即对象仅在第一次使用时才被初始化的实现。在所有 Java 版本中,它允许安全、高度可并行化的延迟初始化,并具有良好的性能。
3使用ThreadLocal
Alexander Terekhov 提供了使用线程局部变量的双重检查锁定的实现。
Java标准库提供了一个特殊的ThreadLocal,它可以在一个线程中传递同一个对象。
ThreadLocal实例通常总是以静态字段初始化如下:
static ThreadLocal<User> threadLocalUser = new ThreadLocal<>();
ThreadLocal可以用来维护“状态是否经过同步初始化”的状态。如果一个线程已经完成了一次同步初始化,它可以确信该对象已经被初始化。
在同步初始化部分中,只有第一个线程会找到null对象并初始化对象。然后所有线程将在第一次同步访问时更改其每个线程的状态,以便它们不会再去获取锁进入同步部分。
廖雪峰 关于ThreadLocal更加详细的
结论
文章讨论了多线程环境下延迟初始化的双重检查锁定问题。它分析了为什么一些直观的解决方案不起作用,并分析了一些可行的解决方案。
编写多线程程序很难。编写正确且安全的多线程程序更加困难。在分析多线程程序的正确性时,需要考虑多个组件,包括编译器、系统和处理器。另一方面,在设计编译器、系统或处理器时,还需要考虑常用的设计模式。
本文主要来自
Double-Checked Locking is Brokenby Hongbo Zhang October 18, 2019