DCL:聪明的,但是不工作的
你知道同步的真实含义吗?
概要
许多java程序员熟悉double-checked locking的方法,它允许你执行延迟初始化,从而削减了同步的花费。虽然很多的书和文章推荐double-checked locking,单不幸的是它并不能保证一定能工作。在这篇文章中,我将探索一些潜在的观点和那些临时的发现,并且投入到java内存模型的未知领域中。
从很有影响力的java样式的原理到javaworld的页面,许多好心的java高手鼓励使用DCL方法,这方法会有一个问题——这个看似聪明的方法可能不能工作。
什么是DCL?
DCL方法被设计为能支持延迟初始化,也就是指当一个类延迟初始化生成对象本身,直到确实需要。
class SomeClass {
private Resource resource = null;
public Resource getResource() {
if (resource == null)
resource = new Resource();
return resource;
}
}
为什么你想延迟初始化?也许创建一个Resource对象是一个开销很大的操作,并且SomeClass的使用者实际上在运行中不需要调用getResource()。在上面的例子中,你能够避免完整的创建Resource。不顾,SomeClass对象能被快速创建如果它不想在构造时创建一个Resource。延迟某些初始化操作直到用户实际上需要,这样能帮助程序提高启动速度。
如果你尝试在多线程的应用中使用SomeClass,这将会怎样?那么这将是一个环境竞争的结果:两个线程同时执行,如果resource是null,结果将会初始化resource两次。在多线程环境中,你应该把getResource()声明成同步的。
不幸的是同步方法运行非常忙,大约比普通没有同步的方法慢100倍。延迟初始化其中的一个动机是效率,但是它的出现是为了达到程序的快速启动,一旦程序开始运行了,你得接受漫长的执行时间。那显然不像一个好的交易。
DCL声称给了我们最好的解决方法。使用DCL, getResource()方法看上去将会像下面一样:
class SomeClass {
private Resource resource = null;
public Resource getResource() {
if (resource == null) {
synchronized {
if (resource == null)
resource = new Resource();
}
}
return resource;
}
}
第一次调用getResource()以后,resource已经被实例化了,在大多数的通用代码中,这可以避免同步命中。在同步块中,DCL通过再次检测resource来消除环境的竞争;那样可以确保只有一个线程将会初始化resource。DCL似乎像一个最优化的方法——但是它不能工作。
接触Java内存模型
更精确的说,DCL不能担保一定能工作。为了理解,我们需要看一看JVM和运行它的计算机环境之间的关系。我们更需要看一看Java内存模型(JMM),由Bill Joy, Guy Steele, James Gosling, and Gilad Bracha编写的(Addison-Wesley, 2000) Java语言规范的17章有详细的说明,其中含有Java操作线程和内存交互的细节。
不像大多数其它语言,Java定义了它和潜在硬件的关系,通过一个能运行所有Java平台的正式内存模型,能够实现Java“写一次,到处运行”的诺言。通过比较,其它语言像C和C++,缺乏一个正式的内存模型;在这些语言中,程序继承了运行该程序硬件平台的内存模型。
当运行在同步(单线程)环境中,一段程序与内存交互相当的简单,或者说至少它的表现如此。程序存贮条目到内存位置上,并且在下一次这些内存位置被检测的时候期望它们仍然在那儿。
实际上,原理是完全不同的。但是通过编译器,Java虚拟机(JVM)维持着一个复杂的幻想,并且硬件把它掩饰起来。虽然,我们认为程序将像程序代码中说明的顺序连续执行,但是事情不是总是这样发生的。编译器,处理器和缓存可以自由的随意的应用我们的程序和数据,只要它们不会影响到计算的结果。例如,编译器能用不同的顺序从明显的程序解释中生成指令,并且存贮变量在寄存器中,而不是内存中;处理器可以并行或者颠倒次序的执行指令;缓存可以改变顺序的把提交内容写入到主存中。Java内存模型(JMM)所说的只要环境维持了as-if-serial语法,所有的各种各样的再排序和优化是可以接受的。也就是说,只要你完成了同样的结果与你在一个严格的连续环境中指令被执行的结果一样。
编译器,处理器和缓存为了达到高性能,需要重新安排程序运行的顺序。近年来,我们看到了计算机的计算性能有了极大的提高。处理器时钟频率的提高对高性能有着充分的贡献,并行能力(管道的形式和超标量体系结构执行单元,动态指令分配表和灵活的执行,精密的多级内存缓存)的提高也是主要的贡献者。当今,编写编译器的任务变得极其复杂,因为在这些复杂性中,编译器必须能保护程序员。
当编写单线程程序时,你不会看到这些多种多样指令或者内存重新排序运行的结果。然而,在多线程程序中,情况则完全不同——一个线程可以读另一个线程已经写了的内存位置。如果线程A用某一种顺序修改了一些变量,在缺乏同步时,线程B可能用同样的顺序也看不到它们,或者根本看不到它们。那可能是由于编译器把指令重新排序或临时在寄存器中存储了变量,后来把它写到内存以外;或者是由于处理器并行的或与编译器指定的不同的顺序执行指令;或者是由于指令在内存的不同区域,而且缓存用与它们写进时不一样的顺序更新了相应的主存位置。无论何种环境,多线程程序先天的缺乏可预见性。除非你通过使用同步明确地保证线程有一个一致的内存视图。
同步的真正含义?
Java处理线程就像在自己的处理器和自己本地内存与共享主存的每次交流和同步。即使在单线程的系统中,因为内存缓存和使用处理器的寄存器来存储变量的效果,模型将会变得有意义。当一个线程修改了本地内存的位置,变动也需要最终在主存中显现出来,为了当Java虚拟机(JVM)必须在本地内存和主存之间传输数据时,Java内存模型(JMM)会制定规则。Java的结构认为一个过度限制性的内存模型会严重破坏程序的性能。他们尝试建立一个内存模型,它允许程序很好的在现代计算机硬件上运行,并仍然能保证线程在预定的路径上交互。
Java用synchronized关键字作为主要的工具来表现预订线程之间的交互。许多程序员认为synchronized严格的执行互斥(mutex)来防止在同一时刻有超过一个的线程在临界部分运行。不幸的是直觉不能完全地描述synchronized的含义。
Synchronized语法中确实包括基于旗语状态的互斥执行,而且也包括有关同步线程与主存之间交互的规则。详细的说,获得和释放锁触发器,内存屏障——在线程本地内存和主存之间强制同步。(象Alpha的有些处理器,有显式的机器指令来操作内存屏障。)当一个线程退出synchronized块,它执行写屏障——在释放锁之前,它必须把块中的变量修改刷新到主存中去。同样地,当进入synchronized块中,它执行读屏障——就像本地内存已经失效,它必须从块中取出与主存有关的变量。
正常的话,使用同步能保证在预定方式中一个线程将会看到另一个线程的结果。但只有当线程A和B在同一对象上保持同步,Java内存模型(JMM)才能保证线程B会看到线程A的变化,并且在synchronized块中,对于线程B而言,线程A的变化会细微的出现(或者整块执行,或者什么也不做)。此外,Java内存模型(JMM)确保synchronized块中在同一对象上同步将会与程序中一样的顺序执行。
DCL将出现什么样的中断?
DCL认为在非同步中使用resource字段是没有问题的,但并非如此。请看,假设线程A在synchronized块中,执行了resource = new Resource()语句;同时线程B正在输入getResource()。考虑内存初始化的结果,为了创建新的Resource对象,内存将会被分配;初始化新对象的成员字段,构造器将会被调用;并且SomeClass的resource字段被分配给新创建的对象。
然而,线程B并不是在synchronized块中执行,可以看到内存以比线程A执行更加不同的顺序操作。线程B以下面的顺序看到这些事件(编译器也可以自由的像这样的重新安排指令):分配内存,分配resource,调用构造器。假设线程B执行时是在内存已经分配完毕并且resource字段已经设置,但是构造器还没有调用。它觉得resource不为空,跳过synchronized块,返回一个部分构造的Resource对象!不必说,这个结果既不是我们期望的,也不是我们想象到的。
我们用这个例子,许多人起先很怀疑,高手们试图修改DCL。但是他们的修订版也不能工作。我们注意到DCL实际上是在不同的Java虚拟机(JVM)上运行,少数Java虚拟机(JVM)则完全实现了Java内存模型(JMM)。依靠实现的细节,你得不到程序的正确结果,特别是错误,这需要你说明使用的特殊的Java虚拟机(JVM)的特殊版本
DCL在非同步块中涉及到写内存和读内存被另一个线程会有并发危险。假设线程A已经完成了Resource的初始化,并退出了synchronized块,此时线程B输入了getResource()。现在Resource已经被完全初始化了,线程A把本地内存刷新到主存中。resource对象可以通过成员字段来关联到其它对象,并可保存到内存中,它也会被刷新。当线程B看到一个新创建的Resource,因为它不执行读屏障,它能看到在resource成员字段中的失效值。
Volatile也不是你考虑的
把SomeClass的resource字段声明成volatile。然而,Java内存模型防止写可变变量从已经被另一个线成重新安排,并确保它们被立即刷新到内存中去。它仍允许读写已经被重新排序的有关不变读写的可变变量。也就是说,除非所有Resource的字段都是volatile的。当resource被设置成新创建的Resource,线程B能感觉到构造的结果
DCL的选择
修改DCL的最有效的方法是避免它。避免它最简单的方法当然是同步。只要被线程写入的变量被另一个线程读,你就应该用同步去保证修改对其它预定方式的线程是可见的。
另一个避免问题的方法是不用延迟初始化,使用eager initialization,相比resource直到第一次使用才延迟初始化,eager initialization是在构造时初始化。类加载器同步所有的类对象,在类初始化时,执行静态初始化块。这意味着类加载时静态初始化的结果自动对所有的线程是可见的。
一个特殊的关于不需要同步也能工作的案例是静态单件。当实例化的对象是一个有一个静态字段没有其它方法和字段的类时,Java虚拟机(JVM)会自动有效的完成延迟初始化。在下面的例子中,直到resource字段被另一个类首次引用时,Resource将会被构造,内存写入resource初始化的结果自动对所有线程可见。
class MySingleton {
public static Resource resource = new Resource();
}
这个初始化被执行当Java虚拟机(JVM)初始化类时。MySingleton没有其它字段和方法,当resource字段被第一次引用时,类初始化会发生。
DCL在32位简单数时也能工作,如果SomeClass的resource字段是Integer类型(不是long、double),那SomeClass会像我们期望的那样工作。然而,你不能修改DCL的问题当你想用延迟初始化一个对象引用或者超过一个简单数。
Java内存模型(JMM)会被修改吗?
马里兰大学的Bill Pugh正在准备Java规范要求(JSR)来修改现在的Java内存模型(JMM)中的问题。这些改进包括:
Java内存模型(JMM)的净化
放宽某些要求,允许通用的编译器最有化
在真实的缺乏竞争的环境中,减小同步的性能影响
修改了volatile的语法,为了防止重新安排写入可变变量和写入其它变量
在我写这篇文章时,这个规范还没有被提交到Java过程团体。
结论
Java内存模型(JMM)是第一个在多方面的程序语言规范内容中尝试定义共享内存的语法。不幸的是,这相当的复杂,难于理解,在用户中Java内存模型(JMM)的复杂结果造成Java虚拟机(JVM)实现的错误和普遍的误解。如很难理解同步是如和工作的,产生了像DCL那样不安全的方法。当有简单化内存模型的提议时,在我们会看到Java规范基本变化之前,这会有一段时间。
在此期间,小心测试你的多线程程序,确保被另一个线程写入的变量引用是完全同步的。
这是本人的翻译处女作,敬请指教!
2002-9-11 by Befresh