RSS
热门关键字:
当前位置 : 主页>编程开发>java编程>入门>列表

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

来源:我要研发网 作者: 时间:1970-01-01 点击:



  Java在语言层次上实现了对线程支持。它提供了Thread/Runnable/ThreadGroup等一系列封装类和接口,让程序员可以高效开发Java多线程应用。为了实现同步,Java提供了synchronize关键字以及objectwait()/notify()机制,可是在简单易用背后,应藏着更为复杂玄机,很多问题就是由此而起。

字串6

  一、Java内存模型

字串3

  在了解Java同步秘密之前,先来看看JMM(Java Memory Model)。

字串1

  Java被设计为跨平台语言,在内存管理上,显然也要有一个统一模型。而且Java语言最大特点就是废除了指针,把程序员从痛苦中解脱出来,不用再考虑内存使用和管理方面问题。 字串9

  可惜世事总不尽如人意,虽然JMM设计上方便了程序员,但是它增加了虚拟机复杂程度,而且还导致某些编程技巧在Java语言中失效。

字串6

  JMM主要是为了规定了线程和内存之间一些关系。对Java程序员来说只需负责用synchronized同步关键字,其它诸如与线程/内存之间进行数据交换/同步等繁琐工作均由虚拟机负责完成。如图1所示:根据JMM设计,系统存在一个主内存(Main Memory),Java中所有变量都储存在主存中,对于所有线程都是共享。每条线程都有自己工作内存(Working Memory),工作内存中保存是主存中某些变量拷贝,线程对所有变量操作都是在工作内存中进行,线程之间无法相互直接访问,变量传递均需要通过主存完成。 字串1

   字串6

  图1 Java内存模型示例图 字串1

  线程若要对某变量进行操作,必须经过一系列步骤:首先从主存复制/刷新数据到工作内存,然后执行代码,进行引用/赋值操作,最后把变量内容写回Main Memory。Java语言规范(JLS)中对线程和主存互操作定义了6个行为,分别为load,save,read,write,assign和use,这些操作行为具有原子性,且相互依赖,有明确调用先后顺序。具体描述请参见JLS第17章。

字串1

  我们在前面章节介绍了synchronized作用,现在,从JMM角度来重新审视synchronized关键字。

字串5


字串5

  假设某条线程执行一个synchronized代码段,其间对某变量进行操作,JVM会依次执行如下动作:

字串8

  (1) 获取同步对象monitor (lock)

字串9

  (2) 从主存复制变量到当前工作内存 (read and load)

字串9

  (3) 执行代码,改变共享变量值 (use and assign)

字串8

  (4) 用工作内存数据刷新主存相关内容 (store and write)

字串1

  (5) 释放同步对象锁 (unlock)

字串4

  可见,synchronized另外一个作用是保证主存内容和线程工作内存中数据一致性。如果没有使用synchronized关键字,JVM不保证第2步和第4步会严格按照上述次序立即执行。因为根据JLS中规定,线程工作内存和主存之间数据交换是松耦合,什么时候需要刷新工作内存或者更新主内存内容,可以由具体虚拟机实现自行决定。如果多个线程同时执行一段未经synchronized保护代码段,很有可能某条线程已经改动了变量值,但是其他线程却无法看到这个改动,依然在旧变量值上进行运算,最终导致不可预料运算结果。

字串4

  二、DCL失效

字串6

  这一节我们要讨论是一个让Java丢脸话题:DCL失效。在开始讨论之前,先介绍一下LazyLoad,这种技巧很常用,就是指一个类包含某个成员变量,在类初始化时候并不立即为该变量初始化一个实例,而是等到真正要使用到该变量时候才初始化之。

字串8

  例如下面代码: 字串1

  代码1 字串1

class Foo
{
 private Resource res = null;
 public Resource getResource()
 {
  if (res == null) res = new Resource();
  return res;
 }
}

  由于LazyLoad可以有效减少系统资源消耗,提高程序整体性能,所以被广泛使用,连Java缺省类加载器也采用这种方法来加载Java类。

字串9

  在单线程环境下,一切都相安无事,但如果把上面代码放到多线程环境下运行,那么就可能会出现问题。假设有2条线程,同时执行到了if(res == null),那么很有可能res被初始化2次,为了避免这样Race Condition,得用synchronized关键字把上面方法同步起来。代码如下:

字串6

  代码2 字串6

Class Foo
{
 Private Resource res = null;
 Public synchronized Resource getResource()
 {
  If (res == null) res = new Resource();
  return res;
 }
}

  现在Race Condition解决了,一切都很字串2

  N天过后,你偶然看了一本Refactoring魔书,深深为之打动,准备自己尝试这重构一些以前写过程序,于是找到了上面这段代码。你已经不再是以前Java菜鸟,深知synchronized过方法在速度上要比未同步方法慢上100倍,同时你也发现,只有第一次调用该方法时候才需要同步,而一旦res初始化完成,同步完全没必要。所以你很快就把代码重构成了下面样子: 字串9


字串3

  代码3 字串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语言规范,上面代码是不可靠字串8

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

  设一行Java代码:

字串6

  Objects[i].reference = new Object(); 字串8

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

字串8

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对象实例引用。 字串3

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


字串6

  原因之二是在共享内存SMP机上,每个CPU有自己Cache和寄存器,共享同一个系统内存。所以CPU可能会动态调整指令执行次序,以更进行并行运算并且把运算结果与主内存同步。这样代码次序调整也可能导致DCL失效。回想一下前面对Java内存模型介绍,我们这里可以把Main Memory看作系统物理内存,把Thread Working Memory认为是CPU内部Cache和寄存器,没有synchronized保护,Cache和寄存器内容就不会及时和主内存内容同步,从而导致一条线程无法看到另一条线程对一些变量改动。

字串8

  结合代码3来举例说明,假设Resource类实现如下: 字串6

  Class Resource{ Object obj;} 字串8

  即Resource类有一个obj成员变量引用了Object一个实例。假设2条线程在运行,其状态用如下简化图表示:

字串3

   字串9

  图2 字串8

  现在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定义操作) 字串1

  有很多人不死心,试图想出了很多精妙办法来解决这个问题,但最终都失败了。事实上,无论是目前JMM还是已经作为JSR提交JMM模型增强,DCL都不能正常使用。在William Pugh论文《Fixing the Java Memory Model》中详细探讨了JMM一些硬伤,更尝试给出一个新内存模型,有兴趣深入研究读者可以参见文后参考资料。 字串9


字串2

  如果你设计对象在程序中只有一个实例,即singleton,有一种可行解决办法来实现其LazyLoad:就是利用类加载器LazyLoad特性。代码如下: 字串1

Class ResSingleton {public static Resource res = new Resource();}

  这里ResSingleton只有一个静态成员变量。当第一次使用ResSingleton.res时候,JVM才会初始化一个Resource实例,并且JVM会保证初始化结果及时写入主存,能让其他线程看到,这样就成功实现了LazyLoad。

字串1

  除了这个办法以外,还可以使用ThreadLocal来实现DCL方法,但是由于ThreadLocal实现效率比较低,所以这种解决办法会有较大性能损失,有兴趣读者可以参考文后参考资料。 字串7

  最后要说明是,对于DCL是否有效,个人认为更多是一种带有学究气推断和讨论。而从纯理论角度来看,存取任何可能共享变量(对象引用)都需要同步保护,否则都有可能出错,但是处处用synchronized又会增加死锁发生几率,苦命程序员怎么来解决这个矛盾呢?事实上,在很多Java开源项目(比如Ofbiz/Jive等)代码中都能找到使用DCL证据,我在具体实践中也没有碰到过因DCL而发生程序异常。个人是:不妨先大胆使用DCL,等出现问题再用synchronized逐步排除之。也许有人偏于保守,认为稳定压倒一切,那就不妨先用synchronized同步起来,我想这是一个见仁见智问题,而且得针对具体项目具体分析后才能决定。还有一个办法就是写一个测试案例来测试一下系统是否存在DCL现象,附带光盘中提供了这样一个例子,感兴趣读者可以自行编译测试。不管结果怎样,这样讨论有助于我们更认识JMM,养成用多线程思路去分析问题习惯,提高我们程序设计能力。

字串3

  三、Java线程同步增强包

字串2

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

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


字串3

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

字串1

   字串7

  图3 Concurrent包Sync接口类关系图 字串6

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

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

字串4

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

字串6

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

字串7

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

public TimeoutSync(Sync sync, long timeout){…}

  具体上锁代码靠构造函数传入sync实例来完成,其自身只负责监测上锁操作是否超时,可与SyncSet合用。 字串1

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

  

字串4


字串2

  图4 Concurrent包Channel接口示意图 字串3

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

  

字串3

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

字串4

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

字串5

  代码4

字串1

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() { // start background thread
 Runnable logger = new Runnable() {
 public void run() {
  try {
   for (; ; )
   System.out.println("Logger: " msgQ.take());
  }
  catch (InterruptedException ie) {}
 }
};
new Thread(logger).start();

字串7


}
}

  Excutor/ThreadFactory接口: 把相关线程创建/回收/维护/调度等工作封装起来,而让调用者只专心于具体任务编码工作(即实现Runnable接口),不必显式创建Thread类实例就能异步执行任务。 字串7


字串8

  使用Executor还有一个处,就是实现线程“轻量级”使用。前面章节曾提到,即使我们实现了Runnable接口,要真正创建线程,还是得通过new Thread()来完成,在这种情况下,Runnable对象(任务)和Thread对象(线程)是1对1关系。如果任务多而简单,完全可以给每条线程配备一个任务队列,让Runnable对象(任务)和Executor对象变成n:1关系。使用了Executor,我们可以把上面两种线程策略都封装到具体Executor实现中,方便代码实现和维护。

字串5

  具体实现有: PooledExecutor,ThreadedExecutor,QueuedExecutor,FJTaskRunnerGroup等

字串6

  类关系图如下:

字串1

  

字串5

  图6 Concurrent包Executor/ThreadFactory接口部分类关系图

字串2

  下面给出一段代码,使用PooledExecutor实现一个简单多线程服务器 字串8

  代码5 字串8

package org.javaresearch.j2seimproved.thread;
import java.net.*;
import EDU.oswego.cs.dl.util.concurrent.*;
public class TestExecutor
{
 public static void main(String[] args)
 {
  PooledExecutor pool = new PooledExecutor(new BoundedBuffer(10), 20);
  pool.createThreads(4);
  try
  {
   ServerSocket socket = new ServerSocket(9999);
   for (; ; )
   {
    final Socket connection = socket.accept();
    pool.execute(new Runnable()
    {
     public void run()
     {
      new Handler().process(connection);
     }
    });
   }
  }
  catch (Exception e) {}
  // die
 }
 static class Handler { void process(Socket s){ } }
}

字串1

最新评论共有 0 位网友发表了评论
发表评论
评论内容:不能超过250字,需审核,请自觉遵守互联网相关政策法规。
用户名: 密码:
匿名?
注册
相关文章