疯狂java


您现在的位置: 疯狂软件 >> 新闻资讯 >> 正文

Java并发环境下指令重排带来的问题


 

   

  JVM内存模型 - 主内存和线程独立的工作内存

  Java内存模型规定,对于多个线程共享的变量,存储在主内存当中,每个线程都有自己独立的工作内存,线程只能访问自己的工作内存,不可以访问其它线程的工作内存。工作内存中保存了主内存共享变量的副本,线程要操作这些共享变量,只能通过操作工作内存中的副本来实现,操作完毕之后再同步回到主内存当中。

  如何保证多个线程操作主内存的数据完整性是一个难题,Java内存模型也规定了工作内存与主内存之间交互的协议,首先是定义了8种原子操作:

  (1) lock:将主内存中的变量锁定,为一个线程所独占

  (2) unclock:将lock加的锁定解除,此时其它的线程可以有机会访问此变量

  (3) read:将主内存中的变量值读到工作内存当中

  (4) load:将read读取的值保存到工作内存中的变量副本中。

  (5) use:将值传递给线程的代码执行引擎

  (6) assign:将执行引擎处理返回的值重新赋值给变量副本

  (7) store:将变量副本的值存储到主内存中。

  (8) write:将store存储的值写入到主内存的共享变量当中。

  1. 内存可见性

  1.1 概念

  通过上面Java内存模型的概述,我们会注意到这么一个问题,每个线程在获取锁之后会在自己的工作内存来操作共享变量,操作完成之后将工作内存中的副本回写到主内存,并且在其它线程从主内存将变量同步回自己的工作内存之前,共享变量的改变对其是不可见的。

  1.2 内存可见性带来的问题

  很多时候我们需要一个线程对共享变量的改动,其它线程也需要立即得知这个改动该怎么办呢?比如以下的情景,有一个全局的状态变量open:

  boolean open=true;

  这个变量用来描述对一个资源的打开关闭状态,true表示打开,false表示关闭,假设有一个线程A,在执行一些操作后将open修改为false:

  //线程A

  resource.close();

  open = false;

  线程B随时关注open的状态,当open为true的时候通过访问资源来进行一些操作:

  //线程B

  while(open) {

  doSomethingWithResource(resource);

  }

  当A把资源关闭的时候,open变量对线程B不可见,如果此时open变量的改动尚未同步到线程B的工作内存中,那么线程B就会用一个已经关闭了的资源去做一些操作,因此产生错误。

  1.3 volatile关键字

  所以对于上面的情景,要求一个线程对open的改变,其他的线程能够立即可见,Java为此提供了volatile关键字,在声明open变量的时候加入volatile关键字就可以保证open的内存可见性,即open的改变对所有的线程都是立即可见的。

  volatile保证可见性的原理是在每次访问变量时都会进行一次刷新,因此每次访问都是主内存中最新的版本。所以volatile关键字的作用之一就是保证变量修改的实时可见性。

  2. 指令重排

  2.1 概念

  指令重排序是JVM为了优化指令,提高程序运行效率。指令重排序包括编译器重排序和运行时重排序。JVM规范规定,指令重排序可以在不影响单线程程序执行结果前提下进行。

  2.2 指令重排带来的问题

  例子1:简单指令重排

  假设有这么两个共享变量a和b:

  private int a;

  private int b;

  在线程A中有两条语句对这两个共享变量进行赋值操作:

  a = 1;

  b = 2;

  假设当线程A对a进行复制操作的时候发现这个变量在主内存已经被其它的线程加了访问锁,那么此时线程A怎么办?等待释放锁?不,等待太浪费时间了,它会去尝试进行b的赋值操作,b这时候没被人占用,因此就会先为b赋值,再去为a赋值,那么执行的顺序就变成了:

  b = 2;

  a = 1;

  例子2:A线程指令重排导致B线程出错

  对于在同一个线程内,这样的改变是不会对逻辑产生影响的,但是在多线程的情况下指令重排序会带来问题。看下面这个情景:

  在线程A中:

  context = loadContext();

  inited = true;

  在线程B中:

  while(!inited ){ //根据线程A中对inited变量的修改决定是否使用context变量

  sleep(100);

  }

  doSomethingwithconfig(context);

  假设线程A中发生了指令重排序:

  inited = true;

  context = loadContext();

  那么B中很可能就会拿到一个尚未初始化或尚未初始化完成的context,从而引发程序错误。

  例子3:指令重排导致单例模式失效

  我们都知道一个经典的懒加载方式的单例模式:

  public class Singleton {

  private static Singleton instance = null;

  private Singleton() { }

  public static Singleton getInstance() {

  if(instance == null) {

  synchronzied(Singleton.class) {

  if(instance == null) {

  instance = new Singleton();

  }

  }

  }

  return instance;

  }

  }

  (更多关于单例模式的实现方式,参考另一篇博文)

  看似简单的一段赋值语句:instance = new Singleton();,其实JVM内部已经转换为多条指令:

  memory = allocate(); //1:分配对象的内存空间

  ctorInstance(memory); //2:初始化对象

  instance = memory; //3:设置instance指向刚分配的内存地址

  但是经过重排序后如下:

  memory = allocate(); //1:分配对象的内存空间

  instance = memory; //3:设置instance指向刚分配的内存地址,此时对象还没被初始化

  ctorInstance(memory); //2:初始化对象

  可以看到指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化被排在了后面,在线程A初始化完成这段内存之前,线程B虽然进不去同步代码块,但是在同步代码块之前的判断就会发现instance不为空,此时线程B获得instance对象进行使用就可能发生错误。

  2.3 volatile关键字

  除了前面内存可见性中讲到的volatile关键字可以保证变量修改的可见性之外,还有另一个重要的作用:在JDK1.5之后,可以使用volatile变量禁止指令重排序。

  例子2和例子3中的变量以关键字volatile修饰之后,就会组织JVM对其相关代码进行指令重排,这样就能够按照既定的顺序指执行。

  总结

  相对于synchronized块的代码锁,volatile应该是提供了一个轻量级的针对共享变量的锁,当我们在多个线程间使用共享变量进行通信的时候需要考虑将共享变量用volatile来修饰。