从Java多线程可见性谈Happens-Before原则

这篇是转载的,以前直接把连接嵌在文章里,以为仍是独立出来比较好。如下是正文:程序员

Happens-Before是一个很是抽象的概念,然而它又是学习Java并发编程不可跨域的部分。本文会先阐述Happens-Before在并发编程中解决的问题——多线程可见性,而后再详细讲解Happens-Before原则自己。编程

Java多线程可见性

在现代操做系统上编写并发程序时,除了要注意线程安全性(多个线程互斥访问临界资源)之外,还要注意多线程对共享变量的可见性,然后者每每容易被人忽略。
可见性是指当一个线程修改了共享变量的值,其它线程可以适时得知这个修改。在单线程环境中,若是在程序前面修改了某个变量的值,后面的程序必定会读取到那个变量的新值。这看起来很天然,然而当变量的写操做和读操做在不一样的线程中时,状况却并不是如此。跨域

/**
 *《Java并发编程实战》27页程序清单3-1
 */
public class NoVisibility {
    private static boolean ready; 
    private static int number;
    
    private static class ReaderThread extends Thread {
        public void run() {
            while(!ready) {
                Thread.yield();
            }
            System.out.println(number);
        }
    }
    
    public static void main(String[] args) {
        new ReaderThread().start(); //启动一个线程
        number = 42;
        ready = true;
    }
}

上面的代码中,主线程和读线程都访问共享变量ready和number。程序看起来会输出42,但事实上极可能会输出0,或者根本没法终止。这是由于上面的程序缺乏线程间变量可见性的保证,因此在主线程中写入的变量值,可能没法被读线程感知到。缓存

为何会出现线程可见性问题

要想解释为何会出现线程可见性问题,须要从计算机处理器结构谈起。咱们都知道计算机运算任务须要CPU和内存相互配合共同完成,其中CPU负责逻辑计算,内存负责数据存储。CPU要与内存进行交互,如读取运算数据、存储运算结果等。因为内存和CPU的计算速度有几个数量级的差距,为了提升CPU的利用率,现代处理器结构都加入了一层读写速度尽量接近CPU运算速度的高速缓存来做为内存与CPU之间的缓冲:将运算须要使用的数据复制到缓存中,让CPU运算能够快速进行,计算结束后再将计算结果从缓存同步到主内存中,这样处理器就无须等待缓慢的内存读写了。
高速缓存的引入解决了CPU和内存之间速度的矛盾,可是在多CPU系统中也带来了新的问题:缓存一致性。在多CPU系统中,每一个CPU都有本身的高速缓存,全部的CPU又共享同一个主内存。若是多个CPU的运算任务都涉及到主内存中同一个变量时,那同步回主内存时以哪一个CPU的缓存数据为准呢?这就须要各个CPU在数据读写时都遵循同一个协议进行操做。安全

参考上图,假设有两个线程A、B分别在两个不一样的CPU上运行,它们共享同一个变量X。若是线程A对X进行修改后,并无将X更新后的结果同步到主内存,则变量X的修改对B线程是不可见的。因此CPU与内存之间的高速缓存就是致使线程可见性问题的一个缘由。
CPU和主内存之间的高速缓存还会致使另外一个问题——重排序。假设A、B两个线程共享两个变量X、Y,A和B分别在不一样的CPU上运行。在A中先更改变量X的值,而后再更改变量Y的值。这时有可能发生Y的值被同步回主内存,而X的值没有同步回主内存的状况,此时对于B线程来讲是没法感知到X变量被修改的,或者能够认为对于B线程来讲,Y变量的修改被重排序到了X变量修改的前面。上面的程序NoVisibility类中有可能输出0就是这种状况,虽然在主线程中是先修改number变量,再修改ready变量,但对于读线程来讲,ready变量的修改有可能被重排序到number变量修改以前。
此外,为了提升程序的执行效率,编译器在生成指令序列时和CPU执行指令序列时,都有可能对指令进行重排序。Java语言规范要求JVM只在单个线程内部维护一种相似串行的语义,即只要程序的最终结果与严格串行环境中执行的结果相同便可。因此在单线程环境中,咱们没法察觉到重排序,由于程序重排序后的执行结果与严格按顺序执行的结果相同。就像在类NoVisibility的主线程中,先修改ready变量仍是先修改number变量对于主线程本身的执行结果是没有影响的,可是若是number变量和ready变量的修改发生重排序,对读线程是有影响的。因此在编写并发程序时,咱们必定要注意重排序对多线程执行结果的影响。
看到这里你们必定会发现,咱们所讨论的CPU高速缓存、指令重排序等内容都是计算机体系结构方面的东西,并非Java语言所特有的。事实上,不少主流程序语言(如C/C++)都存在多线程可见性的问题,这些语言是借助物理硬件和操做系统的内存模型来处理多线程可见性问题的,所以不一样平台上内存模型的差别,会影响到程序的执行结果。Java虚拟机规范定义了本身的内存模型JMM(Java Memory Model)来屏蔽掉不一样硬件和操做系统的内存模型差别,以实现让Java程序在各类平台下都能达到一致的内存访问结果。因此对于Java程序员,无需了解底层硬件和操做系统内存模型的知识,只要关注Java本身的内存模型,就可以解决Java语言中的内存可见性问题了。多线程

Happens-Before原则

上面讨论了Java中多线程共享变量的可见性问题及产生这种问题的缘由。下面咱们看一下如何解决这个问题,即当一个多线程共享变量被某个线程修改后,如何让这个修改被须要读取这个变量的线程感知到。
为了方便程序员开发,将底层的烦琐细节屏蔽掉,JMM定义了Happens-Before原则。只要咱们理解了Happens-Before原则,无需了解JVM底层的内存操做,就能够解决在并发编程中遇到的变量可见性问题。
JVM定义的Happens-Before原则是一组偏序关系:对于两个操做A和B,这两个操做能够在不一样的线程中执行。若是A Happens-Before B,那么能够保证,当A操做执行完后,A操做的执行结果对B操做是可见的。
Happens-Before的规则包括:并发

  1. 程序顺序规则
  2. 锁定规则
  3. volatile变量规则
  4. 线程启动规则
  5. 线程结束规则
  6. 中断规则
  7. 终结器规则
  8. 传递性规则

下面咱们将详细讲述这8条规则的具体内容。app

程序顺序规则

在一个线程内部,按照程序代码的书写顺序,书写在前面的代码操做Happens-Before书写在后面的代码操做。这时由于Java语言规范要求JVM在单个线程内部要维护相似严格串行的语义,若是多个操做之间有前后依赖关系,则不容许对这些操做进行重排序。函数

锁定规则

对锁M解锁以前的全部操做Happens-Before对锁M加锁以后的全部操做。性能

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

上面这段代码,setValue和getValue两个方法共享同一个监视器锁。假设setValue方法在线程A中执行,getValue方法在线程B中执行。setValue方法会先对value变量赋值,而后释放锁。getValue方法会先获取到同一个锁后,再读取value的值。因此根据锁定原则,线程A中对value变量的修改,能够被线程B感知到。
若是这个两个方法上没有synchronized声明,则在线程A中执行setValue方法对value赋值后,线程B中getValue方法返回的value值并不能保证是最新值。
本条锁定规则对显示锁(ReentrantLock)和内置锁(synchronized)在加锁和解锁等操做上有着相同的内存语义。
对于锁定原则,能够像下面这样去理解:同一时刻只能有一个线程执行锁中的操做,因此锁中的操做被重排序外界是不关心的,只要最终结果能被外界感知到就好。除了重排序,剩下影响变量可见性的就是CPU缓存了。在锁被释放时,A线程会把释放锁以前全部的操做结果同步到主内存中,而在获取锁时,B线程会使本身CPU的缓存失效,从新从主内存中读取变量的值。这样,A线程中的操做结果就会被B线程感知到了。

volatile变量规则

对一个volatile变量的写操做及这个写操做以前的全部操做Happens-Before对这个变量的读操做及这个读操做以后的全部操做。

Map configOptions;
char[] configText; //线程间共享变量,用于保存配置信息
// 此变量必须定义为volatile
volatile boolean initialized = false;

// 假设如下代码在线程A中执行
// 模拟读取配置信息,当读取完成后将initialized设置为true以通知其余线程配置可用configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;

// 假设如下代码在线程B中执行
// 等待initialized为true,表明线程A已经把配置信息初始化完成
while (!initialized) {    
    sleep();
}
//使用线程A中初始化好的配置信息
doSomethingWithConfig();

上面这段代码,读取配置文件的操做和使用配置信息的操做分别在两个不一样的线程A、B中执行,两个线程经过共享变量configOptions传递配置信息,并经过共享变量initialized做为初始化是否完成的通知。initialized变量被声明为volatile类型的,根据volatile变量规则,volatile变量的写入操做Happens-Before对这个变量的读操做,因此在线程A中将变量initialized设为true,线程B中是能够感知到这个修改操做的。
可是更牛逼的是,volatile变量不只能够保证本身的变量可见性,还能保证书写在volatile变量写操做以前的操做对其它线程的可见性。考虑这样一种状况,若是volatile变量仅能保证本身的变量可见性,那么当线程B感知到initialized已经变成true而后执行doSomethingWithConfig操做时,可能没法获取到configOptions最新值而致使操做结果错误。因此volatile变量不只能够保证本身的变量可见性,还能保证书写在volatile变量写操做以前的操做Happens-Before书写在volatile变量读操做以后的那些操做。
能够这样理解volatile变量的写入和读取操做流程:
首先,volatile变量的操做会禁止与其它普通变量的操做进行重排序,例如上面代码中会禁止initialized = true与它上面的两行代码进行重排序(可是它上面的代码之间是能够重排序的),不然会致使程序结果错误。volatile变量的写操做就像是一条基准线,到达这条线以后,无论以前的代码有没有重排序,反正到达这条线以后,前面的操做都已完成并生成好结果。
而后,在volatile变量写操做发生后,A线程会把volatile变量自己和书写在它以前的那些操做的执行结果一块儿同步到主内存中。
最后,当B线程读取volatile变量时,B线程会使本身的CPU缓存失效,从新从主内存读取所需变量的值,这样不管是volatile自己,仍是书写在volatile变量写操做以前的那些操做结果,都能让B线程感知到,也就是上面程序中的initialized和configOptions变量的最新值均可以让线程B感知到。
原子变量与volatile变量在读操做和写操做上有着相同的语义。

线程启动规则

Thread对象的start方法及书写在start方法前面的代码操做Happens-Before此线程的每个动做。
start方法和新线程中的动做必定是在两个不一样的线程中执行。线程启动规则能够这样去理解:调用start方法时,会将start方法以前全部操做的结果同步到主内存中,新线程建立好后,须要从主内存获取数据。这样在start方法调用以前的全部操做结果对于新建立的线程都是可见的。

线程终止规则

线程中的任何操做都Happens-Before其它线程检测到该线程已经结束。这个说法有些抽象,下面举例子对其进行说明。
假设两个线程s、t。在线程s中调用t.join()方法。则线程s会被挂起,等待t线程运行结束才能恢复执行。当t.join()成功返回时,s线程就知道t线程已经结束了。因此根据本条原则,在t线程中对共享变量的修改,对s线程都是可见的。相似的还有Thread.isAlive方法也能够检测到一个线程是否结束。
能够猜想,当一个线程结束时,会把本身全部操做的结果都同步到主内存。而任何其它线程当发现这个线程已经执行结束了,就会从主内存中从新刷新最新的变量值。因此结束的线程A对共享变量的修改,对于其它检测了A线程是否结束的线程是可见的。

中断规则

一个线程在另外一个线程上调用interrupt,Happens-Before被中断线程检测到interrupt被调用。
假设两个线程A和B,A先作了一些操做operationA,而后调用B线程的interrupt方法。当B线程感知到本身的中断标识被设置时(经过抛出InterruptedException,或调用interrupted和isInterrupted),operationA中的操做结果对B都是可见的。

终结器规则

一个对象的构造函数执行结束Happens-Before它的finalize()方法的开始。
“结束”和“开始”代表在时间上,一个对象的构造函数必须在它的finalize()方法调用时执行完。
根据这条原则,能够确保在对象的finalize方法执行时,该对象的全部field字段值都是可见的。

传递性规则

若是操做A Happens-Before B,B Happens-Before C,那么能够得出操做A Happens-Before C。

再次思考Happens-Before规则的真正意义

到这里咱们已经讨论了线程的可见性问题和致使这个问题的缘由,并详细阐述了8条Happens-Before原则和它们是如何帮助咱们解决变量可见性问题的。下面咱们在深刻思考一下,Happens-Before原则究竟是如何解决变量间可见性问题的。
咱们已经知道,致使多线程间可见性问题的两个“罪魁祸首”是CPU缓存重排序。那么若是要保证多个线程间共享的变量对每一个线程都及时可见,一种极端的作法就是禁止使用全部的重排序和CPU缓存。即关闭全部的编译器、操做系统和处理器的优化,全部指令顺序所有按照程序代码书写的顺序执行。去掉CPU高速缓存,让CPU的每次读写操做都直接与主存交互。
固然,上面的这种极端方案是绝对不可取的,由于这会极大影响处理器的计算性能,而且对于那些非多线程共享的变量是不公平的。
重排序CPU高速缓存有利于计算机性能的提升,但却对多CPU处理的一致性带来了影响。为了解决这个矛盾,咱们能够采起一种折中的办法。咱们用分割线把整个程序划分红几个程序块,在每一个程序块内部的指令是能够重排序的,可是分割线上的指令与程序块的其它指令之间是不能够重排序的。在一个程序块内部,CPU不用每次都与主内存进行交互,只须要在CPU缓存中执行读写操做便可,可是当程序执行到分割线处,CPU必须将执行结果同步到主内存或从主内存读取最新的变量值。那么,Happens-Before规则就是定义了这些程序块的分割线。下图展现了一个使用锁定原则做为分割线的例子:

如图所示,这里的unlock M和lock M就是划分程序的分割线。在这里,红色区域和绿色区域的代码内部是能够进行重排序的,可是unlock和lock操做是不能与它们进行重排序的。即第一个图中的红色部分必需要在unlock M指令以前所有执行完,第二个图中的绿色部分必须所有在lock M指令以后执行。而且在第一个图中的unlock M指令处,红色部分的执行结果要所有刷新到主存中,在第二个图中的lock M指令处,绿色部分用到的变量都要从主存中从新读取。
在程序中加入分割线将其划分红多个程序块,虽然在程序块内部代码仍然可能被重排序,可是保证了程序代码在宏观上是有序的。而且能够确保在分割线处,CPU必定会和主内存进行交互。Happens-Before原则就是定义了程序中什么样的代码能够做为分隔线。而且不管是哪条Happens-Before原则,它们所产生分割线的做用都是相同的。

小结

在写做本文时,我主要参考的是《Java并发编程实战》和《深刻理解Java虚拟机》的最后一章,此外有部份内容是我本身对并发编程的一些浅薄理解,但愿可以对阅读的人有所帮助。若有错误的地方,欢迎你们指正。

相关文章
相关标签/搜索