JUC / JVM / GC 核心原理与高频场景设计
说明:本文以 JDK 8 为主线,兼顾 JDK 17 / 21 的关键差异。
涉及偏向锁、CMS 等旧特性时,会特别标注其时代背景和现代 JDK 的变化。
阅读方式
- 先看每章开头的“核心概念”,建立整体框架。
- 再看“底层原理”,理解为什么这么设计。
- 最后看“高频场景设计”,把知识点落到工程实践。
- 重点看“详细设计案例库”,那里按背景、目标、架构、代码、风险把方案展开。
- 文末附了官方资料、源码步骤图与链接,适合复盘和继续深挖。
一、JUC:Java 并发编程
1. 线程基础
1.1 线程状态与生命周期
Java 线程在 Thread.State 中分为 6 种状态:
| 状态 | 含义 | 典型场景 |
|---|---|---|
NEW |
线程已创建但未启动 | new Thread() 后未调用 start() |
RUNNABLE |
可运行,包含“运行中”和“就绪” | 已进入调度队列 |
BLOCKED |
等待获取监视器锁 | 进入 synchronized 失败 |
WAITING |
无限期等待 | wait()、join()、park() |
TIMED_WAITING |
有时限等待 | sleep()、wait(timeout)、join(timeout) |
TERMINATED |
线程结束 | run() 执行完毕 |
线程状态变化本质上是 JVM 状态 + OS 调度状态 的组合,不要把 RUNNABLE 误解成“必须正在占用 CPU”。
1.2 线程创建方式
常见创建方式有四类:
- 继承
Thread - 实现
Runnable - 实现
Callable+FutureTask - 使用线程池
工程上最常用的是 线程池。Runnable 适合无返回值任务,Callable 适合有返回值、可抛异常任务。
1.3 线程优先级
Thread 的优先级范围是 1~10,默认值是 5。但它只是 调度提示,不是严格保证。不同 JVM 和操作系统对优先级的支持程度不同,所以不要把业务正确性建立在线程优先级上。
1.4 常见线程方法
start():启动新线程sleep():让出 CPU,但不释放锁wait():进入等待并释放对象监视器,必须在同步块中调用notify()/notifyAll():唤醒等待该监视器的线程join():等待目标线程结束interrupt():设置中断标记,配合阻塞方法使用
wait/notify 依赖对象监视器,park/unpark 依赖“许可位”,后者更适合并发框架内部使用。
2. 线程池
2.1 为什么需要线程池
线程池解决三个核心问题:
- 频繁创建和销毁线程的开销
- 线程数量失控导致的资源争抢
- 任务执行与提交解耦,便于限流、排队、降级
2.2 核心参数
ThreadPoolExecutor 的 7 个核心参数:
| 参数 | 作用 | 设计要点 |
|---|---|---|
corePoolSize |
核心线程数 | 常驻线程数,决定基本并发能力 |
maximumPoolSize |
最大线程数 | 队列满后还能扩张到的上限 |
keepAliveTime |
空闲存活时间 | 非核心线程默认会回收 |
unit |
时间单位 | keepAliveTime 的单位 |
workQueue |
任务队列 | 影响吞吐、延迟、内存占用 |
threadFactory |
线程工厂 | 统一命名、守护线程、异常处理 |
handler |
拒绝策略 | 资源打满时的兜底逻辑 |
2.3 任务执行流程
execute() 的核心逻辑可以概括为:
- 如果当前 worker 数小于
corePoolSize,直接创建核心线程执行任务。 - 否则尝试把任务放入队列。
- 如果队列满了,再尝试创建非核心线程。
- 如果线程数也到上限,则触发拒绝策略。
JDK 8 的实现里,ctl 用一个整型同时记录 运行状态 和 工作线程数。这是一个非常典型的“位运算压缩状态”设计,目的是减少锁竞争并提高状态判断效率。
2.4 生命周期
线程池状态主要有:
RUNNING -> SHUTDOWN -> STOP -> TIDYING -> TERMINATED
shutdown():不再接收新任务,已提交任务继续执行shutdownNow():尝试中断正在执行的任务,并返回队列中未执行任务awaitTermination():等待线程池终止
2.5 拒绝策略
| 策略 | 行为 | 适用场景 |
|---|---|---|
AbortPolicy |
直接抛异常 | 严格控制任务不能丢 |
CallerRunsPolicy |
由提交线程执行 | 轻度降速,形成反压 |
DiscardPolicy |
直接丢弃 | 少量任务可丢的场景 |
DiscardOldestPolicy |
丢最旧任务 | 更偏“最新任务优先” |
生产上常见做法是:有界队列 + 可观测拒绝 + 明确降级。不要默认用无界队列,否则问题只是被延后暴露。
2.6 线程池推荐配置思路
- CPU 密集型:
corePoolSize ≈ CPU核数 + 1 - IO 密集型:线程数可以更高,核心在于等待时间占比
- 批量任务:优先有界队列,防止内存膨胀
- 低峰释放:适当设置
allowCoreThreadTimeOut(true)
2.7 线程池示例
1 | private static final AtomicInteger THREAD_ID = new AtomicInteger(1); |
2.8 源码步骤梳理(架构图)
1 | flowchart TD |
源码步骤可以记成一条主线:
execute()先看当前 worker 数是否小于corePoolSize,能扩核就先扩核。- 不能扩核时,优先把任务放进队列,借助队列削峰。
- 队列满了才尝试扩到
maximumPoolSize,这一步是“最后扩容”。 - 再满就触发拒绝策略,系统开始显式背压或降级。
- Worker 线程实际执行时,会在
runWorker()里循环调用getTask(),任务耗尽后再按空闲时间退出。
3. volatile 与 synchronized
3.1 volatile 的本质
volatile 保证两件事:
- 可见性:写入后对其他线程立即可见
- 有序性:禁止特定的指令重排序
它 不保证复合操作的原子性。例如 i++ 仍然不是线程安全的,因为它包含“读-改-写”三个步骤。
volatile 的典型用法:
- 状态标记
- 配置刷新
- 双重检查单例中的实例引用
- 轻量级发布-订阅信号
3.2 synchronized 的本质
synchronized 通过对象监视器实现互斥,进入同步块时执行 monitorenter,退出时执行 monitorexit。它同时提供:
- 原子性
- 可见性
- 有序性
- 可重入性
3.3 锁升级流程
JDK 8 中经典流程是:
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
但要注意:
- 偏向锁在现代 JDK 中已经被逐步禁用/移除,不应再作为新版本默认前提。
- 现代 JDK 更应把重点放在 轻量级锁、重量级锁、JIT 优化、锁消除、锁粗化 上。
3.4 volatile vs synchronized
| 维度 | volatile |
synchronized |
|---|---|---|
| 作用 | 变量可见性、有序性 | 互斥 + 可见性 + 原子性 |
| 是否阻塞 | 否 | 可能阻塞 |
| 适合场景 | 状态标记、单次发布 | 临界区保护 |
| 性能 | 更轻量 | 竞争下开销更高 |
| 复杂操作 | 不安全 | 安全 |
3.5 DCL 单例示例
1 | public final class Singleton { |
这里的 volatile 关键作用是防止“引用先赋值、对象未完全初始化”的重排序问题。
4. CAS 与原子类
4.1 CAS 是什么
CAS(Compare-And-Swap)比较并交换,核心思想是:
先比较内存中的值是否等于期望值,如果相等,则更新为新值。
它通常借助 CPU 原子指令完成,是很多无锁算法的基础。
4.2 CAS 的优缺点
优点:
- 无阻塞,竞争低时性能很好
- 适合高并发计数、状态更新
缺点:
- 只能保障单个变量级别的原子更新
- 高竞争下会自旋浪费 CPU
- 存在 ABA 问题
4.3 ABA 问题
如果变量从 A -> B -> A,CAS 只看到“还是 A”,就可能误判状态没变。
解决方案:
AtomicStampedReferenceAtomicMarkableReference- 引入版本号或时间戳
4.4 原子类
常见原子类:
AtomicIntegerAtomicLongAtomicReferenceAtomicIntegerArrayAtomicStampedReferenceLongAdder
其中 LongAdder 在高并发计数场景下通常比 AtomicLong 更优,因为它把热点竞争分散到了多个 Cell 上;但它的 sum() 不是强一致快照,适合统计型指标,不适合强实时精确值。
5. AQS 框架
5.1 核心思想
AQS(AbstractQueuedSynchronizer)是 Java 并发同步器的基础框架,核心是:
- 用一个
state表示同步状态 - 用 FIFO 队列管理等待线程
- 通过 CAS +
park/unpark完成阻塞唤醒
5.2 关键结构
| 结构 | 作用 |
|---|---|
state |
同步状态,很多同步器都基于它扩展 |
| CLH 队列 | 保存等待获取锁的线程 |
Node |
队列节点,保存前驱、后继、等待模式 |
ConditionObject |
条件队列,支持 await/signal |
5.3 获取与释放
简化理解:
- 调用
tryAcquire/tryRelease - 失败则入队
- 入队后
park()挂起 - 释放时唤醒后继节点
AQS 把“同步语义”与“排队阻塞”解耦,使得 ReentrantLock、CountDownLatch、Semaphore 等组件可以共享同一套排队逻辑。
5.4 常见组件
| 组件 | 模式 | 作用 |
|---|---|---|
ReentrantLock |
独占 | 可中断、可超时、公平锁 |
CountDownLatch |
共享 | 一次性倒计时 |
Semaphore |
共享 | 限制并发许可数 |
ReentrantReadWriteLock |
读写分离 | 读多写少优化 |
5.5 ReentrantLock 与 synchronized
ReentrantLock 的优势:
- 支持公平/非公平
- 支持可中断获取锁
- 支持超时获取
- 支持多个
Condition
synchronized 的优势:
- 语法简单
- JVM 优化成熟
- 出错概率低
如果同步逻辑简单,优先 synchronized;如果需要超时、可中断、多个条件队列,优先 ReentrantLock。
5.6 源码步骤梳理(架构图)
1 | flowchart TD |
这条链路的核心是“模板方法 + 队列化阻塞”:
acquire()先调用子类实现的tryAcquire(),让不同同步器定义自己的语义。- 失败后线程进入 AQS 队列,进入等待。
- 当持有者释放
state后,AQS 负责唤醒后继节点。 ConditionObject只是把等待队列拆成了“条件队列 + 同步队列”,底层仍然回到同一个 AQS 状态机。
6. 并发容器
6.1 ConcurrentHashMap
JDK 8 的 ConcurrentHashMap 不再使用分段锁,而是采用:
- 数组 + 链表 + 红黑树
- CAS 初始化和扩容
- bin 级别锁竞争控制
关键机制:
| 机制 | 说明 |
|---|---|
sizeCtl |
控制初始化和扩容阈值 |
ForwardingNode |
扩容迁移标记 |
TreeBin |
链表过长后转红黑树 |
CounterCell |
分散计数竞争 |
常见阈值:
- 链表长度达到 8,且表容量足够大时会树化
- 链表过短时可能退化回链表
设计考量是:低竞争走 CAS,高竞争才同步,尽量把锁粒度压缩到单个桶。
6.2 CopyOnWriteArrayList
它的核心是“写时复制”:
- 读:直接读快照数组,无锁
- 写:先加锁,再复制新数组并替换引用
适合:
- 读多写少
- 配置列表
- 监听器集合
不适合:
- 写多
- 大数组频繁变更
6.3 常见容器选型
| 场景 | 推荐容器 |
|---|---|
| 高并发 Map | ConcurrentHashMap |
| 读多写少列表 | CopyOnWriteArrayList |
| 生产者-消费者 | BlockingQueue |
| 计数器 | LongAdder |
| 有序优先级队列 | PriorityBlockingQueue |
6.4 源码步骤梳理(架构图)
1 | flowchart TD |
源码步骤重点在“局部锁 + 全局协作”:
putVal()先算 hash,再定位桶位。- 桶为空时优先 CAS,避免进入锁。
- 桶不为空时,只锁住单个 bin,而不是整张表。
- 链表过长才树化,容量不足时会优先扩容而不是盲目树化。
- 扩容时通过
ForwardingNode和helpTransfer()让多个线程共同搬迁数据。
7. 并发工具类与线程间通信
7.1 常见工具类
| 工具 | 用途 |
|---|---|
CountDownLatch |
等待多个任务完成 |
CyclicBarrier |
多线程互相等待到同一点 |
Semaphore |
控制并发许可数 |
Exchanger |
两个线程交换数据 |
Phaser |
更灵活的阶段性同步 |
FutureTask |
任务包装 + 结果获取 |
CompletableFuture |
异步编排、组合、回调 |
7.2 wait/notify、join、park/unpark
wait/notify:依赖对象监视器,必须在同步块内使用join:本质是等待线程终止park/unpark:更底层的阻塞唤醒机制,常用于 AQS
wait/notify 要始终配合 while 循环检查条件,防止虚假唤醒。
7.3 线程通信模式
- 单次等待:
CountDownLatch - 周期同步:
CyclicBarrier - 令牌控制:
Semaphore - 异步回调:
CompletableFuture - 生产消费:
BlockingQueue
8. JMM 与可见性 / 有序性 / 原子性
8.1 JMM 是什么
Java 内存模型(JMM)定义了:
- 线程如何读写共享变量
- 编译器和 CPU 可进行哪些重排序
- 哪些操作之间存在可见性约束
它不是具体硬件实现,而是 JVM 对并发语义的抽象规范。
8.2 三个核心问题
| 问题 | 说明 | 常见手段 |
|---|---|---|
| 可见性 | 一个线程写了,另一个线程何时可见 | volatile、锁、并发类 |
| 有序性 | 指令重排是否影响结果 | volatile、锁、final 语义 |
| 原子性 | 操作是否可被中断 | 锁、CAS、原子类 |
8.3 happens-before 规则
常见规则:
- 程序次序规则
- 监视器锁规则
volatile变量规则- 线程启动规则
- 线程终止规则
- 中断规则
- 传递性
- 类初始化规则
记忆方式:能建立 happens-before 的地方,JMM 就会帮你建立足够的内存可见性保障。
9. 高频场景设计
9.1 线程池参数定制与使用
思路:
- 先区分 CPU 密集和 IO 密集
- 再决定队列类型和容量
- 最后决定拒绝策略和监控指标
建议:
- CPU 密集:少线程 + 有界队列
- IO 密集:适当增大线程数,但要看响应时间和下游承载
- 核心业务:用有界队列防止雪崩
监控重点:
- 队列长度
- 活跃线程数
- 拒绝次数
- 任务执行时间
- 活跃线程占比
9.2 高并发下的单例设计
推荐优先级:
- 枚举单例
- 静态内部类
- DCL +
volatile
如果单例里有复杂初始化,建议把重资源延迟到显式 init(),避免类加载期间做重活。
9.3 线程安全集合选型
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 并发 Map | ConcurrentHashMap |
读写并发能力强 |
| 读多写少列表 | CopyOnWriteArrayList |
读无锁 |
| 频繁增删且规模大 | ConcurrentLinkedQueue 或分片设计 |
避免 COW 写放大 |
| 共享计数 | LongAdder |
减少热点竞争 |
9.4 并发控制:限流 / 熔断 / 信号量
JUC 本身最直接的并发控制工具是 Semaphore:
1 | public final class ApiGuard { |
说明:
Semaphore解决的是“并发度控制”- 真正的“熔断”通常还需要失败率统计、慢调用统计、开关状态机
- 工程上可与 Resilience4j、Sentinel 等结合使用
9.5 数据一致性保障
常见手段:
- 单写多读用
volatile - 复合写入用锁
- 高并发计数用
LongAdder - 结构性一致性用事务、锁或消息最终一致
典型场景:
- 本地缓存刷新
- 库存扣减
- 状态机切换
- 幂等更新
9.6 异步任务处理
推荐模式:
- 自定义线程池 +
CompletableFuture - 为每个异步分支配置超时和降级
- 合并结果时统一处理异常和上下文
1 | CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> callA(), ORDER_POOL); |
9.7 批量任务并行化
推荐思路:
- 先分片
- 再并行
- 最后汇总
如果任务之间有强依赖,优先保证顺序正确,再考虑并行;不要为了并发而并发。
9.8 死锁排查与规避
规避原则:
- 固定加锁顺序
- 尽量缩小锁范围
- 不在锁内做远程调用
- 使用
tryLock(timeout)做超时兜底
排查思路:
jstack/jcmd Thread.print看线程阻塞关系- 找到互相等待的锁
- 还原锁顺序
- 增加超时与告警
9.9 缓存击穿 / 雪崩的并发方案
常见策略:
- 击穿:单飞加载、互斥锁、
FutureTask合并请求 - 雪崩:随机过期、分批预热、限流降级
- 热点 key:本地缓存 + 分布式缓存双层防护
FutureTask 单飞示例:
1 | private final ConcurrentHashMap<String, FutureTask<Product>> loading = new ConcurrentHashMap<>(); |
10. JUC 面试复习详解
10.1 volatile 为什么能保证可见性,为什么不能保证 i++ 原子性
volatile 解决的是“一个线程写了,另一个线程能不能立刻看到”的问题。它通过内存屏障约束读写顺序,让写入及时刷新到主内存、读取及时从主内存重新感知,因此适合作为状态标记、开关和单次发布信号。
但 i++ 不是单步操作,它至少包含“读出值、计算新值、写回结果”三步。即使变量是 volatile,这三步之间仍然可能被别的线程插入,所以仍然会丢失更新。要保证这类复合操作安全,必须用锁或原子类。
10.2 synchronized 为什么可重入,锁升级在什么条件下发生
synchronized 可重入,是因为 JVM 会记录当前持锁线程以及进入次数,同一个线程再次进入同一把锁时不会被自己挡住,而是把重入计数加一。等退出同步块时,计数逐步减一,直到归零才真正释放锁。
锁升级是 JVM 为了适配不同竞争强度而做的状态演进。竞争弱时走更轻量的路径,竞争增强后再逐步膨胀为更重的锁形态。现代 JDK 里偏向锁不再是重点,面试更重要的是讲清“轻量路径到重量路径”的设计目的,而不是死记某个版本的状态名。
10.3 线程池 execute() 的三步走逻辑是什么
execute() 的主路径可以概括为:先看是否还能创建核心线程,再尝试入队,队列满了再扩到最大线程数,最后才拒绝。它不是简单的“来了就开线程”,而是用队列做削峰,用线程上限做保护。
AbortPolicy 是直接抛异常,适合不能丢任务的硬失败场景;CallerRunsPolicy 是让提交线程自己执行,形成反压,适合允许降速但尽量不丢任务的业务。二者的差异不是“会不会失败”,而是“失败时让谁承担代价”。
10.4 LongAdder 为什么高并发下比 AtomicLong 更快
AtomicLong 的瓶颈在单点 CAS 竞争,线程越多,失败重试越频繁。LongAdder 则把计数拆到多个 Cell 上,把热点竞争分散开,线程尽量打到不同槽位,因此在高并发计数场景下吞吐更高。
代价是它不是强一致快照,求和时需要汇总多个槽位,所以更适合统计指标,而不是严格的实时精确值。
10.5 AQS 为什么能复用给 ReentrantLock、Semaphore、CountDownLatch
AQS 复用的不是某个具体锁,而是一套通用同步框架:state 表示同步状态,FIFO 队列管理等待线程,CAS 和 park/unpark 完成阻塞唤醒。不同组件只需要定义“如何获取 state”“如何释放 state”。
ReentrantLock 是独占语义,Semaphore 是共享许可语义,CountDownLatch 是计数归零语义,但它们底层都在复用同一套排队和唤醒框架,这就是 AQS 最强的地方。
10.6 ConcurrentHashMap 为什么 JDK 8 不再使用分段锁
JDK 7 的分段锁把并发粒度固定在 segment 级别,粒度偏粗,且额外结构多。JDK 8 改成桶级别控制,结合 CAS、链表/红黑树、必要时对桶头加锁和协作扩容,让读写冲突范围更小,吞吐更好。
所以它不是“去掉锁”,而是“把锁粒度缩得更小,再把扩容和树化机制补上”。这也是 JDK 8 版本最值得讲清的设计点。
10.7 CopyOnWriteArrayList 为什么适合读多写少
它的读是直接读快照数组,不加锁,所以很快。写的时候会复制整份数组,再替换引用,因此写成本高。只要写不频繁,这种“用写开销换读性能”的思路就很划算。
如果集合很大、写很频繁,COW 会产生明显的复制和内存压力,这时就不合适了。
10.8 wait/notify 和 park/unpark 的差异是什么
wait/notify 基于对象监视器,必须持有锁才能调用,wait() 会释放锁并进入等待;park/unpark 则更底层,不依赖对象监视器,是 AQS 里挂起和唤醒线程的基础工具。
一个很容易错的点是:park/unpark 不是“加强版 wait/notify”,而是不同层次的原语。前者偏框架内部控制,后者偏对象锁协作。
10.9 什么是 happens-before,如何借它判断并发正确性
happens-before 是 JMM 给并发操作定义的可见性和顺序关系。只要前一个操作 happens-before 后一个操作,前者的结果对后者就是可见的。它是判断并发代码“是不是靠谱”的核心标准。
实际排查时可以这样做:先找写线程有没有经过 volatile、锁、线程启动/终止等同步边界,再看读线程是否一定会经过这些边界。如果没有明确的 happens-before,就不要假设数据已经同步好了。
二、JVM:Java 虚拟机
1. 运行时内存区域
| 区域 | 作用 | 常见异常 |
|---|---|---|
| 程序计数器 | 当前线程执行字节码的位置 | 通常不会 OOM |
| 虚拟机栈 | Java 方法栈帧 | StackOverflowError、OutOfMemoryError |
| 本地方法栈 | JNI / Native 方法 | StackOverflowError、OutOfMemoryError |
| 堆 | 对象实例和数组 | Java heap space |
| 方法区 / 元空间 | 类元数据、常量、方法信息 | Metaspace OOM |
| 直接内存 | NIO、堆外内存 | Direct buffer memory |
1.1 各区域特点
- 程序计数器:线程私有,记录下一条字节码指令地址
- 虚拟机栈:线程私有,保存局部变量、操作数栈、动态链接、返回地址
- 堆:线程共享,GC 主要管理对象区域
- 元空间:JDK 8 起使用本地内存存放类元数据,不再使用永久代
- 直接内存:不在堆里,但仍受进程地址空间和系统内存限制
1.2 常见异常场景
- 栈深递归太深 ->
StackOverflowError - 创建线程过多 ->
unable to create new native thread - 类太多或类加载器泄漏 ->
MetaspaceOOM - 大量
ByteBuffer.allocateDirect()->Direct buffer memory
2. 类加载机制
2.1 七个阶段
| 阶段 | 作用 |
|---|---|
| 加载 | 读取字节流,生成 Class 对象 |
| 验证 | 确保字节码合法、结构正确 |
| 准备 | 为静态变量分配内存并设默认值 |
| 解析 | 符号引用转直接引用 |
| 初始化 | 执行 <clinit> |
| 使用 | 类被正常调用 |
| 卸载 | 类及其类加载器不再可达时回收 |
2.2 类加载器体系
- Bootstrap ClassLoader
- Platform ClassLoader
- Application ClassLoader
- Custom ClassLoader
2.3 双亲委派模型
父加载器优先的目的主要是:
- 避免核心类被篡改
- 保证
java.lang.*等基础类唯一性 - 降低重复加载
但在应用服务器、插件系统、隔离部署场景中,经常会有 child-first 的自定义加载逻辑。
2.4 类加载问题
| 问题 | 现象 | 常见原因 |
|---|---|---|
ClassNotFoundException |
找不到类 | 类路径、依赖缺失 |
NoClassDefFoundError |
编译时有,运行时没有 | 依赖丢失、初始化失败 |
LinkageError |
类链接冲突 | 重复类、版本冲突 |
ClassCastException |
看似同名类却不能强转 | 不同类加载器加载了同名类 |
2.5 类初始化触发点
常见触发包括:
new- 访问静态字段
- 调用静态方法
- 反射
- 初始化子类前先初始化父类
2.6 源码步骤梳理(架构图)
1 | flowchart TD |
源码步骤要抓住“先委派、再定义、再链接、最后初始化”:
loadClass()先检查缓存,避免重复加载。- 默认先交给父加载器,确保核心类唯一性和安全性。
- 父加载器找不到时,子加载器才会走自己的
findClass()。 defineClass()把字节数组变成 JVM 中的Class对象。- 随后完成验证、准备、解析,最后在首次主动使用时执行
<clinit>。
3. 字节码执行引擎
3.1 栈帧结构
每个方法调用对应一个栈帧,主要包含:
- 局部变量表
- 操作数栈
- 动态链接
- 方法返回地址
3.2 方法调用过程
字节码解释执行时,JVM 需要完成:
- 指令取值
- 操作数出栈/入栈
- 常量池解析
- 方法调用分派
- 返回值压栈
常见调用指令:
invokestaticinvokespecialinvokevirtualinvokeinterfaceinvokedynamic
3.3 Interpreter vs JIT
- 解释器:启动快,执行相对慢
- JIT:把热点代码编译成本地机器码,执行快
现代 JVM 一般采用分层编译:
- C1:偏启动和轻量优化
- C2:偏激进优化
- Tiered Compilation:综合前两者
3.4 逃逸分析
逃逸分析判断对象是否“逃出”方法或线程:
- 不逃逸 -> 可能做栈上分配、标量替换
- 不跨线程 -> 可能做锁消除
这也是为什么某些看似“加锁”的代码,在热点编译后性能会很好。
4. 对象创建、布局与访问定位
4.1 对象创建流程
典型流程:
- 检查类是否已加载、初始化
- 为对象分配内存
- 将内存置零
- 设置对象头
- 执行构造方法
4.2 内存分配方式
- 指针碰撞:适合规整堆
- 空闲列表:适合碎片化堆
- TLAB:线程本地分配缓冲,减少竞争
4.3 对象布局
典型对象包含:
- 对象头
- 实例数据
- 对齐填充
对象头里通常有:
- Mark Word
- Klass Pointer
- 数组长度(数组对象)
4.4 访问定位
HotSpot 常见是直接指针访问对象,这样少一层间接寻址,性能更好。
4.5 源码步骤梳理(架构图)
1 | flowchart TD |
对象创建的关键顺序是:
- 先保证类已完成初始化,否则实例都无法构造。
- 再尝试从线程本地缓冲区 TLAB 分配,减少多线程竞争。
- 如果 TLAB 不够,就走堆上的快速分配路径。
- 对象头先写好 Mark Word 和类指针,再进行字段零值初始化。
- 最后才执行构造方法,完成真正的业务初始化。
5. OOM 与内存泄漏
5.1 两者区别
- 内存泄漏:不再使用的对象仍被引用,回收不了
- OOM:可用内存耗尽,最终抛异常
内存泄漏是原因,OOM 是结果。
5.2 常见泄漏点
static集合长期持有对象ThreadLocal未清理- 监听器未注销
- 类加载器未释放
- 直接内存未归还
5.3 常见 OOM 类型
Java heap spaceMetaspaceDirect buffer memoryunable to create new native threadGC overhead limit exceeded
5.4 排查流程
- 先确认 OOM 类型
- 看 GC 日志是否频繁 Full GC
- 导出 heap dump / native memory 信息
- 用 MAT / JProfiler / VisualVM 找 dominator tree
- 看 GC Roots 是否把对象链住
- 结合业务流修复泄漏源
6. 高频场景设计
6.1 OOM 排查
推荐工具:
| 工具 | 作用 |
|---|---|
jcmd |
统一入口,查 heap、线程、类、NMT |
jstack |
线程栈与死锁 |
jmap |
heap dump、对象统计 |
jstat |
GC 统计 |
| MAT | 堆转储分析 |
| JFR | 运行时事件追踪 |
JDK 8 常用 GC 日志参数:
1 | -XX:+PrintGCDetails |
JDK 9+ 推荐:
1 | -Xlog:gc*,safepoint:file=gc.log:time,level,tags |
6.2 类加载问题定位
处理思路:
- 检查类路径和依赖版本
- 看是否存在同名不同版本 jar
- 判断是否是不同类加载器加载导致
- 排查热部署/容器隔离是否引入 child-first 冲突
6.3 热部署方案设计
常见设计:
- 每次部署使用新的类加载器
- 业务类从新加载器加载
- 共享基础类留在父加载器
- 退出时释放线程、连接池、定时器、ThreadLocal
避免点:
- 静态单例持有子加载器对象
- 线程池线程未退出
- JDBC 驱动、SPI、缓存未清理
6.4 JVM 参数调优
基本原则:
- 先定 GC,再调堆
- 先看停顿目标,再看吞吐
- 先保证稳定,再追求极致
常见参数思路:
-Xms与-Xmx设为相等,避免运行中扩容-XX:MaxMetaspaceSize防止类元数据失控-XX:MaxDirectMemorySize约束堆外内存-XX:MaxGCPauseMillis设定 G1 的停顿目标
6.5 字节码增强应用
常见方式:
- JDK 动态代理:接口代理
- CGLIB:子类代理
- ASM / ByteBuddy:直接操作字节码
- Java Agent:运行时插桩
典型应用:
- AOP
- 链路追踪
- ORM 延迟加载
- 监控采集
6.6 运行时监控方案
建议监控维度:
- GC 停顿次数、时长、回收率
- 堆、元空间、直接内存
- 线程数、死锁数
- 类加载数量
- 业务请求延迟与错误率
工程实践里常把 JVM 指标输出到 Prometheus / Grafana,再配合告警阈值和趋势分析。
7. JVM 面试复习详解
7.1 运行时数据区各自的作用是什么
程序计数器负责记录当前线程执行到哪条字节码;虚拟机栈保存方法调用期间的局部变量、操作数栈和返回信息;本地方法栈服务于 JNI 或 Native 调用;堆承载绝大多数对象实例;方法区 / 元空间存放类元数据、常量和方法信息;直接内存则用于 NIO 和堆外缓冲。
最容易出问题的是堆、元空间和直接内存。堆会因为对象太多而 OOM,元空间会因为类元数据太多或类加载器泄漏而膨胀,直接内存则常在 NIO 场景里被忽视。
7.2 元空间和永久代有什么区别
永久代是早期 HotSpot 用来存放类元数据的堆内区域,容量固定,容易受堆大小限制。元空间把这部分元数据移到本地内存里,降低了容量约束,也避免了永久代固定上限带来的频繁溢出问题。
所以 JDK 8 以后,类很多、动态代理很多、热部署很多时,常见的不是“永久代 OOM”,而是 Metaspace 压力或类加载器泄漏。
7.3 双亲委派模型为什么重要
双亲委派的意义是先让父加载器查找,避免核心类被重复定义或篡改,保证基础类唯一性和安全性。java.lang.String 这类核心类如果能被应用随便覆盖,整个 JVM 的基础就不稳了。
不过在插件化、容器化、热部署场景里,开发者也会故意打破双亲委派,改成 child-first。面试里要讲清楚:不是不知道委派,而是知道什么场景下要遵守,什么场景下要主动改变。
7.4 类初始化何时触发
只有“主动使用”才会触发初始化,例如 new、访问静态字段、调用静态方法、反射、初始化父类等。单纯拿到 Class 对象,通常还不会立刻触发 <clinit>。
这题经常和单例、静态变量初始化顺序一起考,核心判断标准就是:有没有真正触发首次主动使用。
7.5 栈帧中有哪些信息
每次方法调用都会创建一个栈帧,栈帧里主要有局部变量表、操作数栈、动态链接和方法返回地址。局部变量表负责参数和临时变量,操作数栈负责字节码执行的中间值,动态链接用于常量池解析,返回地址决定方法结束后返回到哪里。
递归过深会导致虚拟机栈耗尽,表现为 StackOverflowError。如果不是递归,而是线程开太多,也可能因为每个线程都要占用栈空间而触发资源耗尽。
7.6 JIT 和解释器各自的优缺点是什么
解释器启动快,适合冷代码和短生命周期程序;JIT 会把热点方法编译成机器码,长期运行性能更高。现代 JVM 不是二选一,而是通常先解释执行,等热点稳定后再做分层编译。
逃逸分析是 JIT 的重要优化之一,它可以帮助做栈上分配、标量替换和锁消除。你可以把它理解成“编译器替你减少对象分配和同步开销”。
7.7 对象从 new 到可用经历了什么
对象创建通常经历类初始化检查、分配内存、内存清零、设置对象头、执行构造方法几个阶段。分配内存时常优先走 TLAB,减少多线程争抢堆空间的成本。
这题的核心不是死记顺序,而是理解为什么要先清零、为什么要先写对象头、为什么构造方法总是在最后执行。因为 JVM 需要先把对象放进一个一致的内存状态里,再交给业务代码初始化。
7.8 OOM 和内存泄漏如何区分
内存泄漏是“本不该再活的对象还被引用着”,OOM 是“内存最终被耗尽”。所以泄漏是根因,OOM 是结果。排查时先看异常类型,再结合 GC 日志、heap dump 和引用链找根因。
如果是 Metaspace OOM,就优先怀疑类加载器泄漏;如果是堆 OOM,就看大对象、集合和缓存;如果是直接内存 OOM,就查 NIO 缓冲、堆外库和 MaxDirectMemorySize。
7.9 热部署为什么容易引发类加载器泄漏
热部署通常依赖“旧类加载器整体回收,新类加载器重新加载”。只要旧类加载器还被线程、静态字段、ThreadLocal、缓存、SPI 或连接池引用着,它就无法被释放,元空间也会跟着一直占着。
所以热部署的关键不是“重新加载类”,而是“确保旧加载器及其引用链全部断开”。
7.10 类加载问题怎么定位
ClassNotFoundException 往往是类路径或依赖缺失;NoClassDefFoundError 常见于运行时依赖丢失或类初始化失败;ClassCastException 则经常意味着同名类被不同类加载器加载了。定位时先查类路径和依赖版本,再查加载器链路。
一个很实用的判断方法是:只要涉及容器、插件、热部署,先怀疑类加载器;只要本地能跑线上挂,先怀疑依赖冲突。
三、GC:垃圾回收
1. 垃圾判定
1.1 引用计数
给对象维护引用计数,引用加一,失效减一。优点是简单,缺点是 无法处理循环引用,所以现代主流 JVM 并不以它作为核心算法。
1.2 可达性分析
以 GC Roots 为起点向下搜索,凡是不可达的对象都可视为垃圾。
常见 GC Roots:
- 线程栈中的局部变量
- 静态变量
- 常量引用
- JNI 引用
- 同步锁持有关系
1.3 四种引用
| 引用类型 | 作用 | 回收时机 |
|---|---|---|
| 强引用 | 默认引用 | 不会被回收 |
| 软引用 | 内存不足时回收 | 常用于缓存 |
| 弱引用 | 下次 GC 直接回收 | 常用于映射表 |
| 虚引用 | 仅用于通知和清理 | 配合 ReferenceQueue |
2. 分代回收思想
2.1 为什么要分代
经验法则是“绝大多数对象朝生夕死”。因此把对象分为:
- 年轻代:高频分配、高频回收
- 老年代:存活时间长、回收代价高
2.2 典型分区
- Eden:新对象主要分配区
- Survivor:存活对象过渡区
- Old:长期存活对象区
- Metaspace:类元数据,不在 Java 堆内
2.3 晋升与年龄
对象在多次 Minor GC 后会逐步晋升到老年代。是否晋升取决于:
- 对象年龄
- Survivor 空间大小
- 动态年龄判定
3. 回收算法
| 算法 | 思路 | 优点 | 缺点 |
|---|---|---|---|
| 标记-清除 | 标记垃圾后直接清理 | 实现简单 | 产生碎片 |
| 复制 | 存活对象复制到新区域 | 无碎片,速度快 | 空间利用率低 |
| 标记-整理 | 标记后压缩整理 | 无碎片 | 移动成本高 |
| 分代收集 | 按对象年龄选择算法 | 兼顾吞吐和停顿 | 设计更复杂 |
简化理解:
- 年轻代常用复制算法
- 老年代常用标记-清除或标记-整理
4. 经典垃圾收集器
4.1 发展脉络
| 收集器 | 特点 | 适合场景 | 现状 |
|---|---|---|---|
| Serial | 单线程、STW | 小堆、简单场景 | 仍可用 |
| ParNew | 年轻代并行 | 旧 CMS 配套 | 旧时代方案 |
| Parallel Scavenge / Parallel Old | 吞吐优先 | 批处理、后台任务 | JDK 8 常见默认组合 |
| CMS | 低停顿、并发标记清除 | 对延迟敏感但可接受碎片 | JDK 14 已移除 |
| G1 | Region 化、可预测停顿 | 大堆、服务端应用 | JDK 9 起默认 |
| ZGC | 极低停顿、并发搬迁 | 超大堆、低延迟 | 现代主力 |
| Shenandoah | 低停顿、并发压缩 | 低延迟系统 | 现代可选 |
4.2 Serial
- 单线程收集
- 适合小堆和简单客户端场景
- 优点是实现简单、额外开销低
4.3 Parallel
- 追求吞吐量
- 适合批处理、离线任务、后台计算
- 缺点是暂停时间相对不稳定
4.4 CMS
CMS 以“低停顿”为主要目标,采用并发标记和并发清除,但会面临:
- 标记清除产生碎片
- 并发阶段仍有 CPU 开销
- 高碎片时可能触发 Full GC
它是经典老方案,但在现代 JDK 已经退场,更多是理解演进价值。
4.5 G1
G1 的核心思路:
- 把堆切成多个 Region
- 通过收集价值预测控制停顿
- 先收年轻代,再在合适时机做 Mixed GC
关键机制:
- Remembered Set:跨 Region 引用记录
- SATB:并发标记时的快照起点
- Evacuation:对象搬迁
- Humongous Object:大对象走特殊分配路径
G1 适合大多数服务端应用,尤其是需要在 吞吐和停顿之间平衡 的场景。
4.6 ZGC
ZGC 的目标是把停顿压到极低级别。核心特征:
- 大部分工作并发完成
- 采用加载屏障等机制支持对象搬迁
- 适合超大堆和低延迟系统
JDK 21 的 Generational ZGC 进一步加入分代思路,以提升吞吐和分配效率。
4.7 Shenandoah
Shenandoah 也是低停顿收集器,强调并发标记、并发压缩和低延迟。它在理念上和 ZGC 很接近,常用于对 STW 极其敏感的系统。
4.8 源码步骤梳理(架构图)
1 | flowchart TD |
如果把收集器抽象成一条回收流水线,可以这样理解:
- 新对象先进入年轻代,绝大多数对象在这里就被回收掉。
- 能活下来的对象经过多次 Minor GC 后逐步晋升。
- 老年代压力上升后,再进入并发标记和混合回收。
- G1 更强调 Region 级调度,ZGC 和 Shenandoah 更强调并发搬迁与极低停顿。
5. Minor / Major / Full GC
5.1 基本含义
- Minor GC:年轻代回收
- Major GC:通常指老年代回收,具体含义可能因收集器而异
- Full GC:整堆回收,通常伴随 STW,可能涉及类卸载、元空间整理
5.2 常见触发条件
- Eden 放不下新对象
- 老年代空间不足
- 元空间压力过大
- 显式
System.gc() - G1 的并发周期失败或退化
5.3 设计建议
- 尽量避免频繁 Full GC
- 关注分配速率和晋升速率
- 关注大对象和长生命周期对象
6. 高频场景设计
6.1 大内存应用 GC 优化
建议:
- 优先考虑 G1 或 ZGC
Xms与Xmx设成相同- 避免堆频繁扩缩容
- 关注对象分配速率和晋升率
G1 常见参数思路:
1 | -XX:+UseG1GC |
6.2 低延迟系统 GC 选型
优先顺序通常是:
- ZGC
- Shenandoah
- G1
- Parallel / CMS 仅限历史环境
低延迟系统关注的不是“平均停顿”,而是 尾延迟。
6.3 Full GC 频繁问题排查
排查顺序:
- 看是哪种 Full GC
- 看老年代是否持续增长
- 看元空间是否膨胀
- 看是否有大对象或直接内存问题
- 看是否存在类加载器泄漏或 ThreadLocal 泄漏
6.4 OOM 问题分析与解决
思路:
- 先判断堆内还是堆外
- 再判断对象泄漏还是分配过快
- 最后结合 GC 日志与堆转储定位根因
6.5 对象生命周期优化
原则:
- 少创建临时大对象
- 及时释放缓存和集合引用
- 不要长期持有短生命周期对象
- 热点对象可考虑复用,但要防止对象池反而增加复杂度
6.6 内存分配策略设计
常见策略:
- 业务缓存加上过期和容量上限
- 大数组提前预分配,避免反复扩容
- 大对象尽量减少跨代存活
- 关注对象图深度,减少长链引用
7. GC 调优与排查工具
| 工具 | 用途 | 适合阶段 |
|---|---|---|
| GC 日志 | 看回收频率、停顿、晋升 | 第一现场 |
jstat |
看 GC 统计趋势 | 日常观测 |
jcmd |
查 heap、线程、NMT | 快速定位 |
jmap |
导出 heap dump | 深度分析 |
| MAT | 堆转储分析 | 查泄漏根因 |
| JFR | 跟踪事件与延迟 | 线上采样 |
建议把 GC 日志、堆使用率、暂停时间、分配速率、元空间占用一起看,单看一个指标容易误判。
8. GC 面试复习详解
8.1 为什么说“绝大多数对象朝生夕死”
在典型 Java 服务里,大量对象只服务于一次请求、一次函数调用或一次临时计算,很快就失去引用。这种分布非常适合分代回收:新对象优先放年轻代,活不久的对象能快速被清掉,回收成本低。
所以分代思想不是为了“分区而分区”,而是基于真实对象生命周期分布做出的工程优化。
8.2 强引用、软引用、弱引用、虚引用有什么区别
强引用默认不会回收;软引用会在内存紧张时被清理,常用于缓存;弱引用在下一次 GC 时就会被回收,常用于映射表;虚引用本身不决定对象存活,只用于清理前后的通知。
如果面试追问用途,最常见的答法是:软引用偏缓存,弱引用偏引用跟踪,虚引用偏资源清理。
8.3 为什么分代收集有效
因为对象的存活时间往往呈现明显分层。年轻代对象多、死亡率高,适合用复制算法快速回收;老年代对象更稳定,适合采用标记-清除或标记-整理来减少复制成本。
分代收集的本质是“把不同生命周期的对象分开管理”,不是简单把堆切几块。
8.4 标记-清除和标记-整理有什么区别
标记-清除是先标记垃圾,再直接清掉,优点是实现简单,缺点是会产生碎片;标记-整理则会在清理前把存活对象往一边移动,减少碎片,但移动对象本身有额外成本。
所以如果更看重空间连续性,整理更合适;如果更看重实现简单和局部回收成本,清除也有价值。
8.5 CMS 为什么容易碎片化
CMS 的目标是尽量减少停顿,它采用并发标记和并发清除,但不会像整理算法那样主动压缩堆内存。久而久之,老年代会留下很多不连续空洞,导致大对象分配失败或触发 Full GC。
这也是 CMS 最经典的历史缺陷之一,所以它在现代 JDK 中已经退出主流舞台。
8.6 G1 为什么能更好地控制停顿
G1 把堆切成多个 Region,并维护每个 Region 的回收收益模型。它不是“把所有垃圾一起扫”,而是按停顿目标选择最划算的 Region 做回收,因此能在吞吐和停顿之间取得更平衡的结果。
G1 最核心的价值是把 GC 从“整堆粗粒度回收”变成“Region 级的可预测调度”。
8.7 ZGC 为什么适合超大堆低延迟场景
ZGC 的目标是把 STW 压到极低,并让绝大多数回收动作并发完成。它通过加载屏障、并发标记、并发搬迁等机制,把停顿时间控制得非常短,因此特别适合超大堆和尾延迟敏感系统。
JDK 21 的分代 ZGC 进一步把年轻代和老年代的回收思路结合起来,兼顾低延迟和吞吐。
8.8 Minor GC、Mixed GC、Full GC 的区别
Minor GC 一般是年轻代回收;Mixed GC 常见于 G1,既回收年轻代,也回收部分老年代 Region;Full GC 则通常是整堆级回收,停顿最重,也最需要避免。
实战里最关键的不是背定义,而是判断为什么会触发 Full GC:是老年代满了、元空间膨胀了,还是并发回收跟不上分配速度。
8.9 元空间和堆的边界在哪里
堆主要管对象实例,元空间管类元数据。对象本身不在元空间里,但类、方法、常量池、运行时类型信息这些内容会占用元空间。换句话说,堆是“对象世界”,元空间是“类世界”。
如果线上频繁 Metaspace OOM,通常不是对象太多,而是类太多、动态代理太多、热部署没清干净,或者类加载器没有释放。
8.10 大对象和直接内存问题怎么排查
大对象会给年轻代和老年代都带来压力,容易导致提早晋升或回收抖动;直接内存虽然不在堆里,但会影响进程总内存和 NIO 性能。排查时要同时看堆、堆外、元空间和 GC 日志,不能只盯着 heap usage。
如果是大对象问题,重点看分配速率和存活时间;如果是直接内存问题,重点看 NIO 缓冲、本地库和 MaxDirectMemorySize。
下面这部分把前面的原理真正落到设计层面。每个案例都按“背景 - 目标 - 架构 - 代码 - 细节 - 风险”展开,适合直接拿去做方案讨论、面试追问和问题排查。
四、详细设计案例库
1. 订单异步化与线程池治理
背景
订单创建后通常会串联多个非核心步骤,例如发券、发消息、写审计、同步搜索索引、通知下游。它们共同特点是:
- 耗时不稳定
- 某些分支可以失败重试
- 失败不能拖垮主流程
- 高峰期容易把数据库和下游打满
设计目标
- 主链路尽快返回
- 异步任务可控、可观测、可降级
- 高峰时能自动形成背压
- 不因为某个下游慢调用把线程池拖死
架构
1 | flowchart LR |
参数设计
- CPU 密集分支:
corePoolSize接近 CPU 核数 - IO 密集分支:适当放大线程数,但必须配有界队列
- 队列容量:按峰值流量和可接受排队时间估算,不要无界
- 拒绝策略:核心链路一般不用直接丢弃,优先反压或降级
参考实现
1 | public final class OrderAsyncExecutor { |
运行细节
- 主线程只做强一致步骤,不把慢操作放进同步事务里。
- 异步任务拆成独立子任务,便于重试和补偿。
- 队列长度、活跃线程数、拒绝次数、任务耗时要接入监控。
- 如果某个下游持续变慢,先限流,再降级,最后才扩容线程池。
常见坑
- 用无界队列,导致内存和延迟一起失控
- 一个线程池同时承载 CPU 计算和 IO 等待,互相拖累
- 任务里再提交同一个线程池,容易形成隐性死锁
- 忽略任务超时,导致线程长期被占住
2. 高并发单例与配置客户端
场景
配置中心客户端、ID 生成器、限流器、远程服务连接器都适合单例化,但前提是初始化逻辑必须可控。
设计选择
- 纯懒加载、初始化简单:静态内部类
- 需要防反射、反序列化:枚举单例
- 初始化需要传参:显式
init()+ DCL
推荐实现
1 | public final class ConfigClient { |
设计细节
- 构造方法保持轻量,不在里面做远程调用。
- 把重资源初始化放到显式
init(),避免类加载时阻塞。 - 单例对象如果会被序列化,必须处理反序列化重复创建问题。
- 如果运行在容器里,注意类加载器隔离,不要让静态字段跨部署残留。
3. 热点缓存与击穿防护
背景
热点商品、秒杀库存、用户画像、活动配置等 key 在过期或失效时会被大量并发请求击穿到数据库。
目标
- 让同一个 key 在失效瞬间只回源一次
- 防止缓存雪崩把后端打穿
- 保证缓存重建过程可控、可观测
架构
1 | flowchart TD |
单飞加载实现
1 | private final ConcurrentHashMap<String, FutureTask<Product>> loading = new ConcurrentHashMap<>(); |
防雪崩细节
- 过期时间加随机抖动,避免同一批 key 同时失效
- 热点 key 主动预热,不等它自然过期
- 对数据库回源加互斥和限流,防止缓存失效时形成流量尖峰
- 允许短时间返回旧值,换取系统可用性
常见坑
- 缓存穿透只做空值缓存,不做参数校验
- 缓存雪崩只加过期时间,不做预热和限流
- 缓存重建没有超时,导致线程长时间阻塞
4. 限流、熔断与并发控制
设计边界
Semaphore 适合做并发度控制,但熔断本身还需要失败率、慢调用率和状态机。
并发限流示例
1 | public final class ConcurrencyGuard { |
熔断状态机
1 | stateDiagram-v2 |
设计细节
CLOSED正常放行。OPEN直接拒绝,保护下游。HALF_OPEN只允许少量探测请求。- 状态切换必须是原子操作,避免多线程同时翻转。
常见坑
- 把限流和熔断混成一个开关
- 只统计错误次数,不看时间窗口
- 没有熔断恢复路径,开了就回不来
5. 死锁排查与规避
设计原则
- 固定加锁顺序
- 锁内不做远程调用
- 必要时使用
tryLock(timeout)
排查流程
- 用
jstack或jcmd Thread.print找到互相等待的线程。 - 看锁拥有者和等待者。
- 还原代码里的加锁顺序。
- 给高风险临界区加超时和告警。
代码示例
1 | public boolean transfer(Lock first, Lock second) throws InterruptedException { |
运行时辅助判断
1 | ThreadMXBean bean = ManagementFactory.getThreadMXBean(); |
如果 ids 非空,就说明 JVM 已经能识别到循环等待关系,接下来就是定位锁的来源。
6. OOM、类加载器泄漏与堆外内存排查
排查决策树
1 | flowchart TD |
关键动作
- 堆 OOM:先导出 heap dump,再看 dominator tree 和 GC Roots
- 元空间 OOM:先看动态代理、热部署、SPI、类加载器链
- 直接内存 OOM:先看 NIO 堆外分配和
MaxDirectMemorySize - 线程 OOM:先看线程池膨胀和每线程栈大小
参数建议
-XX:MaxMetaspaceSize:控制类元数据上限-XX:MaxDirectMemorySize:控制堆外上限-Xss:控制单线程栈大小,线程多时尤其重要
7. 大内存服务的 GC 选型与调优
选型逻辑
| 场景 | 选型 | 理由 |
|---|---|---|
| 小堆、简单应用 | Serial / Parallel | 实现简单,吞吐优先 |
| 普通服务端 | G1 | 可预测停顿,平衡性好 |
| 超大堆、低延迟 | ZGC / Shenandoah | STW 极低 |
G1 调优步骤
- 固定堆大小,避免运行中扩容。
- 先观察分配速率、晋升速率和暂停时间。
- 通过
MaxGCPauseMillis设停顿目标,但不要把它当硬保证。 - 关注老年代 Region 占用和 humongous object。
G1 示例参数
1 | -XX:+UseG1GC |
ZGC 适用条件
- 堆非常大
- 对尾延迟敏感
- 愿意用更现代的收集器换更低停顿
观察指标
- P99 / P999 停顿
- 分配速率
- 晋升速率
- Full GC 次数
- 元空间和直接内存占用
五、Java / 后端题库(面经抽取)
说明:本章从《面试备考手册.md》中筛选 Java / 后端相关题目,剔除 Go、纯前端和纯算法内容,按“题目 + 详解”重排。
1. Java 基础、集合与并发
Q1:HashMap 为什么线程不安全?具体会出什么问题?
HashMap 的问题不在“不能读”,而在“并发写会破坏内部状态”。多个线程同时 put 时,可能在扩容、链表挂接、size 统计上发生竞态,轻则丢数据,重则让结构不一致。JDK 7 时代最典型的问题是扩容时链表重排可能形成环;JDK 8 虽然结构更稳,但它依然不是并发容器。
- 追问:为什么
ConcurrentHashMap能解决这些问题? - 追问:如果只是多线程读,HashMap 还能不能安全使用?
Q2:ConcurrentHashMap JDK 8 是怎么实现线程安全的?
JDK 8 的 ConcurrentHashMap 不再用分段锁,而是采用“数组 + 桶级锁 + CAS + 协作扩容”。空桶插入时尽量走 CAS,冲突发生时只锁单个桶头,不会锁整张表;扩容时通过 ForwardingNode 和 helpTransfer 让多个线程一起搬迁数据,减少单线程搬表的停顿。
- 追问:
sizeCtl、ForwardingNode、TreeBin各自是干什么的? - 追问:为什么链表长度到一定阈值要树化?
Q3:CopyOnWriteArraySet 内部怎么实现?为什么适合读多写少?
CopyOnWriteArraySet 底层其实是 CopyOnWriteArrayList 的包装,核心思路是“写时复制,读时无锁”。每次写入都会复制一份新数组,再把引用替换掉,因此读线程始终看到一个稳定快照,适合配置列表、监听器、注册表这类读远多于写的场景。
- 追问:为什么它不适合写多场景?
- 追问:和
Collections.synchronizedSet、ConcurrentHashMap.newKeySet()怎么选?
Q4:ThreadLocal 为什么常用?为什么会内存泄漏?
ThreadLocal 适合传递“线程维度上下文”,例如请求 ID、登录态、事务上下文。它的泄漏风险来自两个点:ThreadLocalMap 的 key 是弱引用,但 value 仍然是强引用;而线程池线程是复用的,如果任务结束不 remove(),旧上下文就可能一直挂在工作线程上。
- 追问:为什么在线程池场景下更容易出问题?
- 追问:正确的清理姿势是什么?
Q5:线程安全集合有哪些?怎么选型?
如果是高并发 Map,优先 ConcurrentHashMap;如果是读多写少列表,优先 CopyOnWriteArrayList;如果是队列型生产消费,优先各种 BlockingQueue;如果是计数器,优先 LongAdder。选型的核心不是“哪个名字听起来更安全”,而是看读写比例、是否要求强一致、是否允许复制开销。
- 追问:
LongAdder为什么比AtomicLong在高并发下更快? - 追问:
CopyOnWriteArrayList的读写成本分别是什么?
Q6:线程安全单例怎么写?为什么 DCL 要配 volatile?
常见单例方案有枚举、静态内部类、DCL。DCL 之所以需要 volatile,是因为对象创建并不是一步完成,存在“先分配地址、再初始化对象、最后赋引用”的重排序风险;volatile 用来保证写入顺序和可见性,避免别的线程拿到一个“半初始化对象”。
- 追问:为什么静态内部类天然线程安全?
- 追问:单例在序列化、反射、类加载器隔离下还有哪些坑?
2. JVM / GC / 内存
Q7:Java 项目出现内存溢出,你会怎么排查?
先确认 OOM 类型,再决定看堆、堆外还是类元数据。堆 OOM 先抓 heap dump,再结合 jstack、jstat、GC 日志、MAT 看对象引用链;如果是 Metaspace,优先怀疑类加载器泄漏、动态代理或热部署;如果是 Direct buffer memory,重点看 NIO/Netty/堆外分配。
- 追问:heap dump 里重点看哪些指标?
- 追问:如何区分 OOM 和“只是高水位”?
Q8:堆外内存 OOM 怎么排查?
堆外内存问题常被忽略,因为它不直接出现在 Java heap 里。排查时要看 DirectByteBuffer、JNI、本地库、Netty Arena、MaxDirectMemorySize,必要时配合 NMT(Native Memory Tracking)或系统层工具看进程真实占用。很多线上“堆没满却挂了”的问题,本质上都是堆外打满或 native 资源泄漏。
- 追问:为什么
ByteBuffer.allocateDirect()容易让人误判? - 追问:
-XX:MaxDirectMemorySize应该怎么配?
Q9:怎么判断是内存泄漏,而不是正常的高水位?
正常高水位一般会在某个稳定区间波动,而内存泄漏往往表现为“回收后基线持续抬高”。判断时看 GC 后存活对象是否长期增长、Dominator Tree 是否指向固定持有者、GC Roots 是否形成长链。泄漏不是看“某次涨了”,而是看“涨完以后回不去”。
- 追问:哪些对象最容易成为泄漏源?
- 追问:ThreadLocal、静态集合、缓存分别怎么排查?
Q10:JDK 8、17、25 的区别怎么看?
面试里建议按三层来答:JDK 8 是经典基线,很多并发和 JVM 八股都以它为参照;JDK 17 是 LTS 现代化版本,语言和 JVM 都更成熟;更高版本则体现持续演进,重点看低停顿 GC、语言简化、安全性与运行时优化。不要死背“小版本发布说明”,要抓“特性演进方向”。
- 追问:从 JDK 8 升到 JDK 17,最需要关注哪些兼容性?
- 追问:现代 JDK 对面试里的“偏向锁、CMS”口径有什么影响?
Q11:CMS 失败后会发生什么?
CMS 的目标是低停顿,但如果并发标记/清理赶不上分配速度,可能出现 Concurrent Mode Failure,随后退化成 Full GC。此时 JVM 会进入更重的 STW 回收路径,暂停时间明显拉长。面试里要讲清楚:CMS 的失败不是“逻辑崩了”,而是“并发回收跟不上分配节奏”。
- 追问:CMS 为什么容易碎片化?
- 追问:G1、ZGC、Shenandoah 如何避免同类问题?
3. Spring / AOP / 认证
Q12:AOP 是什么,应用场景是什么?无侵入字节码植入怎么做?
AOP 的本质是把“横切关注点”从业务代码里剥离出来,比如日志、监控、鉴权、事务、限流。常规做法是代理模式;如果要做到跨工程、无侵入,通常要走 Java Agent + Instrumentation + ASM/ByteBuddy 这一套,在类加载或运行时把字节码织入进去。
- 追问:Spring AOP 和字节码增强的边界是什么?
- 追问:为什么 Arthas、SkyWalking 这类工具要走 Agent 路线?
Q13:IOC 和 AOP 的关系是什么?
IOC 解决的是“对象怎么创建、怎么装配、怎么管理生命周期”,AOP 解决的是“怎么在不污染业务代码的情况下织入额外行为”。前者是容器能力,后者是增强能力,二者一起构成 Spring 的核心抽象。面试里最好别把它们说成一个东西。
- 追问:Bean 生命周期里 AOP 是在哪一步织入的?
- 追问:为什么 Spring 里很多切面基于代理而不是直接改字节码?
Q14:JWT 为什么会出现?和 Cookie + Session 有什么不同?
JWT 解决的是“服务端尽量无状态地携带身份信息”的问题。它把身份声明编码进 token,服务端只要验签就能识别身份,适合前后端分离、多服务共享认证、移动端和跨域场景;而 Cookie + Session 更依赖服务端保存会话状态,容易在横向扩展时引入 Session 共享问题。
- 追问:JWT 的 payload 能不能随便放敏感信息?
- 追问:JWT 的吊销和续期怎么设计?
Q15:Spring Boot 自动装配的大致原理是什么?
Spring Boot 的核心是“约定优于配置”。它通过 starter 依赖、条件装配、自动配置类和环境属性绑定,把常见中间件的配置自动接进容器里。你可以把它理解成“Spring 容器 + 条件化默认值 + 一组可覆盖的 starter 方案”。
- 追问:
@ConditionalOnClass、@ConditionalOnMissingBean的作用是什么? - 追问:为什么说 Boot 降低了配置成本,但没有消灭配置复杂度?
4. MySQL / Redis / 数据一致性
Q16:B+树为什么适合做数据库索引?
B+树的优势不在“名字像树”,而在“磁盘友好”。它把数据尽量放在叶子节点,并通过叶子链表支持范围扫描,能减少磁盘 IO 次数;同时非叶子节点只存索引,不存大数据,扇出更高、树高更低。数据库索引追求的不是查找算法的理论最优,而是磁盘访问次数最少。
- 追问:为什么数据库不用红黑树做索引?
- 追问:B 树、B+树、哈希索引的适用场景有什么差异?
Q17:聚簇索引、非聚簇索引、覆盖索引和回表怎么理解?
聚簇索引的叶子节点直接存整行数据,查询主键通常最顺;非聚簇索引的叶子节点存的是主键或行定位信息,命中后可能还要回表。覆盖索引则是“查询需要的字段都在索引里”,因此不需要回表。联合索引要遵循最左前缀,不然很容易让索引失效。
- 追问:什么情况下会出现索引失效?
- 追问:为什么
like '%xxx'很难命中普通索引?
Q18:慢 SQL 怎么优化?
先用 explain 看执行计划,再判断问题是索引没建、索引没走、还是走了但代价太高。优化顺序一般是:先减少扫描行数,再减少回表,再减少排序和临时表,最后才考虑 SQL 改写、分表分库或缓存。很多慢 SQL 不是“SQL 写错了”,而是“表结构和索引设计没跟业务模式对齐”。
- 追问:如何判断是索引问题还是数据量问题?
- 追问:
order by、group by、函数计算对索引有什么影响?
Q19:MVCC 和四种隔离级别怎么理解?
MVCC 解决的是“读写并发下,普通读如何不加锁也能看到一致视图”的问题。它依赖 undo log 和 read view,把不同事务看到的版本隔离开。隔离级别从读未提交到串行化,核心差异就在于脏读、不可重复读和幻读的处理方式,面试里一定要结合“读视图”和“锁”来讲,而不是只背定义。
- 追问:幻读为什么在 InnoDB 里经常和 Next-Key Lock 一起出现?
- 追问:可重复读为什么是 MySQL 默认隔离级别?
Q20:Redis 为什么快?常见数据结构怎么选?
Redis 快的原因是内存访问、单线程事件循环、IO 多路复用和高效数据结构设计。选型上,String 适合缓存值和计数,Hash 适合对象字段,List 适合队列/栈,Set 适合去重和关系判断,ZSet 适合排行榜和延迟队列。面试里别只说“单线程快”,要补上“单线程不代表只有一个线程,而是主命令执行串行化,避免锁竞争”。
- 追问:为什么 Redis 不是万能的持久化数据库?
- 追问:大 Key 和热 Key 为什么危险?
Q21:缓存穿透、击穿、雪崩怎么解决?
缓存穿透是大量请求都查不到数据,直接打到数据库;击穿是某个热点 key 失效后瞬间大量请求回源;雪崩是大量 key 在同一时间失效。应对手段分别是:参数校验 + 空值缓存/布隆过滤器,互斥重建/单飞加载,随机过期 + 预热 + 限流降级。真正稳的方案不是只挡住一次峰值,而是让系统在失效窗口内也能自我保护。
- 追问:为什么空值缓存也要设置 TTL?
- 追问:缓存重建为什么要做互斥?
Q22:分布式锁怎么设计?怎么避免死锁?
最常见的分布式锁是 SET key value NX PX ttl,保证“抢锁、过期、自动释放”三件事。要避免死锁,必须给锁加过期时间、给 value 加唯一标识、释放时做校验,避免误删别人的锁;如果业务有长时间持锁或失败重试,还要考虑续租、重入和幂等。
- 追问:Redlock 的思路是什么?它的争议点在哪里?
- 追问:什么时候不该用分布式锁,而应该改成队列/幂等/乐观并发控制?
Q23:数据一致性中“先更新数据库再删除缓存”是什么意思?
它是一种常见的缓存一致性策略,核心是让数据库成为最终事实源,缓存只是临时加速层。写请求先落库,再删除缓存,下一次读请求会重新回源并重建缓存。这个方案的关键难点不在“删缓存”,而在“并发读写窗口”里如何接受短暂不一致,并配合重试、延迟双删或消息通知降低脏读概率。
- 追问:为什么不是先删缓存再更新数据库?
- 追问:如果缓存删除失败怎么办?
5. 网络 / OS / Linux
Q24:TCP 三次握手、四次挥手和 TIME_WAIT 为什么存在?
三次握手解决的是“双方收发能力都确认”的问题,四次挥手解决的是“双方都能独立关闭方向”的问题。TIME_WAIT 的存在是为了处理迟到报文和保证最后 ACK 可重传,它不是“浪费时间”,而是 TCP 可靠性的组成部分。面试中讲这些要围绕“可靠传输”而不是机械背时序。
- 追问:为什么不是两次握手?
- 追问:TIME_WAIT 太多会带来什么问题?
Q25:HTTP/1.1、HTTP/2、HTTPS / TLS 有什么区别?
HTTP/1.1 解决了基本的请求响应,但头部冗余和队头阻塞明显;HTTP/2 通过多路复用、头部压缩和二进制帧提升了性能;HTTPS 本质上是 HTTP + TLS,用来提供加密、完整性和身份认证。面试里经常会顺带追问 TLS 握手,所以回答时最好把“传输层加密”这条线串起来。
- 追问:HTTPS 的证书链为什么重要?
- 追问:HTTP/2 一定比 HTTP/1.1 快吗?
Q26:进程、线程、协程有什么区别?IO 多路复用怎么理解?
进程是资源分配的基本单位,线程是 CPU 调度的基本单位,协程则更偏用户态轻量调度。IO 多路复用的核心是让一个线程同时关注多个连接的状态,避免“每个连接一个线程”的资源浪费;select/poll/epoll 的差异主要体现在监听规模、数据结构和事件通知效率上。
- 追问:为什么高并发网络服务会偏向 epoll?
- 追问:协程为什么常和异步 IO 一起出现?
Q27:虚拟内存、堆和栈有什么区别?死锁四条件怎么预防?
虚拟内存是操作系统给进程构造的连续地址空间,堆和栈只是其中两类典型区域。栈天然短生命周期、自动回收,堆适合动态分配、生命周期更灵活。死锁的四个条件是互斥、占有并等待、不可抢占、循环等待;预防思路就是打破其中任意一个条件,或者在工程上通过固定锁顺序和超时机制把死锁风险降下来。
- 追问:为什么栈溢出和内存泄漏不是一回事?
- 追问:
tryLock(timeout)为什么是常见防死锁手段?
6. 系统设计与 AI / Agent
Q28:秒杀系统怎么设计,如何防超卖?
秒杀系统的核心是“削峰、限流、预扣、异步化”。前端接入层先做限流和排队,库存扣减用原子操作或预扣库存,成功请求再异步落库,失败则快速返回。防超卖的关键不是数据库事务本身,而是让高并发先在缓存、队列、令牌和本地校验层被拦住。
- 追问:为什么很多秒杀系统会先在 Redis 里预扣库存?
- 追问:如果异步落库失败,如何补偿?
Q29:分布式 ID 怎么设计?
常见思路是雪花算法:时间戳 + 机器标识 + 序列号。它的优点是趋势递增、生成快、无需中心协调;缺点是依赖时钟、机器位规划要提前设计,且业务迁移时要考虑号段、回拨和冲突问题。面试里要讲清“ID 设计不是只看唯一性,还要看趋势性、吞吐和可运维性”。
- 追问:如果机器时钟回拨怎么办?
- 追问:数据库自增 ID、号段模式、UUID 各有什么代价?
Q30:限流怎么做?令牌桶、漏桶、滑动窗口有什么区别?
令牌桶更适合“允许一定突发”的场景,漏桶更强调平滑输出,滑动窗口则更适合按时间粒度统计访问量。工程里常见的做法是入口层限流 + 业务层降级 + 下游保护联动,而不是只放一个限流器就以为万事大吉。
- 追问:为什么令牌桶更适合接口网关?
- 追问:限流和熔断到底有什么区别?
Q31:短链服务怎么设计?敏感词过滤怎么做?
短链服务的核心是“短码生成、映射存储、重定向查询、冲突处理和统计分析”。敏感词过滤则常用 Trie 树或 AC 自动机,把字符流扫描变成前缀匹配问题。两者都属于典型的“高频读、少量写”后端问题,重点不是算法炫技,而是数据结构、缓存和可扩展性的平衡。
- 追问:短链如何防止重复短码?
- 追问:敏感词替换如何支持热更新?
Q32:为什么有时选 Kafka 而不是 RabbitMQ?
Kafka 更适合高吞吐、日志流、事件流和可回放场景,偏“流式数据管道”;RabbitMQ 更偏传统消息队列,路由能力强、延迟相对低、语义更细。选型时要看你更看重吞吐、积压能力、消费模式还是路由灵活度,而不是只看“哪个名字更火”。
- 追问:消息队列在系统设计里最常见的三个作用是什么?
- 追问:如何处理消息重复和消息丢失?
Q33:RAG 是什么?混合检索怎么做?
RAG 的完整链路是:文档加载、切分、向量化、入库、召回、重排、生成。混合检索一般是“关键词检索 + 向量检索”并行召回,再通过 rerank 融合结果,以兼顾语义匹配和精确命中。真正难的不是“接一个向量库”,而是 chunk 策略、召回策略、重排质量和上下文窗口控制。
- 追问:为什么只做向量检索不够?
- 追问:chunk 切分和 overlap 为什么重要?
Q34:ReAct、Plan 和 Workflow 有什么区别?
ReAct 是“思考-行动-观察”的闭环,更适合任务中间信息不确定、需要多轮工具交互的场景;Plan 更偏先规划后执行,适合步骤较稳定的长任务;Workflow 则更像固定流程编排,步骤和分支都较明确。面试里最重要的是说清楚“什么时候该让模型自由推理,什么时候该把流程收紧”。
- 追问:为什么有些 Agent 场景更适合 Workflow 而不是 ReAct?
- 追问:长链路任务里怎样避免死循环?
Q35:Agent、Tool、Skill、MCP、LangChain / LangGraph 怎么理解?
Tool 是最底层的可调用动作,Skill 是对一组工具和逻辑的封装,Agent 是任务理解和协调执行的主体;MCP 则提供工具能力的标准化接入方式,方便动态发现和统一调用。LangChain 更偏上层编排与开发体验,LangGraph 更适合显式状态机和复杂分支控制;如果你的任务链路长、分支多、状态复杂,LangGraph 往往更稳。
- 追问:为什么很多 Agent 项目最后都会回到“状态机 + 工具调用”?
- 追问:如何保证工具调用可靠性和幂等性?
Q36:多 Agent 如何协同?怎么控制上下文膨胀?
多 Agent 的核心价值是分工协作和上下文隔离:主 Agent 负责理解目标和协调,子 Agent 负责搜索、抽取、校验或执行具体步骤。上下文膨胀通常靠“结构化状态、任务拆分、阶段性总结、短记忆 + 长记忆分离”来控制,而不是无脑把所有历史都塞回模型。
- 追问:什么时候多 Agent 比单 Agent 更合适?
- 追问:如何评估一个 Agent 系统的质量?
Q37:SSE 流式输出怎么理解?后端要注意什么?
SSE 适合服务端持续向客户端推送增量结果,比如 Agent 推理过程、长任务进度、消息流式输出。后端实现时要关注连接保活、断线重连、分块发送、错误兜底和超时控制;如果业务有双向交互需求,再考虑 WebSocket,而不是把所有流式场景都硬塞进一个协议。
- 追问:SSE 和 WebSocket 的差异是什么?
- 追问:流式输出里如何做异常中断和部分结果返回?
六、综合速查表
1. JUC 关键组件选择
| 需求 | 推荐组件 | 原因 |
|---|---|---|
| 线程复用 | ThreadPoolExecutor |
控制并发、队列化 |
| 状态标记 | volatile |
轻量可见性 |
| 互斥保护 | synchronized / ReentrantLock |
简单或高级能力 |
| 高并发计数 | LongAdder |
减少热点竞争 |
| 并发 Map | ConcurrentHashMap |
高并发读写 |
| 一次性等待 | CountDownLatch |
简单有效 |
| 并发限流 | Semaphore |
控制并发度 |
| 读多写少列表 | CopyOnWriteArrayList |
读无锁 |
2. JVM / GC 诊断命令速查
1 | jps -lvm |
JDK 8 常用日志:
1 | -XX:+PrintGCDetails |
JDK 9+ 常用日志:
1 | -Xlog:gc*,safepoint:file=gc.log:time,level,tags |
七、参考资料
以下资料用于校准本文的版本口径、并发语义和收集器演进结论:
- Oracle JDK 8
java.util.concurrentpackage summary - Oracle JDK 8
ThreadAPI - Oracle JDK 8
Thread.StateAPI - Oracle JDK 8
ClassLoaderAPI - Oracle JLS 8 Chapter 17: Memory Model
- Oracle JVMS 8 Chapter 2: Runtime Data Areas
- Oracle JVMS 8 Chapter 5: Loading, Linking, and Initializing
- OpenJDK JEP 248: Make G1 the Default Garbage Collector
- OpenJDK JEP 291: Deprecate the Concurrent Mark Sweep (CMS) Garbage Collector
- OpenJDK JEP 363: Remove the CMS Garbage Collector
- OpenJDK JEP 374: Disable Biased Locking
- OpenJDK JEP 377: ZGC Production Ready
- OpenJDK JEP 439: Generational ZGC
- OpenJDK JDK 8u
ThreadPoolExecutorsource - OpenJDK JDK 8u
AbstractQueuedSynchronizersource - OpenJDK JDK 8u
ConcurrentHashMapsource - Oracle JDK 8
CopyOnWriteArrayListAPI
结语
这份文档的主线是:
JUC 解决“怎么安全高效地并发”,JVM 解决“代码怎么被装载和执行”,GC 解决“对象怎么被管理和回收”。
把这三条线串起来,很多面试题和生产问题都会变得非常清楚。
__END__