2.Java内存模型(JMM)与volatile详解

3,791 阅读15分钟

思维导图:点击查看思维导图
文章图片:点击查看图片

1.Java内存模型

JAVA定义了一套在多线程读写共享数据时时,对数据的可见性、有序性和原子性的规则和保障。屏蔽掉不同操作系统间的微小差异。

Java内存模型(Java Memory Model)是一种抽象的概念,并不真实存在,它描述的是一组规则或规范(定义了程序中各个变量的访问方式)。 JVM运行程序的实体是线程,而每个线程创建时 JVM 都会为其创建一个工作内存(栈空间),用于存储线程私有的数据,而Java 内存模型中规定所有变量都存储在主内存主内存是共享内存区域,所有线程都可以访问, 但线程对变量的操作(读取赋值等)必须在工作内存中进行。所以首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量。工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。

基于JMM规范的线程,工作内存,主内存工作交互图 image.png

  • 主内存: 线程的共享数据区域,主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中(包括局部变量、类信息、常量、静态变量)。

  • 工作内存: 线程私有,主要存储当前方法的所有本地变量信息(主内存中的变量副本拷贝) , 每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,即使访问的是同一个共享变量。

对于一个实例对象中的成员方法: 如果方法中包含本地变量是基本数据类型,将直接存储在工作内存的帧栈结构中,如果是引用类型,那么该变量的引用会存储在功能内存的帧栈中,而对象实例将存储在主内存(共享数据区域,堆)中。

需要注意的是,在主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存

image.png

下面是线程读取共享变量count执行count + 1 操作的过程:

image.png 数据同步八大原子操作:

  • (1)lock(锁定): 作用于主内存的变量,把一个变量标记为一条线程独占状态
  • (2)unlock(解锁): 作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后 的变量才可以被其他线程锁定
  • (3)read(读取): 作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存 中,以便随后的load动作使用
  • (4)load(载入): 作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工 作内存的变量副本中
  • (5)use(使用): 作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
  • (6)assign(赋值): 作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内 存的变量
  • (7)store(存储): 作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存 中,以便随后的write的操作
  • (8)write(写入): 作用于工作内存的变量,它把store操作从工作内存中的一个变量的值 传送到主内存的变量中

2.并发三大特性

2.1.原子性

定义: 一个操作在CPU中不可以中途暂停再调度,要么全部执行完成,要么全部都不执行

问题: 两个线程对初始值的静态变量一个做自增,一个做自减同样做10000次的结果很可能不是 0

解决关键字: synchronized、ReentrantLock 建议:

  • 用sychronized对对象加锁的力度建议大一点(减少加解锁次数)
  • 锁住同一个对象

2.2.可见性

定义: 当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程立即看得到修改的值。(即时性)

问题: 两个线程在不同的 CPU ,若线程1改变了变量 i 的值,还未刷新到主存,线程2又使用了 i,那么线程2看到的这个值肯定还是之前的

//线程1
boolean stop = false;
while(stop){
    ....
}
//线程2
stop = true;
//并未退出循环

解决关键字: synchronized、volatile

volatile 关键字,它可以用来修饰成员变量和静态成员变量,避免线程从自己的工作内存中查找变量值,必须到主存中获取它的值,线程操作volatile变量都是直接操作主内存,还可以禁止指令重排。 synchronized语句块既可以保证代码的原子性,也可以保证代码块内部的可见性,但是呢synchronized属于重量级操作,性能相对更低

注意: 对于上述循环代码块,加入System.out.println(); 会退出循环,因为 println 被 synchronized 修饰,所有,不要随便在代码中使用这种打印语句,会极度影响程序性能。

image.png

2.3.有序性

定义: 虚拟机在进行代码编译时,对改变顺序后不会对最终结果造成影响的代码,虚拟机不一定会按我们写的代码顺序运行,有可能进行重排序。实际上虽然重排后不会对变量值有影响,但会造成线程安全问题。

解决关键字: synchronized、ReentrantLock  volatile关键字,可以禁止指令重排

指令重排: JIT 编译器在运行时的一些优化,可以提升 CPU 的执行效率,不让 CPU 空闲下来。对改变顺序后不会对最终结果造成影响的代码,虚拟机不一定会按我们写的代码顺序运行,有可能进行重排序。比如说,我两行代码 X 和 Y,虚拟机认为它们俩的执行顺序不影响程序结果,但 Y 已经在 CacheLine 中存在了,就会优先执行 Y。

分析下面伪代码的运行情况(r.r1的值):

int num = 0;
boolean ready = false;
// 线程1 执行此方法
public void action1(I_Result r) {
    if(ready) {
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }
}
​
// 线程2 执行此方法
public void action2(I_Result r) {
    num = 2;
    ready = true;
}
​
情况1:线程1 先执行,此时 ready = false,所有进入else ,结果为1
情况2:线程2 先执行 num = 2,但还没来得及执行 ready = true,线程1 开始执行,还是进入else ,结果为1
情况3:线程2 先执行到ready = true,线程1 执行,进入else ,结果为4
情况4:指令重排导致,线程2执行 ready = true,切换到线程1,进入 if 分支,相加为0,再切回线程2,执行 num = 2,结果为0

double-checked locking 单例模式: 也存在指令重排问题(不使用volatile,对象实例化是原子操作,但分为几步,每一步又不是原子操作),因此需要在对象前加上 volatile 关键字防止指令重排,这也是个非常经典的禁止指令重排的例子。

public class SingleLazy {
    private SingleLazy() {}
    private volatile static SingleLazy INSTANCE;
    // 获取实体
    public static SingleLazy getInstance() {
        // 实例未被创建,开启同步代码块准备创建
        if (INSTANCE == null) {
            synchronized (SingleLazy.class) {
                // 也许其他线程在判断完后已经创建,再次判断
                if (INSTANCE == null) {
                    INSTANCE = new SingleLazy();
                }
            }
        }
        return INSTANCE;
    }
}

创建对象可以大致分为三步,其中第一步和第二步可能会发生指令重排导致安全性问题:

memory = allocate();//1.分配对象内存空间
instance(memory);//2.初始化对象
instance = memory;//3.设置instance指向刚分配的内存地址,此时instance e != null

注意: JDK1.5前的 volatile 关键字不保证指令重排问题

3.两个规则

as-if-serial 语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结构不被改变

3.1.happens-before规则

定义: 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前

两个操作之间存在 happens-before 关系,并不意味 java 平台的具体实现必须按照 happens-before 关系指定的顺序来执行。如果重排序后的执行结构,与按 happens-before 关系来执行的结果一致,那么这种重排序并不非法(JMM允许这种重排序),happens-before 原则内容如下:

  1. 程序顺序原则 即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行,(时间上)先执行的操作happen-before(时间上后执行的操作)
  2. 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
  3. volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简 单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的 值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
  4. 线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B 的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享 变量的修改对线程B可见
  5. 传递性 A先于B ,B先于C 那么A必然先于C
  6. 线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待 当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的 join方法成功返回后,线程B对共享变量的修改将对线程A可见。
  7. 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到 中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
  8. 对象终结规则 对象的构造函数执行,结束先于finalize()方法

3.2.as-if-serial

不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结构不能被改变。为了遵守as-if-serial 语义,编译器和处理器不会存在数据依赖关系的操作做重排序,但是如果操作之前不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

4.volatile

volatile是Java虚拟机提供的轻量级的同步机制,可以保证可见性,但无法保证原子性。  作用:

  • 保证可见性,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。即时可见通过缓存一致性协议保证
  • 禁止指令重排优化。通过内存屏障实现。
//示例
//并发场景下,count++操作不具备原子性,分为两步先读取值,再写回,会出现线程安全问题
public class VolatileVisibility {
    public static volatile int count = 0;
    public static void increase(){
        count++;
    }
}

4.1.volatile 禁止重排优化的实现

volatile 变量通过内存屏障实现其可见性和禁止重排优化。

内存屏障: 又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性。编译器和处理器都能执行指令重排优化。Intel 硬件提供了一系列的内存屏障,主要有:Ifence(读屏障)、sfence(写屏障)、mfence(全能屏障,包括读写)、Lock前缀等。不同的硬件实现内存屏障的方式不同,Java 内存模型屏蔽了这种底层硬件平台的差异,由 JVM 来为不同的平台生成相应的机器码。 JVM 中提供了四类内存屏障指令:

屏障类型指令说明
LoadLoadLoad1; LoadLoad; Load2保证load1的读取操作在load2及后续读取操作之前执行
StoreStoreLoad1; LoadLoad; Load2在store2及其后的写操作执行前,保证store1的写操作
StoreStoreStore1; StoreStore; Store2在stroe2及其后的写操作执行前,保证load1的读操作
StoreLoadStore1; StoreLoad; Load2保证store1的写操作已刷新到主内存之后,load2及其作

volatile内存语义的实现: JMM 针对编译器制定的 volatile 重排序规则表

操作普通读写volatile读volatile写
普通读写可以重排可以重排不可以重排
volatile读不可以重排不可以重排不可以重排
volatile写可以重排不可以重排不可以重排

比如第二行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。 编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序:

  • 在每个volatile写操作的前面插入一个StoreStore屏障
  • 在每个volatile写操作的后面插入一个StoreLoad屏障
  • 在每个volatile读操作的后面插入一个LoadLoad屏障
  • 在每个volatile读操作的后面插入一个LoadStore屏障

image.png

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写
    }
}

4.2.MESI缓存一致性协议

链接: 并发编程-01.认识底层操作系统与并发基础知识.  多核CPU的情况下,如何保证缓存内部数据的一致性?JAVA引入了MESI缓存一致性协议。 

Java代码的执行流程:

image.png

volatile 修饰的变量(锁也是)翻译的汇编指令前会加 Lock 前缀,OS调度时会 触发硬件缓存锁定机制(总线锁 或 缓存一致性协议) ,CPU 通过总线桥访问内存条,多个 CPU 访问同一内存,首先需要拿到总线权。早期,计算机不发达,性能低,总线锁采用直接占有,其他 CPU 无法继续通过总线桥访问。无法发挥 CPU 的多核能力。现代 CPU 采用采用缓存一致性协议进行保证(跨缓存行CacheLine(缓存存储数据的数据单元) 时会升级为总线锁)。

MESI 是指4种状态的首字母。每个 Cache line 有4个状态,可用2个bit表示:

状态描述监听任务
M 修改(Modified)该CacheLine有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行
E 独享、互斥(Exclusive)该CacheLine有效,数据和内存中的数据一致,数据只存在于本Cache中缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态
S 共享 (Shared)该CacheLine有效,数据和内存中的数据一致,数据存在于很多Cache中缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)
I 无效 (Invalid)该CacheLine无效

MESI 协议状态切换过程分析:

image.png

举例:

image.png

注意:一个 CacheLine 装不下变量,会升级为总线锁。

MESI优化和他们引入的问题:  缓存的一致性消息传递是要时间的,这就使其切换时会产生延迟。当一个缓存被切换状态时其他缓存收到消息完成各自的切换并且发出回应消息这么一长串的时间中CPU都会等待所有缓存响应完成。可能出现的阻塞都会导致各种各样的性能问题稳定性问题

       为了避免这种CPU运算能力的浪费,Store Bufferes 被引入使用。处理器把它想要写入到主存的值写到缓存,然后继续去处理其他事情。当所有失效确认(Invalidate Acknowledge)都接收到时,数据才会最 终被提交。但它也会带来一定的风险:

  • 处理器会尝试从存储缓存(Store buffer)中读取值,但它还没有进行提交。这个的解决方案称为Store Forwarding,它使得加载的时候,如果存储缓存中存在,则进行返回
  • 保存什么时候会完成,这个并没有任何保证,可能会发生重排序(非指令重排)。CPU会读到跟程序中写入的顺序不一样的结果。