原子性

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

通过一个例子回顾一下:有一个初始值为 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. 失效通知:其他线程工作内存里的该变量副本强制失效,必须重新去主内存读取。
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 内部发生了重排:

由于 aflag 没有直接的数据依赖(改 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 时,汇编底层会插入特殊的指令:

  • LoadStore 屏障:保证前面的读操作在后面的写操作之前完成。
  • StoreStore 屏障:保证前面的写操作在后面的写操作之前对其他线程可见。

在上面的例子中: 如果 flag 加了 volatile,线程 A 的代码就会被强制执行:

  1. a = 1
  2. [StoreStore 屏障] —— 像一道墙,规定前面的写操作必须先完成。
  3. flag = true

这样线程 B 只要看到 flagtrue,就一定能确定 a 已经是 1 了。

应用

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 一个对象,单例模式就彻底破功了。

如果没有 volatileinstance = 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++ 保证(一次仅一线程执行)
可见性 保证(强制刷回主存/失效缓存) 保证(解锁前刷回主存)
有序性 保证(通过内存屏障禁止重排) 保证(通过单线程执行保证)
是否阻塞 (轻量级,无线程切换) (可能导致线程阻塞和唤醒)
性能 极高(接近普通变量读写) 相对较低(涉及锁竞争和升级

剩下的JUC再学

完结撒花!