从一个无法稳定停止的后台任务出发,解释 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 且持续执行循环,才需要重点检查循环条件是否可见、是否存在错误的本地缓存或任务逻辑没有检查停止信号。

然后检查三件事:

  1. 共享字段是否被多个线程读写;
  2. 读写之间是否存在 volatile、锁、原子类或明确的线程协作关系;
  3. 任务是否在阻塞调用中,停止信号是否能传递到阻塞点。

日志也要记录线程名、任务标识和停止请求时间,但不要在高频循环中无条件打印日志,否则日志本身可能改变时序,制造“加日志后问题消失”的假象。

实践建议

停止标志只表示“请求停止”,不表示线程已经停止。对线程池任务,应区分 shutdown()、shutdownNow() 和任务自身的退出逻辑;对阻塞任务,应同时处理中断,例如捕获 InterruptedException 后恢复中断状态并结束任务:

try {
    queue.take();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    return;
}

不要为了修复一个可见性问题,把所有字段都改成 volatile。先明确状态的读写模型:只有一个写者还是多个写者,更新是简单赋值还是复合操作,字段之间是否必须保持一致。可见性、原子性和有序性是不同问题,解决方案也不相同。

总结

Java 并发问题最危险的地方,不是代码看不懂,而是代码看起来完全合理。一个普通布尔字段,可能因为缺少可见性保证让停止请求失效;一个 volatile 计数器,又可能因为复合操作而丢失更新。

排查这类问题时,可以沿着三条线索展开:状态是否共享,访问是否同步,线程是否真的在读取这个状态。理解 happens-before、volatile、原子类和锁的边界后,很多“偶现”“换环境才出现”的问题,就能从猜测变成可以验证的内存模型问题。

最后修改:2026 年 10 月 06 日
如果觉得我的文章对你有用,请随意赞赏