Java内存模型JMM与可见性
Java内存模型JMM与可见性
标签(空格分隔): java
1 何为JMM
JMM:通俗地讲,就是描述Java中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节。
结合上图,先介绍几个概念:
主内存:保存了所有的变量。
共享变量:如果一个变量被多个线程使用,那么这个变量会在每个线程的工作内存中保有一个副本,这种变量就是共享变量。
工作内存:每个线程都有自己的工作内存,线程独享,保存了线程用到了变量的副本(主内存共享变量的一份拷贝)。工作内存负责与线程交互,也负责与主内存交互。
JMM对共享内存的操作做出了如下两条规定:
- 线程对共享内存的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写;
- 不同线程无法直接访问其他线程工作内存中的变量,因此共享变量的值传递需要通过主内存完成。
2 共享变量在线程间的可见性
由此可见不同线程都是直接操作自身工作内存中的副本,因此可能导致共享变量的修改在线程间不可见,所谓不可见,是指一个线程对共享变量的修改不能及时地被其他线程看到。导致共享变量在进程间不可见的原因有以下几个:
- 指令重排序 & 线程交叉执行
- 共享变量更新后的值没有在工作内存和主内存间及时更新
线程交叉执行:主要指线程调度。
指令重排序:为了发挥CPU性能,指令执行顺序可能与书写的不同,分为编译器优化的重排序(编译器优化),指令集并行重排序(处理器优化),内存系统的重排序(处理器优化)。
共享变量更新:如果想让线程A对共享变量的修改被线程B看到,需要以下步骤:把线程A的工作内存中更新过的变量刷新到主内存中,再将主内存中最新的共享变量的值刷新到线程B的工作内存中。如果更新不及时,则会导致共享变量的不可见,数据不准确,线程不安全。
说到重排序,就不得不说一下as-if-serial语义:无论如何重排序,程序执行的结果应该与代码顺序执行的结果一致。(编译器,运行时和处理器都会保证Java在单线程下遵循as-if-serial语义)
下面看一段代码:
public class PossibleReordering {
static int x = 0, y = 0;
static int a = 0, b = 0;
public PossibleReordering() {}
public static void main(String[] args) throws InterruptedException {
int result[] = new int[4];
for(int i = 0; i < 1000000; i++){
Thread one = new Thread(new Runnable() {
@Override
public void run() {
a = 1;
x = b;
}
});
Thread two = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
y = a;
}
});
x=y=a=b=0;
one.start();
two.start();
one.join();
two.join();
//注意,此时线程one和two均以结束,其对x,y的修改已经写回到主内存
int r = (x << 1) | (y);
result[r]++;
}
System.out.println(Arrays.toString(result));
}
}
该段代码可能输出的x和y有4种组合,分别为(0,0) (0,1) (1,0) (1,1)。一次典型的运行输出如下:[4, 941466, 58524, 6]。该输出代表(0,0)组合产生了4次,(0,1)组合产生了941466次,(1,0)组合产生了58524次,(1,1)组合产生了6次。
各种组合及其可能的原因如下表:
组合 | 可能的产生原因 |
---|---|
0 1 | 线程one在two开始之前就完成 |
1 0 | 线程two在one开始前就完成 |
1 1 | 线程one和two交叉执行的结果 |
0 0 | 乱序执行或共享变量更新到主内存不及时 |
由此可见,因此在没有正确同步的情况下,即是要推断最简单的并发程序的行为也很困难。
可能的一种乱序执行情况如下图所示:
重排序不会导致单线程的内存可见性问题,但多线程交错执行时,可能导致可见性问题,那么如何解决线程间对共享变量修改的可见性问题呢?
3 Java在语言层面实现可见性的两种方式
使用synchronized实现可见性:
Java中synchronized关键字有两重含义,一是大家所熟知的实现原子性,二就是实现内存可见性。
synchronized可见性规范:
- 线程解锁前必须把共享变量的最新值刷新到主内存中
- 线程加锁时,将清空工作内存中共享变量的值,从而需要从主内存中重新读取最新值。
因此,线程解锁前对共享变量的修改在下次加锁时对其他线程可见。整个过程如下:获得互斥锁-》清空工作内存-》从主内存拷贝-》执行代码-》写回主内存-》释放互斥锁。
与此同时,synchronized还会限制编译器、运行时和硬件对内存操作重排序的方式,从而在实施重排序时不会破坏JMM提供的可见性保证。
共享变量在线程间不可见的原因 | synchronized解决方案 |
---|---|
重排序 & 线程交叉执行 | 原子性(结合as-if-serial语义) |
共享变量未及时更新 | 通过synchronized可见性规范 |
使用volatile实现可见性:
Java中的volatile可以保证volatile变量的可见性,但不保证复合操作的原子性(如++)
volatile可见性规范:
- 对volatile变量执行写操作时,会在写操作后加入一条store写屏障指令,强制将缓存刷新到主内存中
- 对volatile变量执行读操作时,会在读操作前加入一条load读屏障指令,强制使缓冲区缓存失效,所以会从主内存读取最新值。
- 防止指令重排序。
通俗来讲:volatile变量在每次被线程访问时,都强迫从主内存中重读该变量的值,而当该变量发生变化时,又会强迫线程将最新的值刷新到主内存。这样在任何时刻,不同的线程总能看到该变量的最新值。
共享变量在线程间不可见的原因 | volatile解决方案 |
---|---|
重排序 & 线程交叉执行 | 防止指令重排序 |
共享变量未及时更新 | 通过volatile可见性规范 |
synchronized与volatile对比:
- volatile不需要加锁,比synchronized轻量,不会阻塞线程。
- 从内存可见性角度来看,volatile读相当于加锁,volatile写相当于解锁。
- synchronized可以保证可见性+原子性。volatile只能保证可见性,不能保证原子性。
参考资料
慕课网:细说Java多线程之内存可见性
Java并发编程实战
程晓明:深入理解Java内存模型(一)——基础