编者注 :本文指的是针对Java 5.0进行修改之前的Java内存模型。 有关内存顺序的语句可能不再正确。 但是,在新的内存模型下,再次检查锁定的惯用语仍然无效。 有关Java 5.0中的内存模型的更多信息,请参见“ Java理论和实践:修复Java内存模型” 第1 部分和第2部分 。
Singleton创建模式是常见的编程习惯。 与多个线程一起使用时,必须使用某种类型的同步。 为了创建更高效的代码,Java程序员创建了双重检查的锁定习惯用法,以与Singleton创建模式一起使用,以限制同步多少代码。 但是,由于Java内存模型的一些鲜为人知的细节,因此无法保证这种双重检查的锁定习惯。 与其持续失败,不如偶尔失败。 此外,其失败的原因尚不明确,涉及Java内存模型的详细信息。 这些事实使得由于双重检查锁定而导致的代码失败非常难以追踪。 在本文的其余部分中,我们将详细检查经过仔细检查的锁定习惯用法,以了解其破裂的位置。
单例创作成语
要了解双重检查锁定习惯用法的起源,您必须了解常见的单例创建习惯用法,如清单1所示:
清单1. Singleton创建习惯用法
import java.util.*; class Singleton {
private static Singleton instance; private Vector v; private boolean inUse; private
Singleton() { v = new Vector(); v.addElement(new Object()); inUse = true; } public
static Singleton getInstance() { if (instance == null) //1 instance = new
Singleton(); //2 return instance; //3 } }
此类的设计可确保仅创建一个Singleton
对象。 构造函数被声明为private
,而getInstance()
方法仅创建一个对象。 对于单线程程序,此实现很好。 但是,当引入多个线程时,必须通过同步保护getInstance()
方法。 如果getInstance()
方法不受保护,则可以返回Singleton
对象的两个不同实例。 考虑两个线程同时调用getInstance()
方法和以下事件序列:
- 线程1调用
getInstance()
方法并确定instance
在// 1处为null
。 - 线程1进入
if
块,但在执行// 2处的行之前被线程2抢占。 - 线程2调用
getInstance()
方法,并确定instance
在// 1处为null
。 - 线程2进入
if
块并创建一个新的Singleton
对象,并将变量instance
分配给// 2处的该新对象。 - 线程2返回// 3处的
Singleton
对象引用。 - 线程2被线程1抢占。
- 线程1从中断处开始,并执行// 2行,这将导致创建另一个
Singleton
对象。 - 线程1在// 3返回此对象。
结果是,当getInstance()
方法只应创建一个对象时,它创建了两个Singleton
对象。 通过同步getInstance()
方法以一次仅允许一个线程执行代码,可以解决此问题,如清单2所示:
清单2.线程安全的getInstance()方法
public static synchronized
Singleton getInstance() { if (instance == null) //1 instance = new Singleton(); //2
return instance; //3 }
清单2中的代码适用于对getInstance()
方法的多线程访问。 但是,当您对其进行分析时,您意识到仅在第一次调用该方法时才需要同步。 后续调用不需要同步,因为第一次调用是唯一执行// 2处代码的调用,这是唯一需要同步的行。 所有其他调用确定该instance
为非null
并返回它。 多个线程可以安全地同时执行除第一个以外的所有调用。 但是,由于该方法是synced,因此,即使仅在第一次调用时才需要每次调用该方法,也要付出synchronized
的代价。
为了提高此方法的效率,创建了一个称为双重检查锁定的习惯用法。 这样做的目的是避免方法的所有调用(第一个调用除外)的昂贵同步。 同步的成本因JVM而异。 在早期,成本可能会很高。 随着更高级的JVM的出现,同步的成本已经降低,但是进入和离开synchronized
方法或块仍然会降低性能。 不管JVM技术的进步如何,程序员都不想浪费不必要的处理时间。
因为清单2中只有// 2行需要同步,所以我们可以将其包装在一个同步块中,如清单3所示:
清单3. getInstance()方法
public static Singleton getInstance() { if
(instance == null) { synchronized(Singleton.class) { instance = new Singleton(); } }
return instance; }
清单3中的代码与多线程和清单1所示的问题相同。当instance
为null
时,两个线程可以同时进入if
语句内部。 然后,一个线程进入synchronized
块以初始化instance
,而另一个线程被阻塞。 当第一个线程退出synchronized
块时,等待线程进入并创建另一个Singleton
对象。 请注意,当第二个线程进入synchronized
块时,它不会检查instance
是否为非null
。
双重检查锁定
要解决清单3中的问题,我们需要再次检查instance
。 因此,名称为“双重检查锁定”。 将双重检查的锁定习惯用法应用于清单3,结果如清单4所示。
清单4.双重检查的锁定示例
public static Singleton
getInstance() { if (instance == null) { synchronized(Singleton.class) { //1 if
(instance == null) //2 instance = new Singleton(); //3 } } return instance; }
双重检查锁定背后的理论是,/// 2处的第二次检查使得不可能像清单3一样创建两个不同的Singleton
对象。请考虑以下事件序列:
- 线程1进入
getInstance()
方法。 - 线程1在// 1处进入
synchronized
块,因为instance
为null
。 - 线程1被线程2抢占。
- 线程2进入
getInstance()
方法。 - 线程2尝试获取// 1处的锁,因为
instance
仍然为null
。 但是,由于线程1持有该锁,因此线程2在// 1处阻塞。 - 线程2被线程1抢占。
- 执行线程1,并且由于instance在// 2处仍然为
null
,因此创建了Singleton
对象,并将其引用分配给instance
。 - 线程1退出
synchronized
块,并从getInstance()
方法返回实例。 - 线程1被线程2抢占。
- 线程2获取// 1处的锁,并检查
instance
是否为null
。 - 因为
instance
为非null
,所以不会创建第二个Singleton
对象,并且返回由线程1创建的对象。
双重检查锁定背后的理论是完美的。 不幸的是,现实是完全不同的。 双重检查锁定的问题在于无法保证它将在单处理器或多处理器计算机上运行。
双重检查锁定失败的问题不是由于JVM中的实现错误,而是由于当前的Java平台内存模型。 内存模型允许所谓的“乱序写入”,这是该成语失败的主要原因。
无序写入
为了说明问题,您需要重新检查上面清单4中的// 3行。 此行代码创建一个Singleton
对象,并初始化变量instance
以引用此对象。 这行代码的问题在于,在Singleton
构造函数的主体执行之前,变量instance
可以变为非null
。
?? 该陈述可能与您认为可能的一切矛盾,但实际上确实如此。 在解释这种情况如何发生之前,请先接受这一事实,同时检查这是如何破坏双重检查的锁定习惯的。 考虑清单4中的代码的以下事件序列:
- 线程1进入
getInstance()
方法。 - 线程1在// 1处进入
synchronized
块,因为instance
为null
。 - 线程1继续// 3并使实例非
null
,但是要在构造函数执行之前 。 - 线程1被线程2抢占。
- 线程2检查实例是否为
null
。 因为不是,所以线程2将instance
引用返回到完全构造但部分初始化的Singleton
对象。 - 线程2被线程1抢占。
- 线程1通过运行
Singleton
对象的构造函数来完成其初始化,并返回对其的引用。
此事件序列导致一段时间,其中线程2返回了一个其构造函数未执行的对象。
为了说明这种情况是如何发生的,请考虑以下代码行: instance =new Singleton();
mem = allocate(); //Allocate memory for Singleton
object. instance = mem; //Note that instance is now non-null, but //has not been
initialized. ctorSingleton(instance); //Invoke constructor for Singleton passing
//instance.
这种伪代码不仅是可能的,而且在某些JIT编译器上也是如此。 执行顺序被认为是乱序的,但是在当前的内存模型下允许执行。 JIT编译器正是这样做的事实使得双重检查锁定的问题不仅仅是学术上的练习。
为了演示这一点,请考虑清单5中的代码。它包含getInstance()
方法的精简版本。 我删除了“双重检查”以简化对生成的汇编代码的审查(清单6)。 我们只对看到instance=new Singleton();
感兴趣instance=new Singleton();
由JIT编译器编译。 另外,我提供了一个简单的构造函数,以使该构造函数在汇编代码中运行时清晰可见。
清单5.演示无序写入的Singleton类
class Singleton
{ private static Singleton instance; private boolean inUse; private int val; private
Singleton() { inUse = true; val = 5; } public static Singleton getInstance() { if
(instance == null) instance = new Singleton(); return instance; } }
清单6包含Sun JDK 1.2.1 JIT编译器为清单5中的getInstance()
方法的主体生成的汇编代码。
清单6.从清单5中的代码产生的汇编代码
;asm code generated
for getInstance 054D20B0 mov eax,[049388C8] ;load instance ref 054D20B5 test eax,eax
;test for null 054D20B7 jne 054D20D7 054D20B9 mov eax,14C0988h 054D20BE call
503EF8F0 ;allocate memory 054D20C3 mov [049388C8],eax ;store pointer in ;instance
ref. instance ;non-null and ctor ;has not run 054D20C8 mov ecx,dword ptr [eax]
054D20CA mov dword ptr [ecx],1 ;inline ctor - inUse=true; 054D20D0 mov dword ptr
[ecx+4],5 ;inline ctor - val=5; 054D20D7 mov ebx,dword ptr ds:[49388C8h] 054D20DD
jmp 054D20B0
注意:为了在以下说明中引用汇编代码行,我引用了指令地址的最后两个值,因为它们都以054D20
。 例如, B5
表示test eax,eax
。
通过运行一个在无限循环中调用getInstance()
方法的测试程序来生成汇编代码。 程序运行时,运行Microsoft Visual C ++调试器,并将其附加到代表测试程序的Java进程中。 然后,中断执行并找到代表无限循环的汇编代码。
B0
和B5
处的汇编代码的前两049388C8
instance
引用从内存位置049388C8
到eax
并测试null
。 这与清单5中的getInstance()
方法的第一行相对应。第一次调用此方法时, instance
为null
,代码继续进行到B9
。 BE
的代码从堆中为Singleton
对象分配内存,并将指向该内存的指针存储在eax
。 下一行C3
将指针放在eax
并将其存储回内存位置049388C8
的实例引用中。 结果, instance
现在为非null
并且引用了有效的Singleton
对象。 但是,此对象的构造函数尚未运行,这正是打破双重检查锁定的情况。 然后在C8
行, instance
指针被取消引用并存储在ecx
。 行CA
和D0
代表内联构造函数,将true
和5
值存储到Singleton
对象中。 如果在执行C3
行之后但在完成构造函数之前此代码被另一个线程中断,则双重检查锁定将失败。
并非所有的JIT编译器都会生成上述代码。 一些生成的代码使得instance
仅在构造函数执行后变为非null
。 用于Java技术的IBM SDK版本1.3和Sun JDK 1.3均会生成此类代码。 但是,这并不意味着您应在这些情况下使用双重检查锁定。 还有其他可能导致失败的原因。 另外,您并不总是知道您的代码将在哪些JVM上运行,并且JIT编译器可能总是会更改以生成破坏该惯用语的代码。
双重检查锁定:两次
鉴于当前的双重检查锁定代码无法正常工作,我整理了清单7所示的另一个版本的代码,以防止您刚刚看到的乱序写问题。
清单7.尝试解决乱序写问题
public static
Singleton getInstance() { if (instance == null) { synchronized(Singleton.class) {
//1 Singleton inst = instance; //2 if (inst == null) { synchronized(Singleton.class)
{ //3 inst = new Singleton(); //4 } instance = inst; //5 } } } return instance; }
查看清单7中的代码,您应该意识到事情变得有些荒谬了。 请记住,创建双重检查锁定是避免同步简单的三行getInstance()
方法的一种方法。 清单7中的代码已失控。 此外,该代码不能解决问题。 仔细检查揭示了原因。
此代码试图避免乱序写入问题。 它试图通过引入局部变量inst
和第二个synchronized
块来做到这一点。 该理论的工作原理如下:
- 线程1进入
getInstance()
方法。 - 因为
instance
为null
,所以线程1在// 1处进入第一个synchronized
块。 - 局部变量
inst
获取instance
的值,该null
在// 2处为null
。 - 因为
inst
为null
,所以线程1在// 3处进入第二个synchronized
块。 - 然后线程1在// 4开始执行代码,使
inst
为非null
但在Singleton
的构造函数执行之前。 (这是我们刚刚看到的乱序写问题。) - 线程1被线程2抢占。
- 线程2进入
getInstance()
方法。 - 由于
instance
为null
,线程2尝试在// 1处输入第一个synchronized
块。 由于线程1当前持有此锁,因此线程2会阻塞。 - 然后,线程1完成对// 4的执行。
- 然后线程1将完整构造的
Singleton
对象分配给// 5处的变量instance
,并退出两个synchronized
块。 - 线程1返回
instance
。 - 然后线程2执行并将
instance
分配给// 2处的inst
。 - 线程2看到该
instance
为非null
,并返回它。
关键是// 5。 该行应确保instance
仅为null
或引用完全构造的Singleton
对象。 问题发生在理论和现实相互正交的地方。
由于内存模型的当前定义,清单7中的代码不起作用。 Java语言规范(JLS)要求synchronized
块内的代码不得移出synchronized
块。 然而,它并没有说的代码不synchronized
块不能被移动到一个synchronized
块。
JIT编译器将在此处看到优化机会。 此优化将删除// 4处的代码和// 5处的代码,将其组合并生成清单8中所示的代码:
清单8.清单7中的优化代码
public static Singleton getInstance()
{ if (instance == null) { synchronized(Singleton.class) { //1 Singleton inst =
instance; //2 if (inst == null) { synchronized(Singleton.class) { //3 //inst = new
Singleton(); //4 instance = new Singleton(); } //instance = inst; //5 } } } return
instance; }
如果进行了这种优化,则您将遇到我们前面讨论的无序写入问题。
易挥发的人吗?
另一个想法是对变量inst
和instance
使用关键字volatile
。 按照JLS(参见相关主题 ),变量声明volatile
应该是顺序一致的,因此,不会重新排序。 但是,尝试使用volatile
修复双重检查锁定时会出现两个问题:
- 这里的问题不在于顺序一致性。 代码正在移动,而不是重新排序。
- 无论如何,许多JVM在顺序一致性方面都无法正确实现
volatile
。
第二点值得扩展。 考虑清单9中的代码:
清单9. volatile的顺序一致性
class test { private volatile
boolean stop = false; private volatile int num = 0; public void foo() { num = 100;
//This can happen second stop = true; //This can happen first //... } public void
bar() { if (stop) num += num; //num can == 0! } //... }
根据JLS,因为stop
和num
被声明为volatile
,所以它们应该顺序一致。 这意味着,如果stop
为true
,则num
必须设置为100
。 但是,由于许多JVM并未实现volatile
的顺序一致性功能,因此您不能指望这种行为。 因此,如果线程1同时调用了foo
和线程2调用了bar
,则在num
设置为100
之前,线程1可能会将stop
设置为true
。 这可能导致线程2看到stop
为true
,但是num
仍然设置为0
。 volatile
和64位变量的原子性还存在其他问题,但这不在本文讨论范围之内。 请参阅相关主题有关此主题的更多信息。
解决方案
最重要的是,不应以任何形式使用经过仔细检查的锁定,因为您不能保证它可以在任何JVM实现上使用。 JSR-133正在解决有关内存模型的问题,但是,新的内存模型将不支持再次检查锁定。 因此,您有两个选择:
- 接受
getInstance()
方法的同步,如清单2所示。 - 放弃同步并使用
static
字段。
清单10显示了选项2:
清单10.具有静态字段的Singleton实现
class Singleton {
private Vector v; private boolean inUse; private static Singleton instance = new
Singleton(); private Singleton() { v = new Vector(); inUse = true; //... } public
static Singleton getInstance() { return instance; } }
清单10中的代码不使用同步,并且确保在调用static getInstance()
方法之前不创建Singleton
对象。 如果您的目标是消除同步,那么这是一个很好的选择。
字符串不是一成不变的
考虑到乱序写入和在构造函数执行之前引用变为非null
的问题,您可能会想起String
类。 考虑以下代码:
private String str; //... str = new String("hello");
String
类应该是不可变的。 但是,考虑到我们前面讨论的无序写入问题,是否可能在这里引起问题? 答案是可以的。 考虑两个可以访问String str
线程。 一个线程可以看到str
引用引用了其中未运行构造函数的String
对象。 实际上,清单11包含显示这种情况的代码。 请注意,此代码仅在我测试过的旧版JVM时中断。 IBM 1.3和Sun 1.3 JVM均产生预期的不可变String
。
清单11.可变字符串的示例
class StringCreator extends Thread {
MutableString ms; public StringCreator(MutableString muts) { ms = muts; } public
void run() { while(true) ms.str = new String("hello"); //1 } } class StringReader
extends Thread { MutableString ms; public StringReader(MutableString muts) { ms =
muts; } public void run() { while(true) { if (!(ms.str.equals("hello"))) //2 {
System.out.println("String is not immutable!"); break; } } } } class MutableString {
public String str; //3 public static void main(String args[]) { MutableString ms =
new MutableString(); //4 new StringCreator(ms).start(); //5 new
StringReader(ms).start(); //6 } }
此代码在// 4创建一个MutableString
类,该类包含由// 3的两个线程共享的String
引用。 在// 5和// 6行的两个单独的线程上创建了两个对象StringCreator
和StringReader
,将对MutableString
对象的引用传递给它。 StringCreator
类进入无限循环,并在// 1处创建值为“ hello”的String
对象。 StringReader
也进入一个无限循环,并检查当前String
对象是否在// 2处具有值“ hello”。 如果不是, StringReader
线程将打印出一条消息并停止。 如果String
类是不可变的,则永远不会看到该程序的任何输出。 StringReader
看到str
引用不是以“ hello”作为其值的String
对象之外的任何东西的唯一方法是,如果发生乱写问题。
在像Sun JDK 1.2.1这样的旧JVM上运行此代码会导致乱序的写问题,从而导致不可更改的String
。
摘要
为了避免单身人士进行昂贵的同步,程序员非常巧妙地发明了双重检查的锁定习惯。 不幸的是,直到这种习语被广泛使用后,由于当前的内存模型,它显然不是一个安全的编程构造。 正在重新定义内存模型中薄弱的区域。 但是,即使在新提出的内存模型下,双重检查锁定也不起作用。 解决此问题的最佳方法是接受同步或使用static field
。
翻译自: https://www.ibm.com/developerworks/java/library/j-dcl/index.html