本文是由JR主持写作的《J2SE进阶》一书的部分章节整理而成,《J2SE进阶》正在写作、完善阶段。您阅读后,有任何建议、批评,请和我联系,或在这儿留言。《J2SE进阶》写作项目组感谢您阅读本文。
Java 在语言层次上实现了对线程的支持。它提供了Thread/Runnable/ThreadGroup等一系列封装的类和接口,让程序员可以高效的开发 Java多线程应用。为了实现同步,Java提供了synchronize关键字以及object的wait()/notify()机制,可是在简单易用 的背后,应藏着更为复杂的玄机,很多问题就是由此而起。
一、Java内存模型
在了解Java的同步秘密之前,先来看看JMM(Java Memory Model)。
Java被设计为跨平台的语言,在内存管理上,显然也要有一个统一的模型。而且Java语言最大的特点就是废除了指针,把程序员从痛苦中解脱出来,不用再考虑内存使用和管理方面的问题。
可惜世事总不尽如人意,虽然JMM设计上方便了程序员,但是它增加了虚拟机的复杂程度,而且还导致某些编程技巧在Java语言中失效。
JMM 主要是为了规定了线程和内存之间的一些关系。对Java程序员来说只需负责用synchronized同步关键字,其它诸如与线程/内存之间进行数据交换 /同步等繁琐工作均由虚拟机负责完成。如图1所示:根据JMM的设计,系统存在一个主内存(MainMemory),Java中所有变量都储存在主存中, 对于所有线程都是共享的。每条线程都有自己的工作内存(WorkingMemory),工作内存中保存的是主存中某些变量的拷贝,线程对所有变量的操作都 是在工作内存中进行,线程之间无法相互直接访问,变量传递均需要通过主存完成。
图1 Java内存模型示例图
线程若要 对某变量进行操作,必须经过一系列步骤:首先从主存复制/刷新数据到工作内存,然后执行代码,进行引用/赋值操作,最后把变量内容写回 MainMemory。Java语言规范(JLS)中对线程和主存互操作定义了6个行为,分别为load,save,read,write,assign 和use,这些操作行为具有原子性,且相互依赖,有明确的调用先后顺序。具体的描述请参见JLS第17章。
我们在前面的章节介绍了synchronized的作用,现在,从JMM的角度来重新审视synchronized关键字。
假设某条线程执行一个synchronized代码段,其间对某变量进行操作,JVM会依次执行如下动作:
(1) 获取同步对象monitor (lock)
(2) 从主存复制变量到当前工作内存 (read and load)
(3) 执行代码,改变共享变量值 (use and assign)
(4) 用工作内存数据刷新主存相关内容 (store and write)
(5) 释放同步对象锁 (unlock)
可 见,synchronized的另外一个作用是保证主存内容和线程的工作内存中的数据的一致性。如果没有使用synchronized关键字,JVM不保 证第2步和第4步会严格按照上述次序立即执行。因为根据JLS中的规定,线程的工作内存和主存之间的数据交换是松耦合的,什么时候需要刷新工作内存或者更 新主内存内容,可以由具体的虚拟机实现自行决定。如果多个线程同时执行一段未经synchronized保护的代码段,很有可能某条线程已经改动了变量的 值,但是其他线程却无法看到这个改动,依然在旧的变量值上进行运算,最终导致不可预料的运算结果。
二、DCL失效
这一节我们要讨论的是一个让Java丢脸的话题:DCL失效。在开始讨论之前,先介绍一下LazyLoad,这种技巧很常用,就是指一个类包含某个成员变量,在类初始化的时候并不立即为该变量初始化一个实例,而是等到真正要使用到该变量的时候才初始化之。
例如下面的代码:
代码1
class Foo {
private Resource res = null;
public Resource getResource() {
if (res == null)
res = new Resource();
return res;
}
}
由于LazyLoad可以有效的减少系统资源消耗,提高程序整体的性能,所以被广泛的使用,连Java的缺省类加载器也采用这种方法来加载Java类。
在 单线程环境下,一切都相安无事,但如果把上面的代码放到多线程环境下运行,那么就可能会出现问题。假设有2条线程,同时执行到了if(res ==null),那么很有可能res被初始化2次,为了避免这样的RaceCondition,得用synchronized关键字把上面的方法同步起 来。代码如下:
代码2
Class Foo {
Private Resource res = null;
Public synchronized Resource getResource() {
If (res == null)
res = new Resource();
return res;
}
}
现在Race Condition解决了,一切都很好。
N 天过后,好学的你偶然看了一本Refactoring的魔书,深深为之打动,准备自己尝试这重构一些以前写过的程序,于是找到了上面这段代码。你已经不再 是以前的Java菜鸟,深知synchronized过的方法在速度上要比未同步的方法慢上100倍,同时你也发现,只有第一次调用该方法的时候才需要同 步,而一旦res初始化完成,同步完全没必要。所以你很快就把代码重构成了下面的样子:
代码3
Class Foo {
Private Resource res = null;
Public Resource getResource() {
If (res == null){
synchronized(this){
if(res == null){
res = new Resource();
}
}
}
return res;
}
}
这种看起来很完美的优化技巧就是Double-Checked Locking。但是很遗憾,根据Java的语言规范,上面的代码是不可靠的。
造 成DCL失效的原因之一是编译器的优化会调整代码的次序。只要是在单个线程情况下执行结果是正确的,就可以认为编译器这样的“自作主张的调整代码次序”的 行为是合法的。JLS在某些方面的规定比较自由,就是为了让JVM有更多余地进行代码优化以提高执行效率。而现在的CPU大多使用超流水线技术来加快代码 执行速度,针对这样的CPU,编译器采取的代码优化的方法之一就是在调整某些代码的次序,尽可能保证在程序执行的时候不要让CPU的指令流水线断流,从而 提高程序的执行速度。正是这样的代码调整会导致DCL的失效。为了进一步证明这个问题,引用一下《DCL BrokenDeclaration》文章中的例子:
设一行Java代码:
Objects[i].reference = new Object();
经过Symantec JIT编译器编译过以后,最终会变成如下汇编码在机器中执行:
0206106A mov eax,0F97E78h
0206106F call 01F6B210 ;为Object申请内存空间
; 返回值放在eax中
02061074 mov dword ptr [ebp],eax ; EBP 中是objects[i].reference的地址
; 将返回的空间地址放入其中
; 此时Object尚未初始化
02061077 mov ecx,dword ptr [eax] ; dereference eax所指向的内容
; 获得新创建对象的起始地址
02061079 mov dword ptr [ecx],100h ; 下面4行是内联的构造函数
0206107F mov dword ptr [ecx+4],200h
02061086 mov dword ptr [ecx+8],400h
0206108D mov dword ptr [ecx+0Ch],0F84030h
可见,Object构造函数尚未调用,但是已经能够通过objects[i].reference获得Object对象实例的引用。
如 果把代码放到多线程环境下运行,某线程在执行到该行代码的时候JVM或者操作系统进行了一次线程切换,其他线程显然会发现msg对象已经不为空,导致 Lazy load的判断语句if(objects[i].reference ==null)不成立。线程认为对象已经建立成功,随之可能会使用对象的成员变量或者调用该对象实例的方法,最终导致不可预测的错误。
原 因之二是在共享内存的SMP机上,每个CPU有自己的Cache和寄存器,共享同一个系统内存。所以CPU可能会动态调整指令的执行次序,以更好的进行并 行运算并且把运算结果与主内存同步。这样的代码次序调整也可能导致DCL失效。回想一下前面对Java内存模型的介绍,我们这里可以把 MainMemory看作系统的物理内存,把Thread WorkingMemory认为是CPU内部的Cache和寄存器,没有synchronized的保护,Cache和寄存器的内容就不会及时和主内存的 内容同步,从而导致一条线程无法看到另一条线程对一些变量的改动。
结合代码3来举例说明,假设Resource类的实现如下:
Class Resource{
Object obj;
}
即Resource类有一个obj成员变量引用了Object的一个实例。假设2条线程在运行,其状态用如下简化图表示:
图2
现 在Thread-1构造了Resource实例,初始化过程中改动了obj的一些内容。退出同步代码段后,因为采取了同步机制,Thread-1所做的改 动都会反映到主存中。接下来Thread-2获得了新的Resource实例变量res,由于没有使用synchronized保护所以Thread-2 不会进行刷新工作内存的操作。假如之前Thread-2的工作内存中已经有了obj实例的一份拷贝,那么Thread-2在对obj执行use操作的时候 就不会去执行load操作,这样一来就无法看到Thread-1对obj的改变,这显然会导致错误的运算结果。此外,Thread-1在退出同步代码段的 时刻对ref和obj执行的写入主存的操作次序也是不确定的,所以即使Thread-2对obj执行了load操作,也有可能只读到obj的初试状态的数 据。(注:这里的load/use均指JMM定义的操作)
有很多人不死心,试图想出了很多精妙的办法来解决这个问题,但最终都失败了。 事实上,无论是目前的JMM还是已经作为JSR提交的JMM模型的增强,DCL都不能正常使用。在William Pugh的论文《Fixingthe Java MemoryModel》中详细的探讨了JMM的一些硬伤,更尝试给出一个新的内存模型,有兴趣深入研究的读者可以参见文后的参考资料。
如果你设计的对象在程序中只有一个实例,即singleton的,有一种可行的解决办法来实现其LazyLoad:就是利用类加载器的LazyLoad特性。代码如下:
Class ResSingleton {
public static Resource res = new Resource();
}
这里ResSingleton只有一个静态成员变量。当第一次使用ResSingleton.res的时候,JVM才会初始化一个Resource实例,并且JVM会保证初始化的结果及时写入主存,能让其他线程看到,这样就成功的实现了LazyLoad。
除了这个办法以外,还可以使用ThreadLocal来实现DCL的方法,但是由于ThreadLocal的实现效率比较低,所以这种解决办法会有较大的性能损失,有兴趣的读者可以参考文后的参考资料。
最 后要说明的是,对于DCL是否有效,个人认为更多的是一种带有学究气的推断和讨论。而从纯理论的角度来看,存取任何可能共享的变量(对象引用)都需要同步 保护,否则都有可能出错,但是处处用synchronized又会增加死锁的发生几率,苦命的程序员怎么来解决这个矛盾呢?事实上,在很多Java开源项 目(比如Ofbiz/Jive等)的代码中都能找到使用DCL的证据,我在具体的实践中也没有碰到过因DCL而发生的程序异常。个人的偏好是:不妨先大胆 使用DCL,等出现问题再用synchronized逐步排除之。也许有人偏于保守,认为稳定压倒一切,那就不妨先用synchronized同步起来, 我想这是一个见仁见智的问题,而且得针对具体的项目具体分析后才能决定。还有一个办法就是写一个测试案例来测试一下系统是否存在DCL现象,附带的光盘中 提供了这样一个例子,感兴趣的读者可以自行编译测试。不管结果怎样,这样的讨论有助于我们更好的认识JMM,养成用多线程的思路去分析问题的习惯,提高我 们的程序设计能力。