JUC进阶04——Volatile和JMM

volatile和JMM

volatile 是什么

概述

volatile是Java中的一个关键字,用于修饰变量,它提供了轻量级的同步机制,主要用于在并发编程中保证内存可见性。当一个变量被声明为 volatile 时,它具有以下特性:

  • 可见性:对一个 volatile 变量的写操作对所有线程可见。当一个线程修改了一个 volatile 变量的值,其他线程立即能够看到这个修改
  • 有序性:禁止指令重排序。对一个 volatile 变量的写操作前后的代码指令不会被重排序,保证了操作的有序性。
    • 重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段,有时候会改变程序语句的先后顺序,若不存在数据依赖关系,可以重排序;存在数据依赖关系,禁止重排序;但重排后的指令绝对不能改变原有的串行语义!这点在并发设计中必须要重点考虑。

内存语义

volatile的写内存语义是:直接刷新到主内存中,读的内存语义时直接从主内存中读取

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存的共享变量值立即刷新回主内存中
  • 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,重新回到主内存中读取最新共享变量的值

volatile 凭什么可以保证可见性和有序性?

内存屏障Memory Barrier

内存屏障(Memory Barrier)

什么是内存屏障

内存屏障(Memory Barrier)是一类同步屏障指令(也称内存栅栏,屏障指令等),是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作,避免代码重排序。内存屏障其实就是一种JVM指令,java内存模型的重排规则会要求java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了Java 内存模型中的可见性和有序性 (禁止指令重排),但是,volatile无法保证原子性

  • 内存屏障之前的写操作都要写回主内存
  • 内存屏障之后的读操作都能获得内存屏障写操作的最新结果(保证了可见性)

写屏障(Store Memory Barrier):告诉处理器在写屏障之前将所有存储在缓存(store buffers)中的数据同步到主内存,也就是说,当看到store屏障指令,就必须把该指令之前的所有写入指令执行完毕才能继续往下执行

读屏障(Load Memory Barrier):处理器在读屏障之后的读操作,都在读屏障之后执行。也就是说在load屏障指令之后就能保证后面的读取数据指令一定能够读取到最新的数据

因此重排序时,不允许把内存屏障之后的指令重排序到内存屏障之前。一句话:对一个volatile变量的写,先行发生于任意后续对这个volatile变量的读,也叫写后读

内存屏障分类

粗分两种:

  • 读屏障(Load Memory Barrier):在读指令之前插入读屏障,让工作内存和Cpu高速缓存中的数据失效,重新回到主内存中读取数据
  • 写屏障(Store Memory Barrier):在写指令之后插入写屏障,强制把缓冲区数据刷新到主内存

细分四种:

屏障类型 指令示例 说明
LoadLoad Load1;LoadLoad;Load2 保证Load1的读取操作在Load2及后续读取操作之前执行
StoreStore Store1;StoreStore;Store2 在store2及其后的写操作执行前,保证Store1的写操作已经刷新到主内存
LoadStore Load1;LoadStore;Store2 在Store2及其后的写操作执行前,保证Load1的读操作已经结束
StoreLoad Store1;StoreLoad;Load2 保证Store1的写操作已经刷新到主内存后,Load2及其后的读操作才能执行

volatile 特性详解

可见性

保证不同线程对某个共享变量完成操作后结果及时可见性,即该共享变量一旦被修改,所有线程立即可见

案例:

  • 不加volatile,没有可见性,程序不能停止
  • 加volatile,有可见性,程序立即停止
java 复制代码
public class VolatileDemo {
   volatile static boolean flag = true;

    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            System.out.println(Thread.currentThread().getName()+"线程:come in");
            while (flag){

            }
            System.out.println(Thread.currentThread().getName()+"线程:flag被修改为"+flag+":task over");
        },"A").start();

        TimeUnit.SECONDS.sleep(4);
        flag = false;
        System.out.println(Thread.currentThread().getName()+"线程修改flag为:"+flag);

    }
}

执行结果:

css 复制代码
A线程:come in
main线程修改flag为:false
A线程:flag被修改为false:task over

有序性

程序执行过程源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> 最终执行指令

如何保证有序性? 计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令重排,重排序有可能影响程序的执行和实现,因此我们有时候希望告诉JVM不需要自动排序。

所以要保障有序性,需要通过内存屏障禁止指令重排

  • 对于编译器的重排序,JMM会根据重排序的规则,禁止特定类型的编译器重排序
  • 对于处理器的重排序,Java编译器会在生成指令序列的适当位置,插入内存屏障指令,禁止特定情形下的处理器重排序
  • happens-before之volatile变量规则
第一个操作 第二个操作:普通读写 第二个操作:volatile读 第二个操作:volatile写
普通读写 可以重排 可以重排 不可以重排
volatile读 不可以重排 不可以重排 不可以重排
volatile写 可以重排 不可以重排 不可以重排
  • 当第一个操作是volatile读时,不论第二个操作是什么,都不能重排序,这个保证了volatile读之后的操作不会重排到volatile读之前
  • 当第二个操作是volatile写时,不论第一个操作是什么,都不能重排序,这个操作保证了volatile写之前的操作不会被重排到volatile写之后
  • 当第一个操作时volatile写,第二个操作是volatile读时,不能重排序

JMM 将内存屏障插入细分为4种

  • 读屏障:在每个volatile读操作adLoad 屏障或者 LoadStore 屏障
    • 插入LoadLoad屏障:禁止处理器把上面的 volatile读操作和下面的普通读操作重排序
    • 插入LoadStore屏障:禁止处理器把上面的 volatile读操作和下面的普通写操作重排序
  • 写屏障
    • 插入StroeStore屏障:可以volatile写之前,在其之前的所有普通写操作已经刷新到主内存
    • 插入storeLoad屏障:禁止处理器把上面的volatile写操作和下面可能有的volatile读/写操作重排序

禁止指令重排

  • 在每一个volatile写操作前面插入一个StoreStore屏障--->StoreStore屏障可以保证在volatile写之前,其前面所有的普通写操作都已经刷新到主内存中。
  • 在每一个volatile写操作后面插入一个StoreLoad屏障--->StoreLoad屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序
  • 在每一个volatile读操作后面插入一个LoadLoad屏障--->LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序
  • 在每一个volatile读操作后面插入一个LoadStore屏障--->LoadTore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序
案例说明(volatile读写前或后加了屏障保证有序性)

volatile没有原子性

volatile修饰的复合变量不具备原子性

  • 对于volatile修饰的变量具备可见性,JVM只是保证从主内存加载到线程工作内存的值是最新的,也仅仅是数据加载时是最新的。但是多线程环境下,"数据计算"和"数据赋值"操作可能多次出现,若数据在加载完成,主内存volatile修饰变量发在数据修改之后,线程工作内存的操作将会作废 去读取主内存中的最新值,操作出现写丢失问题。即各线程私有内存和主内存公共内存中变量不同步,进而导致数据不一致。由此可见volatile解决的是变量读取时的可见性问题,但是无法保证原子性,对于多线程修改主内存共享变量的场景必须枷锁同步
  • 至于如何去理解这个写丢失问题,就是再将数据读取到本地内存 到 写回主内存中有三个步骤:数据加载 ------> 数据计算 ------> 数据赋值,如果第二个线程在第一个线程读取旧值与写回新值期间读取共享变量的值,那么第二个线程将会与第一个线程一起看到同一个值,并执行自己的操作,一旦其中一个线程对volatile修饰的变量先行完成操作刷回主内存后,另一个线程就会作废自己的操作,然后去读取最新的值再进行操作,这样的话,它自身那次的操作就丢失了,这就造成了线程安全失败,因此,这个问题需要使用枷锁的方式去保证线程的安全性
  • 结论: volatile修饰变量不适合参与到依赖当前值的运算,如 i++,i=i+1之类的,通常用来保存某个状态的 boolean 值或者 int值,也正是由于volatile变量只能保证可见性,在不符合以下规则的运算场景中,我们仍然需要枷锁来保证原子性:
    • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
    • 变量不需要与其他的状态变量共同参与不变约束

总结

volatile变量的读写过程

read(读取)→load(加载)一use(使用)→assign(赋值)store(存储)一write(写入)一lock(锁定)一unlock(解锁)

volatile 保证了可见性:

volatile关键字保证可见性 对一个被volatile关键字修饰的变量
1 写操作的话,这个变量的最新值会立即刷新回到主内存中
2 读操作的话,总是能够读取到这个变量的最新值,也就是这个变量最后被修改的值
3 当某个线程收到通知,去读取volatile修饰的变量的值的时候,线程私有工作内存的数据失效,需要重新回到主内存中去读取最新的数据。

volatile保证了有序性(volatile禁重排)

volatile关键字如何让系统底层加入内存屏障

通过在字节码中添加 ACC_VOLATILE 指令

相关推荐
小菜yh几秒前
关于Redis
java·数据库·spring boot·redis·spring·缓存
宇卿.7 分钟前
Java键盘输入语句
java·开发语言
浅念同学7 分钟前
算法.图论-并查集上
java·算法·图论
希冀1238 分钟前
【操作系统】1.2操作系统的发展与分类
后端
立志成为coding大牛的菜鸟.20 分钟前
力扣1143-最长公共子序列(Java详细题解)
java·算法·leetcode
鱼跃鹰飞21 分钟前
Leetcode面试经典150题-130.被围绕的区域
java·算法·leetcode·面试·职场和发展·深度优先
GoppViper37 分钟前
golang学习笔记29——golang 中如何将 GitHub 最新提交的版本设置为 v1.0.0
笔记·git·后端·学习·golang·github·源代码管理
爱上语文2 小时前
Springboot的三层架构
java·开发语言·spring boot·后端·spring
serve the people2 小时前
springboot 单独新建一个文件实时写数据,当文件大于100M时按照日期时间做文件名进行归档
java·spring boot·后端
qmx_073 小时前
HTB-Jerry(tomcat war文件、msfvenom)
java·web安全·网络安全·tomcat