Java 线程内存模型Word文档格式.docx
《Java 线程内存模型Word文档格式.docx》由会员分享,可在线阅读,更多相关《Java 线程内存模型Word文档格式.docx(12页珍藏版)》请在冰豆网上搜索。
(2)从主存复制变量到当前工作内存(readandload)
(3)执行代码,改变共享变量值(useandassign)
(4)用工作内存数据刷新主存相关内容(storeandwrite)
(5)释放同步对象锁(unlock)
可见,synchronized的另外一个作用是保证主存内容和线程的工作内存中的数据的一致性。
如果没有使用synchronized关键字,JVM不保证第2步和第4步会严格按照上述次序立即执行。
因为根据JLS中的规定,线程的工作内存和主存之间的数据交换是松耦合的,什么时候需要刷新工作内存或者更新主内存内容,可以由具体的虚拟机实现自行决定。
如果多个线程同时执行一段未经synchronized保护的代码段,很有可能某条线程已经改动了变量的值,但是其他线程却无法看到这个改动,依然在旧的变量值上进行运算,最终导致不可预料的运算结果。
二、DCL失效
这一节我们要讨论的是一个让Java丢脸的话题:
DCL失效。
在开始讨论之前,先介绍一下LazyLoad,这种技巧很常用,就是指一个类包含某个成员变量,在类初始化的时候并不立即为该变量初始化一个实例,而是等到真正要使用到该变量的时候才初始化之。
例如下面的代码:
代码1
classFoo
{
privateResourceres=null;
publicResourcegetResource()
{
if(res==null)res=newResource();
returnres;
}
}
由于LazyLoad可以有效的减少系统资源消耗,提高程序整体的性能,所以被广泛的使用,连Java的缺省类加载器也采用这种方法来加载Java类。
在单线程环境下,一切都相安无事,但如果把上面的代码放到多线程环境下运行,那么就可能会出现问题。
假设有2条线程,同时执行到了if(res==null),那么很有可能res被初始化2次,为了避免这样的RaceCondition,得用synchronized关键字把上面的方法同步起来。
代码如下:
代码2
ClassFoo
PrivateResourceres=null;
PublicsynchronizedResourcegetResource()
If(res==null)res=newResource();
现在RaceCondition解决了,一切都很好。
N天过后,好学的你偶然看了一本Refactoring的魔书,深深为之打动,准备自己尝试这重构一些以前写过的程序,于是找到了上面这段代码。
你已经不再是以前的Java菜鸟,深知synchronized过的方法在速度上要比未同步的方法慢上100倍,同时你也发现,只有第一次调用该方法的时候才需要同步,而一旦res初始化完成,同步完全没必要。
所以你很快就把代码重构成了下面的样子:
代码3
PublicResourcegetResource()
If(res==null)
{
synchronized(this)
{
if(res==null)
{
res=newResource();
}
}
}
这种看起来很完美的优化技巧就是Double-CheckedLocking。
但是很遗憾,根据Java的语言规范,上面的代码是不可靠的。
造成DCL失效的原因之一是编译器的优化会调整代码的次序。
只要是在单个线程情况下执行结果是正确的,就可以认为编译器这样的“自作主张的调整代码次序”的行为是合法的。
JLS在某些方面的规定比较自由,就是为了让JVM有更多余地进行代码优化以提高执行效率。
而现在的CPU大多使用超流水线技术来加快代码执行速度,针对这样的CPU,编译器采取的代码优化的方法之一就是在调整某些代码的次序,尽可能保证在程序执行的时候不要让CPU的指令流水线断流,从而提高程序的执行速度。
正是这样的代码调整会导致DCL的失效。
为了进一步证明这个问题,引用一下《DCLBrokenDeclaration》文章中的例子:
设一行Java代码:
Objects[i].reference=newObject();
经过SymantecJIT编译器编译过以后,最终会变成如下汇编码在机器中执行:
0206106Amov eax,0F97E78h
0206106Fcall 01F6B210 ;
为Object申请内存空间
;
返回值放在eax中
02061074mov dwordptr[ebp],eax ;
EBP中是objects[i].reference的地址
将返回的空间地址放入其中
此时Object尚未初始化
02061077mov ecx,dwordptr[eax] ;
dereferenceeax所指向的内容
;
获得新创建对象的起始地址
02061079mov dwordptr[ecx],100h ;
下面4行是内联的构造函数
0206107Fmov dwordptr[ecx+4],200h
02061086mov dwordptr[ecx+8],400h
0206108Dmov dwordptr[ecx+0Ch],0F84030h
可见,Object构造函数尚未调用,但是已经能够通过objects[i].reference获得Object对象实例的引用。
如果把代码放到多线程环境下运行,某线程在执行到该行代码的时候JVM或者操作系统进行了一次线程切换,其他线程显然会发现msg对象已经不为空,导致Lazyload的判断语句if(objects[i].reference==null)不成立。
线程认为对象已经建立成功,随之可能会使用对象的成员变量或者调用该对象实例的方法,最终导致不可预测的错误。
原因之二是在共享内存的SMP机上,每个CPU有自己的Cache和寄存器,共享同一个系统内存。
所以CPU可能会动态调整指令的执行次序,以更好的进行并行运算并且把运算结果与主内存同步。
这样的代码次序调整也可能导致DCL失效。
回想一下前面对Java内存模型的介绍,我们这里可以把MainMemory看作系统的物理内存,把ThreadWorkingMemory认为是CPU内部的Cache和寄存器,没有synchronized的保护,Cache和寄存器的内容就不会及时和主内存的内容同步,从而导致一条线程无法看到另一条线程对一些变量的改动。
结合代码3来举例说明,假设Resource类的实现如下:
ClassResource{Objectobj;
即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都不能正常使用。
在WilliamPugh的论文《FixingtheJavaMemoryModel》中详细的探讨了JMM的一些硬伤,更尝试给出一个新的内存模型,有兴趣深入研究的读者可以参见文后的参考资料。
如果你设计的对象在程序中只有一个实例,即singleton的,有一种可行的解决办法来实现其LazyLoad:
就是利用类加载器的LazyLoad特性。
ClassResSingleton{publicstaticResourceres=newResource();
这里ResSingleton只有一个静态成员变量。
当第一次使用ResSingleton.res的时候,JVM才会初始化一个Resource实例,并且JVM会保证初始化的结果及时写入主存,能让其他线程看到,这样就成功的实现了LazyLoad。
除了这个办法以外,还可以使用ThreadLocal来实现DCL的方法,但是由于ThreadLocal的实现效率比较低,所以这种解决办法会有较大的性能损失,有兴趣的读者可以参考文后的参考资料。
最后要说明的是,对于DCL是否有效,个人认为更多的是一种带有学究气的推断和讨论。
而从纯理论的角度来看,存取任何可能共享的变量(对象引用)都需要同步保护,否则都有可能出错,但是处处用synchronized又会增加死锁的发生几率,苦命的程序员怎么来解决这个矛盾呢?
事实上,在很多Java开源项目(比如Ofbiz/Jive等)的代码中都能找到使用DCL的证据,我在具体的实践中也没有碰到过因DCL而发生的程序异常。
个人的偏好是:
不妨先大胆使用DCL,等出现问题再用synchronized逐步排除之。
也许有人偏于保守,认为稳定压倒一切,那就不妨先用synchronized同步起来,我想这是一个见仁见智的问题,而且得针对具体的项目具体分析后才能决定。
还有一个办法就是写一个测试案例来测试一下系统是否存在DCL现象,附带的光盘中提供了这样一个例子,感兴趣的读者可以自行编译测试。
不管结果怎样,这样的讨论有助于我们更好的认识JMM,养成用多线程的思路去分析问题的习惯,提高我们的程序设计能力。
三、Java线程同步增强包
相信你已经了解了Java用于同步的3板斧:
synchronized/wait/notify,它们的确简单而有效。
但是在某些情况下,我们需要更加复杂的同步工具。
有些简单的同步工具类,诸如ThreadBarrier,Semaphore,ReadWriteLock等,可以自己编程实现。
现在要介绍的是牛人DougLea的Concurrent包。
这个包专门为实现Java高级并行程序所开发,可以满足我们绝大部分的要求。
更令人兴奋的是,这个包公开源代码,可自由下载。
且在JDK1.5中该包将作为SDK一部分提供给Java开发人员。
ConcurrentPackage提供了一系列基本的操作接口,包括sync,channel,executor,barrier,callable等。
这里将对前三种接口及其部分派生类进行简单的介绍。
sync接口:
专门负责同步操作,用于替代Java提供的synchronized关键字,以实现更加灵活的代码同步。
其类关系图如下:
图3Concurrent包Sync接口类关系图
Semaphore:
和前面介绍的代码类似,可用于pool类实现资源管理限制。
提供了acquire()方法允许在设定时间内尝试锁定信号量,若超时则返回false。
Mutex:
和Java的synchronized类似,与之不同的是,synchronized的同步段只能限制在一个方法内,而Mutex对象可以作为参数在方法间传递,所以可以把同步代码范围扩大到跨方法甚至跨对象。
NullSync:
一个比较奇怪的东西,其方法的内部实现都是空的,可能是作者认为如果你在实际中发现某段代码根本可以不用同步,但是又不想过多改动这段代码,那么就可以用NullSync来替代原来的Sync实例。
此外,由于NullSync的方法都是synchronized,所以还是保留了“内存壁垒”的特性。
ObservableSync:
把sync和observer模式结合起来,当sync的方法被调用时,把消息通知给订阅者,可用于同步性能调试。
TimeoutSync:
可以认为是一个adaptor,其构造函数如下:
publicTimeoutSync(Syncsync,longtimeout){…}
具体上锁的代码靠构造函数传入的sync实例来完成,其自身只负责监测上锁操作是否超时,可与SyncSet合用。
Channel接口:
代表一种具备同步控制能力的容器,你可以从中存放/读取对象。
不同于JDK中的Collection接口,可以把Channel看作是连接对象构造者(Producer)和对象使用者(Consumer)之间的一根管道。
如图所示:
图4Concurrent包Channel接口示意图
通过和Sync接口配合,Channel提供了阻塞式的对象存取方法(put/take)以及可设置阻塞等待时间的offer/poll方法。
实现Channel接口的类有LinkedQueue,BoundedLinkedQueue,BoundedBuffer,BoundedPriorityQueue,SynchronousChannel,Slot等。
图5Concurrent包Channel接口部分类关系图
使用Channel我们可以很容易的编写具备消息队列功能的代码,示例如下:
代码4
Packageorg.javaresearch.j2seimproved.thread;
ImportEDU.oswego.cs.dl.util.concurrent.*;
publicclassTestChannel{
finalChannelmsgQ=newLinkedQueue();
//log信息队列
publicstaticvoidmain(String[]args){
TestChanneltc=newTestChannel();
For(inti=0;
i<
10;
i++){
Try{
tc.serve();
Thread.sleep(1000);
}catch(InterruptedExceptionie){
publicvoidserve()throwsInterruptedException{
Stringstatus=doService();
//把doService()返回状态放入Channel,后台logger线程自动读取之
msgQ.put(status);
privateStringdoService(){
//Doservicehere
return"
servicecompletedOK!
"
;
publicTestChannel(){//startbackgroundthread
Runnablelogger=newRunnable(){
publicvoidrun(){
try{
for(;
;
)
System.out.println("
Logger:
+msgQ.take());
catch(InterruptedExceptionie){}
};
newThread(logger).start();
Excutor/ThreadFactory接口:
把相关的线程创建/回收/维护/调度等工作封装起来,而让调用者只专心于具体任务的编码工作(即实现Runnable接口),不必显式创建Thread类实例就能异步执行任务。
使用Executor还有一个好处,就是实现线程的“轻量级”使用。
前面章节曾提到,即使我们实现了Runnable接口,要真正的创建线程,还是得通过newThread()来完成,在这种情况下,Runnable对象(任务)和Thread对象(线程)是1对1的关系。
如果任务多而简单,完全可以给每条线程配备一个任务队列,让Runnable对象(任务)和Executor对象变成n:
1的关系。
使用了Executor,我们可以把上面两种线程策略都封装到具体的Executor实现中,方便代码的实现和维护。
具体的实现有:
PooledExecutor,ThreadedExecutor,QueuedExecutor,FJTaskRunnerGroup等
类关系图如下:
图6Concurrent包Executor/ThreadFactory接口部分类关系图
下面给出一段代码,使用PooledExecutor实现一个简单的多线程服务器
代码5
packageorg.javaresearch.j2seimproved.thread;
import.*;
importEDU.oswego.cs.dl.util.concurrent.*;
publicclassTestExecutor
publicstaticvoidmain(String[]args)
PooledExecutorpool=newPooledExecutor(newBoundedBuffer(10),20);
pool.createThreads(4);
try
ServerSocketsocket=newServerSocket(9999);
)
finalSocketconnection=socket.accept();
pool.execute(newRunnable()
publicvoidrun()
{
newHandler().process(connection);
}
});
catch(Exceptione){}
//die
staticclassHandler{voidprocess(Sockets){}}