目录

ThasBlog

学无止境

X

Java JMM

什么是JMM

物理内存结构

物理内存结构通常包括内存和CPU L1~L3级缓存, 使用缓存一致性协议来保障并发安全.

JVM内存结构

JVM 使用线程工作内存和主内存这两个概念来抽象物理内存结构, 从而屏蔽掉各平台各操作系统的内存结构差异. JMM 为保障这套抽象内存结构的并发安全, 提供了协议和工具的支持.

线程安全三要素

  1. 原子性
    某个线程期望某一段操作能够完整的执行完整, 执行过程不被其他线程打断
  2. 可见性
    某个线程的操作结果期望被其他线程"观察"到
  3. 有序性
    某个线程的一段操作希望被顺序的执行, 不被重新排序

JMM协议:

happen-before原则:

  1. 单一线程顺序原则
    单一线程内, 先书写的代码 happen-before 后书写的代码.
    happen-before指的是先进行的操作的结果对后进行的操作可见, 所以这条规则并不意味代码的顺序就不被重排序了, 比如:

    int a = 1;
    int b = 2;
    System.out.println(a);
    System.out.println(b);
    

    本条原则保障了 a 变量赋值这个操作happen-before 打印 a 变量这个操作, 但是它的顺序依然有可能会被重新排序:

    int a = 1;
    System.out.println(a);
    int b = 2;
    System.out.println(b);
    
  2. volatile 变量原则
    volatile 变量的写操作 happen-before 该变量的读操作

  3. 管程锁定原则
    退出管程锁定代码块之前发生的操作 happen-before 下一个进入管程锁定的代码块后的线程的操作

  4. 线程启动原则
    线程的 start 方法 happen-before 被启动的线程内的代码执行

  5. 线程加入原则
    被join的线程的代码执行 happen-before 执行join方法的结果返回

  6. 线程中断原则
    线程interrupt方法 happen-before 被 interrupt 的线程监听到中断事件的发生

  7. 对象终结原则
    对象创建方法 happen-before 对象终结的finalize 方法

  8. 传递性
    如果A happen-before B, B happen-before C, 则 A happen-before C.
    因此规则 2 中的两个happen-before 规则, 让没有被 volatile 修饰的 a 变量也受惠了. 线程 2 use a的操作, 能够读取到线程 1 写 b 变量之前的写 a 变量的操作结果.
    规则3 规则4

JMM工具

  1. volatile
    虽然变量的赋值看上去是一步到位的, 但是实际会拆解成好几步.
    使用变量的过程:

    1. read 从主内存读取变量
    2. load 将读取的变量载入线程本地缓存
    3. use 线程使用变量

    变量赋值的过程:

    1. assign 赋值命令, 写入线程本地缓存
    2. store 将其写入主内存

    对于被 volatile 修饰的变量, JVM 会自动为其添加内存屏障, 读变量前添加读屏障, 写变量后将入写屏障.
    内存屏障类型:

    1. loadload 先load的线程的操作happen-before后load的线程
    2. loadstore 先load的线程的操作happen-before后store的线程
    3. storestore 先store的线程的操作happen-before后store的线程
    4. storeload 先store的线程的操作happen-before后load的线程
      storeload是最高级别的内存屏障, 涵盖前3种, 它保障了, 变量的写操作happen-before其他线程后发生的读操作, 也就是其他线程一定读取到在他们之前方式的写操作的最新结果.
      JVM实现storeload屏障的方式就是在执行store操作时锁住CPU总线, 根据缓存一致性协议, 此时暂存在storebuffer中的值将会强制刷新会主存, 所以CPU中cache的旧值都会失效, 必须从主存重新读取

    volatile变量通常被描述成可以禁止内存指令重排, 但是这并不是因为指令被重排了.
    CPU 对一个值更新后, 这个值并不会被立即同步给其他CPU, 而是放进了storebuffer中, 这就是一个事务的提交, 这个事务需要被其他CPU确认, 当某个CPU提交了多个这种事务时, 其他CPU对这个值的确认可能并不是顺序的, (这里就是内存指令重排, CPU 为了优化执行速度, 把更容易确认的事务提前了), 因此其他CPU可能会得到一个乱序的假象:

    // 线程1
    a = 1;
    b = 2; // volatile
    
    // 线程2
    // use b
     // use a
    

    线程1 先更新了a 再更新了 b, 但是 a 的这个事务可能晚于 b 的确认, 所以线程2 可能会看到 b 更新了, 但是 a 没有更新问题, 看上去就像是线程1 的指令被重排序了.
    所以 volatile 解决的就是这个问题, 它可以保障被 volatile 修饰的 b 变量的修改的事务一定能被线程2 读取到, 且在此之前的事务全部得到确认. 看上去就像上 b 的写和读操作下面加了一堵墙(内存屏障), 禁止 a 越位一样
    volatile还有一个作用, 32位系统赋值 64 位数值的操作拆分成了两步, volatile 可以保障这个操作的原子性.

  2. CAS
    CompareAndSwap, 原本比较和赋值不是一步操作, 操作系统提供了这样一个指令保障这两个操作一次性执行完(原子性)

  3. 原子类
    对 CAS 的封装

  4. synchronize/其他Lock
    Java 内置的 synchronize 使用管程实现锁.
    在进入同步代码块之前, 插入monitorneter指令, 退出代码后插入monitorexit指令, 同一时间只有一个线程能获得monitor的所有权. 如果是同步方法, 则插入ACC_SYNCHRONIZED
    JUC 并发包使用的AQS, 使用 CAS 和 LockSupport 来实现锁.

  5. final
    对象创建分为 内存空间分配, 赋值内存空间指针, 对象初始化三个部分, 这三个部分有可能被重排序.
    同一个线程使用没有问题, 因为遵循"单一线程顺序原则", 但是并发情况, 其他线程有可能观察到这个三个部分任何一个状态.
    final 修饰的变量, 需要在构造器中完成初始化, 如果其所在对象的构造块中, this指针没有逃逸出去, 那么 final 可以保障初始化完成后其他对象一定能观察到完整的对象.


标题:Java JMM
作者:thas
地址:https://thas.cc/articles/2020/12/06/1607225846315.html