Java 线程/内存模型的缺陷和增强










   


  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的设计,系统存在一个主内存(Main Memory),Java中所有变量都储存在主存中,对于所有线程都是 1 2 3 4 5 :  二、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次,为了避免这样的Race Condition,得用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的语言规范,上面的代码是不可靠的。

  7 1
2
3 4 5 8 :  造成DCL失效的原因之一是编译器的优化会调整代码的次序。只要是在单个线程情况下执行结果是正确的,就可以认为编译器这样的“自作主张的调整代码次序”的行为是合法的。JLS在某些方面的规定比较自由,就是为了让JVM有更多余地进行代码优化以提高执行效率。而现在的CPU大多使用超流水线技术来加快代码执行速度,针对这样的CPU,编译器采取的代码优化的方法之一就是在调整某些代码的次序,尽可能保证在程序执行的时候不要让CPU的指令流水线断流,从而提高程序的执行速度。正是这样的代码调整会导致DCL的失效。为了进一步证明这个问题,引用一下《DCL Broken Declaration》文章中的例子:

  设一行Java代码:

Objects.reference = new Object();

  经过Symantec JIT编译器编译过以后,最终会变成如下汇编码在机器中执行:





0206106A mov  eax,0F97E78h
0206106F call 01F6B210       ;为Object申请内存空间
                  ; 返回值放在eax中
02061074 mov  dword ptr [ebp],eax  ; EBP 中是objects.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.reference获得Object对象实例的引用。

  如果把代码放到多线程环境下运行,某线程在执行到该行代码的时候JVM或者操作系统进行了一次线程切换,其他线程显然会发现msg对象已经不为空,导致Lazy load的判断语句if(objects.reference == null)不成立。线程认为对象已经建立成功,随之可能会使用对象的成员变量或者调用该对象实例的方法,最终导致不可预测的错误。

  原因之二是在  1 2 3 4 5 :  三、Java线程同步增强包

  相信你已经了解了Java用于同步的3板斧:synchronized/wait/notify,它们的确简单而有效。但是在某些情况下,我们需要更加复杂的同步工具。有些简单的同步工具类,诸如ThreadBarrier,Semaphore,ReadWriteLock等,可以自己编程实现。现在要介绍的是牛人Doug Lea的Concurrent包。这个包专门为实现Java高级并行程序所开发,可以满足我们绝大部分的要求。更令人兴奋的是,这个包公开源代码,可自由下载。且在JDK1.5中该包将作为SDK一部分提供给Java开发人员。

  Concurrent Package提供了一系列基本的操作接口,包括sync,channel,executor,barrier,callable等。这里将对前三种接口及其部分派生类进行简单的介绍。

  sync接口:专门负责同步操作,用于替代Java提供的synchronized关键字,以实现更加灵活的代码同步。其类关系图如下:


[img]/image20010518/109310.jpg[/img]

图3 Concurrent包Sync接口类关系图

  Semaphore:和前面介绍的代码类似,可用于pool类实现资源管理限制。提供了acquire()方法允许在设定时间内尝试锁定信号量,若超时则返回false。

  Mutex:和Java的synchronized类似,与之不同的是,synchronized的同步段只能限制在一个方法内,而Mutex对象可以作为参数在方法间传递,所以可以把同步代码范围扩大到跨方法甚至跨对象。

  NullSync:一个比较奇怪的东西,其方法的内部实现都是空的,可能是作者认为如果你在实际中发现某段代码根本可以不用同步,但是又不想过多改动这段代码,那么就可以用NullSync来替代原来的Sync实例。此外,由于NullSync的方法都是synchronized,所以还是保留了“内存壁垒”的特性。

  ObservableSync:把sync和observer模式结合起来,当sync的方法被调用时,把消息通知给订阅者,可用于同步性能调试。

  TimeoutSync:可以认为是一个adaptor,其构造函数如下:





public TimeoutSync(Sync sync, long timeout){…}
  具体上锁的代码靠构造函数传入的sync实例来完成,其自身只负责监测上锁操作是否超时,可与SyncSet合用。

  Channel接口:代表一种具备同步控制能力的容器,你可以从中存放/读取对象。不同于JDK中的Collection接口,可以把Channel看作是连接对象构造者(Producer)和对象使用者(Consumer)之间的一根管道。如图所示:


[img]/image20010518/109311.jpg[/img]

图4 Concurrent包Channel接口示意图

  通过和Sync接口配合,Channel提供了阻塞式的对象存取方法(put/take)以及可设置阻塞等待时间的offer/poll方法。实现Channel接口的类有LinkedQueue,BoundedLinkedQueue,BoundedBuffer,BoundedPriorityQueue,SynchronousChannel,Slot等。


[img]/image20010518/109312.jpg[/img]

图5 Concurrent包Channel接口部分类关系图

  使用Channel我们可以很容易的编写具备消息队列功能的代码,示例如下:

  代码4





Package org.javaresearch.j2seimproved.thread;

Import EDU.oswego.cs.dl.util.concurrent.*;

public class TestChannel {
 final Channel msgQ = new LinkedQueue(); //log信息队列

 public static void main(String[] args) {
  TestChannel tc = new TestChannel();
  For(int i = 0;i < 10;i  ){
   Try{
    tc.serve();
    Thread.sleep(1000);
   }catch(InterruptedException ie){
  }
 }
}

public void serve() throws InterruptedException {
 String status = doService();
 //把doService()返回状态放入Channel,后台logger线程自动读取之
 msgQ.put(status);
}

private String doService() {
 // Do service here
 return "service completed OK! ";
}

public TestChannel() { // star

 感谢原创者的辛勤劳动,希望对您有所帮助,转载请注明原出处。
 您可能对 [Java] 的这些文章也感兴趣:

Java开发者的十大戒律
J2EE的Web服务原理和体系结构慨述
X3D实战基础讲座之七X3D
【转载】初学java第一步——JDK环境变量配置
Rational XDE Java Code Model Importer简介
JavaScript Development Toolkit 简介
java 读取 propterties 文件
浅谈Java桌面应用程序开发
JAVA专题技术综述之线程篇
【转帖】学好编程的关键