『读书笔记』Java并发编程的艺术(JMM内存模型)

前言

本文并非按照书中目录所写,为自己读后总结,个人觉得这本书有着比较深的学习价值,在此致敬本书作者。

并发编程模型两个关键问题

并发编程需要着手解决原子性、有序性、可见性三个问题,这三个问题侧重在线程通信与线程同步上。针对于这两个问题,有两种机制来保证: 共享内存 | 消息传递。
共享内存屏蔽通信细节,但需要显式指定线程同步顺序;消息传递由程序员主动发送消息,显式执行线程通信,线程同步由于自带发送顺序,隐式进行。

\ 线程通信 线程同步 典型语言
共享内存 隐式 显式 Java
消息传递 显式 隐式 Go

原子性、有序性、可见性

原子性:操作不可分割。CPU层面保证基础指令的原子性,对于复杂原子指令,比如交换指令CMPXCHG,采用总线锁or缓存行锁来保证原子性。需要注意的是,32位操作系统不对64位数据写入保证原子性,比如long类型或者double类型变量写入。
有序性:涉及到的指令重排分三种,编译级指令重排(编译器优化)、指令级指令重排(CPU指令并行)、 内存系统指令重排(CPU读/写缓存区),单线程模型下,CPU与编译器不会对有间接依赖的指令重排序。
可见性:针对上述三种指令重排,而引发线程之间的内存可见性问题。

进一步充电 缓存一致性协议之MESI

Java内存模型的抽象结构

JMM定义了共享变量存储于主存之中,每个线程都有一个私有的本地内存,存储共享变量的副本。这里的本地内存是一个抽象的概念,并不真实存在,它涵盖了CPU高速缓存(L1,L2,L3)、写缓冲区、编译器优化等等。为了保证内存可见,Java编译器在生成指令序列的适当位置插入内存屏障。

JMM内存屏障

JMM把内存屏障指令分为4类,见下表。


JMM内存屏障指令

上面这四个内存屏障简单来说,Load用于读取装载数据,Store用于存储,会保证前面的装载or存储<优先于>后面的装载or存储

volatile内存语义

当写一个volatile变量时,JMM会把该线程的本地内存的共享变量值刷新到主存。
当读一个volatile变量时,JMM会把该线程的本地内存置为无效,从主存获取共享变量。

  • volatile写之前的操作不会被编译器重排序到volatile写之后。
  • volatile读之后的操作不会变编译器重排序到volatile读之前。
  • 当第一个操作是volatile写,第二个操作是volatile读,不能重排序。

为了实现volatile内存语义,编译器生成字节码通过插入内存屏障来禁止重排序

  • 在每个volatile写前面插入StoreStore屏障,确保volatile写之前的数据刷新到主存,并且不会重排序到volatile写之后。
  • 在每个volatile写后面插入StoreLoad 屏障,确保volatile写与后续可能的volatile读/写操作重排序(这个开销昂贵)。
  • 在每个volatile读后面插入LoadLoad 屏障,确保volatile读不会与后续的普通读重排序。
  • 在每个volatile读后面插入LoadStore 屏障,确保volatile读不会与后续的普通写重排序。

比较有意思的是volatile写之后的StoreLoad屏障,JMM可以选择在每个volatile写之后或者volatile读之前插入StoreLoad屏障,但由于通常共享变量读多写少,JMM最终选择在volatile写之后插入StoreLoad屏障,来提供一定的性能提升。
上面内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意程序中都能保证volatile的正确语义。JMM针对不同平台不同代码,会省略部分内存屏障来做优化。

锁(ReentrantLock)的内存语义

  • 公平锁与非公平锁释放时,都要写volatile变量state。
  • 公平锁获取时,首先会读volatile变量。
  • 非公平锁获取时,首先CAS更新volatile变量。
    编译器会为CAS的交换指令CMPXCHG加入lock前缀,lock前缀同时具有volatile读与volatile写的内存语义。

总结来说:加锁具有和volatile读相同的内存语义,解锁具有和volatile写相同的内存语义。
并发包下的大部分锁,同步器都是基于AQS实现的,并发包的基石是volatile、synchronize、cas,JUC的包有个通用的实现模式:首先声明共享变量为volatile,然后使用CAS原子更新实现线程之间同步,同时配合CAS或volatile读写的内存语义来实现线程之间的通信。

final域重排序规则

  • 编译器会在final域写之后,构造函数返回之前插入StoreStore内存屏障,禁止final域的写重排序到构造函数之外。
  • 初次读包含final域的对象引用,再初次读final域,禁止重排序。这两个操作之间存在间接依赖,大多数处理器本身就不会重排序,但也有少部分的处理器允许间接依赖的关系进行重排序。
    final的语义保证了正确构建的对象不需要使用同步,其他线程都能看到正确的被初始化之后的值。
    以下为错误示例代码,final引用从构造函数溢出
/**
 * @author YuanChong
 * @create 2020-03-29 18:50
 * @desc final引用从构造函数溢出示例
 */
public class FinalExample {
    private final int data;

    private static FinalExample ref;
    
    private FinalExample(int data) {
        this.data = data;
        ref = this;
    }
    
    public static void instanceObject() {
        new FinalExample(1);
    }

    /**
     * 并发下,A线程执行instanceObject,B线程执行readFinal,B线程读到的可能是0也可能是1
     * @return
     */
    public static int readFinal() {
        return ref.data;
    }
}

JMM屏蔽内存模型细节

JMM提供了as-if-serial语义与happens-before原则保证程序的正确执行。
happens-before提供给程序员易于理解,简单易懂的并发下内存可见性保证。
as-if-serial语义保证了不管怎么重排序,单线程程序的执行结果不能被改变。
需要注意的是,这两种语义只是JMM对程序员的保证承诺,JMM只保证执行结果,但具体是否涉及重排序还要看编译器与处理器的优化。这是JMM在编译优化与简单易懂的内存模型之间的一个权衡结果。因此,happens-before更应该理解成生效可见于,他与执行顺序无关

  • 程序顺序原则:本线程的每个操作生效可见于后续发生的所有操作
  • 锁规则:当前线程解锁生效可见于后续其他线程的加锁
  • volatile规则:volatile写生效可见于后续对volatile的读
  • 传递性规则:如果A happens-before B,B happens-before C,那么A happens-before C
  • start规则:如果A线程执行Thread.start()启动线程B,A线程的Thread.start()生效可见于B线程的后续操作
  • join规则:如果A线程执行Thread.join(),B线程的任意操作生效可见于A从Thread.join()中返回

我们结合happens-before的几个原则,可以分析出线程同步代码是否有可见性问题
比如A线程执行Thread.start()启动B线程,A线程做的共享变量的修改生效可见于B线程,这是由顺序性规则,start规则,传递性规则同时推断出来的。

楼主之前也分析过锁的happens-before推断,详见从happen-before角度分析synchronized与lock的内存可见性问题

你可能感兴趣的:(『读书笔记』Java并发编程的艺术(JMM内存模型))