并发编程—如何解决可见性和有序性问题

在上一篇并发编程之BUG源头我们介绍了导致并发编程出现诡异问题的三大源头,即:缓存导致了可见性问题,线程切换带来了原子性问题,编译优带来了有序性问题,这三个Bug源头在所有的编程语言中都会遇到,那么今天就聊聊 Java是通过什么技术解决的。

Java中解决可见性和有序性问题的主角当属 Java内存模型了。说到Java内存模型,在很多面试中都会问到,是一个热门考点,也是一个程序员并发水平的具体体现。只有掌握了Java内存模型,才能在解决问题时慧眼如炬。

什么是Java内存模型

我们知道,导致可见性的原因是缓存,导致有序性的原因是指令重排序,那么解决可见性、有序性对直接的办法就是禁用缓存和指令重排序,但是如果解决了这些问题,可能又会引发性能问题。那么合理的方案就是按需禁用缓存和编译优化。如何才能做到“按需禁用”呢?由于并发程序也是程序员写的,程序员是知道程序该怎么运行才是正确的。所以,为了解决可见性和有序性,只要提供给程序员按需禁用缓存和编译优化的方法即可。Java内存模型就是规范了JVM如何提供按需禁用缓存和编译优化的方法。Java中提供的方法包括 volatilesynchronizedfinal 三个关键字,以及六项 Happens-Before规则。接下来就介绍以下这几种方法

一、volatile

1、volatile内存写-读的内存语义

** volatile写的内存语义:**

当写一个volatile变量是,JMM会把该线程对应的CPU缓存中的共享变量值刷新到主内存中。

** volatile读的内存语义:**

当读一个volatile变量时,JMM会把该线程对应的CPU缓存中的值值为无效,线程接下来将从主内存中读取共享变量。

2、如何解决可见性

volatile是它在处理器开发中保证了共享变量的“可见性”。在X86处理器下,可以看到在Java中被volatile修饰的变量,在转变为汇编指令后会添加一个 lock前缀,lock前缀的指令在多核处理器下面会引发两件事。

1)、将当前处理器缓存行的数据写回到系统内存。

2)、这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。

那么如何理解这两件事呢?

写回缓存:也就是说,只要某个线程修改了volatile修饰的共享变量,就会引发修改后的值立即回写到主内存中。

数据失效:也就是说,线程A修改了volatile修饰的变量x,后在线程B中缓存的变量x的值就会失效,如果线程B在操作变量x就必须重新到主内存读取x的值。

由上一章《并发编程——可见性、原子性、有序性 BUG源头》介绍的可见性的问题就是由CPU的缓存导致的,而使用volatile修饰的变量,会引发写内存,使其他CPU缓存失效,所以volatile修饰的共享变量保证了线程间的可见性。

当一个变量被volatile修饰后,它表达的语义就是:告诉编译器,对这个变量的读写,不能使用CPU缓存,必须从内存中读取或写入

3、如何解决有序性

JMM是通过限制指令重排序来保证程序的有序性的。下表是JMM针对编译器指定的volatile重排序规则。

| | 第二个操作 |
| 第一个操作 | 普通读/写 | volatile读 | volatile写 |
| 普通读/写 | | | NO |
| volatile读 | NO | NO | NO |
| volatile写 | | NO | NO |

从表中可以看出:

  • 当第二个操作时volatile写时,不管第一个操作是什么,都不能重排序。这个确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  • 当第一个操作时volatile读时,不管第二个操作是什么,都不能重排序。这个确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  • 当第一个操作时volatile写时,第二个操作是volatile读时,不能重排序。

由此可知,volatile禁止了编译器在编译时对指令的重排序,之前说过,指令重排序除了编译器,处理器也会对指令进行重排序,那么volatile又是怎么做到的呢?这个是通过在编译器生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器进行重排序的。

除了提到的禁止指令重排序之外,Java内存模型还定制了Happens-before规则,在happens-before规则中的volatile规则是这样描述的。

对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。

结合指令重排序和happens-before规则即可保证线程之间的有序性。

二、synchronized

在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。众所周知,锁可以让临界区互斥执行。所有通过synchronized加锁的临界区同一时间只能有一个线程执行。

1、锁的释放和获取内存语义

当释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。

当获取锁时,JMM会把该线程对应的本地内存置为无效,从而使得被监视其保护的临界区代码必须从主内存中读取共享变量。

2、如何解决可见性

由锁的释放和获取内存语义,保证了共享变量在线程间的可见性。

3、如果解决有序性

在Java内存模型的Happens-before规则中有一条规则为监视器规则,描述如下:

对一个锁的解锁,happens-before于随后对这个锁的加锁

综上所述,通过锁的特性、锁的释放获取内存语义和happens-before规则中的监视器规则,synchronized可以解决可见性和有序性。

三、final

我们都知道,在Java中使用final修饰的变量为常量,即赋值后,就不允许在修改了。

1、如何解决可见性

由于final修饰的变量是常量,也就是说不可变的,因为final在类加载或者类初始化的时候已经确定了final修饰的变量的值,所以final可以保证可见性。

2、如果解决有序性

final的有序性也是通过重排序规则来保证的,对于final域,编译器和处理器要遵守以下两个重排序规则。

1)、在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

2)、初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

写final域重排序规则

写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则包含两方面

1、JMM禁止编译器吧final域的写重排序到构造函数之外。

2、编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外

读final域重排序规则

读final域的重排序规则是,在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。

final引用不能从构造函数内“溢出”

前面我们提到过,写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了。其实,要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是对象引用不能在构造函数中“逸出”。

如下代码所示: this就存在“溢出”情况。

public class FinalExample {
     final int i;
     static FinalExample obj;
    public FinalExample(){
        i = 0;
        obj = this; // this 此处就存在着 “移除”
    }
}

四、Happens-Before规则

如何理解Happens-Before呢?如果望文生义,那就南辕北辙了,Happens-Before并不是说前面一个操作发生在后续操作的前面,它真正要表达的是:前面一个操作的结果对后续操作是可见的。所以比较正式的说法就是:Happens-Before约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵循Happens-Before规则。

Happens-Before规则一共涉及六项,

1、程序顺序性规则:这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before于后续的任意操作。

2、监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。

3、volatile变量规则:对一个volatile域的写,Happens-Before于任意后续对这个volatile域的读。

4、传递性规则:如果A Happens-Before B,且 B Happens-Before C,那么 A happens-before C。

5、start()规则:如果线程A执行操作 ThreadB.start()(启动线程B),那么A线程的ThreadB.start() 操作Happens-Before 于线程B中的人员操作。

6、join()规则:如果线程A执行操作 ThreadB.join() 并成功返回,那么线程B中的任意操作 happens-before于线程A从ThreadB.join()操作成功返回。

7、线程中断规则:读线程interrupt()方法的调用happens-before 于被中断线程的代码检测到中断事件的发生,

8、对象终结规则:一个对象的初始化完成(构造函数执行结束) happens-before 于它的 finalize()方法的开始。

五、总结

Java内存模型是并发编程领域的一次重要创新,在Java语言里面,Happens-Before 的语义本质上是一种可见性,A Happens-Before B,意味着 A事件 对B事件可见。Java内存模型重要分为两部分,一部分是面向写并发编程的应用开发人员,一部分是面向JVM的实现人员。掌握了 Happens-Before规则,对Java并发编程就会有更深入的认识。


参考资料:

  • Java并发编程实战*

  • 《Java并发编程的艺术》——方腾飞等著*

你可能感兴趣的:(并发编程—如何解决可见性和有序性问题)