五、内存模型

5.1、Java 内存模型概述

简单来说,JVM 定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序性和原子性规则和保证

5.2、原子性

1、概述

一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

原子性就像数据库里面的事务一样,他们是一个团队,同生共死。

2、问题分析

  • 问题

两个线程对初始值为0的静态变量一个做自增,一个做自减,各做5000次,结果是0吗?

以上的结果可能是正数、负数、零。为什么呢?因为Java中对静态变量的自增,自减并不是原子操作

  • 对于 i++ 而言(i 为静态变量),实际上会产生以下的 jvm 字节码指令:
1
2
3
4
getstatic         1        // 获取静态变量 i 的值
iconst_1 // 准备常量1
iadd // 加法
putstatic 1 // 将修改后的值存入静态变量 i 中
  • 同时对于 i-- 也是类似:
1
2
3
4
getstatic         1        // 获取静态变量 i 的值
iconst_1 // 准备常量1
isub // 减法
putstatic 1 // 将修改后的值存入静态变量 i 中

image-20210529151151431

  • 出现负数的情况(假设 i 的初始值为0,在多线程环境下运行)
1
2
3
4
5
6
7
8
getstatic         1        // 线程1获取静态变量 i 的值
getstatic 1 // 线程2获取静态变量 i 的值
iconst_1 // 线程1-准备常量1
iadd // 线程1自增-加法,此时线程1中 i = 1
putstatic 1 // 线程1-将修改后的值存入静态变量 i 中,此时静态变量 i = 1
iconst_1 // 线程2-准备常量1
isub // 线程2自减-减法,此时线程2中 i = -1
putstatic 1 // 线程2-将修改后的值存入静态变量 i 中,此时静态变量 i = -1
  • 出现整数的情况
1
2
3
4
5
6
7
8
getstatic         1        // 线程1获取静态变量 i 的值
getstatic 1 // 线程2获取静态变量 i 的值
iconst_1 // 线程1-准备常量1
iadd // 线程1自增-加法,此时线程1中 i = 1
iconst_1 // 线程2-准备常量1
isub // 线程2自减-减法,此时线程2中 i = -1
putstatic 1 // 线程2-将修改后的值存入静态变量 i 中,此时静态变量 i = -1
putstatic 1 // 线程1-将修改后的值存入静态变量 i 中,此时静态变量 i = 1

3、解决方法和原理解析

  • 解决方法

使用 synchronized 解决并发问题

  • 原理解析

只有使用了同步关键字,Monitor 才会生效,Monitor 可分为 Owner (监视器所有者)、 EntryList(排队等候区,进入这个区域的线程会阻塞) 和 WaitSet 三个区域

同一时刻只能一个监视器中只能有一个所有者(Owner),当一个线程进入 Monitor 后发现 Owner 没有被其他线程,那么这个线程就会成为 Owner ,表示这个线程拿到了锁(对应字节码指令中的 monitorenter),当执行字节码指令 monitorexit 时,这个线程会释放锁,此时 Owner 区重置。

如果此时有第二个线程进入 Monitor ,由于此时 Owner 已经被占据了,所以第二个线程只能进入 EntryList 进行等待,EntryList 中的线程在 Owner 区为空时有机会占据 Owner (获得锁)

  1. Thread1 进入 Monitor 时发现 Owner 区为空(锁空闲),于是获得锁入主 Owner ,而慢半拍的 Thread2 进入 Monitor 时发现 Owner 已被占据,于是在 EntryList 中等待。

image-20210529155059445

  1. Thread1 执行完代码后,执行 monitorexit 字节码指令释放锁,此时退出 Owner 区,此时在 EntryList 中等待的 Thread2 上前一步获得锁,入住 Owner

image-20210529155702933

5.3、可见性

1、退不出的循环

  • 先看一个现象, main 线程中对 run 变量的修改对于 t 线程不可见,导致 t 线程无法停止
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class JVMStack {
static boolean run = true;

public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while(run) {
...
}
});
t.start();

Thread.sleep(1000);
run = false; // 线程 t 不会停下来
}
}

2、概述

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

对于可见性,Java提供了volatile关键字来保证可见性,volatile可以用来修饰成员变量和静态成员变量,避免线程从自己的工作缓存中查找自己变量的值,必须到主存中获取变量的值,线程操作 volatile 变量都是直接操作主存。

当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

3、问题产生原因和解决方法

  • 产生原因
  1. 初始状态, t 线程刚开始从主内存中读取 run 的值到自己线程的工作内存中

image-20210529160713626

  1. 因为 t 线程要频繁地从主内存中读取 run 变量的值,而 JIT 编译器会将 run 的值缓存到自己的工作内存中的高速缓存中,减少对主存中 run 变量的访问,提高效率

image-20210529160850926

  1. 1 秒后,main 线程修改了 run 变量的值,并同步到主存中,而 t 是仍然从自己线程的工作内存的高速缓存中读取这个变量的值,结果永远是没修改过的旧值。

image-20210529161205042

  • 解决方法

使用 volatile 修饰 run 变量,或在线程循环中使用 System.out.println() 输出任意内容,由于 println 方法底层使用 synchronized 修饰,所以可以保证原子性、可见性和有序性

1
2
3
4
public class JVMStack {
volatile static boolean run = true;
...
}

println 方法

1
2
3
4
5
6
public void println(String x) {
synchronized (this) {
print(x);
newLine();
}
}

4、线程内存的高速缓存

  • 线程内存的高速缓存实际上是 CPU 的缓存

CPU 的速度非常快,如果每次取值都需要到内存中取的话,那么可能会拖累CPU 的运算速度,针对这个问题引入了CPU的三级高速缓存,三级缓存的作用是进一步降低内存的延迟,同时提升海量数据量计算时的性能。和每核独享的一级缓存、二级缓存不同的是,三级缓存是核心共享的,能够将容量做的很大。

image-20210605234206618

CPU 的三级缓存遵守缓存一致性协议

5.3、有序性

1、概述

即程序执行的顺序按照代码的先后顺序执行

1
2
3
4
int i = 0;              
boolean flag = false;
i = 1; //语句1
flag = true; //语句2

上面代码定义了一个 int 型变量,定义了一个 boolean 类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)

指令重排:一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

2、解决

使用 volatile 修饰的变量,可以禁用指令重排

3、单例双检锁中使用 volatile 来防止指令重排

  • 在单例模式中,双检锁是一种比较经典的实现方式,它的实现代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class JVMStack {
public volatile static JVMStack INSTANCE;
private JVMStack() {}
public static JVMStack getInstance() {
// 只有实例没创建,才会进入内部的 synchronized 块
if (INSTANCE == null) {
synchronized (JVMStack.class) {
// 也许已经有其他线程创建了实例,所以再判断一次
if (INSTANCE == null) {
INSTANCE = new JVMStack();
}
}
}
return INSTANCE;
}
}

为什么这里需要使用 volatile 修饰单例对象?我们可以先看看初始化对象的过程

  • 初始化对象的过程

INSTANCE = new JVMStack() 字节码为

1
2
3
4
17: new           #3                  // class com/hzx/4homework/JVMStack 分配空间,结果是将对象引用放入操作数栈
20: dup
21: invokespecial #4 // Method "<init4":()V
24: putstatic #2 // Field INSTANCE:Lcom/hzx/homework/JVMStack;
  1. 构建对象

首先先在 main 线程中申请一个自己的栈空间,然后调用 main 方法生成一个 main 方法的栈帧。然后执行 new JVMStack(),向堆中申请一块内存并构建对象,结果是将对象引用放入操作数栈

  1. 初始化对象

调用构造器方法,执行初始化

  1. 引用对象

将对象引用赋值给变量。

  • 需要使用 volatile 修饰单例变量的原因

在程序运行过程中,可能发生指令重排,如果在一个线程执行程序构造对象的过程中,虚拟机将上面过程中的 2 3 指令进行重排变为 3 2 ,那么此线程执行 1 3 后,此时栈中的对象引用已经指向了一块内存空间,即(INSTANCE != null),如果此时线程二进入方法,由于此时 INSTANCE 已经不为空,那么就会返回线程一中已经执行 13 ,但还未调用构造器,进行初始化的对象,这个对象不是完整的。

所以我们需要使用 volatile 修饰单例变量,来保证有序性和对象的完整性

要注意在 JDK 5 以上的版本的 volatile 才会真正生效。

4、happens-before

从 JDK5 开始,java 内存模型提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。

如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间

这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。

  • 线程对 volatile 变量的写,对接下来其他线程对该变量的读可见。

在以下代码中, t2 线程可以读到 t1 线程修改后的值 10

1
2
3
4
5
6
7
8
9
10
volatile static int x;
public static void main(String[] args) {
new Thread(() -> {
x = 10;
},"t1").start();

new Thread(() -> {
System.out.println(x);
},"t2").start();
}
  • 线程 start 前对变量的写,对该线程开始后对该变量的读可见。
1
2
3
4
5
6
7
8
9
10
11
public class JVMStack {
volatile static int x;

public static void main(String[] args) {
x = 10;
new Thread(() -> {
System.out.println(x); // 输出 10
},"t1").start();

}
}
  • 一个线程中的每个操作,对接下来该线程中的任意后续操作可见

  • 线程解锁 m 之前对变量的写,对接下来对 m 解锁的其他线程的读可见

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
volatile static int x;
static Object m = new Object();

public static void main(String[] args) {
new Thread(() -> {
synchronized (m) {
x = 10;
}
},"t1").start();

new Thread(() -> {
synchronized (m) {
System.out.println(x);
}
},"t2").start();
}

5.4、CAS

1、概述

CAS即 Compare and Swap ,它体现的一种乐观锁的思想。

在获取共享变量时,为了保证变量的可见性,需要使用 volatile 修饰。结合 CAS 和 volatile 可以实现无锁并发,适用于竞争不激烈、多核CPU的场景下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 需要进行不断尝试
while (true) {
// 比如拿到当前的旧值 0
int 旧值 = 共享变量;
// 在旧值 0 的基础上增加 1,正确结果为 1
int 结果 = 旧值 + 1;
/*
这个时候如果别的线程将共享变量改为了 5 ,本线程的正确结果 1 旧作废了
这个时候 compareAndSwap 返回 false ,需要重新尝试
知道 compareAndSwao 返回 true , 表示本线程做修改的同时,没有其他线程进行干扰
*/
if (compareAndSwap( 旧值, 新值 )) {
// 成功,退出循环
}

}
  • 因为没有使用synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
  • 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响

2、底层

CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令,下面是直接使用 Unsafe 对象进行线程安全保护的一个例子

3、乐观锁和悲观锁

  • CAS是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
  • synchronized是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。

5.5、synchronized 优化

在 JDK 6 后,synchronized 的性能得到极大地优化,在某些情况下性能甚至要好于 CAS。

Java HotSpot 虚拟机中,每个对象都有对象头(包括 class 指针和Mark Word)。Mark Word平时存储这个对象的哈希码、分代年龄,当加锁时,这些信息就根据情况被替换为标记位、线程锁记录指针、重量级锁指针、线程ID等内容

1、轻量级锁

如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。比如说:

学生(线程A)用课本占座,上了半节课,出门了(CPU时间到),回来一看,发现课本没变,说明没有竞争,继续上他的课。如果这期间有其它学生(线程B)来了,会告知(线程A)有并发访问,线程A随即升级为重量级锁,进入重量级锁的流程。而重量级锁就不是那么用课本占座那么简单了,可以想象线程A走之前,把座位用一个铁栅栏围起来。

每个线程的栈帧中都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word

2、轻量级锁 - 锁膨胀

如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁

3、重量级锁 - 自旋

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

在Java 6之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。

  • 自旋会占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势。
  • 好比等红灯时汽车是不是熄火,不熄火相当于自旋(等待时间短了划算),熄火了相当于阻塞(等待时间长了划算)
  • Java 7之后不能控制是否开启自旋功能

4、偏向锁

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行CAS操作。Java 6中引入了偏向锁来做进一步优化:只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS.

  • 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)
  • 访问对象的 hashCode 也会撤销偏向锁
  • 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程T1的对象仍有机会重新偏向T2,重偏向会重置对象的Thread ID
  • 撤销偏向和重偏向都是批量进行的,以类为单位
  • 如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的
  • 可以主动使用 -XX:-UseBiasedLocking 禁用偏向锁

六、Monitor

6.1、Java 对象头

这里以 32 位虚拟机为例

1、普通对象

image-20210529200822864

  • Klass Word

指向类的指针,对象通过这个指向类的指针来明确自己的类型,通过这个指针可以找到类对象。

2、数组对象

数组对象除了有 Mark WordKlass Word 之外,还有一个 32 bits 的数组长度

image-20210529202351574

3、Mark Word 说明

Mark Word(32 bits)State
hashCode(哈希码,25 bits) | age(存活年龄,4 bits) | biased_lock(偏向锁 1 bits) | lock(2 bits,此时值为 01 )Normal
thread(23 bits)| epoch (2 bits) | age(存活年龄,4 bits) | biased_lock(偏向锁 1 bits) | lock(2 bits,此时值为 01)Biased
ptr_to_lock_record(30 bits) | lock(2 bits,此时值为 00)Lightweight Locked
ptr_to_heavyweight_monitor(30 bits) | lock(2 bits,此时值为 10)Heavyweight Locked
lock(2 bits,此时值为 11)Marked for GC
  1. hashCode :对象的哈希码
  2. age:对象的存活年龄,当存活年龄达到阈值后,这个对象会从新生代晋升到老年代
  3. biased_lock:对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。lock和biased_lock共同表示对象处于什么锁状态。
  4. thread:持有偏向锁的线程 ID
  5. epoch:偏向锁的时间戳
  6. ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。
  7. ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。
  8. Normal:未锁定(此时加锁状态为01)
  9. Biased:偏向锁(此时加锁状态为01)
  10. lock:2位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock标记。该标记的值不同,整个Mark Word表示的含义不同。

6.2、Monitor

Monitor 被翻译为监视器或者管程

每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 为对象上锁(重量级)之后,该对象头的 Mark Word 就被设置为 Monitor 对象的指针

1、Monitor 结构

Monitor 结构如下,Monitor 对象由操作系统提供

image-20210529205751405

  • 刚开始 Monitor 中 Owner 为 null,令 synchronized 锁定的对象关联 Monitor 对象,如果临界区代码为 synchronized(obj) ,那么此时让 obj 关联 Monitor 对象
  • 当 Thread-2 执行 synchronized(obj) 时会将 Monitor 的所有者 Owner 置为 Thread-2 ,Monitor 中只能有一个 Owner
  • 在 Thread-2 上锁的过程中,如果 Thread-3、Thread-4、Thread-5 也来执行 synchronized(obj) ,就会进入 EntryList 阻塞(BLOCKED)

image-20210529210905878

  • Thread-2 执行完同步代码块的代码,然后唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的。
  • 上上张图中 WaitSet 中的 Thread-0 和 Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程。

2、注意

  • synchronized 必须是进入同一个对象的 monitor 才有上述效果
  • 不加 synchronized 的对象不会关联监视器,自然不满足上述规则

3、Monitor 工作原理 – 字节码角度

  • 反编译以下代码的字节码
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
  public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: getstatic #2 // Field lock:Lja
va/lang/Object;
3: dup
4: astore_1
5: monitorenter // 将 lock 对象的 Mark Word 置为 Monitor 指针(加锁)
6: getstatic #3 // Field counter:
I
9: iconst_1
10: iadd
11: putstatic #3 // Field counter:
I
14: aload_1
15: monitorexit // 将lock对象的 Mark Word 重置,唤醒 EntryList
16: goto 24
19: astore_2
20: aload_1
21: monitorexit
22: aload_2
23: athrow
24: return
  • 可以看到,synchronized 的底层使用了 monitorentermonitorexit 两个指令进行加锁和释放锁