前言
本文并非按照书中目录所写,为自己读后总结,个人觉得这本书有着比较深的学习价值,在此致敬本书作者。
并发编程模型两个关键问题
并发编程需要着手解决原子性、有序性、可见性三个问题,这三个问题侧重在线程通信与线程同步上。针对于这两个问题,有两种机制来保证: 共享内存 | 消息传递。
共享内存屏蔽通信细节,但需要显式指定线程同步顺序;消息传递由程序员主动发送消息,显式执行线程通信,线程同步由于自带发送顺序,隐式进行。
\ | 线程通信 | 线程同步 | 典型语言 |
---|---|---|---|
共享内存 | 隐式 | 显式 | Java |
消息传递 | 显式 | 隐式 | Go |
原子性、有序性、可见性
原子性:操作不可分割。CPU层面保证基础指令的原子性,对于复杂原子指令,比如交换指令CMPXCHG,采用总线锁or缓存行锁来保证原子性。需要注意的是,32位操作系统不对64位数据写入保证原子性,比如long类型或者double类型变量写入。
有序性:涉及到的指令重排分三种,编译级指令重排(编译器优化)、指令级指令重排(CPU指令并行)、 内存系统指令重排(CPU读/写缓存区),单线程模型下,CPU与编译器不会对有间接依赖的指令重排序。
可见性:针对上述三种指令重排,而引发线程之间的内存可见性问题。
进一步充电 缓存一致性协议之MESI
Java内存模型的抽象结构
JMM定义了共享变量存储于主存之中,每个线程都有一个私有的本地内存,存储共享变量的副本。这里的本地内存是一个抽象的概念,并不真实存在,它涵盖了CPU高速缓存(L1,L2,L3)、写缓冲区、编译器优化等等。为了保证内存可见,Java编译器在生成指令序列的适当位置插入内存屏障。
JMM内存屏障
JMM把内存屏障指令分为4类,见下表。
上面这四个内存屏障简单来说,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的内存可见性问题