本文分为四个部分来讲解:
- Java内存模型的基础, 主要介绍内存模型相关的基本概念;
- Java内存模型中的顺序一致性, 主要介绍重排序与顺序一致性内存模型;
- 同步原语, 主要介绍三个同步原语(
synchronized
,volatile
,final
)的内存语义及重排序规则在处理器中的实现; - Java内存模型的设计, 主要介绍Java内存模型的设计原理, 及其与处理器内存模型和顺序一致性内存模型的关系;
Java内存模型基础
并发编程模型的两个关键问题
- 线程之间如何通信;
- 线程之间如何同步;
线程通信机制主要有两种: 共享内存和消息传递. Java的并发采用的是共享内存模型.
Java内存模型(JMM)的抽象结构
在Java中, 所有实例域, 静态域和数组元素都存储在堆内存中, 堆内存在线程之间共享. 局部变量, 方法定义参数,
和异常处理参数不会在线程之间共享, 它们不会有内存可见性问题, 也不受内存模型的影响.
JMM定义了线程和主内存(Main Memory)之间的抽象关系, 属于语言级的内存模型:
线程之间的共享变量存储在主内存中, 每个线程又有一个私有的本地内存(Local Memory, 实际上就是Java虚拟机栈, 寄存器, 处理器高速缓存等), 本地内存中存储了该线程已操作过的共享变量的副本.
本地内存是JMM的一个抽象概念, 并不真实存在, 因为它涵盖了缓存, 写缓冲区, 寄存器及其他硬件和编译器的诸多优化的集合.
如果线程A和线程B要通信的话, 必须要经历下面两个步骤:
- 线程A把本地内存更新过的共享变量刷新到主内存中;
- 线程B到主内存去读取线程A之前已更新过的共享变量.
可以看出, JMM通过控制主内存和每个线程的本地内存(包含缓存, 寄存器等等)之间的交互, 来为Java程序提供内存可见性的保证.
从源代码到指令序列的重排序
重排序主要是为了提高性能, 通常分为三种:
- 编译器优化的重排序. 原则是在不改变单线程程序语义的前提下, 重新安排语句的执行顺序;
- 指令级并行的重排序. 在不存在数据依赖性的时候, 处理器可以改变语句对应的机器指令的执行顺序, 甚至并行执行指令;
- 内存系统的重排序. 由于处理器使用高速缓存和读/写缓冲区, 这使得加载和存储操作看上去可能是在乱序执行.
Java从源代码到最终执行的指令序列, 会依次进行以上三种重排序. 其中1属于编译器重排序, 2和3属于处理器重排序.
重排序会导致内存可见性的问题. JMM通过设定重排序规则, 禁止特定的编译器重排序, 对于处理器重排序,
则是通过插入特定类型的内存屏障(Memory Barriers)指令, 来禁止特定类型的处理器重排序, 以确保在不同编译器和处理器平台下,
始终能为程序员提供一致的内存可见性保证.
内存屏障类型表
注意: 内存屏障要特别注意Store类型的屏障, 每个Store类型的屏障都对应着将线程私有的写缓冲写回到主存的操作, 也就是实现线程间可见性的操作
内存屏障实际上是通过限制单线程内指令的重排序来作用的.
JMM将内存屏障指令分为4种类型:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | Load1; LoadLoad; Load2 | 确保Load1数据的装载先于Load2指令的装载(load2的装载是本线程内部的状态,其他线程的决定不了) |
StoreStore Barriers | Store1; StoreStore; Store2 | 确保Store1数据对其它处理器可见(将Store1及之前的Store操作数据刷入主内存中)先于Store2的存储(store2的存储是本线程内部的存储, 其他线程的存储决定不了) |
LoadStore Barriers | Load1; LoadStore; Store2 | 确保Load1数据的装载先于Store2的存储(store2的存储是本线程内部的存储, 其他线程的存储决定不了) |
StoreLoad Barriers | Store1; StoreLoad; Load2 | 确保Store1数据对其它处理器可见(将Store1及之前的Store操作刷入主内存中)先于Load2的装载(load2的装载发生在线程私有内存内部) |
其中, StoreLoad屏障是一个全能屏障, 因为它包含了其他所有屏障的效果, 但是开销大, 因为要把写缓冲区的所有数据全部刷新到内存中.
happens-before简介
在JMM中, 如果一个操作执行的结果要对另一个操作可见(通常指的是数据依赖性), 那么这两个操作之间必须要存在happens-before关系. 主要有以下规则:
- 程序顺序规则: 单线程中的某操作, happens-before于对其有数据依赖性的操作;
- 监视器锁规则: 对一个锁的解锁, happens-before于随后对这个锁的加锁;
volatile
变量规则: 对一个volatile
域的写, happens-before于后续对这个域的读(实质上是通过缓存锁定的LOCK信号来实现的);
使所有处理器的相应地址的缓存行失效, 强制重新从共享主存中读取. 而且不允许两个线程同时更改同一个缓存行- 传递性: A happens-before B happens-before C, 则 A happens-before C
重排序
重排序遵守一个统一的原则, 就是让重排序后的程序至少能够在单线程的情况下正确运行(意思是在单线程下和重排序前的运行结果相同).
顺序一致性
即所有操作具有全序关系, 是一个理想化的模型. 但是JMM天然并不能保证顺序一致性,
需要通过同步原语(Synchronized
, volatile
, final
)来辅助完成.
volatile的内存语义
volatile
作用于一个filed上, 能够确保它的可见性. 例如, 现在有一个filed名为l
, 我们定义private volatile long l
,
就相当于定义:
private long l;
public synchronized long get() {
return this.l;
}
public synchronized set(long l) {
this.l = l;
}
volatile写-读与内存屏障
从内存语义的角度来说, volatile
的写和锁的释放有相同的内存语义; volatile
的读与锁的获取有相同的语义.
volatile
底层实际上是通过内存屏障的方式来确保了可见性, 以下是volatile
附近的内存屏障的情况:
- 在每个
volatile
写操作的前面插入一个StoreStore屏障; - 在每个
volatile
写操作的后面插入一个StoreLoad屏障; - 在每个
volatile
读操作后面插入一个LoadLoad屏障; - 在每个
volatile
读操作后面插入一个LoadStore屏障;
实际使用中volatile
常用做if
或者循环的标识位.
定义成volatile
的变量, 能够在线程间保持可见性, 能够被多线程同时读(注意: 内存屏障只是限制了单线程内的语句排序), 但是同时只能被一个线程写.
锁的内存语义
当线程释放锁时, JMM会把该线程对应的本地内存中的共享变量刷新到主内存中去;
当线程获取锁时, JMM会把该线程对应的本地内存置为无效, 临界区代码必须从主内存重新读取共享变量;
在底层的实现上
- 在锁的释放上, 公平锁和非公平锁最后都需要写一个
volatile
变量state
; - 在锁的获取时, 公平锁会读
volatile
变量, 非公平锁会用CAS更新volatile
变量.
所以锁的释放与volatile
的写, 锁的获取同时具有volatile
读写的语义.
concurrent包的实现
concurrent包的基础就是volatile
变量的读/写, 以及CAS. CAS兼具volatile
变量读写的内存语义
final域的内存语义
final
域的写之后, 会插入一个StoreStore屏障final
域的读之前, 会插入一个LoadLoad屏障
只要被构造的对象的引用在构造函数中没有逸出, 那么基于上述两条规则, 就不需要使用同步,
就可以保证任意线程都能看到这个final
域在构造函数中被初始化之后的值. 如果逸出了, 那么可能会引起重排序, 导致引用在final
域初始化之前被其他线程获取, 导致获得未经初始化的final
域的值.
happens-before
最实用的三种happens-before
1.
volatile
写, happens-before后续volatile
读;
以下是一个例子:
/**
* 下面一段语句, 能够保证1 happens before 4, 也就是无论运行多少次, 结果都输出100
*
* <p>所以结论是, volatile变量非常适合作为循环的标识位.
*
* Created by yihao.cong@outlook.com on 16-11-4.
*/
public class VolatileHappensBefore {
private volatile static boolean ready = true;
private static int number = 1;
private static class ReaderThread extends Thread {
@Override
public void run() {
// 3. 子线程读volatile变量
while (VolatileHappensBefore.ready) {
// 这里是LoadLoad+LoadStore屏障
}
// 这里是LoadLoad+LoadStore屏障
// 4. 子线程读共享变量
out.println(VolatileHappensBefore.number);
}
}
public static void main(String[] args) throws InterruptedException {
ReaderThread readerThread = new ReaderThread();
readerThread.start();
Thread.sleep(100);
/*下面语句复现的是volatile写读的happens-before规则*/
// 1. 主线程修改共享变量
VolatileHappensBefore.number = 100;
// 这里是StoreStore屏障
// 2. 主线程写volatile变量
VolatileHappensBefore.ready = false;
// 这里是StoreLoad屏障
// 如此一来能够保证只要volatile变量的修改能够读到, 那么之前的修改一定能够被读到
}
}
2.
start()
规则: 如果线程A执行操作ThreadB.start()
, 那么线程AThreadB.start()
操作happens-before线程B中的任何操作;
3.
join()
规则: 如果线程A执行操作ThreadB.join()
并成功返回, 那么线程B中的任意操作happens-before与线程A在ThreadB.join()
操作的成功返回.
单例模式 - 双重检查锁定与延迟初始化
双重检查锁定其实是错误的, 因为可能一个实例还没有被完全初始化, 就返回了引用. 导致外层的检查失效, 使得其他线程获得一个不完整的对象引用.
替代方案1: 使用volatile
关键字修饰单例对象, 确保可见性, 不会让写了一半的对象被其他线程读到;
替代方案2: 基于类的初始化方案;
替代方案3(推荐): 使用enum进行单例的初始化;
总结
- CAS操作同时具有
volatile
的读写语义, 也就是之前之后的代码都不能重排序. 底层是通过一个lock指令, 进行缓存锁定, 确保读-改-写操作的原子性. - 缓存一致性和缓存锁定说的是同一件事, 都是lock指令造成的缓存锁定(或者说独占仅那一个地址的主存和缓存).
- 只有
volatile
写操作或者是CAS(一种内置的复合操作)才会触发lock