Java内存模型

概述

JMM即 Java Memory Model,它定义了主存、工作内存抽象概念。

底层对应着复杂的优化:

  • CPU 增加了缓存,以均衡与内存的速度差异;
  • 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

引发的问题体现在以下几个方面:

  • 原子性:保证指令不会受到线程上下文切换的影响
  • 可见性:保证指令不会受cpu缓存的影响
  • 有序性:保证指令不会受cpu指令并行优化的影响

原子性

原子性是指一个操作或者多次操作是不可中断的。即使在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

例如:两个线程对同一个变量进行修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class 原子性 {
static int t = 100;
public static void main(String[] args) {

// 减少t
new Thread(() -> {
while (t > 0){
t--;
System.out.println(t);
try {
sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
// 减少t
new Thread(() -> {
while(t > 0){
t--;
System.out.println(t);
try {
sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();

}
}

问题:出现错误数据

image-20230414223911287

解决:对临界区代码进行加锁(也是我们之前一直通过synchronized和各种Lock解决的事情)。

可见性

由于cpu缓存的影响,有时候线程对变量的修改,并不会让其他线程看见。

例如:

1
2
3
4
5
6
7
8
9
10
11
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ....
}
});
t.start();
sleep(1);
run = false; // 线程t不会如预想的停下来
}

原因:

Java内存主要是分为主内存和线程独有的工作内存;

  1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
  2. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率
  3. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值

解决:

给需要保证可见性的变量加上volatile(易变关键字,只能保证可见性,它最原始的意义就是禁用 CPU 缓存)

  • 它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存

    volatile的底层实现原理是内存屏障,Memory Barrier (Memory Fence)

    • 对volatile变量的写指令后会加入写屏障
      • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
    • 对volatile变量的读指令前会加入读屏障
      • 读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据

    能够保证的有序性也正是,写屏障会让指令不会出现在屏障后面,读屏障会让指令不会在屏障前面。但是不能解决指令的交错问题还是得用synchronized等解决。

  1. 用synchronized锁住需要保证可见性的变量代码,因为synchronized同样可以保证变量的可见性,但比较重量级(例如使用System.out.println()同样也可以保证可见性,因为底层也是synchronized)

happens-before规则

happens-before规定了对共享变量的写操作对其它线程的读操作可见,即前面的一个操作的结果对后续操作是可见的,它是可见性与有序性的一套规则总结,抛开以下happens-before规则,JMM并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见。

  • synchronized锁中对共享变量的读写是能够保证可见性的。
  • 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见。
  • 线程 start 前对变量的写,对该线程开始后对该变量的读可见。
  • 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待
    它结束)
  • interrupted打断之前对共享变量的修改是对其他线程可见的。
  • 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
  • 具有传递性,volatile变量赋值前其他变量的赋值也会被同步到主存中,对于其他线程也是可见的。

有序性

JVM会在不影响正确性的前提下,可以调整语句的执行顺序。这种特性称之为『指令重排』,但是多线程下『指令重排』会影响正确性。

在多线程下的指令重排序机制问题:

使用两个线程执行两种方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int num = 0
boolean ready = false;
// 线程1 执行此方法
public void actor1(Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程2 执行此方法
public void actor2(Result r) {
num = 2;
ready = true;
}

如果JVM进行指令重排序,让actor2的两条语句颠倒,就会得到r.r1=0的错误结果。

解决:给禁止重排序的变量加上volatile,这样在该变量上面的代码就不会重排序。