JAVA并发编程-可见性和有序性

170 阅读16分钟

Java并发编程的三大问题: 原子性, 可见性, 有序性. CAS可以解决原子性问题.

cpu物理缓存结构

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/81dbcdcafe3b4ea6aee9099df35d7185~tplv-k3u1fbpfcp-zoom-1.image

由于cpu的运行速度和主存的存取速度差别很大, 为cpu设置了多级缓存L1/2/3, 其中L1/2是cpu中每个核心单独使用, L3是多个内核可以共享的区域, 离cpu核心越近的缓存存取速度越快, 容量越小, 越远就反之.

cpu设置多级缓存的优势:

  1. cpu不需要停止下来等待内存的阻塞写入.
  2. 批处理的方式刷新缓冲区, 减少对内存总线的占用.

并发编程的3大问题

原子性问题

原子操作: 一个不可被中断的操作. 从开始执行到结束不会有线程切换的发生.

i++ 操作不是原子操作

public class CountSample {
    int sum = 0;
    public void increase(){
        sum++;
    }
}

查看汇编代码:

> javap -c ./CountSample.class
Compiled from "CountSample.java"
public class main.java.com.lee.cas.CountSample {
  int sum;

  public main.java.com.lee.cas.CountSample();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: aload_0
       5: iconst_0
       6: putfield      #2                  // Field sum:I
       9: return

  public void increase();
    Code:
       0: aload_0
       1: dup
       2: getfield      #2                  // Field sum:I 
       5: iconst_1
       6: iadd
       7: putfield      #2                  // Field sum:I
      10: return
}

sum++操作在汇编代码中分为4步执行:

  1. getfield: 获取当前sum变量值, 并放入栈顶.
  2. iconst_1: 常量1放入栈顶.
  3. iadd: 把栈顶的两个值相加: sum+1, 并把结果放入栈顶
  4. putfield: 把栈顶结果在赋值给sum变量.

可以得出结论++操作不是原子操作. 在汇编代码层面分为4步执行, 在这些步骤之间可能会发生线程切换或中断, 所以在并发场景下会发生原子性问题.

可见性问题

多个线程在操作同一个共享变量时, 一个线程对共享变量的修改, 其他线程能够感知到共享变量被修改了就是所谓的可见性.

在Java内存模型中, 每个线程都会把需要用到的变量拷贝一份副本到自己的线程工作内存中, 在工作内存中完成修改后, 再刷到主存中. 在线程把修改后的变量刷回主存之前, 如果有其他线程读取到了主存中未修改的变量, 那么就发生了可见性问题.

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/87600c6658f54eaaaa5e67700cb77bab~tplv-k3u1fbpfcp-zoom-1.image

示例: 在主存中共享变量sum=0, 线程1读取后在私有内存内存中执行+1后, 在线程T1把结果刷回主存之前, 线程T2从主存中读取变量sum=0然后执行sum++操作, 由于线程T1, T2对共享变量的操作对其他线程不可见, 导致在线程T1,T2刷回主存之后, sum最终结果为1, 就发生了可见性问题. 可以通过关键字volatile关键字来修饰共享变量解决可见性问题, 但是不能解决原子性问题. 所以还是有并发安全问题.

有序性问题

示例代码:

public class OrderingDemo {
    private volatile static int x,y;
    private static int a, b;
    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for (; ; ) {
            i++;
            a = b = x = y = 0;
            Thread t1 =new Thread(()->{
                a=1;
                x=b;
            });
            Thread t2 =new Thread(()->{
                b=1;
                y=a;
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            String result = "第" + i + "次(" + x + ", " + y + ")";
            System.out.println(result);
            if (x == 0 && y == 0) {
                System.out.println(result);
            }
        }
    }
}

按照正常输出:

如果t1在t2开始之前执行完成, 结果应该为: (0, 1)

如果t2在t1开始之前执行完成, 结果应该为: (1, 0)

如果交互执行应该为(1, 1).

实际结果是:

(0, 0), (0, 1), (1, 0), (1, 1)应该都会出现.

💡 但是据我实测: 我没跑出来(1, 1) 😂, 可能是我跑的时间不够长.

可见在并发执行环境中, 代码并没有老老实实的按照我们写的顺序执行下去.

所以: 原子性, 可见性, 有序性任何一个问题都会导致我们在并发环境中产生线程安全问题.

硬件层的MESI协议原理

可见性问题同样发生在cpu的计算过程中, 由于多核cpu的L1/2级缓存是单个核心私有缓存, 所以不同线程运行在不同核心上时, 看到的同一个缓存在不同核心的私有缓存中的值是不一样的. 而硬件层面的MESI协议就是一种用于解决这种内存可见性的问题的.

为了解决内存可见性问题, cpu提供了两种解决办法: 总线锁, 缓存锁.

总线锁

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/2a1c032ab9b3470cb6a382dd83299857~tplv-k3u1fbpfcp-zoom-1.image

cpu中每个核心访问公共资源(内存, 高速缓存等), 都是通过消息总线来完成的, 总线锁的原理就是, 任何一个核心在修改公共变量的私有空间副本值时, 都会向总线发送一个lock指令, 这样就会阻塞其他核心继续使用公共资源, 这样就不会造成缓存不一致的问题, 但是这样会引入效率问题.

由于总线锁的粒度是整个总线, 对cpu执行效率的阻塞程度非常高.

缓存锁

缓存锁的原理是, 为了实现各个cpu核的缓存一致性, 需要各个cpu内核在访问共享内存时遵循一些协议, 在存取数据时遵循这些协议来操作, 常见的协议: MSI, MESI, MOSI.

大致的原理就是在某个核修改了共享内存中的变量后, 会通知其他内核, 废弃该变量在自己私有高速缓存中的拷贝. 在需要数据时重新从共享内存中读取最新值.

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0cb2bf88e76c40b69ae3c602259652b5~tplv-k3u1fbpfcp-zoom-1.image

高速缓存中内容都是主存中的副本, 所以应当保持和主存一致, 而高速缓存写主存有两种模式: Write-Through, Write-Back

Write-Through: 在数据修改时, 直接写入低一级的高速缓存和主存, 操作简单, 但是效率低, 一次修改的周期变长.

Write-Back: 在修改数据时, 不立即写回主存, 只在需要写出时(被替换), 或者变成共享状态时发现数据有变动才写入主存. 但是实现复杂.

MSI协议

缓存一致性协议MSI, 也称失效协议, 同一时刻只能有一个内核拿到线程总线的操作权限, 如果多个核同时写入一个变量值, 总线就会把这些内核的写操作变为顺序操作, 且先写后会让其他内核中的缓存失效. 也就是回写模式.

加入有多个内核同时操作一个共享变量, 那么根据MSI协议会要求这些核心顺序操作, 先操作的核心在写完本地高速缓存后, 通知其他核心中的该变量已过期, 其他核心再操作时需要重新从共享内存中读取最新值, 由于共享缓存中没有该值的最新值(已过期), 所以从第一个执行的核心的私有高速缓存中读取最新值(回写模式). 然后第一个操作的核心更新主存中改变量的值, 然后该值变为共享状态.

MESI协议及RFO请求

目前最主流的一致性协议: MESI, 写入失效协议, MESI是MSI的扩展版本, 在MESI中, 每个缓存行有四个状态: M(Modified), E(Exclusive), S(Shared), I(Invalid). 用2bit表示.

M: 一个变量只在一个cpu高速缓存中有缓存, 且被修改过(和主存不一致). 被当前cpu核心修改过. 且未更新到主存中.

E: 一个变量只在一个cpu高速缓存中有缓存, 且没有被修改(和主存保持一致).

S: 一个变量在多个cpu高速缓存中有缓存, 且与主存状态一致(没有被修改过).

I: 一个变量在某个cpu高速缓存中被修改. 其他地方的缓存是无效的.

TODO: 这个好难描述, 哈哈, 等理解透彻了再回来写.

volatile原理

cpu的高速缓存解决了写的性能瓶颈为题, 确带来了可见性问题, Volatile关键字就解决了这个可见性问题. 它要求共享变量的修改需要立即刷回到主存. 当共享内存中的一个缓存行被volatile修饰时, 就会对该缓存行进行一致性校验.

在汇编层面: 在操作volatile修饰的变量之前, 会多出一个lock前缀指令lock addl. 该lock指令有三个功能:

  1. 将当前cpu缓存行的数据立即写回到系统内存.
  2. 引起其他cpu缓存中该内存地址的数据无效.
  3. lock前缀指令禁止指令重排: 作为内存屏障, 禁止指令重排, 避免多线程环境下程序出现乱序执行现象.

有序性和内存屏障

cpu会优化待执行的指令顺序, 导致指令的执行顺序会好代码的顺序有所不同, 可能会导致代码执行出现有序新问题.

内存屏障是一系列的cpu指令, 作用就是禁止在内存屏障的前后执行指令重排序.

重排序

为了提供性能, 编译器和cpu都会都指令进行重排序.

编译器重排

Java编译器在代码编译阶段, 在不改变执行结果的前提下, 对指令进行乱序编译.

cpu重排

在cpu层面只要两个指令不存在数据依赖关系即可进行排序, 所以无法保证业务上的隐形的顺序关系.

cpu重排的两类:

  1. 指令级重排序: 将没有数据依赖的指令并行执行从而提供运行效率
  2. 内存系统重排序: 高速缓存的回写操作不是修改后就立即回写, 而是先读, 最后批次性的回写. 会有短暂的不一致现象.

As-if-Serial规则

规则要求: 编译器重排和cpu指令重排都需要保证代码在单线程模式下的执行结果是正确的.

但是无法保证多内核以及跨cpu指令重排后的执行结果正确.

硬件层面的内存屏障

内存屏障是让一个cpu高速缓存的内存状态对其他cpu内核可见的一项技术, 也是一项跨cpu内核有序执行指令的技术.

三种内存屏障: 读屏障, 写屏障, 全屏障

读屏障让高速缓存中的缓存失效, 在指令前插入读屏障, 可以让高速缓存中的数据失效, 强制重新从主存中加载数据, 且要求cpu和编译器, 先与该屏障的指令必须先执行. 在该指令之后的读操作都在该指令之后被执行, 且本地高速缓存中的数据全部失效. 需要重新读取该共享变量的值.

写屏障: 在指令后插入写屏障会让高速缓存中的数据最新值都更新到主存中, 对其他cpu可见, 并且要求cpu和编译器, 在该指令之后的指令必须后执行.

全屏障: 同时拥有读屏障和写屏障的功能.

作用:

  1. 阻止屏障前后的指令发生重排
  2. 强制高速缓存中的数据失效.

JMM详解

Java内存模型

Java内存模型中的两个概念: 主存, 工作内存

主存: 主要保存的是Java实例对象, 所有线程创建的实例对象都是保存在这里的, 这部分区域的数据是被所有线程共享的, 所以多条线程同时访问时会有线程安全问题.

工作内存: 某个线程的私有存储空间, 主要存储主存变量的副本, 由于是线程私有内存, 只能被拥有线程访问, 不存在线程安全问题.

JMM内存模型规定:

  1. 所有变量都保存在主存中.
  2. 每个线程都有自己的工作内存, 且线程对变量的操作都是在工作内存中完成的.
  3. 线程之间无法直接访问彼此的工作内存, 要实现通信只能通过主存实现.

关系图:

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/33242545496b4c61a8c9d2e294702c0e~tplv-k3u1fbpfcp-zoom-1.image

这里的工作模式跟硬件层面的CPU工作机制类似, 所以也需要解决缓存可见性和重排序问题, JMM提供了一套自己的方案用于禁用缓存和禁止指令重拍来解决可见性和有序性问题. 其中就包括: volatile, synchronized, final等, 实现策略就是JMM定义一系列的内存操作的抽象指令集, 然后把这些指令集包含到关键字: volatile, synchronized等关键字的语义中. 并要求JVM在实现这些关键字时必须具备它们包含的一系列CPU指令的能力.

JMM与JVM物理内存的区别

JMM属于语言级别的内存模型, 确保了在不同编译器和不同的CPU平台上为Java应用提供一致的内存可见性保证和指令并发执行的有序性.

JVM属于概念和规范维度的模型. JVM模型定义了一系列指令集, 虚拟计算架构, 和执行模型, 具体的JVM实现需要遵循JVM模型. JVM可以理解为实体的, 实现维度的虚拟机, 例如HotSpot VM.

在JVM模型中把内存划分为了几个不同的数据区域, 每个区域都有不同的用处. 大致如图:

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b19f2b3debeb4185a8602be288bf2cb6~tplv-k3u1fbpfcp-zoom-1.image

JVM模型定义了java虚拟机规范, 不同JVM的具体实现各不相同, 但是会遵循JVM规范.

💡 JMM模型中的主存和工作内存不和物理层面的存储介质产生强绑定, 即工作内存可以是高速缓存L1/L2/L3以及内存条, 主存也可以是高速缓存L1/L2/L3以及内存条.

JMM的八个操作

操作作用对象说明
Read主存从主存中读到工作内存中
Load工作内存把Read的结果载入到工作内存中的副本中.
Use工作内存执行引擎使用工作内存中的变量副本
Assign工作内存执行引擎操作完成后回写变量值到工作内存中
Store工作内存把工作内存中的变量传递到主存中, 供Write使用
Write主存把Store阶段的变量值放入到主存的变量中
Lock主存将主存中的某个变量标识为某个线程独占状态
UnLock主存释放主存中被锁定的变量, 可以供 其他线程锁定操作

如果需要把一个变量从主存中复制到工作内存中, 需要执行 read和load操作.

如果需要把工作内存中的变量同步回主存, 需要执行Store和Write操作,

read和load, store和write, 必须顺序执行, 但不要求连续.

八个操作关系图:

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/8fa0cca9a7594a3caaf76336b28fe943~tplv-k3u1fbpfcp-zoom-1.image

JMM如何解决有序性

JMM提供了自己的内存屏障指令, 主要的两种, 读屏障, 写屏障

读指令前加读屏障, 让缓存失效, 重新从主存中读取数据.

写指令之后加写屏障, 最新写入缓存的数据同步回主存中.

实际的使用场景是读写屏障组合使用, LL, SS, LS, SL

LL: 使用示例:

load1; LL; load2

含义: LL保障load2要读取的数据被访问之前, load1要读取的数据被读取完毕.

SS:

Store1; SS; Store2;

保障在Store2的写入操作执行之前, store1的写入结果对其他CPU可见.

LS:

Load1; LS; Store2;

保障store2的写入操作执行之前, Load1的读取操作读取完毕.

SL:

Store1; SL; Load2;

保障Load2的读取操作执行之前, Store1的写入结果对其他cpu可见.

SL屏障是开销最大的一个屏障, 具备其他三个屏障的功能, 大部分的CPU都支持这个屏障.

volatile语义中的内存屏障

volatile在Java代码中的两层语义

  1. 一个线程对volatile变量执行修改后, 对其他线程立即可见.
  2. 禁止指令重排(编译器重排, cpu指令重排)

volatile保证有序性的原理就是禁止指令重排, 禁止指令重排的原理就是通过插入内存屏障, 具体策略:

读操作后插入LL屏障.

读操作后加入LS屏障

写操作前插入SS屏障

写操作后插入SL屏障.

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3f979aef80e849b786730acf635416fb~tplv-k3u1fbpfcp-zoom-1.image

volatile写屏障实例

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/2da8bf01dd524931a178d43a887e7d20~tplv-k3u1fbpfcp-zoom-1.image

volatile读屏障

Happens-Before规则

JMM的内存屏障对Java工程师无感知, 是对JVM实现的要求, 一般Java工程师写的Java代码如何保证内存可见性和有序性, 只要遵循JMM定义的Happens-Before规则, 且保证Java语句之前存在Happens-Before关系, JMM就能确保Java语句的有序性和内存可见性.

介绍

HB规则:

  1. 同一个线程中有依赖关系的操作必然按照先后顺序执行.
  2. volatile变量写操作必然先与读操作.
  3. 传递性规则. a→b, b→c ===⇒ a→c
  4. 监视锁规则, 解锁后与加锁
  5. start规则, 线程的start操作先行于线程的任何其他操作.
  6. join规则, join操作一定晚于join线程的其他操作.

volatile不具备原子性

volatile能保证数据的可见性, 有序性, 但是不能保证原子性, 所以不能用于保证线程安全.

volatile变量的复合操作不具备原子性的原理

这个很好理解, 对于volatile变量的操作, 其能保证的顺序性, 但是不能保证顺序不间断, 例如线程1读取变量a, 修改后写回主存, 大致分为几步:

read→load→use→assign→store→write.

假设在一个线程在执行完store, 还未执行完write, 其他线程从主存中读取到的变量任然是旧值. 所以就出现了线层安全问题.

谨记: volatile的作用: 可见性体现在写回完主存后, 让其他缓存中的缓存失效, 上面的例子线程1明显还没写完.

顺序性不代表连续性.

💡 纯理论知识, 很冗余, 但很重要. 耐着性子也要看完, 而且不止一遍.

Java高并发核心编程读书笔记