在 Java 多线程编程中,标志位(Flag) 是一种常见的线程协作与控制手段,用于通知线程"是否继续运行"、"是否停止任务"等。但它的使用有严格的前提条件,否则会导致可见性问题、响应延迟甚至死循环。
标志位通常是一个 boolean 类型的共享变量,一个线程通过修改它来通知另一个线程改变行为。
以下提供 4 种代码,来体现使用标志位时的注意事项
错误代码一:程序卡住!"子线程结束" 永远不打印!
此时,几乎 100% 会死循环!因为 JVM 会优化这个循环:
-
第一次读取
running到寄存器 -
后续直接使用寄存器中的
true,永不重新读主内存
为什么后续 JVM直接使用寄存器中的 true,永不重新读主内存:
步骤 1:JVM 分析代码
-
JVM 发现:在这个线程的执行路径中,没有任何地方修改
running。 -
而且
running不是volatile,也没有被synchronized保护。 -
所以 JVM 合理推断:
running的值在本线程中永远不会改变。
步骤 2:激进优化 ------ "提升为常量" 或 "缓存到寄存器"
步骤 3:后果 ------ 多线程下失效
-
主线程修改了
running = false,写入主内存。 -
但子线程早已不再访问主内存,它只看寄存器或本地缓存中的旧值
true。 -
结果:死循环,永远看不到变化。
java
public class Demo9_3 {
private static boolean running = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while(running) {
//空循环
}
System.out.println("子线程结束");
});
t.start();
Thread.sleep(1000);
running = false;
System.out.println("主线程已设 running = false");
//你会发现:程序卡住!"子线程结束" 永远不打印!
}
}
错误代码二:表面上看,它"似乎能正常结束"------但这只是"偶然正确",本质上是存在严重并发 bug 的!
为什么 Thread.sleep() 有时能"救"回来?
虽然 sleep() 不是 JMM 规范定义的同步点(不像 volatile),但在实际 JVM 实现中:
-
Thread.sleep()是一个 native 方法,会触发线程状态切换(RUNNABLE → TIMED_WAITING)。 -
在进入/退出内核态、上下文切换时,CPU 缓存可能被刷新,或 JVM 保守地重新加载内存状态。
-
所以从
sleep()返回后,再次读取running时,可能碰巧读到主内存的新值。
⚠️ 但这不是规范保证的行为!不同 JVM、不同 OS、不同 CPU 架构下表现可能不同。
java
public class Demo9_1 {
private static boolean running = true;
public static void main(String[] args) {
Thread t = new Thread(() -> {
while(running) {
System.out.println("hello");
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("thread 线程结束");
});
t.start();
Scanner scanner = new Scanner(System.in);
System.out.println("输入任意内容, 控制 t 线程结束: ");
scanner.next();
running = false;
}
}
解决方案一:使用 volatile
保证可见性(Visibility)
-
当一个线程写入
volatile变量时,强制将该值刷新到主内存。 -
当其他线程读取该
volatile变量时,强制从主内存重新加载最新值,跳过 CPU 缓存和寄存器缓存。
类比理解(现实例子)
想象两个办公室:
-
主内存 = 公司中央公告栏
-
CPU 缓存/寄存器 = 员工自己的笔记本
没有 volatile(无通知机制):
-
老板(主线程)在公告栏贴了"今天下班"(
running = false) -
员工 A(子线程)早上抄了一条"今天上班"(
running = true)到笔记本 -
之后他再也不看公告栏,只看笔记本 → 一直加班到死 💀
有 volatile(强制看公告栏):
-
老板贴通知时,广播:"所有人立刻看公告栏!"
-
员工 A 下次看
running时,必须去公告栏看最新内容 → 看到"下班",就走了 ✅
java
public class Demo9_4 {
private static volatile boolean running = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while(running) {
//空循环
}
System.out.println("子线程结束");
});
t.start();
Thread.sleep(1000);
running = false;
System.out.println("主线程已设置 running = false");
}
}
解决方法二:使用 AtomicBoolean(更现代)
使用 AtomicBoolean 能解决多线程中标志位的可见性与安全性问题,核心原因在于它内部通过 volatile + CAS(Compare-And-Swap)机制,既保证了内存可见性,又提供了原子操作语义。
一、为什么 AtomicBoolean 能解决问题?
1. 底层使用 volatile 修饰值
AtomicBoolean.get() 和 set() 操作都作用于一个 volatile int,因此天然具备 内存可见性 ------ 主线程调用 set(false) 后,子线程调用 get() 一定能读到最新值。
2. 支持原子的"比较并设置"操作(CAS)
虽然你的场景只用到 get() 和 set(),但 AtomicBoolean 还提供:
java
public final boolean compareAndSet(boolean expect, boolean update)
- 这个操作是 原子的(通过 CPU 的 CAS 指令实现)
- 即使多个线程同时尝试修改,也能保证"读-改-写"不被干扰:当多个线程同时对同一个共享变量执行"先读取当前值 → 根据值做计算 → 写回新值"这一系列操作时,使用 CAS(如
AtomicBoolean.compareAndSet)能确保整个过程不会被其他线程打断,从而避免数据错误。
二、与 volatile boolean 对比
| 特性 | volatile boolean |
AtomicBoolean |
|---|---|---|
| 可见性 | ✅(靠 volatile) |
✅(内部用 volatile) |
| 原子性(单次读/写) | ✅(读/写本身是原子的) | ✅ |
| 复合操作原子性(如"如果为 true 则设为 false") | ❌ | ✅(用 compareAndSet) |
| 语义清晰度 | 一般 | ✅(明确表达"这是个原子布尔标志") |
| 可扩展性 | 弱 | ✅(支持更多原子操作) |
三、为什么它能避免"死循环"问题?
✅ 关键点:
-
running.get()→ 读取volatile int value→ 强制从主内存加载 -
running.set(false)→ 写入volatile int value→ 立即刷新到主内存 -
因此,子线程下一次调用
get()就能看到false,循环退出
🚫 不会出现"缓存旧值导致死循环"的问题!
总结:为什么 AtomicBoolean 能解决问题?
| 原因 | 说明 |
|---|---|
1. 内部使用 volatile |
保证 get()/set() 的内存可见性,解决"看不到修改"的问题 |
| 2. 对象引用在线程间共享 | 作为堆对象,所有线程访问的是同一个实例(不像局部变量被捕获副本) |
| 3. 语义明确、不易误用 | 明确表达"这是一个线程安全的布尔状态" |
| 4. 支持未来扩展 | 如需原子条件更新,直接用 compareAndSet,无需重构 |
java
import java.util.Scanner;
import java.util.concurrent.atomic.AtomicBoolean;
public class Demo9_3 {
public static void main(String[] args) {
AtomicBoolean running = new AtomicBoolean(true);
Thread t = new Thread(() -> {
while (running.get()) {
System.out.println("hello thread");
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
System.out.println("thread 结束");
});
t.start();
Scanner scanner = new Scanner(System.in);
System.out.println("输入任意内容, 控制 t 线程结束: ");
scanner.next();
running.set(false); // 原子操作,线程安全
}
}
总结
使用标志位并不能唤醒等待,要是想唤醒等待,必须使用 t.interrupt(); 来终止线程
使用标志位注意事项:
✅ 1. 必须保证内存可见性
问题 :一个线程修改标志位,另一个线程可能永远看不到更新。
原因:CPU 缓存、JVM 优化导致变量值未同步到主内存。
✅ 正确做法(任选其一):
-
使用
volatile修饰标志位:javaprivate static volatile boolean running = true; -
使用
AtomicBoolean:javaprivate final AtomicBoolean running = new AtomicBoolean(true);
为什么要加 final:
✅ 1. 防止引用被意外修改(保证"对象不变性")
-
final表示:running这个引用变量不能再指向其他AtomicBoolean对象。 -
你仍然可以调用
running.set(false)修改其内部状态(因为AtomicBoolean本身是可变的)。 -
但你不能做:
javarunning = new AtomicBoolean(false); // ❌ 编译错误!
为什么这很重要?
-
如果允许多个线程重新赋值
running引用,会导致:-
不同线程看到不同的
AtomicBoolean实例 -
原来的原子状态丢失
-
线程间失去同步基础 → 严重并发 bug
-
java
public class UnsafeTask {
// ❌ 没有 final,引用可变!
private AtomicBoolean running = new AtomicBoolean(true);
public void startWorker() {
Thread t1 = new Thread(() -> {
// t1 拿到当前 running 引用(指向 instance A)
AtomicBoolean localRef = this.running;
while (localRef.get()) {
System.out.println("T1: running = " + localRef.get());
try { Thread.sleep(1000); } catch (InterruptedException e) { break; }
}
});
Thread t2 = new Thread(() -> {
try { Thread.sleep(2000); } catch (InterruptedException e) {}
// ⚠️ 危险操作:创建新实例,并赋值给 running!
this.running = new AtomicBoolean(false); // ← 指向 instance B
System.out.println("T2: 已替换 running 为新对象");
});
t1.start(); t2.start();
}
}
❌ 结果:
-
t1在启动时捕获了this.running的引用(假设叫 instance A)。 -
t2后来执行this.running = new AtomicBoolean(false),让running指向 instance B。 -
但
t1仍然在检查 instance A 的值(它一直是true!)。 -
→
t1永远不会退出循环!死锁式 bug!
💥 这就是"不同线程看到不同
AtomicBoolean实例"的真实场景!
🔑final保证所有线程操作的是同一个AtomicBoolean对象,这是线程安全的前提。
✅ 2. 表达设计意图:这是一个不可变的引用
-
final向阅读代码的人(包括未来的你)明确传达:"这个字段在对象创建后就不会再变了,所有线程都共享同一个状态容器。"
-
这符合 "不可变引用 + 可变状态" 的经典并发设计模式:
-
引用不可变(
final)→ 安全共享 -
内部状态可变(
AtomicBoolean)→ 支持并发更新
-
✅3. 避免低级错误
想象你不小心写了:
java
private AtomicBoolean running = new AtomicBoolean(true);
public void reset() {
running = new AtomicBoolean(true); // 重置?看似合理...
}
但问题来了:
-
如果有线程正在使用旧的
running对象,它们永远不会收到新对象的状态变化。 -
旧对象可能还在被某些线程使用,导致逻辑混乱。
而如果 running 是 final,这种错误在编译期就被阻止了。