内存可见性问题

图中是个双核 CPU 系统架构,每个核都有自己的 1 级缓存 (L1) ,还有个所有CPU共享的二级缓存 (L2),Java 中的工作内存就对应着 1 级缓存加上一些硬件。
当一个线程操作共享变量时,它首先从主内存复制共享变量到自己的工作内存,然后对工作内存里的变量进行处理,处理完后将变量值更新到主内存。由于Cache 的存在,会导致内存不可见问题。
就拿上面这张 CPU 架构图来举例

总结:线程 B 修改了 X 的值并更新到主内存,但是线程 A 读 X 不会直接去主内存读,而是先从自己的缓存中读,所以无法获得最新值。导致了内存不可见性问题。
如何解决内存不可见问题呢?
Java 中提供的 volatile 可以解决,使用同步锁 synchronized 也可以解决。我们来一一介绍这两个关键字。
synchronized关键字
简介
synchronized 关键字是 Java 提供的一种原子性内置锁,是排它锁。
也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁 。另外,由于 Java 中的线程是与操作系统的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,所以使用 synchronized 就会导致上下文切换。
synchronized 可以作用于:
- 普通方法,锁的是当前实例
- 静态方法,锁的是当前类
- 作用于代码块,锁的是 synchonized 括号里配置的对象或者类。
作用于代码块
public class SyncTest1 {
static class MyTask implements Runnable{
@Override
public void run() {
synchronized (this){
String name = Thread.currentThread().getName();
for (int i = 0; i < 6; i++) {
System.out.println(name + "---" + i);
try {
Thread.sleep(500); // 便于观察
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
public static void main(String[] args) {
MyTask task = new MyTask();
Thread t1 = new Thread(task,"t1");
Thread t2 = new Thread(task,"t2");
t1.start();
t2.start();
}
}

线程 t1 执行时,t2 会被阻塞,因为 t1 已经得到了 task 对象的同步锁。
将上述 main 函数代码改成如下
public static void main(String[] args) {
MyTask task1 = new MyTask();
MyTask task2 = new MyTask();
Thread t1 = new Thread(task1,"t1");
Thread t2 = new Thread(task2,"t2");
t1.start();
t2.start();
}

这是 t2 不会被阻塞,因为 t1 获得了 task1 对象的同步锁,t2 获得了 task2 对象的同步锁.
将 synchroinzed 括号里的改成 MyTask.class,锁住的就是一个类。运行结果:

作用于普通方法
public class SyncTest2 {
static class MyTask implements Runnable {
@Override
public synchronized void run() {
String name = Thread.currentThread().getName();
for (int i = 0; i < 5; i++) {
System.out.println(name + "---" + i);
try {
Thread.sleep(500); // 便于观察
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
MyTask task1 = new MyTask();
MyTask task2 = new MyTask();
Thread t1 = new Thread(task1,"t1");
Thread t2 = new Thread(task2,"t2");
t1.start();
t2.start();
}
}
此时锁的是对象,t1 和 t2 交替打印
作用于静态方法
static class MyTask implements Runnable {
@Override
public void run() {
test();
}
public synchronized static void test(){
String name = Thread.currentThread().getName();
for (int i = 0; i < 5; i++) {
System.out.println(name + "---" + i);
try {
Thread.sleep(500); // 便于观察
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
MyTask task1 = new MyTask();
MyTask task2 = new MyTask();
Thread t1 = new Thread(task1,"t1");
Thread t2 = new Thread(task2,"t2");
t1.start();
t2.start();
}
此时锁住的是整个 MyTask 类,同一时间只有一个线程输出打印。
synchronized内存语义
进入 synchronized 块的内存语义是把在 synchronized 块内使用到的变量从线程的工作内存中清除,这样在 synchronized 块内使用到该变量时就不会从线程的工作内存中获取,而是 直接从主内存中获取,退出 synchronized 块的内存语义是把在 synchronized 块内对共享变量的修改刷新到主内存。
在多线程学习第一天也提到了内存间交互操作 lock 和 unlock 的规则。
获取锁后会清空锁块内本地内存中将会被用到的共享变量,在使用这些共享变量时从主内存中直接加载,在释放锁时将本地内存中修改的共享变刷新到主内存。
这就是 synchronized 能解决内存可见性的原因但是。 synchronized 会引起线程上下文切换并带来线程调度开销。
可重入性
synchronized 是可重入锁,具体请看下面的例子
public class ReentrancyTest {
public synchronized void test1(){
System.out.println("test1");
test2();
}
public synchronized void test2(){
System.out.println("test2");
}
public static void main(String[] args) {
ReentrancyTest reentrancyTest = new ReentrancyTest();
reentrancyTest.test1();
}
}
test1 先获取内置锁,然后进入 test2 ,如果内置锁是不可重入的,那么调用线程会一直被阻塞。

当获取了该锁的线程再次获取锁时发现锁拥有者是自己,就会把计数器值加 +1,当释放锁后计数器值 -1,当计数器值为 0 时,锁里面的线程标示被重置为 null,这时候被阻塞的线程会被唤醒来竞争获取该锁
在jvm中的实现
private static Object resource = new Object();
public static void test() {
synchronized (resource){
}
}

javap -verbose指令(详情请百度) 反汇编后可以看到 synchronized在jvm中的实现机制是 monitorenter 和 monitorexit,至于为什么有两个monitorexit出现,因为一个与异常处理相关,只有一个 monitorexit 会被执行。
volatile关键字
简介
volatile 是轻量级的 synchronized,因为它不会引起线程上下文的切换
如果一个字段被声明成 volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。 volatile是如何来保证可见性的呢?( volatile 还能禁止指令重排序)
内存语义
通过查看 JIT 编译器生成的汇编指令,volatile 变量会执行第多出二行代码

lock前缀的指令会引发:
- 将当前处理器缓存行的数据写回到主内存
- 一个处理器的缓存回写到内存后,会导致其他处理器的缓存无效,其他缓存再次对该数据操作时,需要重新从主内存中读取
volatile 能保证一个变量的更新对其他变量立即可见,在文章开头提到的例子中:
如果用 volatile 修饰了 X,线程 B 修改 X的值后,线程A的缓存数据X会无效,线程 A 再次使用 X 时,就必须从主内存中读取到最新的值,就解决了内存可见性问题。
下面这个例子中,共享变量 value 就不是线程安全的,因为可能读取到的是缓存中的数据,导致内存可见性问题
private Integer value; // 用volatile修饰就不会导致内存不可见性问题
public Integer getValue() {
return value;
}
volatile不能保证原子性
volatile 只能保证可见性,不能保证原子性。
所谓原子性,是指执行系列操作时,这些操作要么全部执行,要么全部不执行,不存在只执行其中一部分的情况,例如下面的 count++ 就是读改写的过程,如果不能保证这个过程是原子性的,就会出现线程安全的问题。
public class VolatileTest {
public volatile int count = 0;
public void increase() {
count++;
}
public static void main(String[] args) throws InterruptedException {
final VolatileTest test = new VolatileTest();
for (int i=0;i<10;i++){
new Thread(()->{
for (int j = 0; j < 1000; j++) {
test.increase();
}
}).start();
}
Thread.sleep(2000); // 保证前面的线程执行完
System.out.println("count的值:" + test.count);
}
}
如果 volatile 能保证原子性,该程序结果就会为 10000 ,但实际上每次结果都小于 10000 ,并且不一样。
为什么会这样呢?
比如在某个时刻:
count 的值为 100 ,线程 1 读取了 count 的值,然后线程 1 让出了 CPU 调度,此时线程 2 对变量 count 进行自增操作,线程 2 也去读取变量 count 的原始值,由于线程 1 只是对变量 count 进行读取操作,还没有对变量进行修改操作,所以不会导致线程 2 的工作内存中缓存变量 count 的缓存行无效,线程 2 读到的 count 值仍是 100 ,然后进行加 1 操作,并把 101 写入工作内存,最后写入主存。此时线程 t1 接着进行自增操作,把 101 写入工作内存,最后写入主存。 实际上 count 被递增了两次,由于原子性问题,实际值只被加了 1 次。
是不是很疑惑:线程 1 在读取 count 为 100 后让出了 cpu 调度,没有进行修改所以不会去通知其他线程,此时线程 2 拿到的还是 100 ,这点可以理解。但是后来线程 2 修改了 count 变成 101 后写回主内存,这下是修改了,线程 1 再次运行时,难道不会去主存中获取最新的值吗?按照 volatile 的定义,如果 volatile 修饰的变量发生了变化,其他线程应该去主存中拿变化后的值才对啊
严格的说,对任意单个 volatile 变量的读/写具有原子性,但类似于 ++ 这种复合操作不具有原子性。具体原因:
因为自增操作是三个原子操作组合而成的复合操作(读、写、改),在一个操作中,读取了 count 变量后,是不会再读取的 count 的(已经发生了,不能撤回),所以它的值还是之前读的 100 ,它的下一步是自增操作
那如何保证操作的原子性呢,请看第三天的内容~
volatile使用场景
- 因为 volatile 不具有原子性,所以写入变量值如果依赖当前变量值(类似 i++ ),就不要用 volatile。
- 读写变量时,若没有加锁,可以使用 volatile,因为加锁本身可以保证内存可见性。
volatile 通常用于标识状态变量。
指令重排序
在执行程序时,为了提高性能,编译器和处理器常常会指令做重排序,重排序需要遵循哪些规则呢?
as-if-serial语义
不管怎么重排序,(单线程下)程序的执行结果不能被改变。编译器和处理器都必须遵守 as-if-serial 语义。
为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
这段代码的数据依赖关系如图:

A 和 C 之间存在数据依赖关系, B 和 C 之间也存在数据依赖关系。因此在最终执行的指令序列中,C 不能被重排序到 A 和 B 的前面,A 和 B 没有数据依赖关系,编译器和执行器可以对他们进行重排序

as-if-serial 语义保护了单线程程序,使程序员无需担心重排序会干扰单线程程序的结果
对多线程程序的影响
public class ReOrderTest {
private static boolean flag;
private static int a;
public static class WriteThread extends Thread{
@Override
public void run() {
a = 1; // 1
flag = true; // 2
}
}
public static class ReadThread extends Thread{
@Override
public void run() {
if (flag){ // 3
int i = a*a; // 4
System.out.println(i);
}
}
}
public static void main(String[] args) throws InterruptedException {
WriteThread writeThread = new WriteThread(); // 线程A
ReadThread readThread = new ReadThread(); //线程B
writeThread.start();
readThread.start();
}
}
这段代码执行后 i 一定为 1 吗?不一定,可能为 0
由于操作 1 和操作 2 没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作 3 和操作 4 没有数据依赖关系,编译器和处理器也可以对这两个操作重排序

上图中操作 1,2 进行了重排序,程序执行时,线程 A 首先写标记变量 flag ,随后线程 B 读这个变量。由于条件判断为真,线程 B 将读取变量 a 。此时变量 a 还没有被线程 A 写入,在这里多线程程序的语义被重排序破坏了。
volatile禁止指令重排序
在 Java 内存模型中,通过内存屏障 确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后,确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。
在上一个例子中,若 flag 用 volatile 修饰, 则保证 1 在 2 的前面执行,3 在 4 的前面执行
happens-before原则
简介
从 JDK5 开始,JMM (Java内存模型) 中使用 happenens-before 原则来阐述操作之间的内存可见性关系。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。
JSR-133 中对 happens-before 的定义如下:
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见。
- 两个操作之间存在 happens-before 关系,并不意味着Java平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系 来执行的结果一致,那么这种重排序并不非法。
8大原则
- 单线程 happens-before 原则:在同一个线程中,书写在前面的操作 happens-before 后面的操作。
- 锁的 happens-before 原则:同一个锁的 unlock 操作 happens-before 此锁的lock操作。
- volatile 的 happens-before 原则:对一个 volatile 变量的写操作 happen-before 对此变量的任意操作
- 传递性原则 :如果 A 操作 happens-before B 操作,B 操作 happens-before C 操作,那么 A 操作 happens-before C 操作。
- 线程启动原则 :同一个线程的 start 方法 happens-before 此线程的其它方法。
- 线程中断原则 :对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
- 线程终结原则:线程中的所有操作都 happens-before 线程的终止检测。
- 对象终结原则:一个对象的初始化完成先行发生于他的 finalize() 方法的开始。
重排序必须遵循这8大原则。
总结
as-if-serial 语义和 happens-before 这么做的目的,都是为了在不改变程序执行结果的前提 下,尽可能地提高程序执行的并行度。
原创不易,求点赞