写在前面:
一开始我是不知道JMM内存模型这个概念的,2年前面试的时候面试官问了我对java内存模型的理解,当时说了堆栈,还有GC,事后才知道回答错了,当时匆匆看了相关的博客准备下一场面试,也没有好好总结。最近通过看《Java并发编程的艺术》、《深入理解Java虚拟机》、JSR133,有了自己的理解,记录一下吧。
一、Java内存模型基础
并发编程的两大难题,如何解决线程间的通信和同步?
- 线程间如何通信
通信是指线程如何交换自己的信息,在命令式编程中有两个解决方案。- 线程通过共线公共状态通过写-读的方式隐式交换信息。
- 线程通过消息传递显示传递信息
- 线程间如何同步
- 在共线内存的编发模型里,方法或代码块需要显示指定在线程中互斥执行
- 在消息传递的并发模型里,由于消息的发送必须在接收之前,同步是隐式的。
二、Java内存抽象结构
在Java中,共享变量(包括实例字段、静态字段及构成数组对象的元素)都存储在主存中。每个线程有自己的工作内存,线程的工作内存中保存了主存的副本(并不是完全copy过来,例如一个对象有3M,只有用到元素才会保存副本),线程不能直接操作主存中的数据,必须先读/写本地线程工作内存中。
volatile类型的变量有会有本地工作内存的副本,只是操作会立刻反应到主存中,看起来像直接操作主存一样。
三、从源代码到指令的重排序
- 编译器重排
编译器在不改变单线程语义的情况下,可以对指令进行重排。 - 处理器重排
现代处理器采用指令级并行技术,可以将多条指令折叠执行。如果不存在数据的依赖性,处理器可以改变语句对应机器执行的执行顺序 - 内存系统重排序
由于处理器会使用缓存和读/写缓冲区,有可能加载和存储操作看起来是乱序执行的。
从源代码到实际的指令执行序列会经历以下过程:
上面的1属于编译器重排,2,3属于处理器重排。JMM尽可能的放松对重排的干预,保证cpu的性能得到极致的压榨,同时会禁止特定的编译器重排和处理器重排。
四、Happens-Before(先行发生)原则
Happens-Before是JMM模型定义的两项操作之间的排序关系,如果操作A
先行发生于操作B,就是在操作B之前,A操作产生的影响一定能被B看到,影响包括,改变了某变量的值,调用了某个方法等。
举个例子:
// 线程A操作
i = 1;
// 线程B操作
j = i;
// 线程C操作
i = 2;
假设线程A先行发生于操作B(不考虑C),那就可以肯定B线程中的j一定等于1,因为A操作产生的影响能被一定能被B看到;考虑线程C呢,如果线程B和线程C没有先行发生关系,那么B的值就不确定了,因为B有可能看到C的操作,有可能看不到。
JMM向程序猿保证的线程发生关系,如果两个操作不在此列,则两个操作没有先行发生关系
- 程序顺序规则:一个线程前面的每一个操作先行发生后面的操作,前面的改变对后面是立即可见的,虽然可能没有刷新到主存
- 管程协定规则:一个unlock()操作先行发生于后面的lock操作,这个后面指的是是时间上的后面,必须是同一个lock。
- volatile变量原则:一个volatile的写操作先行发生于对一个volatile的读操作
- 传递性:A先行发生于B,B先行发生于C,则A先行发生于C
- 线程启动规则:Thread#start()先行发生于这个线程的任何操作
- 线程终止规则:线程的所有操作都先行发生于线程的终止操作
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thead#interrupted()检测
- 对象终结个规则:一个对象的初始化完成先行于它的 finalize()方法
Java能保证的先行发生关系只有这些。
先行发生与时间先后的关系,一个操作A先行发生于操作B,操作A一定比操作B时间上先执行完吗?如果一个操作时间上比操作B时间上先完成,那么操作A一定先行发生于操作B吗?答案都是否定的。举个栗子:
// 在同一个线程中
//操作A
i = 1;
// 操作B
j = 2;
由单线程顺序规则可以保证A先行发生于B,只需要要保证A操作的结果对B可见,但B对A是没有数据依赖的,可能会出现重排序,最后的指令可能会反过来执行,由此可见,先行发生前的操作不一定时间上先执行。
// 重排序影响
//先执行操作B
j = 2;
// 再执行操作A
i = 1;
再看个例子:
private int v = 0;
public int reader() {
return v;
}
public void wirte(int newV) {
this.v = newV;
}
假如 线程A先调用 wirte(1)方法,线程B调用reader()方法,线程B看到的一定是1吗,不一定的,因为JMM所有的先行发生规则都不适用,所有线程A的操作不保证对B可见。可以通过volatile规则让A操作先行发生于B操作(对volatile变量的写操作先行发生于对volatile的读操作)
private volatile int v = 0;
public int reader() {
return v;
}
public void wirte(int newV) {
this.v = newV;
}
加上volatile之后可以保证线程B能够看到线程A修改的最新值。也可以套用管程规则(同一个锁的解锁操作先行发生于获取锁的操作),也能保证线程B看到的变量为最新值1。
private int v = 0;
public synchronized int reader() {
return v;
}
public synchronized void wirte(int newV) {
this.v = newV;
}
五、volatile语义
JMM为volatile定义了一些特殊的规则,当一个变量被volatile修饰,它将具备以下的特性:
- 保证此变量对所有线程的可见性,一个线程对变量做了修改,保证新值对其他线程来说是立刻知道的,会立即刷新到主存中。当一个线程写volatile变量时,会是其他处理器缓存数据无效,根据处理器不同会锁总线或者部分缓存,修改完后解锁,同时将主存中最新数据加载过来。
可见性
下面的代码是线程安全的吗?
private volatile i = 0;
private void add() {
i++;
}
答案是否定的,因为i++操作并不是原子的,至少包含下面三个操作:
int temp = i;
temp = i + 1;
i = temp;
volatile能够保证 temp看到的i值是最新的,i = temp会立刻刷新到主存中,并且其他线程会立刻得知,但temp 的值不会从主存中读取,所以即使被 volatile修饰 i++,也不是安全的。
下列场景适合用volatile:
volatile boolean shutDown = false;
void work() {
while (shutDown) {
// doSomeTing
}
}
JMM能够保证shutDown一旦被修改为true,work会立刻停下来。
- 禁止指令重排优化
普通变量能保证在该方法执行时,所有依赖到该变量的地方都能获取到正确的结果(编译器和处理器会为了提升效率,不改变单线程语义的情况下进行重排)。
看个代码吧:
boolean init = false;
void work() {
if (init) {
// startWork
}
}
void init() {
// 先进行初始化
doSomeThing();
// 再将初始化标志写为ture
init = true;
}
void doSomeThing() {
}
init方法可能会被重排序优如下;这样程序多线程就会出现问题了,因为实际上还没有进行初始化work()方法依赖初始化操作的方法就会执行错误了。为了解决这个问题,可以将init变量改为 volatile 变量。这样就能够保证 先执行初始化方法doSomeThing(),然后init 置为true,work方法也能再次运转正常了。
void init() {
// 先写初始化标志写为ture
init = true;
// 再进行初始化
doSomeThing();
}
在旧的线程模型中,volatile的写-读没有释放锁-获取锁所具有的内存语义。
为了提供一种轻量级锁的功能,JSR-133的专家们增加了volatile的语义,严格禁止编译器和处理器对volatile变量和普通变量进行重排序,通过在指定位置插入内存屏障的方式禁止。
一个volatile实现轻量级锁的demo,相信大家和我一样都看过一个题目:三个线程顺序打印ABC 10次
第一个方案通过锁来实现:
private static final Object monitor = new Object();
private static int count = 0;
public static void main(String[] args) {
Thread a = new Thread(() -> {
while (count <= 30) {
synchronized (monitor) {
if (count % 3 == 0) {
System.out.println("A");
count++;
}
}
}
});
Thread b = new Thread(() -> {
while (count <= 30) {
synchronized (monitor) {
if (count % 3 == 1) {
System.out.println("B");
count++;
}
}
}
});
Thread c = new Thread(() -> {
while (count <= 30) {
synchronized (monitor) {
if (count % 3 == 2) {
System.out.println("C");
count++;
}
}
}
});
c.start();
b.start();
a.start();
}
这样简单粗暴的synchronized锁自然是没问题的,下面通过volatile轻量级锁的语义来实现:
private static volatile int count = 0;
public static void main(String[] args) {
Thread a = new Thread(() -> {
while (count < 30) {
if (count % 3 == 0) {
System.out.println("A" + " " + count);
count++;
}
}
});
Thread b = new Thread(() -> {
while (count < 30) {
if (count % 3 == 1) {
System.out.println("B" + " " + count);
count++;
}
}
});
Thread c = new Thread(() -> {
while (count < 30) {
if (count % 3 == 2) {
System.out.println("C" + " " + count);
count++;
}
}
});
c.start();
b.start();
a.start();
}
程序分析,if (count % 3 == n)读取主存中最新的值,当不满足条件时自旋,volatile 语义会禁止
下面的语句重排:打印语句与count++(实际上时更底层的指令),同时写入操作立刻刷新到主存,其他打印线程停止自旋获取到锁开始执行打印语句。前面不是说volatile 下的i++不能保证多线程的安全性,这里count++会不会有问题呢,不会的啊,因为if() {}里面的语句已经被锁住了,没有多线程操作,这里的count++是线程安全的。
while (count < 30) {
// 读取最新的值
if (count % 3 == 2) {
System.out.println("C" + " " + count);
count++;
}
// 自旋等待锁
}
Lock接口 也是利用volatile语义实现了锁的功能,当然会比三个线程打印ABC更为复杂,关于Lock接口以及其它J.U.C包下的工具类,准备这个月在Java并发专栏下面记录。
六、synchronized 内存语义
// todo 2020-08-01待更新
七、final语义
// todo 2020-08-01待更新