Files
JavaYouth/docs/java_concurrency/Java并发体系-第二阶段-锁与同步-[1].md
2022-07-24 20:30:09 +08:00

39 KiB
Raw Permalink Blame History

title, tags, categories, keywords, description, cover, abbrlink, date
title tags categories keywords description cover abbrlink date
Java并发体系-第二阶段-锁与同步-[1]
Java并发
原理
源码
Java并发
原理
Java并发原理源码 万字系列长文讲解-Java并发体系-第二阶段,从C++和硬件方面讲解。 https://npm.elemecdn.com/lql_static@latest/logo/Java_concurrency.png 230c5bb3 2020-10-06 22:09:58
  • 本阶段文章讲的略微深入,一些基础性问题不会讲解,如有基础性问题不懂,可自行查看我前面的文章,或者自行学习。
  • 本篇文章比较适合校招和社招的面试笔者在2020年面试的过程中也确实被问到了下面的一些问题。

并发编程中的三个问题

由于这个东西,和这篇文章比较配。所以虽然在第一阶段写过了,这里再回顾一遍。

可见性

可见性概念

可见性Visibility是指一个线程对共享变量进行修改另一个线程立即得到修改后的新值。

可见性演示

/* 笔记
 * 1.当没有加Volatile的时候,while循环会一直在里面循环转圈
 * 2.当加了之后Volatile,由于可见性,一旦num改了之后,就会通知其他线程
 * 3.还有注意不能用if,if不会重新拉回来再判断一次。(也叫做虚假唤醒)
 * 4.案例演示:一个线程对共享变量的修改,另一个线程不能立即得到新值
 * */
public class Video04_01 {

    public static void main(String[] args) {
        MyData myData = new MyData();

        new Thread(() ->{
            System.out.println(Thread.currentThread().getName() + "\t come in ");
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //睡3秒之后再修改num,防止A线程先修改了num,那么到while循环的时候就会直接跳出去了
            myData.addTo60();
            System.out.println(Thread.currentThread().getName() + "\t come out");
        },"A").start();


        while(myData.num == 0){
            //只有当num不等于0的时候,才会跳出循环
        }
    }
}

class MyData{
    int num = 0;

    public void addTo60(){
        this.num = 60;
    }
}

由上面代码可以看出,并发编程时,会出现可见性问题,当一个线程对共享变量进行了修改,另外的线程并没有立即看到修改后的最新值。

原子性

原子性概念

原子性Atomicity在一次或多次操作中要么所有的操作都成功执行并且不会受其他因素干扰而中 断,要么所有的操作都不执行或全部执行失败。不会出现中间状态

原子性演示

案例演示:5个线程各执行1000次 i++;

/**
 * @Author: 吕
 * @Date: 2019/9/23 15:50
 * <p>
 * 功能描述: volatile不保证原子性的代码验证
 */
public class Video05_01 {

    public static void main(String[] args) {
        MyData03 myData03 = new MyData03();

        for (int i = 0; i < 20; i++) {
             new Thread(() ->{
                 for (int j = 0; j < 1000; j++) {
                     myData03.increment();
                 }
             },"线程" + String.valueOf(i)).start();
        }

        //需要等待上面的20个线程计算完之后再查看计算结果
        while(Thread.activeCount() > 2){
            Thread.yield();
        }

        System.out.println("20个线程执行完之后num:\t" + myData03.num);
    }
}


class MyData03{
    static int num = 0;

    public void increment(){
        num++;
    }

}

1、控制台输出由于并发不安全每次执行的结果都可能不一样

20个线程执行完之后num: 19706

正常来说如果保证原子性的话20个线程执行完结果应该是20000。控制台输出的值却不是这个说明出现了原子性的问题。

2、使用javap反汇编class文件对于num++可以得到下面的字节码指令:

9: getstatic     #12                 // Field number:I   取值操作
12: iconst_1 
13: iadd 
14: putstatic     #12                 // Field number:I  赋值操作

由此可见num++是由多条语句组成,以上多条指令在一个线程的情况下是不会出问题的,但是在多线程情况下就可能会出现问题。

比如num刚开始值是7。A线程在执行13: iadd时得到num值是8B线程又执行9: getstatic得到前一个值是7。马上A线程就把8赋值给了num变量。但是B线程已经拿到了之前的值7B线程是在A线程真正赋值前拿到的num值。即使A线程最终把值真正的赋给了num变量但是B线程已经走过了getstaitc取值的这一步B线程会继续在7的基础上进行++操作最终的结果依然是8。本来两个线程对7进行分别进行++操作得到的值应该是9因为并发问题导致结果是8。

3、并发编程时会出现原子性问题当一个线程对共享变量操作到一半时另外的线程也有可能来操作共 享变量,干扰了前一个线程的操作。

有序性

有序性概念

有序性Ordering是指程序中代码的执行顺序Java在编译时和运行时会对代码进行优化重排序来加快速度会导致程序终的执行顺序不一定就是我们编写代码时的顺序

 instance = new SingletonDemo() 是被分成以下 3 步完成
	 memory = allocate();     分配对象内存空间
	 instance(memory);        初始化对象
	 instance = memory;	   设置 instance 指向刚分配的内存地址此时 instance != null

步骤2 和 步骤3 不存在数据依赖关系,重排与否的执行结果单线程中是一样的。这种指令重排是被 Java 允许的。当 3 在前时instance 不为 null但实际上初始化工作还没完成会变成一个返回 null 的getInstance。这时候数据就出现了问题。

有序性演示

jcstress是java并发压测工具。https://wiki.openjdk.java.net/display/CodeTools/jcstress 修改pom文件添加依赖

<dependency>   
 <groupId>org.openjdk.jcstress</groupId>    
<artifactId>jcstress-core</artifactId>    
<version>${jcstress.version}</version> 
</dependency>
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.I_Result;

 @JCStressTest
 // @Outcome: 如果输出结果是1或4我们是接受的(ACCEPTABLE)并打印ok
 @Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
 //如果输出结果是0我们是接受的并且感兴趣的并打印danger
 @Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "danger")
 @State
public class Test03Ordering {

    int num = 0;
    boolean ready = false;
    // 线程1执行的代码
    @Actor //@Actor表示会有多个线程来执行这个方法
    public void actor1(I_Result r) {
        if (ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }

    // 线程2执行的代码
    // @Actor
    public void actor2(I_Result r) {
        num = 2;
        ready = true;
    }
}

1、实际上上面两个方法会有很多线程来执行为了讲解方便我们只提出线程1和线程2来讲解。

2、I_Result 是一个保存int类型数据的对象有一个属性 r1 用来保存结果,在多线程情况下可能出现几种结果?

情况1线 程1先执行actor1这时ready = false所以进入else分支结果为1。

情况2线程2执行到actor2执行了num = 2;和ready = true线程1执行这回进入 if 分支,结果为 4。

情况3线程2先执行actor2只执行num = 2但没来得及执行 ready = true线程1执行还是进入 else分支结果为1。

情况40发生了指令重排

 // 线程2执行的代码
    // @Actor
    public void actor2(I_Result r) {
        num = 2;    //pos_1
        ready = true;//pos_2
    }
   

pos_1处代码和pos_2处代码没有什么数据依赖关系或者说没有因果关系。Java可能对其进行指令重排排成下面的顺序。

 // 线程2执行的代码
    // @Actor
    public void actor2(I_Result r) {
    	ready = true;//pos_2
        num = 2;    //pos_1
    }

此时如果线程2先执行到ready = true;还没来得及执行 num = 2; 。线程1执行直接进入if分支此时num默认值为0。 得到的结果也就是0。

指令重排

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。

为什么指令重排序可以提高性能?

简单地说,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此,流水线技术产生了它的原理是指令1还没有执行完就可以开始执行指令2而不用等到指令1执行结束之后再执行指令2这样就大大提高了效率。

但是,流水线技术最害怕中断,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。指令重排就是减少中断的一种技术。

我们分析一下下面这个代码的执行情况:

a = b + c;
d = e - f ;

先加载b、c注意即有可能先加载b也有可能先加载c但是在执行add(b,c)的时候需要等待b、c装载结束才能继续执行也就是增加了停顿那么后面的指令也会依次有停顿,这降低了计算机的执行效率。

为了减少这个停顿我们可以先加载e和f,然后再去加载add(b,c),这样做对程序(串行)是没有影响的,但却减少了停顿。既然add(b,c)需要停顿,那还不如去做一些有意义的事情。

综上所述,指令重排对于提高CPU处理性能十分必要。虽然由此带来了乱序的问题但是这点牺牲是值得的。

指令重排一般分为以下三种:

  • 编译器优化重排

    编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  • 指令并行重排

    现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。

  • 内存系统重排

    由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。

as-if-serial语义

as-if-serial语义的意思是不管编译器和CPU如何重排序必须保证在单线程情况下程序的结果是正确的。 以下数据有依赖关系,不能重排序。

写后读:

int a = 1; 
int b = a;

写后写:

int a = 1; 
int a = 2;

读后写:

int a = 1; 
int b = a; 
int a = 2;

编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

int a = 1; 
int b = 2; 
int c = a + b;

Java内存模型(JMM)

在介绍Java内存模型之前先来看一下到底什么是计算机内存模型。

计算机结构

计算机结构简介

冯诺依曼,提出计算机由五大组成部分,输入设备,输出设备存储器,控制器,运算器。

输入设备:鼠标,键盘等等

输出设备:显示器,打印机等等

存储器:内存条

运算器和控制器组成CPU

CPU

中央处理器是计算机的控制和运算的核心我们的程序终都会变成指令让CPU去执行处理程序中 的数据。

内存

我们的程序都是在内存中运行的内存会保存程序运行时的数据供CPU处理。

缓存

CPU的运算速度和内存的访问速度相差比较大。这就导致CPU每次操作内存都要耗费很多等待时间。内 存的读写速度成为了计算机运行的瓶颈。于是就有了在CPU和主内存之间增加缓存的设计。靠近CPU 的缓存称为L1然后依次是 L2L3和主内存CPU缓存模型如图下图所示。

CPU Cache分成了三个级别: L1 L2 L3。级别越小越接近CPU速度也更快同时也代表着容量越小。速度越快的价格越贵。

1、L1是接近CPU的它容量小例如32K速度快每个核上都有一个L1 Cache。

2、L2 Cache 更大一些例如256K速度要慢一些一般情况下每个核上都有一个独立的L2 Cache。

3、L3 Cache是三级缓存中大的一级例如12MB同时也是缓存中慢的一级在同一个CPU插槽 之间的核共享一个L3 Cache。

上面的图中有一个Latency指标。比如Memory这个指标为59.4ns表示CPU在操作内存的时候有59.4ns的延迟一级缓存最快只有1.2ns。

CPU处理数据的流程

Cache的出现是为了解决CPU直接访问内存效率低下问题的。

1、程序在运行的过程中CPU接收到指令 后它会先向CPU中的一级缓存L1 Cache去寻找相关的数据如果命中缓存CPU进行计算时就可以直接对CPU Cache中的数据进行读取和写人当运算结束之后再将CPUCache中的新数据刷新 到主内存当中CPU通过直接访问Cache的方式替代直接访问主存的方式极大地提高了CPU 的吞吐能 力。

2、但是由于一级缓存L1 Cache容量较小所以不可能每次都命中。这时CPU会继续向下一级的二 级缓存L2 Cache寻找同样的道理当所需要的数据在二级缓存中也没有的话会继续转向L3 Cache、内存(主存)和硬盘。

Java内存模型

1、Java Memory Molde (Java内存模型/JMM)千万不要和Java内存结构JVM划分的那个堆方法区混淆。关于“Java内存模型”的权威解释参考 https://download.oracle.com/otn-pub/jcp/memory_model1.0-pfd-spec-oth-JSpec/memory_model-1_0-pfd-spec.pdf

2、 Java内存模型是Java虚拟机规范中所定义的一种内存模型Java内存模型是标准化的屏蔽掉了底层不同计算机的区别。 Java内存模型是一套规范描述了Java程序中各种变量(线程共享变量)的访问规则以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节具体如下。

3、Java内存模型根据官方的解释主要是在说两个关键字一个是volatile,一个是synchronized

主内存

主内存是所有线程都共享的,都能访问的。所有的共享变量都存储于主内存。

工作内存

每一个线程有自己的工作内存,工作内存只存储该线程对共享变量的副本。线程对变量的所有的操 作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接 访问对方工作内存中的变量。

Java的线程不能直接在主内存中操作共享变量。而是首先将主内存中的共享变量赋值到自己的工作内存中再进行操作操作完成之后刷回主内存。

Java内存模型的作用

Java内存模型是一套在多线程读写共享数据时对共享数据的可见性、有序性、和原子性的规则和保障。 synchronized,volatile

CPU缓存内存与Java内存模型的关系

  • 通过对前面的CPU硬件内存架构、Java内存模型以及Java多线程的实现原理的了解我们应该已经意识到多线程的执行终都会映射到硬件处理器上进行执行。 但Java内存模型和硬件内存架构并不完全一致。
  • 对于硬件内存来说只有寄存器、缓存内存、主内存的概念并没有工作内存和主内存之分也就是说Java内存模型对内存的划分对硬件内存并没有任何影响 因为JMM只是一种抽象的概念是一组规则不管是工作内存的数据还是主内存的数据对于计算机硬 件来说都会存储在计算机主内存中当然也有可能存储到CPU缓存或者寄存器中因此总体上来说 Java内存模型和计算机硬件内存架构是一个相互交叉的关系是一种抽象概念划分与真实物理硬件的交叉。

JMM内存模型与CPU硬件内存架构的关系

工作内存可能对应CPU寄存器也可能对应CPU缓存也可能对应内存。

  • Java内存模型是一套规范描述了Java程序中各种变量(线程共享变量)的访问规则以及在JVM中将变量 存储到内存和从内存中读取变量这样的底层细节Java内存模型是对共享数据的可见性、有序性、和原子性的规则和保障。

再谈可见性

1、图中所示是 个双核 CPU 系统架构 每个核有自己的控制器和运算器其中控制器包含一组寄存器和操作控制器运算器执行算术逻辅运算。每个核都有自己的1级缓存在有些架构里面还有1个所有 CPU 共享的2级缓存。 那么 Java 内存模型里面的工作内存,就对应这里的 Ll 或者 L2 存或者 CPU 寄存器。

2、一个线程操作共享变量时它首先从主内存复制共享变量到自己的工作内存然后对工作内存里的变量进行处理处理完后将变量值更新到主内存。

3、那么假如线程A和线程B同时处理一个共享变量会出现什么情况?我们使用图所示CPU架构假设线程A和线程B使用不同CPU执行并且当前两级Cache都为空那么这时候由于Cache的存在将会导致内存不可见问题具体看下面的分析。

  • 线程A首先获取共享变量X的值由于两级Cache都没有命中所以加载主内存中X的值假如为0。然后把X=0的值缓存到两级缓存线程A修改X的值为1然后将其写入两级Cache并且刷新到主内存。线程A操作完毕后线程A所在的CPU的两级Cache 内和主内存里面的X的值都是1。
  • 线程B获取X的值首先一级缓存没有命中然后看二级缓存二级缓存命中了所以返回X=1;到这里一切都是正常的因为这时候主内存中也是X=1。然后线程B修改X的值为2并将其存放到线程2所在的一级Cache和共享二级Cache中最后更新主内存中X 的值为2;到这里一切都是好的。
  • 线程A 这次又需要修改X的值获取时一级缓存命中并且X=1到这里问题就出现了明明线程B已经把X的值修改为了2为何线程A获取的还是1呢?这就是共享变量的内存不可见问题也就是线程B写入的值对线程A不可见。那么如何解决共享变量内存不可见问题?使用Java中的volatile和synchronized关键字就可以解决这个问题下面会有讲解。

主内存与工作内存之间的交互

为了保证数据交互时数据的正确性Java内存模型中定义了8种操作来完成这个交互过程这8种操作本身都是原子性的。虚拟机实现时必须保证下面 提及的每一种操作都是原子的、不可再分的。

(1)lock:作用于主内存的变量,它把一个变量标识为一条线程独占的状态。

(2)unlock:作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其它线程锁定。

(3)read:作用于主内存的变量它把一个变量的值从主内存传输到线程的工作内存中以便随后的load动作使用。

(4)load:作用于工作内存的变量它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

(5)use:作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时都会执行这个操作。

(6)assign:作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

(7)store:作用于工作内存的变量它把工作内存中一个变量的值传送到主内存中以便随后的write使用。

(8)write:作用于主内存的变量它把store操作从工作内存中得到的变量的值放入主内存的变量中。

注意:

  1. 如果对一个变量执行lock操作将会清空工作内存中此变量的值
  2. 对一个变量执行unlock操作之前必须先把此变量同步到主内存中
  3. lock和unlock操作只有加锁才会有。synchronized就是通过这样来保证可见性的。

如果没有synchronized那就是下面这样的

happens-before

什么是happens-before?

一方面程序员需要JMM提供一个强的内存模型来编写代码另一方面编译器和处理器希望JMM对它们的束缚越少越好这样它们就可以最可能多的做优化来提高性能希望的是一个弱的内存模型。

JMM考虑了这两种需求并且找到了平衡点对编译器和处理器来说只要不改变程序的执行结果(单线程程序和正确同步了的多线程程序),编译器和处理器怎么优化都行。

而对于程序员JMM提供了happens-before规则JSR-133规范满足了程序员的需求——**简单易懂,并且提供了足够强的内存可见性保证。**换言之程序员只要遵循happens-before规则那他写的程序就能保证在JMM中具有强的内存可见性。

JMM使用happens-before的概念来定制两个操作之间的执行顺序。这两个操作可以在一个线程以内也可以是不同的线程之间。因此JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证。

happens-before关系的定义如下

  1. 如果一个操作happens-before另一个操作那么第一个操作的执行结果将对第二个操作可见而且第一个操作的执行顺序排在第二个操作之前。
  2. 两个操作之间存在happens-before关系并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果与按happens-before关系来执行的结果一致那么JMM也允许这样的重排序。

happens-before关系本质上和as-if-serial语义是一回事。

as-if-serial语义保证单线程内重排序后的执行结果和程序代码本身应有的结果是一致的happens-before关系保证正确同步的多线程程序的执行结果不被重排序改变。

总之,如果操作A happens-before操作B那么操作A在内存上所做的操作对操作B都是可见的不管它们在不在一个线程。

天然的happens-before关系

在Java中有以下天然的happens-before关系

  • 1、程序次序规则一个线程内按照代码顺序书写在前面的操作先行发生于书写在后面的操作

  • 2、锁定规则一个unLock操作先行发生于后面对同一个锁的lock操作比如说在代码里有先对一个lock.lock()lock.unlock()lock.lock()

  • 3、volatile变量规则对一个volatile变量的写操作先行发生于后面对这个volatile变量的读操作volatile变量写再是读必须保证是先写再读

  • 4、传递规则如果操作A先行发生于操作B而操作B又先行发生于操作C则可以得出操作A先行发生于操作C

  • 5、线程启动规则Thread对象的start()方法先行发生于此线程的每个一个动作thread.start()thread.interrupt()

  • 6、线程中断规则对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生

  • 7、线程终结规则线程中所有的操作都先行发生于线程的终止检测我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行

  • 8、对象终结规则一个对象的初始化完成先行发生于他的finalize()方法的开始

上面这8条原则的意思很显而易见就是程序中的代码如果满足这个条件就一定会按照这个规则来保证指令的顺序。

举例1

int a = 1; // A操作
int b = 2; // B操作
int sum = a + b;// C 操作
System.out.println(sum);

根据以上介绍的happens-before规则假如只有一个线程那么不难得出

1> A happens-before B 
2> B happens-before C 
3> A happens-before C

注意真正在执行指令的时候其实JVM有可能对操作A & B进行重排序因为无论先执行A还是B他们都对对方是可见的并且不影响执行结果。

如果这里发生了重排序这在视觉上违背了happens-before原则但是JMM是允许这样的重排序的。

所以我们只关心happens-before规则不用关心JVM到底是怎样执行的。只要确定操作A happens-before操作B就行了。

重排序有两类JMM对这两类重排序有不同的策略

  • 会改变程序执行结果的重排序,比如 A -> CJMM要求编译器和处理器都禁止这种重排序。
  • 不会改变程序执行结果的重排序,比如 A -> BJMM对编译器和处理器不做要求允许这种重排序。

举例2

//伪代码
volatile boolean flag = false;
    //线程1
    prepare();

    flag = false;

    //线程2
    while(!flag){
        sleep();
    }

    //基于准备好的资源进行操作
    execute();

这8条原则是避免说出现乱七八糟扰乱秩序的指令重排要求是这几个重要的场景下比如是按照顺序来但是8条规则之外可以随意重排指令。

比如这个例子如果用volatile来修饰flag变量一定可以让prepare()指令在flag = true之前先执行这就禁止了指令重排。

因为volatile要求的是volatile前面的代码一定不能指令重排到volatile变量操作后面volatile后面的代码也不能指令重排到volatile前面。

volatile

volatile不保证原子性只保证可见性和禁止指令重排

CPU术语介绍

private static volatile SingletonDemo instance = null;

    private SingletonDemo() {
        System.out.println(Thread.currentThread().getName() + "\t 执行单例构造函数");
    }

    public static SingletonDemo getInstance(){

        if(instance == null){
            synchronized (SingletonDemo.class){
                if(instance == null){
                    instance = new SingletonDemo(); //pos_1
                }
            }
        }
        return instance;
    }

pos_1处的代码转换成汇编代码如下

0x01a3de1d: movb $0×0,0×1104800(%esi);
0x01a3de24: lock addl $0×0,(%esp);

volatile保证可见性原理

有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码通过查IA-32架 构软件开发者手册可知Lock前缀的指令在多核处理器下会引发了两件事情。

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

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

为了提高处理速度处理器不直接和主内存进行通信而是先将系统内存的数据读到内部缓存L1L2或其他后再进行操作但操作完不知道何时会写到内存。如果对声明了volatile的 变量进行写操作JVM就会向处理器发送一条Lock前缀的指令将这个变量所在缓存行的数据写回到系统内存。但是就算写回到内存如果其他处理器缓存的值还是旧的再执行计算操作就会有问题。所以在多处理器下为了保证各个处理器的缓存是一致的就会实现MESI缓存一致性协议每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了当处理器发现自己缓存行对应的内存地址被修改就会将当前处理器的缓存行设置成无效状态当处理器对这个数据进行修改操作的时候会重新从系统内存中把数据读到处理器缓存里。

注意lock前缀指令是同时保证可见性和有序性也就是禁止指令重排

注意lock前缀指令相当于一个内存屏障【后文讲】

volatile禁止指令重排的原理

public class VolatileExample {
    int a = 0;
    volatile boolean flag = false;

    public void writer() {
        a = 1; // step 1
        flag = true; // step 2
    }

    public void reader() {
        if (flag) { // step 3
            System.out.println(a); // step 4
        }
    }
}

在JSR-133之前的旧的Java内存模型中是允许volatile变量与普通变量重排序的。那上面的案例中可能就会被重排序成下列时序来执行

  1. 线程A写volatile变量step 2设置flag为true
  2. 线程B读同一个volatilestep 3读取到flag为true
  3. 线程B读普通变量step 4读取到 a = 0
  4. 线程A修改普通变量step 1设置 a = 1

可见如果volatile变量与普通变量发生了重排序虽然volatile变量能保证内存可见性也可能导致普通变量读取错误。

所以在旧的内存模型中volatile的写-读就不能与锁的释放-获取具有相同的内存语义了。为了提供一种比锁更轻量级的线程间的通信机制JSR-133专家组决定增强volatile的内存语义严格限制编译器和处理器对volatile变量与普通变量的重排序。

编译器还好说JVM是怎么还能限制处理器的重排序的呢它是通过内存屏障来实现的。

什么是内存屏障硬件层面内存屏障分两种读屏障Load Barrier和写屏障Store Barrier。内存屏障有两个作用

  1. 阻止屏障两侧的指令重排序;
  2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,或者让缓存中相应的数据失效。

注意这里的缓存主要指的是上文说的CPU缓存如L1L2等

保守策略下

  • 在每个volatile写操作的前面插入一个StoreStore屏障。

  • 在每个volatile写操作的后面插入一个StoreLoad屏障。

  • 在每个volatile读操作的前面插入一个LoadLoad屏障。

  • 在每个volatile读操作的后面插入一个LoadStore屏障。

编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。编译器选择了一个比较保守的JMM内存屏障插入策略,但它可以保证在任意处理器平台,任意的程序中都能 得到正确的volatile内存语义。

再逐个解释一下这几个屏障。注下述Load代表读操作Store代表写操作

LoadLoad屏障对于这样的语句Load1; LoadLoad; Load2在Load2及后续读取操作要读取的数据被访问前保证Load1要读取的数据被读取完毕。 StoreStore屏障对于这样的语句Store1; StoreStore; Store2在Store2及后续写入操作执行前这个屏障会吧Store1强制刷新到内存保证Store1的写入操作对其它处理器可见。 LoadStore屏障对于这样的语句Load1; LoadStore; Store2在Store2及后续写入操作被刷出前保证Load1要读取的数据被读取完毕。 StoreLoad屏障对于这样的语句Store1; StoreLoad; Load2在Load2及后续所有读取操作执行前保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的冲刷写缓冲器清空无效化队列。在大多数处理器的实现中这个屏障是个万能屏障兼具其它三种内存屏障的功能

对于连续多个volatile变量读或者连续多个volatile变量写编译器做了一定的优化来提高性能比如

第一个volatile读;

LoadLoad屏障

第二个volatile读

LoadStore屏障

1、下面是保守策略下volatile写插入内存屏障后生成的指令序列示意图

图中的StoreStore屏障可以保证在volatile写之前其前面的所有普通写操作已经对任 意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。这里比较有意思的是volatile写后面的StoreLoad屏障。此屏障的作用是避免volatile写与 后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面 是否需要插入一个StoreLoad屏障比如一个volatile写之后方法立即return。为了保证能正确 实现volatile的内存语义JMM在采取了保守策略在每个volatile写的后面或者在每个volatile 读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑JMM最终选择了在每个volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个 写线程写volatile变量多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时 选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM在实现上的一个特点首先确保正确性然后再去追求执行效率

2、下面是在保守策略下volatile读插入内存屏障后生成的指令序列示意图

图中的LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。 LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。 上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。

优化举例:

class VolatileBarrierExample {
        int a;
        volatile int v1 = 1;
        volatile int v2 = 2;

        void readAndWrite() {
            int i = v1; // 第一个volatile读
            int j = v2; // 第二个volatile读
            a = i + j; // 普通写
            v1 = i + 1; // 第一个volatile写
            v2 = j * 2; // 第二个 volatile写
        }
        // 其他方法 }
    }

针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化

注意最后的StoreLoad屏障不能省略。因为第二个volatile写之后方法立即return。此时编译器可能无法准确断定后面是否会有volatile读或写为了安全起见编译器通常会在这里插入一个StoreLoad屏障。

上面的优化针对任意处理器平台由于不同的处理器有不同“松紧度”的处理器内存模型内存屏障的插入还可以根据具体的处理器内存模型继续优化。以X86处理器为例图中除最后的StoreLoad屏障外其他的屏障都会被省略。

X86处理器优化

前面保守策略下的volatile读和写在X86处理器平台可以优化成如下图所示。

X86处理器仅会对写-读操作做重排序。X86不会对读-读、读-写和写-写操作 做重排序因此在X86处理器中会省略掉这3种操作类型对应的内存屏障。在X86中JMM仅需在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在X86处理器中volatile写的开销比volatile读的开销会大很多因为执行StoreLoad屏障开销会比较大

volatile的用途

下面的代码在前面可能已经写过了,这里总结一下

从volatile的内存语义上来看volatile可以保证内存可见性且禁止重排序。

在保证内存可见性这一点上volatile有着与锁相同的内存语义所以可以作为一个“轻量级”的锁来使用。但由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁可以保证整个临界区代码的执行具有原子性。所以在功能上锁比volatile更强大在性能上volatile更有优势

在禁止重排序这一点上volatile也是非常有用的。比如我们熟悉的单例模式其中有一种实现方式是“双重锁检查”比如这样的代码

public class Singleton {

    private static Singleton instance; // 不使用volatile关键字

    // 双重锁检验
    public static Singleton getInstance() {
        if (instance == null) { // 第7行
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton(); // 第10行
                }
            }
        }
        return instance;
    }
}

如果这里的变量声明不使用volatile关键字是可能会发生错误的。它可能会被重排序

instance = new Singleton(); // 第10行

// 可以分解为以下三个步骤
1 memory=allocate();// 分配内存 相当于c的malloc
2 ctorInstanc(memory) //初始化对象
3 s=memory //设置s指向刚分配的地址

// 上述三个步骤可能会被重排序为 1-3-2也就是
1 memory=allocate();// 分配内存 相当于c的malloc
3 s=memory //设置s指向刚分配的地址
2 ctorInstanc(memory) //初始化对象

而一旦假设发生了这样的重排序比如线程A在第10行执行了步骤1和步骤3但是步骤2还没有执行完。这个时候另一个线程B执行到了第7行它会判定instance不为空然后直接返回了一个未初始化完成的instance

所以JSR-133对volatile做了增强后volatile的禁止重排序功能还是非常有用的。