原子性

定义: 指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

通过一个例子回顾一下:有一个初始值为 0 的静态变量,一个线程对其自增,一个线程对其自减,进行 5000 次,最终结果会是 0 吗?

结果不一定是 0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static int i = 0;

public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
i++;
}
});

Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
i--;
}
});

t1.start();
t2.start();

t1.join();
t2.join();

System.out.println(i);

}

分析

对于 i++ 而言(i 是静态变量),实际会产生如下的 JVM 字节码指令:

1
2
3
4
getstatic     i // 获取静态变量 i 的值
iconst_1 // 将常量 1 压入操作数栈顶
iadd // 加法
putstatic i // 将修改后的值存入静态变量 i

对应 i– 也是类似:

1
2
3
4
getstatic     i // 获取静态变量 i 的值
iconst_1 // 准备常量 1
isub // 减法
putstatic i // 将修改后的值存入静态变量 i

Java 的内存模型如下,完成静态变量的自增、自减需要在主内存和线程内存(工作内存)中进行数据交换:

image-20260401132149118

在多线程的环境下,这 8 行指令可能会交错执行,导致最终结果错误。

出现负数的情况:

1
2
3
4
5
6
7
8
9
10
11
12
// 假设 i 的初始值是 0
getstatic i // 线程 1 - 获取静态变量 i 的值,线程内 i = 0
getstatic i // 线程 2 - 获取静态变量 i 的值,线程内 i = 0
iconst_1 // 线程 1 - 准备常量 1
iadd // 线程 1 - 自增,线程内 i = 1
pubstatic i // 线程 1 - 将修改后的值存入静态变量 i,静态变量 i = 1
iconst_1 // 线程 2 - 准备常量 1
isub // 线程 2 - 自减,线程内 i = -1
pubstatic i // 线程 2 - 将修改后的值存入静态变量 i,静态变量 i = -1



解决方案

使用synchronized

1
2
3
synchronized ( 对象 ) {
要作为原子操作的代码
}
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
32
static int i = 0;

static final Object obj = new Object();

public static void main(String[] args) throws InterruptedException {

Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
// 加锁、减锁操作会执行 5000 次,更建议将其放到 for 循环外
synchronized (obj) {
i++;
}
}
});

Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
synchronized (obj) {
i--;
}
}
});

t1.start();
t2.start();

t1.join();
t2.join();

System.out.println(i);

}

可见性

在下列代码中,main 线程对 run 变量的修改对于 t 线程不可见,导致 t 线程无法停止:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static boolean run =  true;

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

t.start();

Thread.sleep(1000);

// 线程 t 并不按预想的那样停下来
run = false;

}



分析

初始状态下,t 线程刚开始从主内存中读取了 run 的值到工作内存中:

image-20260401133634413

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

image-20260401133752214

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

image-20260401133840566

解决方案

A. volatile 关键字(最轻量级)

它是可见性的代名词。一旦一个变量被声明为 volatile,JMM 会强制执行两件事:

  1. 立刻刷回:一旦线程修改了该变量,必须立即刷回主内存。
  2. 失效通知:其他线程工作内存里的该变量副本强制失效,必须重新去主内存读取。

通过底层的汇编代码加lock前缀实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static volatile boolean run =  true;

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

t.start();

Thread.sleep(1000);


run = false;

}

B. synchronized 关键字

它的可见性保证更强,规则如下:

  • 加锁前:清空工作内存,从主内存重新读取。
  • 解锁前:必须把工作内存的值刷回主内存。

如果不使用 volatile 关键字,而是在死循环内部加入 System.out.println() 方法,会发现线程 t 也能正常停止,这是因为在 println() 方法内部使用了 synchronized 关键字,保证了主内存和工作内存之间的数据一致。

有序性

线程 A 的代码(生产者):

Java

1
2
a = 1;          // 步骤 1:准备数据
flag = true; // 步骤 2:告诉别人数据准备好了

线程 B 的代码(消费者):

Java

1
2
3
if (flag) {     // 步骤 3:检查标志位
System.out.println(a); // 步骤 4:使用数据
}

在正常逻辑下,我们预期输出一定是 1。但如果没有 volatile 保证有序性,发生了重排序:

分析

线程 A 内部发生了重排:

由于 a 和 flag 没有直接的数据依赖(改 a 不影响改 flag),CPU 为了优化,可能会先执行 flag = true。

  1. 时刻 T1:线程 A 执行了 flag = true(步骤 2 提前了)。此时 a 还是 0。
  2. 时刻 T2:线程 B 进来执行,发现 flag 已经是 true 了。
  3. 时刻 T3:线程 B 执行 System.out.println(a),结果输出了 0!
  4. 时刻 T4:线程 A 这才执行 a = 1(步骤 1 滞后了)。

结论:线程 B 拿到了一个“逻辑上还没准备好”的脏数据。这就是重排序导致的逻辑断层。

解决方案

为了禁止这种“乱排”行为,Java 使用了内存屏障。当你给变量加上 volatile 时,汇编底层会插入特殊的指令

在 volatile 写操作时:

  • 前面插入 StoreStore 屏障: 保证在这次 volatile 写之前,所有的普通写操作都已经真正写到了内存里,绝对不能把前面的普通写排到 volatile 写之后。
  • 后面插入 StoreLoad 屏障: (最重的一个屏障)保证这次 volatile 写完,后续所有的读写操作才能开始。

在 volatile 读操作时:

  • 后面插入 LoadLoad 屏障: 保证 volatile 读完之后,后面的普通读才能进行。
  • 后面插入 LoadStore 屏障: 保证 volatile 读完之后,后面的普通写才能进行。

应用

DCL 单例模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Singleton {
private static volatile Singleton instance; // 必须加 volatile

public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 问题的核心点
}
}
}
return instance;
}

}

第一次检查 (if 外面): 如果 instance 已经创建好了,直接返回即可,不需要进入锁环节。这极大提高了高并发下的性能(毕竟 synchronized 是有开销的)。

加锁 (synchronized): 确保同一时刻只有一个线程能执行创建对象的逻辑。

第二次检查 (if 里面): 这是为了拦截那些“第一批冲进来的线程”。

场景模拟: 线程 A 和 B 同时发现 instance == null,A 先拿到了锁,B 在锁外面排队。A 创建完对象释放锁后,B 拿到锁进来了。如果没有第二次检查,B 会再 new 一个对象,单例模式就彻底破功了。

如果没有 volatile,instance = new Singleton(); 这一行会被拆成三步:

  1. 分配内存空间(给对象找个地儿)。
  2. 初始化对象(执行构造方法,填入数据)。
  3. 将 instance 指向分配的内存地址(此时 instance 不再是 null)。

由于指令重排序,步骤 2 和 3 可能会反过来:

  1. 分配空间。
  2. 将 instance 指向内存地址(此时对象还没初始化!)。
  3. 执行构造方法。

后果: 线程 A 刚跑完第 2 步,线程 B 刚好执行到第一次检查 if (instance == null)。发现 instance 不为 null,于是高高兴兴地把这个还没初始化完的“半成品”对象拿去用了,直接报空指针异常(NPE)。

两个关键字的对比

特性 volatile synchronized
类型 变量修饰符(仅用于变量) 关键字(可修饰方法、代码块)
原子性 不保证(如无法解决 i++) 保证(一次仅一线程执行)
可见性 保证(强制刷回主存/失效缓存) 保证(解锁前刷回主存)
有序性 保证(通过内存屏障禁止重排) 保证(通过单线程执行保证)
是否阻塞 否(轻量级,无线程切换) 是(可能导致线程阻塞和唤醒)
性能 极高(接近普通变量读写) 相对较低(涉及锁竞争和升级

happens-before

“happens-before” 表达的是:如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见。

常见的:

  1. 程序顺序规则:单线程内,前面的操作 happens-before 后面的操作。
  2. volatile 变量规则:对一个 volatile 变量的写,happens-before 后续对该变量的读。
  3. 锁规则:unlock happens-before 后面对同一把锁的 lock。
  4. 传递性:A→B,B→C,则 A→C。
  5. 线程 start 规则:线程 A 执行 ThreadB.start(),则 A 中 start 前的操作对 B 可见。
  6. 线程 join 规则:B join 到 A,则 B 的所有操作对 A 可见。

剩下的JUC再学

完结撒花!