从一个无法稳定停止的后台任务出发,解释 Java 内存模型中的可见性、有序性与原子性,比较 volatile、synchronized 和 AtomicInteger 的适用边界,并给出排查并发假象与选择同步方案的实践方法。
很多 Java 后台任务都有类似的生命周期:线程启动后持续轮询,业务线程在需要关闭时设置一个标志位,工作线程看到标志位变化后退出。
代码看起来很直观,但在并发环境中,下面这种写法并没有提供可靠的停止保证:
public class Worker implements Runnable {
private boolean running = true;
@Override
public void run() {
while (running) {
doWork();
}
}
public void stop() {
running = false;
}
private void doWork() {
// 模拟持续执行的任务
}
}调用线程执行 stop() 后,工作线程可能仍然运行很久,甚至在某些情况下表现为始终不退出。这里通常不是线程没有执行赋值,而是另一个线程没有获得“必须看到这个赋值”的内存语义。
问题的根源:共享变量不是天然可见的
Java 内存模型关注的是多个线程如何读写共享变量,以及一个线程做出的修改何时对另一个线程可见。每个线程都可能使用自己的工作内存,处理器和编译器也可能在不改变单线程语义的前提下调整读写顺序。
这并不意味着 Java 一定会为每个线程复制一份完整变量,也不意味着每次读取都会直接访问主内存。工程上更重要的结论是:
如果多个线程访问同一个可变状态,并且没有建立明确的同步关系,就不能假设一个线程的写入会及时、稳定地被另一个线程看到。
在没有同步约束的循环中,编译器可能认为 running 在当前线程中没有发生变化,从而减少重复读取;处理器缓存和内存屏障也会影响读写的可见顺序。具体表现并不要求每次都复现,所以这类问题尤其容易在开发环境中“看起来没问题”,到了生产环境或换一台机器后才暴露。
volatile 解决了什么
对于单纯的停止标志,可以将字段声明为 volatile:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class VolatileStopDemo {
private static class Worker implements Runnable {
private volatile boolean running = true;
@Override
public void run() {
while (running) {
doWork();
}
System.out.println("worker stopped");
}
public void stop() {
running = false;
}
private void doWork() {
// 模拟一小段工作,避免空循环占满 CPU
Thread.onSpinWait();
}
}
public static void main(String[] args) throws Exception {
Worker worker = new Worker();
ExecutorService executor = Executors.newSingleThreadExecutor();
executor.submit(worker);
TimeUnit.MILLISECONDS.sleep(100);
worker.stop();
executor.shutdown();
if (!executor.awaitTermination(1, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
}
}volatile 写入与后续对该变量的读取之间建立了可见性保证,同时对相关读写提供了一定的有序性约束。这里的关键不是“变量被放到了主内存”,而是 Java 内存模型规定了访问语义,使得其他线程不能无条件地使用过期值。
Thread.onSpinWait() 只是向运行时表达当前可能处于自旋等待状态,并不是停止机制,也不能替代 volatile。如果任务本身会阻塞在队列、网络或锁上,仅修改标志位也未必能让线程立即醒来。
volatile 不等于线程安全
很多错误来自把“可见性”误认为“原子性”。例如:
private volatile int count;
public void increment() {
count++;
}count++ 实际上包含读取、加一、写回三个步骤。两个线程可能同时读取到相同的旧值,最后写入相同的新值,导致一次更新被覆盖。volatile 能保证每次读取和写入具有可见性,但不能把这三个步骤合并成不可分割的原子操作。
需要计数时,应根据语义选择合适的工具:
import java.util.concurrent.atomic.AtomicInteger;
public class Counter {
private final AtomicInteger count = new AtomicInteger();
public int increment() {
return count.incrementAndGet();
}
public int get() {
return count.get();
}
}AtomicInteger 适合简单的原子更新。它通常通过 CAS 完成操作,在竞争不高时开销较小;竞争激烈、更新逻辑复杂,或者需要同时维护多个字段的一致性时,使用 synchronized 或 Lock 往往更容易证明正确性。
synchronized 的价值不只是互斥
synchronized 同时提供互斥和可见性。一个线程退出同步块时对共享变量的修改,在另一个线程随后进入同一把锁保护的同步块后应当可见:
public class SafeCounter {
private int count;
public synchronized void increment() {
count++;
}
public synchronized int get() {
return count;
}
}注意,只有使用同一把锁,或者建立了明确的同步关系,才能获得这种保证。下面的写法并没有真正保护访问:
private final Object lock = new Object();
public void update() {
synchronized (new Object()) {
// 每次都是不同的锁,无法形成互斥
}
}锁对象必须稳定、可共享,且所有相关读写都要遵守同一套加锁约定。把锁加在字符串常量、可被外部访问的对象上,也可能让无关代码意外参与竞争,增加排查难度。
安全发布:对象构造完成不代表其他线程一定能正确看到
JMM 还影响对象发布。如果一个线程把对象放入普通集合、普通字段或缓存,另一个线程同时读取,在没有同步关系的情况下,读线程可能看不到最新引用,或者无法获得构造过程中的完整可见性。
常见的安全发布方式包括:
- 通过静态初始化器初始化对象;
- 将对象放入线程安全容器;
- 使用
volatile引用发布不可变对象; - 在锁保护下写入和读取;
- 通过线程启动、线程结束、
Future.get()等既有同步关系传递结果。
双重检查单例必须让实例引用使用 volatile:
public class Singleton {
private static volatile Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
Singleton result = instance;
if (result == null) {
synchronized (Singleton.class) {
result = instance;
if (result == null) {
result = new Singleton();
instance = result;
}
}
}
return result;
}
}这里的 volatile 不只是为了让线程看到最新引用,也用于限制对象引用发布与构造过程之间可能出现的错误重排序。实际项目中,如果没有延迟初始化等明确需求,静态内部类或枚举通常更简单,也更不容易出错。
遇到“线程不退出”时怎么排查
第一步不是立刻扩大线程池或增加 sleep,而是确认线程当前究竟在做什么。可以使用线程转储观察它处于 RUNNABLE、WAITING、TIMED_WAITING 还是 BLOCKED 状态:
jcmd <pid> Thread.print如果线程处于 WAITING,它可能在等待队列或条件变量,单纯修改停止标志不会唤醒它;如果处于 BLOCKED,重点应转向锁竞争;如果处于 RUNNABLE 且持续执行循环,才需要重点检查循环条件是否可见、是否存在错误的本地缓存或任务逻辑没有检查停止信号。
然后检查三件事:
- 共享字段是否被多个线程读写;
- 读写之间是否存在
volatile、锁、原子类或明确的线程协作关系; - 任务是否在阻塞调用中,停止信号是否能传递到阻塞点。
日志也要记录线程名、任务标识和停止请求时间,但不要在高频循环中无条件打印日志,否则日志本身可能改变时序,制造“加日志后问题消失”的假象。
实践建议
停止标志只表示“请求停止”,不表示线程已经停止。对线程池任务,应区分 shutdown()、shutdownNow() 和任务自身的退出逻辑;对阻塞任务,应同时处理中断,例如捕获 InterruptedException 后恢复中断状态并结束任务:
try {
queue.take();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}不要为了修复一个可见性问题,把所有字段都改成 volatile。先明确状态的读写模型:只有一个写者还是多个写者,更新是简单赋值还是复合操作,字段之间是否必须保持一致。可见性、原子性和有序性是不同问题,解决方案也不相同。
总结
Java 并发问题最危险的地方,不是代码看不懂,而是代码看起来完全合理。一个普通布尔字段,可能因为缺少可见性保证让停止请求失效;一个 volatile 计数器,又可能因为复合操作而丢失更新。
排查这类问题时,可以沿着三条线索展开:状态是否共享,访问是否同步,线程是否真的在读取这个状态。理解 happens-before、volatile、原子类和锁的边界后,很多“偶现”“换环境才出现”的问题,就能从猜测变成可以验证的内存模型问题。