编者注 :本文指的是针对Java 5.0进行修改之前的Java内存模型。 有关内存顺序的语句可能不再正确。 但是,在新的内存模型下,再次检查锁定的惯用语仍然无效。 有关Java 5.0中的内存模型的更多信息,请参见“ Java理论和实践:修复Java内存模型” 第1 部分和第2部分 。
Singleton创建模式是常见的编程习惯。 与多个线程一起使用时,必须使用某种类型的同步。 为了创建更高效的代码,Java程序员创建了双重检查的锁定习惯用法,以与Singleton创建模式一起使用,以限制同步多少代码。 但是,由于Java内存模型的一些鲜为人知的细节,因此无法保证这种双重检查的锁定习惯。 与其持续失败,不如偶尔失败。 此外,其失败的原因尚不明确,涉及Java内存模型的详细信息。 这些事实使得由于双重检查锁定而导致的代码失败非常难以追踪。 在本文的其余部分中,我们将详细检查经过仔细检查的锁定习惯用法,以了解其破裂的位置。
要了解双重检查锁定习惯用法的起源,您必须了解常见的单例创建习惯用法,如清单1所示:
此类的设计可确保仅创建一个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()方法的多线程访问。 但是,当您对其进行分析时,您意识到仅在第一次调用该方法时才需要同步。 后续调用不需要同步,因为第一次调用是唯一执行// 2处代码的调用,这是唯一需要同步的行。 所有其他调用确定该instance为非null并返回它。 多个线程可以安全地同时执行除第一个以外的所有调用。 但是,由于该方法是synced,因此,即使仅在第一次调用时才需要每次调用该方法,也要付出synchronized的代价。
为了提高此方法的效率,创建了一个称为双重检查锁定的习惯用法。 这样做的目的是避免方法的所有调用(第一个调用除外)的昂贵同步。 同步的成本因JVM而异。 在早期,成本可能会很高。 随着更高级的JVM的出现,同步的成本已经降低,但是进入和离开synchronized方法或块仍然会降低性能。 不管JVM技术的进步如何,程序员都不想浪费不必要的处理时间。
因为清单2中只有// 2行需要同步,所以我们可以将其包装在一个同步块中,如清单3所示:
清单3中的代码与多线程和清单1所示的问题相同。当instance为null时,两个线程可以同时进入if语句内部。 然后,一个线程进入synchronized块以初始化instance ,而另一个线程被阻塞。 当第一个线程退出synchronized块时,等待线程进入并创建另一个Singleton对象。 请注意,当第二个线程进入synchronized块时,它不会检查instance是否为非null 。
要解决清单3中的问题,我们需要再次检查instance 。 因此,名称为“双重检查锁定”。 将双重检查的锁定习惯用法应用于清单3,结果如清单4所示。
双重检查锁定背后的理论是,/// 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编译器编译。 另外,我提供了一个简单的构造函数,以使该构造函数在汇编代码中运行时清晰可见。
清单6包含Sun JDK 1.2.1 JIT编译器为清单5中的getInstance()方法的主体生成的汇编代码。
注意:为了在以下说明中引用汇编代码行,我引用了指令地址的最后两个值,因为它们都以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中的代码,您应该意识到事情变得有些荒谬了。 请记住,创建双重检查锁定是避免同步简单的三行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中所示的代码:
如果进行了这种优化,则您将遇到我们前面讨论的无序写入问题。
另一个想法是对变量inst和instance使用关键字volatile 。 按照JLS(参见相关主题 ),变量声明volatile应该是顺序一致的,因此,不会重新排序。 但是,尝试使用volatile修复双重检查锁定时会出现两个问题:
这里的问题不在于顺序一致性。 代码正在移动,而不是重新排序。 无论如何,许多JVM在顺序一致性方面都无法正确实现volatile 。第二点值得扩展。 考虑清单9中的代码:
根据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中的代码不使用同步,并且确保在调用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 。
此代码在// 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