Java 并发:JMM、锁与线程池

围绕可见性、原子性、有序性梳理 volatile、CAS、AQS、线程池和 ThreadLocal,补上虚拟线程的适用边界

并发题最忌讳只背名词。建议先建立一条主线:多个线程访问共享状态时,需要同时处理可见性、原子性和有序性,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 命令列出排查死锁的操作。

参考资料

评论

为保障内容质量,评论需站长审核通过后才会展示;提交后没立刻看到属正常,请勿重复提交。

还没有评论,来抢沙发~