
并发问题很少是“线程不安全”这么笼统。更准确的问法是:另一个线程能否看到更新?一组操作是否必须一起完成?编译器和 CPU 能否调整执行顺序?这三个问题分别对应可见性、原子性和有序性。
JMM 是什么,Happens-before 原则有哪些
Java 内存模型不要求我们记住某一颗 CPU 的缓存细节,但给出了跨线程可见性的规则。若 A happens-before B,A 的结果对 B 可见,且 A 的执行顺序先于 B。
常用来源包括:同一监视器的解锁先于后续加锁、对 volatile 的写先于随后对它的读、线程启动与 join 的边界,以及规则的传递性。它们让并发推理有了比“多试几次”更可靠的基础。
volatile 能保证什么,为什么不能保证 i++ 原子性
volatile 读写能建立可见性,并抑制与该变量相关的危险重排序。因此它非常适合作为开关、配置快照或一次性发布信号:
private volatile boolean stopped;
public void stop() { stopped = true; }
public void run() { while (!stopped) { work(); } }
但 count++ 是“读、计算、写”三个步骤。即使 count 是 volatile,两个线程仍可能读到同一旧值并覆盖彼此的结果。这里缺的不是可见性,而是把读改写合为一个不可分割操作的原子性。
CAS 是什么,有哪些问题
CAS 会比较预期值与内存当前值,匹配时才更新。原子类基于它实现无锁更新;冲突时通常自旋重试。写冲突很高时,自旋会转化为 CPU 消耗,此时 LongAdder 通过分散更新热点来换取更高吞吐,但读取总值需要汇总多个 Cell,因而不适合作为要求每次读取都严格一致的余额。
CAS 还要面对 ABA:值从 A 变 B 再变 A,单纯比较值看不出历史。需要识别版本时,应考虑带 stamp 的原子引用,或干脆让状态机以业务版本号为准。
volatile、CAS 和 synchronized 如何选择
当多个字段必须一起变化、需要条件等待,或者临界区已经是复杂业务逻辑时,锁通常更直接。synchronized 简洁且自动释放;ReentrantLock 则提供可中断获取、超时、多个 Condition 等能力。
真正的选择顺序应该是:先写下“哪些状态必须同时成立”,再选 volatile、CAS 或锁。把工具倒过来当设计起点,往往才是并发代码难以维护的原因。
Java 线程有哪些状态
Java 线程会经历 NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING 与 TERMINATED 等状态。排查卡顿时,不要只看“线程很多”,而要区分它们是在抢 monitor、等待条件、等待 I/O 还是正常运行。
sleep 只让当前线程暂停,并不释放已经持有的锁;wait 必须在持有对应 monitor 时调用,进入等待后会释放该 monitor,并依赖 notify / notifyAll 或超时重新竞争。把两者当作可以互换的“暂停 API”,是很多并发 bug 的起点。
进程、线程、协程和虚拟线程的区别
进程拥有独立资源;线程共享进程的堆等资源,但有自己的程序计数器和栈。虚拟线程降低的是阻塞 I/O 场景下的线程承载成本,不会让 CPU 密集计算凭空获得更多核心,也不会消除数据库连接、下游限流等外部资源的上限。
因此迁移到虚拟线程前,仍要问:任务在等待什么?是否持有不该跨阻塞点持有的锁?下游能承受多少并发?并发模型变轻,不意味着系统背压可以被忽略。
死锁产生的条件、排查和预防
死锁的经典条件包括互斥、请求并保持、不可剥夺和循环等待。工程上最可执行的约束是固定资源获取顺序;对可超时的路径,使用 tryLock 并设计失败后的回退。真正怀疑死锁时,先抓取 jstack,从线程等待环确认,而不是靠日志猜测。
并发学习最终不是记关键字,而是建立一套从状态、内存可见性到资源依赖的排查顺序。
Java 进程、线程、协程和虚拟线程的理解
进程是操作系统分配资源的基本单位,拥有独立的地址空间;线程是进程内部的执行单元,共享堆和类元数据等进程资源,同时拥有自己的程序计数器与栈。线程切换需要保存和恢复执行上下文,因此数量上去以后,调度本身会成为成本。
协程把部分调度交给用户态运行时,切换更轻;Java 虚拟线程在 JDK 19 以预览形式出现、在 JDK 21 正式可用,使用 M:N 模型把大量虚拟线程复用到较少的平台线程上。它特别适合大量阻塞 I/O 的服务,例如等待远程 HTTP、数据库或消息系统响应。
但虚拟线程不是“无限并发”的许可证。计算密集任务仍然受 CPU 核心数限制;数据库连接池、下游配额、文件句柄和内存仍然是有限资源。设计上应该把“线程是否便宜”和“任务是否能被下游承受”分开讨论。虚拟线程能减少等待线程的占用,却不能替我们决定并发上限与背压策略。
线程生命周期、状态和上下文切换
NEW 表示线程刚创建还未启动;RUNNABLE 包含正在运行或就绪等待 CPU 的状态;BLOCKED 常见于等待进入 synchronized 监视器;WAITING 与 TIMED_WAITING 分别表示无期限和有期限的等待;TERMINATED 则表示执行结束。
看到线程 dump 中大量 BLOCKED,优先找同一个 monitor 是否被长时间持有;大量 WAITING,则查看等待的条件、队列或锁;大量 RUNNABLE 并不必然是业务在忙,也可能是自旋或 GC 线程。把状态和栈顶方法、请求量、CPU 使用率放在一起,才有解释力。
线程发生切换的原因也不止时间片耗尽:主动让出、等待 I/O、争锁失败、等待条件、系统调度都可能改变执行权。性能问题因此不能只用“线程池太小”解释;有时真正的原因是锁竞争、下游慢请求,或错误的忙等循环。
sleep 和 wait 的区别
Thread.sleep 是静态方法,暂停当前线程但不会释放已持有的 monitor。Object.wait 必须在持有该对象监视器时调用;进入等待后会释放 monitor,醒来后还要重新竞争锁。wait 的唤醒来自 notify、notifyAll、中断或超时,醒来不等于条件已经成立。
因此正确模式始终是围绕条件的循环,而不是一次 if 判断:
synchronized (queue) {
while (queue.isEmpty()) {
queue.wait();
}
return queue.remove();
}
循环可以应对虚假唤醒,也能应对多个等待线程争抢同一条件。现代业务中,阻塞队列、Condition、CountDownLatch 等更高层工具通常更合适;理解 wait/notify 仍然重要,因为它解释了条件等待与 monitor 的基础语义。
Java 内存模型 JMM
Java 内存模型规定了共享变量如何在主内存与线程工作内存之间交互,以及编译器、运行时和处理器在并发语义下允许怎样重排指令。这里的“工作内存”是抽象概念,不应简单等同于 CPU 缓存或寄存器。
JMM 之所以必要,是因为单线程中可观察等价的优化,在多线程中可能改变另一个线程看到的顺序。happens-before 给出的正是程序员可依赖的最小承诺:若 A happens-before B,A 的写入对 B 可见,且 A 的顺序先于 B。它不是一条“让线程立即执行”的命令,也不会自动让没有同步关系的操作变得有序。
常见规则包括程序次序、监视器解锁先于后续加锁、volatile 写先于后续读、线程 start 与 join,以及传递性。例如把对象字段写好,再写一个 volatile ready = true;另一线程读到 ready 为 true 后,可以看到此前的字段初始化。若没有这条发布关系,读到半初始化状态并非不可能。
volatile 如何保证可见性和有序性
volatile 的关键价值是可见性与有序性。对 volatile 变量的写会在内存语义上发布之前的写入;随后读到该变量的线程可以获得相应可见性。它适合停止标志、状态机的单一状态、不可变配置快照引用,以及双重检查中用于安全发布的引用字段。
但不能把它理解为“每次都从主内存读,因此线程安全”。i++ 由读取、加一、写回构成,两个线程可以先后读取相同值,再分别写回相同结果。volatile 让读写可见,却没有把这三步合为原子操作。需要计数时可选原子类;需要维护多个字段的一致关系时,通常需要锁或单线程串行化。
乐观锁和悲观锁的区别
悲观锁假设冲突可能发生,先互斥后执行;乐观方案则先尝试更新,冲突时再重试或失败。没有哪一种永远更快:读多写少、临界区短且冲突低时,乐观方案很有吸引力;写热点强、冲突频繁或失败代价很高时,自旋重试可能让 CPU 飙升,清晰的锁反而更稳定。
这也是并发设计的主线:先识别共享数据和不变量,再估计冲突与失败成本,最后选择同步原语。把“无锁”当成目标,而不是当成在特定条件下的手段,通常会得到更难诊断的系统。
面试总结
JUC 的基础不是一串 API,而是四个层次:执行单元如何被调度,线程处于什么状态,内存何时可见,资源如何等待与唤醒。后续的 CAS、锁、AQS 和线程池都建立在这套模型之上;先能解释这些边界,才能在真实问题里选对工具。
volatile 的双重检查单例为什么需要 volatile
对象创建在机器层面可能包含分配内存、执行构造和把引用赋给变量等步骤。若没有合适的内存语义,另一个线程理论上可能先看到非空引用,却还看不到完整初始化状态。把实例引用声明为 volatile,可以限制这种发布过程中的危险重排序,让外层空检查与内层同步检查具有正确的可见性边界。它解决的是对象发布,不是让整个构造过程变成多线程可重入。
线程中断是什么,为什么不能只 catch 后忽略
中断是一种协作信号,不会强制杀死线程。处于 sleep、wait 或可中断阻塞中的线程通常会收到 InterruptedException;捕获异常后中断标志可能被清除。若当前方法不能继续完成工作,应向上抛出异常,或在清理后重新设置中断标志,让调用链知道取消请求仍然存在。吞掉中断会让线程池取消、服务停机等控制流程失效。
多线程问题如何定位
先确认现象是 CPU 高、请求慢、吞吐下降还是任务不结束;再抓线程 dump,按状态与栈顶分类;最后把锁、队列、GC、下游调用与时间线对齐。工具只是证据采集方式,真正需要回答的是:哪个线程持有什么资源,谁在等它,等待是否有超时或取消出口。这比只看到某个 BLOCKED 就盲目加线程更可靠。
FIELD NOTES / DISCUSS
文章讨论
读完后,欢迎留下你的补充、疑问或不同看法。
正在读取评论…