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