Java内存模型JMM与volatile

目录
  • 1.Java内存模型
  • 2.并发三大特性
    • 2.1.原子性
    • 2.2.可见性
    • 2.3.有序性
  • 3.两个规则
    • 3.1.happens-before规则
    • 3.2.as-if-serial
  • 4.volatile
    • 4.1.volatile 禁止重排优化的实现
    • 4.2.MESI缓存一致性协议

1.Java内存模型

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

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

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

  • 主内存: 线程的共享数据区域,主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中(包括局部变量、类信息、常量、静态变量)。
  • 工作内存: 线程私有,主要存储当前方法的所有本地变量信息(主内存中的变量副本拷贝) , 每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,即使访问的是同一个共享变量。

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

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

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

数据同步八大原子操作:

  • (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 修饰,所有,不要随便在代码中使用这种打印语句,会极度影响程序性能。

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 原则内容如下:

程序顺序原则 即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行,(时间上)先执行的操作happen-before(时间上后执行的操作)

锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。

volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简 单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的 值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。

线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B 的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享 变量的修改对线程B可见

传递性 A先于B ,B先于C 那么A必然先于C

线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待 当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的 join方法成功返回后,线程B对共享变量的修改将对线程A可见。

线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到 中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。

对象终结规则 对象的构造函数执行,结束先于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 中提供了四类内存屏障指令:

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

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

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

比如第二行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。

编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序:

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

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缓存一致性协议

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

Java代码的执行流程:

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 协议状态切换过程分析:

举例:

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

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

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

但它也会带来一定的风险:

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

到此这篇关于Java内存模型JMM与volatile的文章就介绍到这了,更多相关Java内存模型内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • 学习Java内存模型JMM心得

    有时候编译器.处理器的优化会导致runtime与我们设想的不一样,为此Java对编译器和处理器做了一些限制,JAVA内存模型(JMM)将这些抽象出来,这样编写代码时就无需考虑那么多底层细节,并保证"只要遵循JMM的规则编写程序,其运行结果一定是正确的". JMM的抽象结构 在Java中,所有的实例.静态变量存储在堆内存中,堆内存是可以在线程间共享的,这部分也称为共享变量.而局部变量.方法定义参数.异常处理参数是在栈中的,栈内存不在线程间共享. 而由于编译器.处理器的优化,会导致共享变量

  • Java内存模型(JMM)及happens-before原理

    我们知道java程序是运行在JVM中的,而JVM就是构建在内存上的虚拟机,那么内存模型JMM是做什么用的呢? 我们考虑一个简单的赋值问题: int a=100; JMM考虑的就是什么情况下读取变量a的线程可以看到值为100.看起来这是一个很简单的问题,赋值之后不就可以读到值了吗? 但是上面的只是我们源码的编写顺序,当把源码编译之后,在编译器中生成的指令的顺序跟源码的顺序并不是完全一致的.处理器可能采用乱序或者并行的方式来执行指令(在JVM中只要程序的最终执行结果和在严格串行环境中执行结果一致,这

  • java高并发的volatile与Java内存模型详解

    public class Demo09 { public static boolean flag = true; public static class T1 extends Thread { public T1(String name) { super(name); } @Override public void run() { System.out.println("线程" + this.getName() + " in"); while (flag) { ;

  • Java并发编程之volatile与JMM多线程内存模型

    目录 一.通过程序看现象 二.为什么会产生这种现象(JMM模型)? 三.MESI 缓存一致性协议 一.通过程序看现象 在开始为大家讲解Java 多线程缓存模型之前,我们先看下面的这一段代码.这段代码的逻辑很简单:主线程启动了两个子线程,一个线程1.一个线程2.线程1先执行,sleep睡眠2秒钟之后线程2执行.两个线程使用到了一个共享变量shareFlag,初始值为false.如果shareFlag一直等于false,线程1将一直处于死循环状态,所以我们在线程2中将shareFlag设置为true

  • Java内存模型JMM详解

    Java Memory Model简称JMM, 是一系列的Java虚拟机平台对开发者提供的多线程环境下的内存可见性.是否可以重排序等问题的无关具体平台的统一的保证.(可能在术语上与Java运行时内存分布有歧义,后者指堆.方法区.线程栈等内存区域). 并发编程有多种风格,除了CSP(通信顺序进程).Actor等模型外,大家最熟悉的应该是基于线程和锁的共享内存模型了.在多线程编程中,需要注意三类并发问题: ·原子性 ·可见性 ·重排序 原子性涉及到,一个线程执行一个复合操作的时候,其他线程是否能够看

  • 并发编程之Java内存模型volatile的内存语义

    1.volatile的特性 理解volatile特性的一个好办法是把对volatile变量的单个读/写,看成是使用同一个锁对单个读/写操作做了同步. 代码示例: package com.lizba.p1; /** * <p> * volatile示例 * </p> * * @Author: Liziba * @Date: 2021/6/9 21:34 */ public class VolatileFeatureExample { /** 使用volatile声明64位的long型

  • Java内存模型JMM与volatile

    目录 1.Java内存模型 2.并发三大特性 2.1.原子性 2.2.可见性 2.3.有序性 3.两个规则 3.1.happens-before规则 3.2.as-if-serial 4.volatile 4.1.volatile 禁止重排优化的实现 4.2.MESI缓存一致性协议 1.Java内存模型 JAVA定义了一套在多线程读写共享数据时时,对数据的可见性.有序性和原子性的规则和保障.屏蔽掉不同操作系统间的微小差异. Java内存模型(Java Memory Model)是一种抽象的概念,

  • JAVA内存模型(JMM)详解

    目录 前言 JAVA并发三大特性 可见性 有序性 原子性 Java内存模型真面目 Happens-Before规则 1.程序的顺序性规则 2. volatile 变量规则 3.传递性 锁的规则 5.线程 start() 规则 6.线程 join() 规则 使用JMM规则 方案一: 使用volatile 方案二:使用锁 小结: volatile 关键字 synchronized 关键字 总结 前言 开篇一个例子,我看看都有谁会?如果不会的,或者不知道原理的,还是老老实实看完这篇文章吧. @Slf4

  • 详细分析Java内存模型

    目录 一.为什么要学习并发编程 二.为什么需要并发编程 三.从物理机中得到启发 四.Java 内存模型 五.原子性 5.1.什么是原子性 5.2.如何保证原子性 六.可见性 6.1.什么是可见性 6.2.如何保证可见性 七.有序性 7.1.什么是有序性 7.2.如何保证有序性 一.为什么要学习并发编程 对于 "我们为什么要学习并发编程?" 这个问题,就好比 "我们为什么要学习政治?" 一样,我们(至少作为学生党是这样)平常很少接触到,然后背了一堆 "正确且

  • 并发编程之Java内存模型

    目录 一.Java内存模型的基础 1.1 并发编程模型的两个关键问题 1.2 Java内存模型的抽象结构 1.3 从源代码到指令重排序 1.4 写缓冲区和内存屏障 1.4.1 写缓冲区 1.4.2 内存屏障 1.5 happens-before 简介 简介: Java线程之间的通信对程序员完全透明,内存可见性问题很容易困扰Java程序员,这一系列几篇文章将揭开Java内存模型的神秘面纱. 这一系列的文章大致分4个部分,分别是: Java内存模型基础,主要介绍内存模型相关基本概念 Java内存模型

  • Java 内存模型(JMM)

    目录 四.Happens-Before 规则 Java 内存模型 一.什么是 Java 内存模型 Java 内存模型定义如下: 内存模型限制的是共享变量,也就是存储在堆内存中的变量,在 Java 语言中,所有的实例变量.静态变量和数组元素都存储在堆内存之中.而方法参数.异常处理参数这些局部变量存储在方法栈帧之中,因此不会在线程之间共享,不会受到内存模型影响,也不存在内存可见性问题. 通常,在线程之间的通讯方式有共享内存和消息传递两种,很明显,Java 采用的是第一种即共享的内存模型,在共享的内存

随机推荐