并发题最忌讳只背名词。建议先建立一条主线:多个线程访问共享状态时,需要同时处理可见性、原子性和有序性,volatile、锁、CAS、线程池都是围绕这三件事展开的工具。
| 工具 | 解决什么 | 不解决什么 |
|---|---|---|
volatile | 可见性、禁止重排 | 复合操作原子性 |
synchronized | 互斥、可见性、有序性 | 跨进程、超时中断 |
| CAS | 单个变量的原子更新 | 多变量事务、ABA |
| 线程池 | 线程复用、限流、治理 | 任务内部的线程安全 |
1. 创建线程有哪几种方式?为什么推荐线程池?
本质上只有一种:构造 Thread 并调用 start()。所谓多种方式是任务的定义形式不同:继承 Thread、实现 Runnable、实现 Callable 配合 FutureTask、提交到线程池,JDK 21 还可以用虚拟线程执行器。
推荐线程池的原因:线程创建和销毁有系统调用与栈分配开销;无限制创建会耗尽内存和调度资源;池化后可以统一控制并发度、队列、拒绝策略、命名和监控。
直接用 new Thread 的代码通常缺少统一的异常处理和关闭逻辑。execute 抛出的异常会交给线程的未捕获异常处理器,submit 则会把它封装进 Future,不调用 get 就永远看不到——这是线上「任务静默失败」的常见原因。
2. synchronized 的实现原理是什么?锁升级是怎么回事?
synchronized 编译成 monitorenter / monitorexit 字节码,修饰方法时通过方法访问标志隐式加锁。锁信息记录在对象头的 Mark Word 中。
传统讲解分为偏向锁、轻量级锁、重量级锁:无竞争时偏向某个线程;出现竞争时用 CAS 尝试把 Mark Word 指向栈上的锁记录;竞争加剧则膨胀为操作系统互斥量,未抢到的线程进入等待队列。
需要补充版本边界:偏向锁从 JDK 15 起默认关闭并被标记废弃,后续版本已移除,现代 JDK 不应再把它当作必然存在的优化。锁升级在常规理解中不可逆,但具体实现有安全点等细节,面试不必展开。
3. volatile 能保证什么、不能保证什么?
它保证可见性:写操作会刷新到主内存,读操作会从主内存重新读取;同时禁止特定重排序,建立 happens-before 关系。它不保证复合操作的原子性,count++ 依然是读、加、写三步。
典型正确用法:状态标志位、一次性安全发布(双重检查锁单例的实例字段)、作为 CAS 的底层语义基础。典型错误用法:用 volatile 修饰计数器,或用它保护「先检查再修改」的逻辑。
双重检查锁里必须给实例字段加 volatile,否则可能先写入引用、后完成构造,其他线程会看到一个「半初始化」对象。
4. CAS 是什么?ABA 问题怎么解决?
CAS 包含三个值:内存位置、期望值、新值,仅当当前值等于期望值时才更新,由 CPU 指令保证原子性。Java 通过 Unsafe 的本地方法暴露,AtomicInteger 等类封装了它。
它避免了加锁的阻塞和上下文切换,但有几个问题:自旋失败会空耗 CPU;只能保证单个变量的原子性;存在 ABA——值从 A 改成 B 又改回 A,CAS 无法察觉中间变化。
ABA 的解决思路是加版本号,AtomicStampedReference 就是「值 + 版本」;如果对象引用变化本身无害,也可以忽略。高并发计数场景可以用 LongAdder,它把热点分散到多个 Cell,代价是求和结果不是某一瞬间的精确快照。
5. AQS 的原理是什么?ReentrantLock 和 synchronized 怎么选?
AQS 用一个 volatile int state 表示同步状态,加一个 FIFO 的双向等待队列。获取失败的线程被包装成节点入队并挂起,释放时唤醒后继节点;子类只需实现 tryAcquire / tryRelease 等模板方法。
ReentrantLock 用 state 记录重入次数,Semaphore 用它表示许可数,CountDownLatch 用它表示剩余计数。公平锁在获取时先检查队列中是否有前驱,非公平锁允许新线程直接抢,吞吐通常更高。
选型上:需要可中断获取、超时、公平性或多个条件队列时用 ReentrantLock;否则优先 synchronized,它由 JVM 优化、写法简单、不会忘记释放。虚拟线程场景下还要注意,JDK 21 的 synchronized 会钉住载体线程。
6. 线程池的核心参数和执行流程?线程数怎么设?
ThreadPoolExecutor 有七个参数:核心线程数、最大线程数、空闲存活时间、时间单位、工作队列、线程工厂、拒绝策略。
提交任务时:核心线程未满就新建线程;核心满了进队列;队列满了且未达最大线程数就继续建线程;再满则执行拒绝策略。所以无界队列会让最大线程数永远不生效。
线程数没有万能公式。CPU 密集任务通常取核数或核数加一;IO 密集可用「核数 ×(1 + 等待时间 / 计算时间)」估算,再结合实际压测。比公式更重要的是给队列设上界、给线程命名、监控活跃数与队列长度。
拒绝策略默认是 AbortPolicy,直接抛异常。 CallerRunsPolicy 让提交线程执行,能形成天然反压,但会阻塞上游,需要确认业务能接受。
7. ThreadLocal 的原理和内存泄漏原因?
每个 Thread 内部有一个 ThreadLocalMap,key 是 ThreadLocal 的弱引用,value 是强引用。线程复用(尤其是线程池)时,map 会跟着线程长期存活。
泄漏的根源不是 key 的弱引用本身,而是「key 被回收后,value 仍被 Entry 强引用,而 Entry 又被线程持有」。虽然 get、set 会顺带清理部分过期条目,但不保证及时。
规范做法是在 finally 中调用 remove()。InheritableThreadLocal 只在创建线程时拷贝,线程池复用会失效;跨线程池传递上下文需要 TransmittableThreadLocal 这类方案。虚拟线程数量极大,更要注意 ThreadLocal 的内存占用。
8. 如何排查死锁?虚拟线程又解决了什么问题?
死锁需要同时满足互斥、持有并等待、不可抢占、循环等待四个条件。排查时先用 jcmd <pid> Thread.print -l 或 jstack 抓线程栈,JDK 会直接输出 Found one Java-level deadlock 以及相互等待的锁和代码行。
预防手段:统一加锁顺序、用 tryLock 带超时、缩小临界区、避免在持锁时调用外部服务。数据库死锁则是另一套机制,要看 SHOW ENGINE INNODB STATUS。
虚拟线程解决的是「大量阻塞任务导致平台线程耗尽」的问题:阻塞时自动让出载体线程,调度成本低,适合高并发 IO。它不提升单核计算能力,也不能突破下游连接池、数据库连接数的瓶颈;用信号量限制并发往往比池化虚拟线程更合适。
易错点与自测
volatile不等于原子,synchronized不等于公平,ConcurrentHashMap不等于复合操作原子。Executors的快捷工厂方法隐藏了无界队列和无上限线程数,生产环境应显式构造ThreadPoolExecutor。- ThreadLocal 必须
remove,线程池与虚拟线程会放大忘记清理的后果。
自测:① 写出双重检查锁单例并解释 volatile 的作用;② 说明线程池在「核心满、队列满、未到最大线程数」时的行为;③ 用 jcmd 命令列出排查死锁的操作。
参考资料
- JLS SE 21 第 17 章:线程与锁 — happens-before 与内存模型的规范定义
- ThreadPoolExecutor API — 参数语义、执行流程与拒绝策略
- JEP 374:Deprecate and Disable Biased Locking — 偏向锁的版本边界
- JEP 444:Virtual Threads — 虚拟线程的调度模型与限制