Java内存模型JMM之三先行发生原则happens-before

一、前言

通过前面的Java内存模型JMM了解,我们知道多线程程序在执行过程中,不仅存在多个工作内存与主内存之间的内存交互,而且还可能存在指令重排序的情况。这将导致在多线程环境下,线程间操作的可见性变得难以把握。

 

我们不能就所有场景来规定某个线程的修改何时对其他线程可见,但可以制定某些规则,一旦满足这些规则我们就能轻松的确定线程间操作的可见性,这些规则就是happens-before中文也译为先行发生原则。从JDK 1.5 开始,JMM就使用happens-before的概念来阐述多线程之间的内存可见性。

 

二、happens-before原则

        没错,happens-before原则描述的其实就是可见性的问题,顺带着有一些由于禁止重排序而带来的有序性的问题,但没有原子性的含义。相比于单线程中的as-if-serial,happens-before更上一层楼,囊括了单线程、多线程,从更抽象更广义的角度定义规则,禁止某些情况下的重排序,保证内存可见性,程序的正确执行。

那么到底何为happens-before(先行发生)原则?先行发生是JMM中定义的两个操作之间的偏序关系,如果操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到。“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。举例说明:

 

i = 1 ;       //线程A执行
j = i ;      //线程B执行

 如果线程A的操作“i = 1”先行发生于线程B的操作“j = i”,那么我们就可以确定在线程B的操作执行后,变量j的值一定是等于1。因为根据先行发生原则,“i = 1”的结果可以在线程B执行之前被观察到。如果他们不存在happens-before原则,那么j = 1 不一定成立。

有很多文献资料描述为“若要保证一个操作的结果对另一个操作可见,必须保证两个操作之间存在先行发生原则”,以我目前的理解,我觉得应该是当我们满足了先行发生原则或者说在写代码时遵从了这些原则,才能确保这些操作之间的可见性,即可见性是先行发生原则产生的结果,而不是为了达到可见性而故意制造出先行发生原则。

 

JMM规定了一些“天然的”先行发生关系,这些先行发生关系不需要任何的同步器协助就已经存在,如果两个操作之间的关系不在此列,或者无法从下列规则推导出来,则它们之间将没有可见性保障,虚拟机还能对其进行重排序。

 

  • 程序次序规则:在同一个线程中,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确的说应该是控制流顺序,而不是书写顺序,因为要考虑分支、循环等结构,例如:switch、for。
  • 锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。注意是同一个锁,而“后面”是指时间上的先后顺序。
  • volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作。这里的“后面”同样是指时间上的先后顺序。
  • 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,则可以得出操作A先行发生于操作C的结论。
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作。
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()检测到是否有中断发生。
  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

最后先行发生原则还有如下特性

  1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序理当 排在第二个操作之前。
  2. 在1中我用了“理当”二字,因为这并不是绝对的,因为两个存在happens-before关系的操作,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before原则制定的顺序来执行的结果一致,那么这种重排序是被允许的,也就是存在happens-before关系的两个操作,在不影响程序执行结果的情况下也可能会被指令重排序。示例:
//以下两个操作在同一个线程中执行
int i = 1; 
int j = 2;

      依据程序次序规则,"int i = 1" 的操作先行发生于"int j = 2",但是“int j = 2”完全可能先被处理器执行,这并不影响先行发生原则的正确性,因为我们在这条线程中没办法感知到这一点。所以这里是允许被重排序执行的。

   3. 时间上先后顺序执行的两个操作,并不一定是线程安全的,示例:

private int value = 0;
public void setValue(int value){
	this.value = value;
}
public int getValue(){
	return value;
}

   上例是一组简单的getter/setter方法,假设有线程A时间上先调用了"setValue(1)",然后有线程B调用了同一个对象的“getValue()”,那么线程B收到的返回值是什么呢?我们依次拿上面的8条先行发生原则分析,发现没有一条规则满足,所以尽管我们可以判定线程A在操作时间上先于线程B,但是仍然不能确定线程B中“getValue()”方法的返回结果,所以时间上的先后顺序并不一定能得出先行发送原则,换句话说,这里面的操作不是线程安全的。

因此,时间上的先后顺序与先行发生原则之间基本上没有太大的联系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。

注意:上述一直提到的“操作”并不等同于“语句”,这里的操作应该是单个虚拟机指令,而单条语句可能由多个操作指令组成。

三、happens-before规则详解

  1. 程序次序规则

    as-if-serial语义保证了在单线程执行过程中,不论是否进行指令重排序,不能影响程序执行结果,所以程序最终执行的结果与顺序执行的结果是一致的,所以时间上先执行的影响一定对后执行的可见。
  2. 锁定规则

    无论是单线程还是多线程,根据JMM内存操作约束,一个锁对象在同一时刻只能被一条线程执行lock操作,一个处于被锁定状态的锁必须先执行unlock操作后面才能对其进行lock操作。根据这条规则以及后文的传递性,我们可以得出在临界区内对共享变量的修改一定能被下一次lock操作之前可见。
  3. volatile变量规则

    这是一条比较重要的规则,它标志着volatile保证了线程可见性。通俗点讲就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作一定是立即被后面的读线程可见的。详见下一篇volatile关键字详解。
  4. 传递性规则

    体现了happens-before原则具有传递性,非常重要的规则,根据该规则可以推导出很多具有先行发生原则特性的规则。
  5. 线程启动规则

    也就是说,先start了线程,才有线程中的操作,这个规则主要是和①程序次序规则、④传递性规则联合起来运用,也就是在如果线程A通过调用Thread对象实例B的start()方法启动了线程B,那么线程A在启动线程B之前对共享变量的修改操作在接下来线程B开始执行后对线程B一定可见。另外我们通过查看Thread的start方法源码知道其是一个synchronized修饰的方法,根据synchronized语义会将该线程对应的本地内存置为无效,从而使临界区的代码必须重新从主内存去读取共享变量,所以也能得出该结论。
  6. 线程中断规则

    该规则主要运用了③volatile变量规则、④传递性规则,因为通过分析openJDK中Thread的interrupt方法源码,你会发现它其实是修改了底层一个volatile修饰的状态变量,而中断检测方法interrupted其实正好也是对同一个volatile修饰的那个变量的状态判断,根据③volatile变量规则和④传递性规则,所以interrupt方法的调用肯定先行发生于中断检测方法interrupted检测到中断的发生。
  7. 线程终止规则

    该规则是指,假定线程A在执行的过程中,通过调用ThreadB.join()等待线程B终止,那么线程B在终止之前对共享变量的修改在线程A调用ThreadB.join()方法等待返回后立即可见。通过查看Thread的join方法源码,发现它也是synchronized修饰的方法,所以可以根据synchronized语义轻松得出该结论,但是该规则中还提到了通过Thread.isAlive()方法检测线程终止,虽然join方法最终也是通过调用该方法来进行检测线程是否终止,但isAlive并没有synchronized修饰,如何能够得出该结论,通过分析openJDK源码中关于isAlive的实现,可以看到是通过Thread的eetop找到内部的JavaThread, 如果为NULL,则表示线程已死。而设置这个JavaThread为null的时机是当Thread的run方法执行结束,会调用JavaThread::exit方法清理资源,在exit方法(注意不是Thread的exit而是openJDK中JavaThread类中的exit)中,设置Thread的eetop内部的JavaThread为null的代码位于一个加锁的临界区内:
    //由于原方法较长,删除不相关部分
    void JavaThread::exit(bool destroy_vm, ExitType exit_type) {
      ensure_join(this);
      assert(!this->has_pending_exception(), "ensure_join should have cleared");
    
     // Remove from list of active threads list, and notify VM thread if we are the last non-daemon thread
      Threads::remove(this);
    }
    
    static void ensure_join(JavaThread* thread) {
      // 获取Threads_lock
      Handle threadObj(thread, thread->threadObj());
      assert(threadObj.not_null(), "java thread object must exist");
      ObjectLocker lock(threadObj, thread);
      // 忽略pending exception (ThreadDeath)
      thread->clear_pending_exception();
      //设置java.lang.Thread的threadStatus为 TERMINATED.
      java_lang_Thread::set_thread_status(threadObj(), java_lang_Thread::TERMINATED);
      //清除native线程,这将会导致isAlive()方法返回false 
      java_lang_Thread::set_thread(threadObj(), NULL);
     //通知所有等待thread锁的线程, join的wait方法将返回,由于isActive返回false,join方法将执行结束并返回
      lock.notify_all(thread);
    }
    
       根据lock语义,在释放锁的时候,JMM会将该线程对应的本地内存中的共享变量刷新到主内存中,并使其他线程的本地变量失效。所以当线程A检测到线程B终结之后,会重新从主内存加载共享变量。
  8. 对象终结规则

    这条规则指出,一个对象在被垃圾回收之前必须已经进过初始化,垃圾回收不可能也不能去回收一个根本不存在的对象。

猜你喜欢

转载自pzh9527.iteye.com/blog/2391252