• 基础 | 并发编程 - [导论 & volatile]


    并发编程

    并发编程的特性

    • 原子性
      一个或一组操作在执行过程中不会被其他操作插入或中断
    • 可见性
      一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改
    • 有序性
      程序执行的顺序按照代码的先后顺序执行

    先行发生原则(Happens-Before)

    • 先行发生原则,是对 可见性有序性 的约束
    • 若 java 内存中,操作 A 先行发生于 操作 B ,则 操作 B 可以观测到 操作 A 产生的影响
    • 操作 A 与 操作 B具有先行发生关系,并不意味两操作一定按先行发生顺序执行
      若两操作指令重拍后,执行结果与不重排一致,则这种指令重排合法
    • 用于判断数据是否存在线程竞争或是否线程安全,对先行发生的场景的指令重排是有限制的

    java 语法天然支持下面 8 个先行发生场景
    下面8个场景中,操作 A 先行发生于操作 B 时,操作 B 可以观测到 操作 A 的结果

    • 程序次序规则(Program Order Rule)
      同一个线程内,流程控制上在前的操作先行发生于流程控制中在后的操作时
    • 管程锁定规则(Monitor Lock Rule)
      同一个锁的 unlock 操作 先行发生于 此锁的 lock 操作时
    • volatile 变量规则(Volatile Variable Rule)
      同一个 volatile 变量的写操作 先行发生于对于 这个变量的读操作之前时
    • 线程启动规则(Thread Start Rule)
      同一个线程 start()方法 先行发生 此线程其他方法时
    • 线程终止规则(Thread Termination Rule)
      同一个线程的 所有其他操作 先行发生 此线程的终止检测(join() 和 isAlive())时
    • 线程中断规则(Thread Interruption Rule)
      同一个线程的 interrupt() 方法的调用先行发生 此线程的终止检测(interrupt())时
    • 对象终结规则(Finalizer Rule)
      同一个对象的 构造方法执行结束 先行发生于 此对象 finalize() 方法开始时
    • 传递性(Transitivity)
      操作A 先行发生于 操作B
      操作B 先行发生与 操作C
      操作A 先行发生于 操作C

    场景举例

    private int value = 0;
    public int geValue(){ // 线程 2 调用
    	return value;
    }
    public int setValue(){ // 线程 1 调用
    	return ++value;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    分析

    • 首先,不涉及线程的启动、中断、终止,以及对象的终结
    • 其次,因为是多线程调用,所以不涉及 程序次序
      程序次序的前提是在同一个线程中
    • 再次,两个方法没有使用锁,不满足 管程锁定
    • 又次,涉及变量不被 volatile 修饰,所以不满足 == volatile 变量规则==
    • 最后,一共只有两个操作(取值和赋值),因此无所谓 传递性
    • 综上,不能推定上面两操作具有先行发生关系
      只能认为调用这两个方法的线程根据调用顺序具有一定的优先

    volatile 关键字

    volatile 关键字是 java 虚拟机提供的轻量级(线程间)同步机制,这意味着 volatile

    • 保证可见性
      写操作时,会立刻将结果同步到主内存
      读操作时,会强迫本地内存与主内存同步
      相当于直接往主内存写,从主内存读
    • 不保证原子性
      volatile 可能导致写操作丢失
      因为其他线程获取了同一个值并在此线程之前提交成功
    • 禁用指令重排

    指令重排
    为提高性能,编译器和处理器会对指令进行重排
    包括编译器优化重排指令并行重排内存重排

    • 指令在执行的过程中,若交换顺序不影响执行结果,则允许指令重排
      即,指令重排不能破坏数据原本的依赖关系
    • 编译器优化重排:编译器可能导致指令重排
      JMM 可以根据重排规则,禁止特定编译器的重排
    • 指令并行重排:处理器可能导致指令重排
      编译器可以通过在指令序列中插入 内存屏障 禁用
    • 内存重排:内存系统可能导致指令重排
      由处理器使用缓存或读写缓冲区导致

    单线程时,指令重排前后保持一致
    多线程时,可能出现问题

    指令重排会出问题的场景

    • 写后读
      若重排,会导致读到旧值
      i = 1;
      j = i;
      
      • 1
      • 2
    • 写后写
      若重排,会导致实际最后执行的语句生效
      i = 1;
      i = 10;
      
      • 1
      • 2
    • 读后读
      若重排,会导致读取到新值
      i = j;
      j = 10;
      
      • 1
      • 2

    多线程下指令重排问题示例
    说明
    假设只有一个 AAA 对象
    有两个线程,线程 1 调用 m1,线程 2 调用 m2

    此测试程序编译并执行多次,
    每次都是 m1 的第一句执行后,m2 开始执行

    m1 先执行 a=1 时,输出 6
    m1 先执行 f=true 时,输出 5

    class  AAA{
        int a = 0;
        boolean f = false;
    
        public void m1(){
            a = 1;
            f = true;
        }
    
        public void m2(){
            if (f){
                a = a+5;
                System.out.println(a);
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    内存屏障(Memory Barrier)
    内存屏障是一个 CPU 指令,作用如下

    • 内存屏障前后的指令不能与之调换顺序,因此前后指令也不会调换顺序
    • 内存屏障可以强制刷新 CPU 的缓存数据
      详见 内存屏障的划分

    当一个变量被声明为 volatile 时,字节码会添加 ACC_VOLATITL 标记
    JVM 遇到此标记时,会按下面 JVM 指令重排的规定 的顺序添加内存屏障

    内存屏障的划分
    写屏障(store fence): 使内存屏障之前的写操作回写主内存
    读屏障(load fence): 使内存屏障之后的所有读操作都能获得屏障前写操作的值
    全屏障(full fence): 上面两者合并

    实际使用时,会更细化分为 4 个屏障

    • LoadLoad,保证两个读操作有序
      对应汇编语句 movq 0(%%rsp) ,%0
      将 rsp (寄存器)中的值存入 thread.sp (线程的共享内存,sp 是共享内存 sharded memory 的一部分)中,完成rsp的存储
    • StoreStore,保证两个写操作有序
    • LoadStore,保证先读后写有序
    • StoreLoad,保证先写后读有序

    JVM 指令重排的规定

    是否允许指令重排后 普通读后 普通写后 volatile 读后 volatile 写
    先 普通读×
    先 普通写×
    先 volatile 读×(但应该可以? )×××
    先 volatile 写××

    概括的讲,下面场景禁用指令重排

    • 先 volatile 读时
      防止后面的操作重排到 volatile 读之前
    • 后 volatile 写时
      防止前面的操作重排到 volatile 写之后
      比如先读后写重排为先写后读,会导致读到的值不一致
    • 先 volatile 写后读时
      防止重排为先读后写,导致读的值不一致

    JMM 内存屏障策略
    JMM 在 volatile 读写前后添加屏障如下面两组

    volatile 写组

    • 写写屏障(StoreStore)
    • volatile 写
    • 写读屏障(StoreLoad)

    volatile 读组

    • volatile 读
    • 读读屏障(LoadLoad)
    • 读写屏障(LoadStore)

    上面两组直接与普通读写自由前后组合,如先 volatile 读,后 volatile 写可以等效为

    • volatile 读
    • 读读屏障(LoadLoad)
    • 读写屏障(LoadStore)
    • 写写屏障(StoreStore)
    • volatile 写
    • 写读屏障(StoreLoad)
    是否允许指令重排后 普通读后 普通写后 volatile 读后 volatile 写
    先 普通读---写写屏障(StoreStore)
    先 普通写---写写屏障(StoreStore)
    先 volatile 读读读屏障(LoadLoad)读写屏障(LoadStore)读读屏障(LoadLoad)写写屏障(StoreStore
    先 volatile 写--写读屏障(StoreLoad)写读屏障(StoreLoad)

    volatile 的适用场景
    总结

    • volatile 不保证 原子性,因此不适用于基于当前值的运算
      比如在当前值基础上 + 1 等操作
    • volatile 可以保证 可见性有序性,因此适用于 无所谓原值而直接复制 的操作
      比如用于标识某些状态的 boolean 和 int
      单例
      双重检验懒汉式(DCL)理论上有很小概率出现问题
      语句 instance = new Singleton() 不是原子的
      由 3 步组成
    • 在堆中分配内存空间
    • 初始化对象
    • 变量 instance 指向内存空间

    若 2 、3 步发生指令重排,且出现并发问题
    则因为引用了内存空间,内外两层校验检查时 != null
    但此时返回的是一个未初始化完成的对象
    可以使用 volatile 禁用指令重排
    读写锁缓存
    读通过 volatile 保证可见性和顺序性来代替锁,因为可以认为简单的读天然带有原子性
    写操作依然通过锁保证
    JUC 包中

  • 相关阅读:
    关于 Android 没有文件存储权限保存文件的问题
    探索项目追踪平台的多样性及功能特点
    mysql的mvcc详解
    排序算法的奥秘:JAVA中的揭秘与实现
    听说JetBrains要涨价,我赶紧把Goland续费到2025年!!!
    SqlServer 2019 安装教程(图文)
    选择排序、冒泡排序、快速排序、归并排序
    ClickHouse进阶(十三):Clickhouse数据字典-3-文件数据源及Mysql数据源
    245:vue+openlayers 利用canvas绘制边线纹路
    汇编 --- 用户程序的结构 和 程序加载
  • 原文地址:https://blog.csdn.net/ZEUS00456/article/details/126481356