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 线程创建方式

常见创建方式有四类:

  1. 继承 Thread
  2. 实现 Runnable
  3. 实现 Callable + FutureTask
  4. 使用线程池

工程上最常用的是 线程池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 为什么需要线程池

线程池解决三个核心问题:

  1. 频繁创建和销毁线程的开销
  2. 线程数量失控导致的资源争抢
  3. 任务执行与提交解耦,便于限流、排队、降级

2.2 核心参数

ThreadPoolExecutor 的 7 个核心参数:

参数 作用 设计要点
corePoolSize 核心线程数 常驻线程数,决定基本并发能力
maximumPoolSize 最大线程数 队列满后还能扩张到的上限
keepAliveTime 空闲存活时间 非核心线程默认会回收
unit 时间单位 keepAliveTime 的单位
workQueue 任务队列 影响吞吐、延迟、内存占用
threadFactory 线程工厂 统一命名、守护线程、异常处理
handler 拒绝策略 资源打满时的兜底逻辑

2.3 任务执行流程

execute() 的核心逻辑可以概括为:

  1. 如果当前 worker 数小于 corePoolSize,直接创建核心线程执行任务。
  2. 否则尝试把任务放入队列。
  3. 如果队列满了,再尝试创建非核心线程。
  4. 如果线程数也到上限,则触发拒绝策略。

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private static final AtomicInteger THREAD_ID = new AtomicInteger(1);

private static final ExecutorService ORDER_POOL = new ThreadPoolExecutor(
8,
16,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2000),
r -> {
Thread t = new Thread(r);
t.setName("order-pool-" + THREAD_ID.getAndIncrement());
t.setDaemon(false);
return t;
},
new ThreadPoolExecutor.CallerRunsPolicy()
);

2.8 源码步骤梳理(架构图)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
flowchart TD
A[execute 提交任务] --> B{workerCount < corePoolSize}
B -- 是 --> C[addWorker 创建核心线程]
B -- 否 --> D[workQueue.offer 入队]
D -- 成功 --> E{再次检查 pool 状态}
E -- RUNNING --> F[任务等待 worker 消费]
E -- 非 RUNNING --> G[移除任务并拒绝]
D -- 失败 --> H{workerCount < maximumPoolSize}
H -- 是 --> I[addWorker 创建非核心线程]
H -- 否 --> J[RejectedExecutionHandler]
C --> K[runWorker 循环执行]
I --> K
K --> L[getTask 获取下一个任务]
L --> M[执行任务并回收异常]
M --> L

源码步骤可以记成一条主线:

  1. execute() 先看当前 worker 数是否小于 corePoolSize,能扩核就先扩核。
  2. 不能扩核时,优先把任务放进队列,借助队列削峰。
  3. 队列满了才尝试扩到 maximumPoolSize,这一步是“最后扩容”。
  4. 再满就触发拒绝策略,系统开始显式背压或降级。
  5. Worker 线程实际执行时,会在 runWorker() 里循环调用 getTask(),任务耗尽后再按空闲时间退出。

3. volatilesynchronized

3.1 volatile 的本质

volatile 保证两件事:

  1. 可见性:写入后对其他线程立即可见
  2. 有序性:禁止特定的指令重排序

不保证复合操作的原子性。例如 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public final class Singleton {
private static volatile Singleton instance;

private Singleton() {
}

public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

这里的 volatile 关键作用是防止“引用先赋值、对象未完全初始化”的重排序问题。


4. CAS 与原子类

4.1 CAS 是什么

CAS(Compare-And-Swap)比较并交换,核心思想是:

先比较内存中的值是否等于期望值,如果相等,则更新为新值。

它通常借助 CPU 原子指令完成,是很多无锁算法的基础。

4.2 CAS 的优缺点

优点:

  • 无阻塞,竞争低时性能很好
  • 适合高并发计数、状态更新

缺点:

  • 只能保障单个变量级别的原子更新
  • 高竞争下会自旋浪费 CPU
  • 存在 ABA 问题

4.3 ABA 问题

如果变量从 A -> B -> A,CAS 只看到“还是 A”,就可能误判状态没变。

解决方案:

  • AtomicStampedReference
  • AtomicMarkableReference
  • 引入版本号或时间戳

4.4 原子类

常见原子类:

  • AtomicInteger
  • AtomicLong
  • AtomicReference
  • AtomicIntegerArray
  • AtomicStampedReference
  • LongAdder

其中 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 获取与释放

简化理解:

  1. 调用 tryAcquire/tryRelease
  2. 失败则入队
  3. 入队后 park() 挂起
  4. 释放时唤醒后继节点

AQS 把“同步语义”与“排队阻塞”解耦,使得 ReentrantLockCountDownLatchSemaphore 等组件可以共享同一套排队逻辑。

5.4 常见组件

组件 模式 作用
ReentrantLock 独占 可中断、可超时、公平锁
CountDownLatch 共享 一次性倒计时
Semaphore 共享 限制并发许可数
ReentrantReadWriteLock 读写分离 读多写少优化

5.5 ReentrantLocksynchronized

ReentrantLock 的优势:

  • 支持公平/非公平
  • 支持可中断获取锁
  • 支持超时获取
  • 支持多个 Condition

synchronized 的优势:

  • 语法简单
  • JVM 优化成熟
  • 出错概率低

如果同步逻辑简单,优先 synchronized;如果需要超时、可中断、多个条件队列,优先 ReentrantLock

5.6 源码步骤梳理(架构图)

1
2
3
4
5
6
7
8
9
10
flowchart TD
A[acquire / lock] --> B[tryAcquire]
B -- 成功 --> C[获得 state]
B -- 失败 --> D[addWaiter 入队]
D --> E[acquireQueued]
E --> F{前驱是否为 head 且可再次尝试?}
F -- 是 --> G[成功后设置 head]
F -- 否 --> H[park 挂起]
H --> I[release / unlock]
I --> J[unparkSuccessor 唤醒后继]

这条链路的核心是“模板方法 + 队列化阻塞”:

  1. acquire() 先调用子类实现的 tryAcquire(),让不同同步器定义自己的语义。
  2. 失败后线程进入 AQS 队列,进入等待。
  3. 当持有者释放 state 后,AQS 负责唤醒后继节点。
  4. 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
2
3
4
5
6
7
8
9
10
11
12
13
14
flowchart TD
A[put / compute / get] --> B{table 已初始化?}
B -- 否 --> C[initTable 初始化数组]
B -- 是 --> D[spread hash 计算桶位]
D --> E{桶位为空?}
E -- 是 --> F[CAS 直接插入节点]
E -- 否 --> G{桶头是否 ForwardingNode?}
G -- 是 --> H[helpTransfer 协助扩容]
G -- 否 --> I[synchronized 锁定桶头]
I --> J[链表或红黑树中查找/更新]
J --> K{链表长度是否达到树化阈值?}
K -- 是 --> L[treeifyBin 树化]
J --> M{元素数量是否触发扩容?}
M -- 是 --> H

源码步骤重点在“局部锁 + 全局协作”:

  1. putVal() 先算 hash,再定位桶位。
  2. 桶为空时优先 CAS,避免进入锁。
  3. 桶不为空时,只锁住单个 bin,而不是整张表。
  4. 链表过长才树化,容量不足时会优先扩容而不是盲目树化。
  5. 扩容时通过 ForwardingNodehelpTransfer() 让多个线程共同搬迁数据。

7. 并发工具类与线程间通信

7.1 常见工具类

工具 用途
CountDownLatch 等待多个任务完成
CyclicBarrier 多线程互相等待到同一点
Semaphore 控制并发许可数
Exchanger 两个线程交换数据
Phaser 更灵活的阶段性同步
FutureTask 任务包装 + 结果获取
CompletableFuture 异步编排、组合、回调

7.2 wait/notifyjoinpark/unpark

  • wait/notify:依赖对象监视器,必须在同步块内使用
  • join:本质是等待线程终止
  • park/unpark:更底层的阻塞唤醒机制,常用于 AQS

wait/notify 要始终配合 while 循环检查条件,防止虚假唤醒。

7.3 线程通信模式

  1. 单次等待:CountDownLatch
  2. 周期同步:CyclicBarrier
  3. 令牌控制:Semaphore
  4. 异步回调:CompletableFuture
  5. 生产消费: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 线程池参数定制与使用

思路:

  1. 先区分 CPU 密集和 IO 密集
  2. 再决定队列类型和容量
  3. 最后决定拒绝策略和监控指标

建议:

  • CPU 密集:少线程 + 有界队列
  • IO 密集:适当增大线程数,但要看响应时间和下游承载
  • 核心业务:用有界队列防止雪崩

监控重点:

  • 队列长度
  • 活跃线程数
  • 拒绝次数
  • 任务执行时间
  • 活跃线程占比

9.2 高并发下的单例设计

推荐优先级:

  1. 枚举单例
  2. 静态内部类
  3. DCL + volatile

如果单例里有复杂初始化,建议把重资源延迟到显式 init(),避免类加载期间做重活。

9.3 线程安全集合选型

场景 推荐方案 理由
并发 Map ConcurrentHashMap 读写并发能力强
读多写少列表 CopyOnWriteArrayList 读无锁
频繁增删且规模大 ConcurrentLinkedQueue 或分片设计 避免 COW 写放大
共享计数 LongAdder 减少热点竞争

9.4 并发控制:限流 / 熔断 / 信号量

JUC 本身最直接的并发控制工具是 Semaphore

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public final class ApiGuard {
private final Semaphore semaphore = new Semaphore(100);

public <T> T invoke(Callable<T> task) throws Exception {
if (!semaphore.tryAcquire(50, TimeUnit.MILLISECONDS)) {
throw new RejectedExecutionException("system busy");
}
try {
return task.call();
} finally {
semaphore.release();
}
}
}

说明:

  • Semaphore 解决的是“并发度控制”
  • 真正的“熔断”通常还需要失败率统计、慢调用统计、开关状态机
  • 工程上可与 Resilience4j、Sentinel 等结合使用

9.5 数据一致性保障

常见手段:

  • 单写多读用 volatile
  • 复合写入用锁
  • 高并发计数用 LongAdder
  • 结构性一致性用事务、锁或消息最终一致

典型场景:

  • 本地缓存刷新
  • 库存扣减
  • 状态机切换
  • 幂等更新

9.6 异步任务处理

推荐模式:

  • 自定义线程池 + CompletableFuture
  • 为每个异步分支配置超时和降级
  • 合并结果时统一处理异常和上下文
1
2
3
4
5
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> callA(), ORDER_POOL);
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> callB(), ORDER_POOL);

CompletableFuture<List<String>> all = CompletableFuture.allOf(f1, f2)
.thenApply(v -> List.of(f1.join(), f2.join()));

9.7 批量任务并行化

推荐思路:

  1. 先分片
  2. 再并行
  3. 最后汇总

如果任务之间有强依赖,优先保证顺序正确,再考虑并行;不要为了并发而并发。

9.8 死锁排查与规避

规避原则:

  • 固定加锁顺序
  • 尽量缩小锁范围
  • 不在锁内做远程调用
  • 使用 tryLock(timeout) 做超时兜底

排查思路:

  1. jstack / jcmd Thread.print 看线程阻塞关系
  2. 找到互相等待的锁
  3. 还原锁顺序
  4. 增加超时与告警

9.9 缓存击穿 / 雪崩的并发方案

常见策略:

  • 击穿:单飞加载、互斥锁、FutureTask 合并请求
  • 雪崩:随机过期、分批预热、限流降级
  • 热点 key:本地缓存 + 分布式缓存双层防护

FutureTask 单飞示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private final ConcurrentHashMap<String, FutureTask<Product>> loading = new ConcurrentHashMap<>();

public Product getProduct(String id) throws Exception {
for (;;) {
FutureTask<Product> task = loading.get(id);
if (task == null) {
FutureTask<Product> newTask = new FutureTask<>(() -> loadFromDb(id));
task = loading.putIfAbsent(id, newTask);
if (task == null) {
task = newTask;
task.run();
}
}
try {
return task.get();
} catch (CancellationException | ExecutionException e) {
loading.remove(id, task);
throw e;
}
}
}

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 为什么能复用给 ReentrantLockSemaphoreCountDownLatch

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/notifypark/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 方法栈帧 StackOverflowErrorOutOfMemoryError
本地方法栈 JNI / Native 方法 StackOverflowErrorOutOfMemoryError
对象实例和数组 Java heap space
方法区 / 元空间 类元数据、常量、方法信息 Metaspace OOM
直接内存 NIO、堆外内存 Direct buffer memory

1.1 各区域特点

  • 程序计数器:线程私有,记录下一条字节码指令地址
  • 虚拟机栈:线程私有,保存局部变量、操作数栈、动态链接、返回地址
  • :线程共享,GC 主要管理对象区域
  • 元空间:JDK 8 起使用本地内存存放类元数据,不再使用永久代
  • 直接内存:不在堆里,但仍受进程地址空间和系统内存限制

1.2 常见异常场景

  • 栈深递归太深 -> StackOverflowError
  • 创建线程过多 -> unable to create new native thread
  • 类太多或类加载器泄漏 -> Metaspace OOM
  • 大量 ByteBuffer.allocateDirect() -> Direct buffer memory

2. 类加载机制

2.1 七个阶段

阶段 作用
加载 读取字节流,生成 Class 对象
验证 确保字节码合法、结构正确
准备 为静态变量分配内存并设默认值
解析 符号引用转直接引用
初始化 执行 <clinit>
使用 类被正常调用
卸载 类及其类加载器不再可达时回收

2.2 类加载器体系

  • Bootstrap ClassLoader
  • Platform ClassLoader
  • Application ClassLoader
  • Custom ClassLoader

2.3 双亲委派模型

父加载器优先的目的主要是:

  1. 避免核心类被篡改
  2. 保证 java.lang.* 等基础类唯一性
  3. 降低重复加载

但在应用服务器、插件系统、隔离部署场景中,经常会有 child-first 的自定义加载逻辑。

2.4 类加载问题

问题 现象 常见原因
ClassNotFoundException 找不到类 类路径、依赖缺失
NoClassDefFoundError 编译时有,运行时没有 依赖丢失、初始化失败
LinkageError 类链接冲突 重复类、版本冲突
ClassCastException 看似同名类却不能强转 不同类加载器加载了同名类

2.5 类初始化触发点

常见触发包括:

  • new
  • 访问静态字段
  • 调用静态方法
  • 反射
  • 初始化子类前先初始化父类

2.6 源码步骤梳理(架构图)

1
2
3
4
5
6
7
8
9
10
11
12
flowchart TD
A[ClassLoader.loadClass] --> B{已加载?}
B -- 是 --> C[直接返回 Class]
B -- 否 --> D[父加载器委派]
D -- 找到 --> C
D -- 未找到 --> E[findClass]
E --> F[读取字节码]
F --> G[defineClass]
G --> H[verify 验证]
H --> I[prepare 准备]
I --> J[resolve 解析]
J --> K[initialize 执行 clinit]

源码步骤要抓住“先委派、再定义、再链接、最后初始化”:

  1. loadClass() 先检查缓存,避免重复加载。
  2. 默认先交给父加载器,确保核心类唯一性和安全性。
  3. 父加载器找不到时,子加载器才会走自己的 findClass()
  4. defineClass() 把字节数组变成 JVM 中的 Class 对象。
  5. 随后完成验证、准备、解析,最后在首次主动使用时执行 <clinit>

3. 字节码执行引擎

3.1 栈帧结构

每个方法调用对应一个栈帧,主要包含:

  • 局部变量表
  • 操作数栈
  • 动态链接
  • 方法返回地址

3.2 方法调用过程

字节码解释执行时,JVM 需要完成:

  1. 指令取值
  2. 操作数出栈/入栈
  3. 常量池解析
  4. 方法调用分派
  5. 返回值压栈

常见调用指令:

  • invokestatic
  • invokespecial
  • invokevirtual
  • invokeinterface
  • invokedynamic

3.3 Interpreter vs JIT

  • 解释器:启动快,执行相对慢
  • JIT:把热点代码编译成本地机器码,执行快

现代 JVM 一般采用分层编译:

  • C1:偏启动和轻量优化
  • C2:偏激进优化
  • Tiered Compilation:综合前两者

3.4 逃逸分析

逃逸分析判断对象是否“逃出”方法或线程:

  • 不逃逸 -> 可能做栈上分配、标量替换
  • 不跨线程 -> 可能做锁消除

这也是为什么某些看似“加锁”的代码,在热点编译后性能会很好。


4. 对象创建、布局与访问定位

4.1 对象创建流程

典型流程:

  1. 检查类是否已加载、初始化
  2. 为对象分配内存
  3. 将内存置零
  4. 设置对象头
  5. 执行构造方法

4.2 内存分配方式

  • 指针碰撞:适合规整堆
  • 空闲列表:适合碎片化堆
  • TLAB:线程本地分配缓冲,减少竞争

4.3 对象布局

典型对象包含:

  • 对象头
  • 实例数据
  • 对齐填充

对象头里通常有:

  • Mark Word
  • Klass Pointer
  • 数组长度(数组对象)

4.4 访问定位

HotSpot 常见是直接指针访问对象,这样少一层间接寻址,性能更好。

4.5 源码步骤梳理(架构图)

1
2
3
4
5
6
7
8
9
10
flowchart TD
A[new 指令] --> B[检查类是否已初始化]
B --> C[计算对象大小]
C --> D{TLAB 可用?}
D -- 是 --> E[在 TLAB 中分配]
D -- 否 --> F[指针碰撞或空闲列表分配]
E --> G[对象头初始化]
F --> G
G --> H[实例字段置零]
H --> I[调用构造方法]

对象创建的关键顺序是:

  1. 先保证类已完成初始化,否则实例都无法构造。
  2. 再尝试从线程本地缓冲区 TLAB 分配,减少多线程竞争。
  3. 如果 TLAB 不够,就走堆上的快速分配路径。
  4. 对象头先写好 Mark Word 和类指针,再进行字段零值初始化。
  5. 最后才执行构造方法,完成真正的业务初始化。

5. OOM 与内存泄漏

5.1 两者区别

  • 内存泄漏:不再使用的对象仍被引用,回收不了
  • OOM:可用内存耗尽,最终抛异常

内存泄漏是原因,OOM 是结果。

5.2 常见泄漏点

  • static 集合长期持有对象
  • ThreadLocal 未清理
  • 监听器未注销
  • 类加载器未释放
  • 直接内存未归还

5.3 常见 OOM 类型

  • Java heap space
  • Metaspace
  • Direct buffer memory
  • unable to create new native thread
  • GC overhead limit exceeded

5.4 排查流程

  1. 先确认 OOM 类型
  2. 看 GC 日志是否频繁 Full GC
  3. 导出 heap dump / native memory 信息
  4. 用 MAT / JProfiler / VisualVM 找 dominator tree
  5. 看 GC Roots 是否把对象链住
  6. 结合业务流修复泄漏源

6. 高频场景设计

6.1 OOM 排查

推荐工具:

工具 作用
jcmd 统一入口,查 heap、线程、类、NMT
jstack 线程栈与死锁
jmap heap dump、对象统计
jstat GC 统计
MAT 堆转储分析
JFR 运行时事件追踪

JDK 8 常用 GC 日志参数:

1
2
3
4
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintTenuringDistribution
-Xloggc:gc.log

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
2
3
4
5
6
7
8
9
flowchart TD
A[对象分配] --> B[Eden 填满]
B --> C[Minor GC]
C --> D[存活对象复制到 Survivor 或 Old]
D --> E{老年代压力是否升高}
E -- 否 --> A
E -- 是 --> F[并发标记]
F --> G[Mixed GC 或 Full GC]
G --> H[回收并整理空间]

如果把收集器抽象成一条回收流水线,可以这样理解:

  1. 新对象先进入年轻代,绝大多数对象在这里就被回收掉。
  2. 能活下来的对象经过多次 Minor GC 后逐步晋升。
  3. 老年代压力上升后,再进入并发标记和混合回收。
  4. 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
  • XmsXmx 设成相同
  • 避免堆频繁扩缩容
  • 关注对象分配速率和晋升率

G1 常见参数思路:

1
2
3
4
5
-XX:+UseG1GC
-Xms4g
-Xmx4g
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=45

6.2 低延迟系统 GC 选型

优先顺序通常是:

  1. ZGC
  2. Shenandoah
  3. G1
  4. Parallel / CMS 仅限历史环境

低延迟系统关注的不是“平均停顿”,而是 尾延迟

6.3 Full GC 频繁问题排查

排查顺序:

  1. 看是哪种 Full GC
  2. 看老年代是否持续增长
  3. 看元空间是否膨胀
  4. 看是否有大对象或直接内存问题
  5. 看是否存在类加载器泄漏或 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. 主链路尽快返回
  2. 异步任务可控、可观测、可降级
  3. 高峰时能自动形成背压
  4. 不因为某个下游慢调用把线程池拖死

架构

1
2
3
4
5
6
7
8
9
flowchart LR
A[订单请求] --> B[主线程快速落库]
B --> C[提交异步任务]
C --> D[有界线程池]
D --> E[执行发券/发消息/索引同步]
E --> F[埋点与告警]
D --> G{队列满?}
G -- 是 --> H[CallerRuns 或 降级]
G -- 否 --> E

参数设计

  • CPU 密集分支:corePoolSize 接近 CPU 核数
  • IO 密集分支:适当放大线程数,但必须配有界队列
  • 队列容量:按峰值流量和可接受排队时间估算,不要无界
  • 拒绝策略:核心链路一般不用直接丢弃,优先反压或降级

参考实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public final class OrderAsyncExecutor {
private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(
8,
16,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2000),
r -> {
Thread t = new Thread(r);
t.setName("order-async-" + THREAD_ID.incrementAndGet());
return t;
},
new ThreadPoolExecutor.CallerRunsPolicy()
);

private static final AtomicInteger THREAD_ID = new AtomicInteger(0);

public static CompletableFuture<Void> submit(Runnable task) {
return CompletableFuture.runAsync(task, EXECUTOR);
}
}

运行细节

  1. 主线程只做强一致步骤,不把慢操作放进同步事务里。
  2. 异步任务拆成独立子任务,便于重试和补偿。
  3. 队列长度、活跃线程数、拒绝次数、任务耗时要接入监控。
  4. 如果某个下游持续变慢,先限流,再降级,最后才扩容线程池。

常见坑

  • 用无界队列,导致内存和延迟一起失控
  • 一个线程池同时承载 CPU 计算和 IO 等待,互相拖累
  • 任务里再提交同一个线程池,容易形成隐性死锁
  • 忽略任务超时,导致线程长期被占住

2. 高并发单例与配置客户端

场景

配置中心客户端、ID 生成器、限流器、远程服务连接器都适合单例化,但前提是初始化逻辑必须可控。

设计选择

  • 纯懒加载、初始化简单:静态内部类
  • 需要防反射、反序列化:枚举单例
  • 初始化需要传参:显式 init() + DCL

推荐实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public final class ConfigClient {
private static volatile ConfigClient instance;
private volatile boolean initialized;

private ConfigClient() {
}

public static ConfigClient getInstance() {
if (instance == null) {
synchronized (ConfigClient.class) {
if (instance == null) {
instance = new ConfigClient();
}
}
}
return instance;
}

public void init(Config config) {
if (initialized) {
return;
}
synchronized (this) {
if (initialized) {
return;
}
// 建立连接、加载配置、预热本地缓存
initialized = true;
}
}
}

设计细节

  1. 构造方法保持轻量,不在里面做远程调用。
  2. 把重资源初始化放到显式 init(),避免类加载时阻塞。
  3. 单例对象如果会被序列化,必须处理反序列化重复创建问题。
  4. 如果运行在容器里,注意类加载器隔离,不要让静态字段跨部署残留。

3. 热点缓存与击穿防护

背景

热点商品、秒杀库存、用户画像、活动配置等 key 在过期或失效时会被大量并发请求击穿到数据库。

目标

  • 让同一个 key 在失效瞬间只回源一次
  • 防止缓存雪崩把后端打穿
  • 保证缓存重建过程可控、可观测

架构

1
2
3
4
5
6
7
8
9
flowchart TD
A[请求到达] --> B{本地缓存命中?}
B -- 是 --> C[直接返回]
B -- 否 --> D{Redis 命中?}
D -- 是 --> E[回填本地缓存]
D -- 否 --> F[单飞加载]
F --> G[查数据库]
G --> H[写 Redis + 本地缓存]
H --> C

单飞加载实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private final ConcurrentHashMap<String, FutureTask<Product>> loading = new ConcurrentHashMap<>();

public Product getProduct(String id) throws Exception {
for (;;) {
FutureTask<Product> task = loading.get(id);
if (task == null) {
FutureTask<Product> newTask = new FutureTask<>(() -> loadFromDb(id));
task = loading.putIfAbsent(id, newTask);
if (task == null) {
task = newTask;
task.run();
}
}
try {
return task.get();
} catch (CancellationException | ExecutionException e) {
loading.remove(id, task);
throw e;
}
}
}

防雪崩细节

  • 过期时间加随机抖动,避免同一批 key 同时失效
  • 热点 key 主动预热,不等它自然过期
  • 对数据库回源加互斥和限流,防止缓存失效时形成流量尖峰
  • 允许短时间返回旧值,换取系统可用性

常见坑

  • 缓存穿透只做空值缓存,不做参数校验
  • 缓存雪崩只加过期时间,不做预热和限流
  • 缓存重建没有超时,导致线程长时间阻塞

4. 限流、熔断与并发控制

设计边界

Semaphore 适合做并发度控制,但熔断本身还需要失败率、慢调用率和状态机。

并发限流示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public final class ConcurrencyGuard {
private final Semaphore permits = new Semaphore(100);

public <T> T call(Callable<T> task) throws Exception {
if (!permits.tryAcquire(50, TimeUnit.MILLISECONDS)) {
throw new RejectedExecutionException("busy");
}
try {
return task.call();
} finally {
permits.release();
}
}
}

熔断状态机

1
2
3
4
5
6
stateDiagram-v2
[*] --> CLOSED
CLOSED --> OPEN : 错误率过高
OPEN --> HALF_OPEN : 到达冷却时间
HALF_OPEN --> CLOSED : 探测成功
HALF_OPEN --> OPEN : 探测失败

设计细节

  1. CLOSED 正常放行。
  2. OPEN 直接拒绝,保护下游。
  3. HALF_OPEN 只允许少量探测请求。
  4. 状态切换必须是原子操作,避免多线程同时翻转。

常见坑

  • 把限流和熔断混成一个开关
  • 只统计错误次数,不看时间窗口
  • 没有熔断恢复路径,开了就回不来

5. 死锁排查与规避

设计原则

  • 固定加锁顺序
  • 锁内不做远程调用
  • 必要时使用 tryLock(timeout)

排查流程

  1. jstackjcmd Thread.print 找到互相等待的线程。
  2. 看锁拥有者和等待者。
  3. 还原代码里的加锁顺序。
  4. 给高风险临界区加超时和告警。

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public boolean transfer(Lock first, Lock second) throws InterruptedException {
if (!first.tryLock(100, TimeUnit.MILLISECONDS)) {
return false;
}
try {
if (!second.tryLock(100, TimeUnit.MILLISECONDS)) {
return false;
}
try {
return true;
} finally {
second.unlock();
}
} finally {
first.unlock();
}
}

运行时辅助判断

1
2
ThreadMXBean bean = ManagementFactory.getThreadMXBean();
long[] ids = bean.findDeadlockedThreads();

如果 ids 非空,就说明 JVM 已经能识别到循环等待关系,接下来就是定位锁的来源。

6. OOM、类加载器泄漏与堆外内存排查

排查决策树

1
2
3
4
5
6
7
8
9
10
flowchart TD
A[线上 OOM] --> B{异常类型}
B -- heap space --> C[查堆 dump]
B -- Metaspace --> D[查类加载器和动态代理]
B -- Direct buffer memory --> E[查堆外分配]
B -- unable to create new native thread --> F[查线程数和栈大小]
C --> G[MAT 看 dominator tree]
D --> H[查 classloader 泄漏]
E --> I[查 NIO / Netty / native 库]
F --> J[查线程池与系统限制]

关键动作

  • 堆 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 调优步骤

  1. 固定堆大小,避免运行中扩容。
  2. 先观察分配速率、晋升速率和暂停时间。
  3. 通过 MaxGCPauseMillis 设停顿目标,但不要把它当硬保证。
  4. 关注老年代 Region 占用和 humongous object。

G1 示例参数

1
2
3
4
5
6
-XX:+UseG1GC
-Xms4g
-Xmx4g
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=45
-XX:+ParallelRefProcEnabled

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,冲突发生时只锁单个桶头,不会锁整张表;扩容时通过 ForwardingNodehelpTransfer 让多个线程一起搬迁数据,减少单线程搬表的停顿。

  • 追问:sizeCtlForwardingNodeTreeBin 各自是干什么的?
  • 追问:为什么链表长度到一定阈值要树化?

Q3:CopyOnWriteArraySet 内部怎么实现?为什么适合读多写少?

CopyOnWriteArraySet 底层其实是 CopyOnWriteArrayList 的包装,核心思路是“写时复制,读时无锁”。每次写入都会复制一份新数组,再把引用替换掉,因此读线程始终看到一个稳定快照,适合配置列表、监听器、注册表这类读远多于写的场景。

  • 追问:为什么它不适合写多场景?
  • 追问:和 Collections.synchronizedSetConcurrentHashMap.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,再结合 jstackjstat、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 里很多切面基于代理而不是直接改字节码?

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 bygroup 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
2
3
4
5
6
7
jps -lvm
jstack -l <pid>
jcmd <pid> Thread.print
jcmd <pid> GC.heap_info
jstat -gcutil <pid> 1000
jmap -dump:format=b,file=heap.hprof <pid>
jcmd <pid> VM.native_memory summary

JDK 8 常用日志:

1
2
3
4
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintTenuringDistribution
-Xloggc:gc.log

JDK 9+ 常用日志:

1
-Xlog:gc*,safepoint:file=gc.log:time,level,tags

七、参考资料

以下资料用于校准本文的版本口径、并发语义和收集器演进结论:

  1. Oracle JDK 8 java.util.concurrent package summary
  2. Oracle JDK 8 Thread API
  3. Oracle JDK 8 Thread.State API
  4. Oracle JDK 8 ClassLoader API
  5. Oracle JLS 8 Chapter 17: Memory Model
  6. Oracle JVMS 8 Chapter 2: Runtime Data Areas
  7. Oracle JVMS 8 Chapter 5: Loading, Linking, and Initializing
  8. OpenJDK JEP 248: Make G1 the Default Garbage Collector
  9. OpenJDK JEP 291: Deprecate the Concurrent Mark Sweep (CMS) Garbage Collector
  10. OpenJDK JEP 363: Remove the CMS Garbage Collector
  11. OpenJDK JEP 374: Disable Biased Locking
  12. OpenJDK JEP 377: ZGC Production Ready
  13. OpenJDK JEP 439: Generational ZGC
  14. OpenJDK JDK 8u ThreadPoolExecutor source
  15. OpenJDK JDK 8u AbstractQueuedSynchronizer source
  16. OpenJDK JDK 8u ConcurrentHashMap source
  17. Oracle JDK 8 CopyOnWriteArrayList API

结语

这份文档的主线是:

JUC 解决“怎么安全高效地并发”,JVM 解决“代码怎么被装载和执行”,GC 解决“对象怎么被管理和回收”。

把这三条线串起来,很多面试题和生产问题都会变得非常清楚。

__END__