凌晨两点,值班群里弹出一条告警:“订单服务线程池队列堆积 10 万”。你打开监控,活跃线程数满格、队列满格、CPU 却只有 15%。这画面我见过不止一次——线程都在等,没有人在算。这时候面试八股里那些“核心线程数、阻塞队列、拒绝策略”,突然就成了救命的知识。
这篇把并发和 JVM 放在一起写,因为线上问题永远是两者交织:线程池打满常常是 GC 停顿引起的,Full GC 又常常是并发场景下的对象堆积引起的。怎么用:每个专题按 🌱 初级(是什么)→ 🌿 中级(为什么、怎么选)→ 🌳 高级(怎么落地、出事怎么办)三层组织,每层给标准回答要点、追问链和加分项。文中的默认值和行为都对照了 JDK 21 官方文档,文末有出处。
一、知识地图
并发这条线的核心是“三个性质(原子性、可见性、有序性)+ 一套工具(锁、原子类、并发容器、线程池)”;JVM 这条线的核心是“内存怎么划分、对象怎么死、类怎么来、出事怎么查”。
二、线程与线程池
🌱 初级:线程基础与线程池是什么
标准回答要点:
- 创建线程的方式:继承
Thread、实现Runnable、实现Callable配合FutureTask、线程池、JDK 21 的虚拟线程。本质上都是Thread对象 + 一个任务。 - 线程状态(6 种,
Thread.State):NEW、RUNNABLE、BLOCKED(等 monitor 锁)、WAITING(wait/join/park无限等)、TIMED_WAITING(带超时)、TERMINATED。 start()vsrun():start才会新建线程执行;直接run只是普通方法调用;同一线程不能start两次。sleepvswait:sleep是 Thread 的静态方法,不释放锁;wait是 Object 的方法,必须在 synchronized 里调用,会释放锁并进入等待集,需notify唤醒。- 为什么用线程池:复用线程降低创建销毁开销、控制并发数防止资源耗尽、统一管理与监控。
🌿 中级:ThreadPoolExecutor 的运行规则
七个参数:corePoolSize、maximumPoolSize、keepAliveTime + 时间单位、workQueue、threadFactory、handler(拒绝策略)。官方 Javadoc 里的提交规则一定要能默写:
- 无界队列(
LinkedBlockingQueue默认Integer.MAX_VALUE):线程数永远不会超过 core,maximumPoolSize形同虚设,任务堆积直到 OOM。Executors.newFixedThreadPool、newSingleThreadExecutor就是这个坑。newCachedThreadPool用SynchronousQueue且 max 为Integer.MAX_VALUE,突发流量会创建海量线程。这就是阿里规范说“禁用 Executors 创建线程池”的原因。 - 拒绝策略(官方文档):
AbortPolicy(默认,抛RejectedExecutionException)、CallerRunsPolicy(调用者线程自己执行,天然反压)、DiscardPolicy(静默丢弃)、DiscardOldestPolicy(丢队头再重试)。 - 核心线程会被回收吗:默认不会;
allowCoreThreadTimeOut(true)后核心线程也受 keepAlive 约束。prestartAllCoreThreads()可预热。 submitvsexecute:submit返回 Future,任务异常被封装进 Future(不调get就吞掉);execute的异常交给线程的UncaughtExceptionHandler,默认打到 stderr,且这个工作线程会终止后被替换。- 线程数怎么估算:CPU 密集型约
N或N+1;IO 密集型约N × (1 + 等待时间/计算时间)。这只是起点,必须靠压测和线上指标修正。
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8, 32, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200), // 有界队列,规范要求
new ThreadFactory() { // 给线程起有意义的名字,排查靠它
private final AtomicInteger n = new AtomicInteger();
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "order-async-" + n.incrementAndGet());
t.setUncaughtExceptionHandler((th, ex) -> log.error("uncaught in " + th.getName(), ex));
return t;
}
},
new ThreadPoolExecutor.CallerRunsPolicy());🌳 高级:线程池治理与线上问题
- 按业务隔离线程池:支付、通知、报表各用各的池(舱壁模式)。共用一个池,慢下游会把所有业务一起拖死。给每个池命名,监控指标带 pool 名。
- 必备监控:
getActiveCount、getQueue().size()、getPoolSize、getCompletedTaskCount、拒绝次数、任务排队时长与执行时长。告警条件:队列使用率 > 80%、拒绝次数 > 0。 - 动态调参:
setCorePoolSize / setMaximumPoolSize可以运行时修改;很多团队把参数放配置中心(Nacos/Apollo)联动修改,队列容量要自己实现可变(LinkedBlockingQueue的 capacity 是 final,需自定义队列)。 - Tomcat 线程池与 JDK 的差异:Tomcat 自定义了
TaskQueue,让线程数先扩到 max 再入队(更适合 IO 型 Web 请求)。这也是面试“如果想让线程池先扩容再排队怎么做”的标准答案:改造队列的offer。 - 线程池套线程池的死锁:任务 A 在池里提交子任务 B 到同一个池并
get()等待,池满时 B 拿不到线程,A 永远等——线程饥饿死锁。父子任务用不同的池,或者用CompletableFuture组合而不是阻塞等待。 - 优雅关闭:
shutdown()(不再接收,已提交的执行完)→awaitTermination(timeout)→ 超时shutdownNow()(中断运行中线程、返回未执行任务)。任务要响应中断,否则shutdownNow也无能为力。Spring 里ThreadPoolTaskExecutor设置waitForTasksToCompleteOnShutdown与awaitTerminationSeconds。 - “队列堆积、CPU 很低”怎么排查:
jstack/jcmd Thread.print看池里线程都卡在哪——多半是同一个下游 IO(数据库连接池耗尽、HTTP 无超时);排查顺序:线程栈 → 下游延迟 → 连接池指标 → GC 日志。
三、synchronized 与锁升级
🌱 初级:用法与语义
- 三种用法:修饰实例方法(锁 this)、静态方法(锁 Class 对象)、代码块(锁指定对象)。
- 保证原子性 + 可见性 + 有序性(相对临界区);可重入(同一线程可重复获取同一把锁);非公平;不可中断地等待(等锁时 BLOCKED)。
- 与
ReentrantLock对比:后者需手动unlock(必须放 finally)、支持可中断(lockInterruptibly)、超时(tryLock)、公平锁、多 Condition。功能不需要时优先 synchronized,简单不易错。
🌿 中级:锁升级与对象头
对象头里有 Mark Word,锁状态就编码在里面。传统讲法的升级路径:无锁 → 偏向锁 → 轻量级锁 → 重量级锁,只能升不能降(至少在传统实现里)。
| 锁状态 | 适用场景 | 机制 |
|---|---|---|
| 偏向锁 | 始终只有一个线程访问 | Mark Word 记录线程 ID,之后进入无需 CAS |
| 轻量级锁 | 多线程交替执行、几乎无竞争 | 在栈帧创建 Lock Record,CAS 把 Mark Word 指向它;失败则自旋 |
| 重量级锁 | 真正竞争 | 膨胀为 ObjectMonitor,等锁线程进入 EntryList 并被操作系统挂起(涉及内核态切换) |
- 重要的版本事实:偏向锁在 JDK 15 起默认关闭(JEP 374,理由:受益的多是老式同步集合场景,而它给同步子系统带来大量复杂代码),之后版本移除了该实现。所以“锁升级四阶段”在 JDK 17/21 上实际是“无锁 → 轻量级 → 重量级”。面试时讲清楚这个版本差异是加分项。
- 自适应自旋:JIT/VM 根据历史成功率决定自旋多久,避免过早挂起线程。
- JIT 的锁优化:锁消除(逃逸分析发现对象不逃逸,锁被去掉)、锁粗化(循环里反复加解同一把锁合并成一次)。这也是“局部变量用 StringBuffer 也不慢”的原因。
- wait/notify 的注意:必须在同步块内;条件判断要用
while而不是if(防虚假唤醒);notify唤醒任意一个,大多数场景用notifyAll。 - 死锁四条件:互斥、持有并等待、不可剥夺、循环等待;破坏任一即可。工程手段:固定加锁顺序、
tryLock带超时、缩小临界区。排查:jstack会直接打印 “Found one Java-level deadlock”。
🌳 高级:锁的工程取舍
- 减小锁粒度与范围:只锁共享状态的读写,IO、RPC、序列化放到锁外;用
ConcurrentHashMap、分段计数(LongAdder)、读写分离(ReentrantReadWriteLock/StampedLock)替代大锁。 - StampedLock:提供乐观读(
tryOptimisticRead+validate),读多写少下比读写锁快;但不可重入、不支持 Condition,用错容易死锁。 - 分布式场景下 JVM 锁没用:多实例部署,synchronized 只锁本进程。要用 Redis/ZooKeeper 分布式锁或数据库唯一约束、乐观锁版本号;先想能不能靠幂等和唯一索引消灭锁。
- 可观测:用 JFR 的
jdk.JavaMonitorEnter事件看锁竞争热点;jcmd <pid> Thread.print -l可以打印持有的java.util.concurrent锁。
四、volatile、JMM 与 CAS
🌱 初级:volatile 干什么
- 可见性:一个线程写 volatile 变量,其他线程立刻能读到最新值。
- 禁止指令重排序(在 volatile 读写前后插入内存屏障)。
- 不保证原子性:
count++(读-改-写三步)用 volatile 仍会丢更新,要用AtomicInteger或加锁。 - 典型场景:状态标志位、双重检查锁单例。
public class Singleton {
private static volatile Singleton instance; // 必须 volatile
public static Singleton get() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) instance = new Singleton();
}
}
return instance;
}
}
// 不加 volatile:new 分为“分配内存、初始化、赋引用”,后两步可能被重排,
// 另一个线程可能拿到“已赋引用但未初始化完”的对象。🌿 中级:JMM 与 happens-before
- JMM 是抽象规范:每个线程有工作内存(对应 CPU 缓存/寄存器的抽象),主内存是共享变量,规范规定何时对其他线程可见。它不是 JVM 内存区域划分,别混。
- happens-before 核心规则:程序顺序规则;监视器锁(unlock 先于后续 lock);volatile 变量(写先于后续读);线程启动(
start()先于线程内动作);线程终止(线程内动作先于join()返回);传递性;final 字段的安全发布。happens-before 不是“时间上先发生”,而是“前一个操作的结果对后一个可见”。 - CAS(Compare And Swap):CPU 指令级原子操作(x86 的
cmpxchg)。AtomicInteger基于Unsafe/VarHandle,自旋重试。三大问题:ABA(用AtomicStampedReference加版本号)、自旋开销(高竞争下浪费 CPU,所以有LongAdder)、只能保证单个变量(多变量打包成对象用AtomicReference)。 LongAdder:把热点变量拆成base + Cell[],线程按哈希落到不同 cell,读时累加,写吞吐远高于AtomicLong;代价是读得到的是近似瞬时值。ConcurrentHashMap的计数用的是同一思想。- final 的语义:构造器结束时 final 字段对其他线程可见,这是不可变对象天然线程安全的基础。
🌳 高级:从内存屏障到无锁设计
- 四类内存屏障:LoadLoad、StoreStore、LoadStore、StoreLoad。x86 是强内存模型(TSO),只有 StoreLoad 需要显式指令(
lock addl),ARM(如 Graviton/Apple Silicon)更弱,屏障更多。在 x86 上跑得好好的无同步代码,迁到 ARM 服务器可能突然出现可见性问题——这是迁云到 ARM 实例时的真实风险。 - 伪共享(False Sharing):多个线程写同一缓存行(64 字节)里的不同变量,导致缓存行反复失效。
LongAdder.Cell用@Contended填充避免;自定义高并发计数器时可参考。 - VarHandle(JDK 9+)替代
Unsafe,提供 plain/opaque/acquire-release/volatile 多档内存序,适合写高性能并发库;业务代码用不到,知道它存在即可。 - 设计原则:优先不共享(线程封闭)→ 其次不可变 → 再次并发容器/原子类 → 最后才是显式锁。会写无锁代码不等于应该写,可维护性比那几个纳秒重要。
五、AQS 与并发工具
🌱 初级:常用工具类
CountDownLatch:一次性倒计时门闩,一个/多个线程等 N 个事件完成;CyclicBarrier:可循环使用的栅栏,N 个线程互相等待到齐;Semaphore:许可证,限制并发数(限流、连接数控制)。ReentrantLock:可重入互斥锁,Condition实现精确唤醒;ReentrantReadWriteLock:读读共享、读写/写写互斥。Exchanger、Phaser了解即可。
🌿 中级:AQS 原理
AbstractQueuedSynchronizer 是上面大多数同步器的基石。核心三件套:
volatile int state:同步状态。ReentrantLock里表示重入次数(0 = 空闲);Semaphore里表示剩余许可;CountDownLatch里表示剩余计数;ReentrantReadWriteLock把 32 位拆成高 16 位读锁数、低 16 位写锁重入数。- CLH 变体的双向 FIFO 等待队列:获取失败的线程封装成 Node 入队,
LockSupport.park挂起;前驱释放时唤醒后继。入队用 CAS 保证无锁。 - 模板方法:子类实现
tryAcquire/tryRelease(独占)或tryAcquireShared/tryReleaseShared(共享),AQS 负责排队、阻塞、唤醒。
ReentrantLock 加锁流程(非公平):先直接 CAS 抢 state → 失败则 tryAcquire(如果 state=0 再 CAS;如果持有者是自己则 state+1)→ 仍失败则入队 → 自旋若前驱是头结点再尝试 → 否则 park。公平锁与非公平锁的差别就在“新来的线程能不能插队”:公平锁在 tryAcquire 里先检查 hasQueuedPredecessors()。非公平吞吐更高(省去唤醒后继的上下文切换),代价是可能饥饿。
lock.lock();
try {
// 临界区
} finally {
lock.unlock(); // 必须在 finally,否则异常时锁泄漏,整个业务卡死
}Condition:每个 Condition 有自己的等待队列,await 会释放锁并加入条件队列,signal 把节点从条件队列转移到 AQS 同步队列。可以实现“生产者只唤醒消费者”的精确通知。
🌳 高级:用 AQS 思路自定义同步器与选型
- 面试常让你“用 AQS 手写一个不可重入锁 / 限流的 Semaphore”。骨架就是继承 AQS 写内部
Sync,tryAcquire用compareAndSetState(0,1),tryRelease把 state 置 0。 - 工具选型:限制单实例内对某个下游的并发度用
Semaphore(比线程池隔离便宜);多阶段任务用Phaser;只需等待一批异步任务用CompletableFuture.allOf而不是CountDownLatch(更好组合异常和超时)。 - 读写锁的坑:
ReentrantReadWriteLock不支持读锁升级为写锁(会死锁),只支持写锁降级为读锁;读多写极少才划算,写线程可能被持续读饿死(非公平模式下已有对写线程头结点的“避免饥饿”启发式)。 - 可中断与超时是生产必需:所有可能长时间等待的地方都要有超时(
tryLock(timeout)、future.get(timeout)、HTTP/DB 超时)。“没有超时的等待,就是延迟发生的事故”。
六、ThreadLocal 与 CompletableFuture
🌱 初级:ThreadLocal 是什么
每个线程独享一份变量副本,典型用途:用户上下文、请求 ID(日志链路 traceId)、SimpleDateFormat(现在推荐 DateTimeFormatter)、数据库事务连接(Spring 的 TransactionSynchronizationManager 就是 ThreadLocal)。
🌿 中级:内存泄漏与线程池陷阱
- 结构:数据不存在 ThreadLocal 里,而是存在每个 Thread 对象的
threadLocals(ThreadLocalMap)里;Entry 的 key 是对 ThreadLocal 的弱引用,value 是强引用。 - 泄漏原因:ThreadLocal 对象外部引用消失后 key 会被 GC 成 null,但 value 仍被线程的 map 强引用。线程池的线程长期存活,这些“key=null”的 value 就一直占内存(
get/set/remove会顺带清理过期 entry,但不保证及时)。官方文档也强调:线程存活期间,它持有其副本的隐式引用。 - 规范:
try { set } finally { remove() }。Web 项目在过滤器/拦截器的afterCompletion里清理。 - 跨线程传递:
InheritableThreadLocal只在创建子线程时拷贝一次,线程池里复用线程时失效(拿到的是线程创建时刻的旧值,甚至串数据)。异步透传上下文用阿里开源的 TransmittableThreadLocal(TTL),或手动在提交任务时捕获并在任务内设置。 - ThreadLocal 与虚拟线程:虚拟线程支持 ThreadLocal,但 JEP 444 建议谨慎使用——虚拟线程数量可能百万级,每个线程持有大对象的成本很高;JDK 21 起作用域值
ScopedValue(预览)是更合适的替代。
CompletableFuture:异步编排
- 🌱
supplyAsync/runAsync发起异步;thenApply转换;thenAccept消费;thenCompose拍平嵌套(类似 flatMap);thenCombine合并两个结果;allOf / anyOf等待全部/任一。 - 🌿 默认线程池是
ForkJoinPool.commonPool()(官方文档;并行度不足 2 时甚至每个任务新建一个线程)。commonPool 是 JVM 全局共享且线程数≈CPU 核数,IO 密集任务放进去会饿死别的并行流。生产必须传自定义 Executor。 - 🌿 异常处理:异常会沿依赖链传播;
exceptionally只处理异常、返回兜底值;handle同时拿到结果与异常;whenComplete不改变结果。忘了处理,异常就悄悄躲在 future 里。join()抛CompletionException,get()抛ExecutionException。 - 🌿 超时:JDK 9+ 有
orTimeout、completeOnTimeout;JDK 8 没有,要自己配合调度线程实现。 - 🌿
thenApply与thenApplyAsync:前者可能在完成前一阶段的线程(或调用者线程)上执行,后者提交到 executor。
CompletableFuture<User> userF = CompletableFuture.supplyAsync(() -> userClient.get(uid), ioPool);
CompletableFuture<List<Order>> ordF = CompletableFuture.supplyAsync(() -> orderClient.list(uid), ioPool);
Profile p = userF.thenCombine(ordF, Profile::new)
.orTimeout(800, TimeUnit.MILLISECONDS) // JDK 9+
.exceptionally(ex -> Profile.degraded(uid)) // 降级兜底
.join();- 🌳 高级:并发编排的治理——超时必须“逐个下游”设置而不是最外层一个总超时;被
orTimeout超时的任务并不会被取消(CompletableFuture.cancel也不会中断底层线程),线程池里仍在跑,必须自己在任务里检查取消/超时;扇出调用要有并发上限(Semaphore)防止把下游打挂;每个 future 都要能落到exceptionally/handle,避免“静默失败”;日志 traceId 要跨线程透传。
七、虚拟线程(JDK 21)
🌱 初级:是什么、怎么用
- JEP 444:虚拟线程是
java.lang.Thread的轻量实现,不与操作系统线程一一对应,由 JDK 调度器以 M:N 方式映射到少量平台线程(载体线程,carrier)上。阻塞时会被卸载(unmount),载体线程去跑别的虚拟线程。 - 目标:让“每请求一线程”的简单编程模型在高并发 IO 场景下也扩展得动,不必被迫改成响应式。
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> { callRemote(); return null; }); // 一万个任务,一万个虚拟线程
}
} // close() 会等待所有任务完成
// Spring Boot 3.2+ 也可以通过配置 spring.threads.virtual.enabled=true 让 Tomcat 使用虚拟线程🌿 中级:适用边界与 pinning
- 适合:高并发、IO 密集(大量等待数据库/HTTP)。不适合:CPU 密集——官方原话大意是虚拟线程“不是更快的线程”,线程数超过核数对 CPU 密集任务毫无帮助。
- 不要池化虚拟线程:它们便宜,每个任务一个即可;要限制并发用
Semaphore,而不是靠池大小。 - Pinning(钉住):JDK 21 中,虚拟线程在
synchronized块/方法内或执行 native 方法时阻塞,无法卸载,会占住载体线程。少量无所谓,频繁且长时间的 pinning 会耗尽载体线程,吞吐塌陷。JEP 444 建议把频繁使用且包含阻塞 IO 的 synchronized 换成ReentrantLock;可用 JFR 事件jdk.VirtualThreadPinned(JDK 21 还有-Djdk.tracePinnedThreads)定位。 - JDK 24 起:JEP 491(Synchronize Virtual Threads without Pinning)让虚拟线程在 synchronized 里阻塞时也能卸载,“几乎消除”了这类 pinning;仍会钉住的只剩类初始化、符号引用解析等少数场景,以及 native 帧。所以:JDK 21 要防 synchronized pinning,JDK 24+ 基本不用操心。
- 其他注意:虚拟线程是守护线程;ThreadLocal 谨慎;数据库连接池、下游限流依然是瓶颈——线程不再是瓶颈后,瓶颈会转移到连接池和下游。
🌳 高级:落地与取舍
- 什么时候值得上:服务是典型“调用大量下游 + 等待”的 BFF/聚合层,且已用线程池调大到几百上千线程;JDK 已升到 21+。若是 CPU 密集或已经是 Reactor 全响应式栈,收益有限。
- 灰度与验证:先在低风险服务开启,对比 P99、GC、连接池等待;压测时观察“载体线程数、jdk.VirtualThreadPinned 事件、连接池排队”。
- 与结构化并发的关系:
StructuredTaskScope(JDK 21 预览)用来把一组子任务当作一个整体管理生命周期,天然配合虚拟线程;预览特性生产慎用。 - 加分项:能解释“为什么虚拟线程让同步阻塞写法重新可用,却不能替代背压设计”——无限制地创建虚拟线程只是把过载从线程数转移到了下游与内存。
八、JVM 内存模型与对象
🌱 初级:运行时数据区
| 区域 | 线程私有? | 存什么 | 典型异常 |
|---|---|---|---|
| 程序计数器 | 是 | 当前字节码行号 | 无 |
| 虚拟机栈 | 是 | 栈帧:局部变量表、操作数栈、动态链接、返回地址 | StackOverflowError / OOM(无法创建线程) |
| 本地方法栈 | 是 | native 方法 | 同上 |
| 堆 | 否 | 对象实例、数组,GC 主战场 | OOM: Java heap space |
| 元空间 Metaspace | 否 | 类元数据(JDK 8 起在本地内存,取代永久代) | OOM: Metaspace |
| 直接内存 | 否 | NIO DirectByteBuffer、Netty 堆外 | OOM: Direct buffer memory |
堆分代(经典分代 GC 视角):年轻代(Eden + 2 个 Survivor)+ 老年代。新对象在 Eden 分配;Minor GC 后存活对象进 Survivor,年龄达到阈值(默认最大 15)晋升老年代;大对象可直接进老年代(G1 里叫 Humongous)。
🌿 中级:对象创建、逃逸分析、可达性
- 对象创建流程:类加载检查 → 分配内存(指针碰撞/空闲列表;多线程用 TLAB 线程本地分配缓冲减少竞争)→ 零值初始化 → 设置对象头 → 执行
<init>。 - 对象内存布局:对象头(Mark Word + 类型指针,开启压缩指针时共约 12 字节)+ 实例数据 + 对齐填充(8 字节对齐)。
- 逃逸分析:JIT 判断对象不会逃出方法/线程,就可以做栈上分配(实际是标量替换)、锁消除。所以“对象都在堆上”并不总是对的。
- GC Roots:虚拟机栈/本地方法栈中引用的对象、静态变量、常量、JNI 引用、被 synchronized 持有的对象、活跃线程等。可达性分析而不是引用计数,所以能处理循环引用。
- 四种引用:强、软(内存不足时回收,适合缓存)、弱(下次 GC 就回收,ThreadLocalMap 的 key)、虚(配合引用队列,做回收通知;
Cleaner的基础)。 - 栈溢出 vs 堆溢出:无限递归 →
StackOverflowError(-Xss调栈深度);对象一直不释放 → 堆 OOM。
🌳 高级:容器化下的内存规划
- JVM 进程总内存 ≠ 堆:堆 + 元空间 + 线程栈(每线程默认约 1MB,×线程数)+ 直接内存 + 代码缓存 + GC 数据结构 + 本地库。容器 limit 只给堆的话,进程会被 OOMKilled(
dmesg/K8s 事件里显示 OOMKilled,Java 日志里什么都没有)。经验:堆通常占容器内存的 60%–75%。 - JDK 10+ 已识别 cgroup 限制;用
-XX:MaxRAMPercentage=70比写死-Xmx更便于随规格伸缩;生产建议-Xms与-Xmx设为一致,避免运行期堆伸缩抖动。 - Native Memory Tracking:怀疑“堆正常但 RSS 一直涨”时,开
-XX:NativeMemoryTracking=summary,用jcmd <pid> VM.native_memory summary看各区域,配合baseline与summary.diff找增长点(官方 jcmd 文档均有说明)。常见元凶:Netty 堆外泄漏、大量线程、JNI 库、glibc arena。
九、垃圾回收与 GC 选型
🌱 初级:算法与收集器概览
- 算法:标记-清除(碎片)、标记-复制(年轻代,快、要空间)、标记-整理(老年代,移动对象);分代假说:大部分对象朝生夕死。
- Stop-The-World:GC 某些阶段暂停所有应用线程,收集器演进的主线就是缩短 STW。
- 收集器:Serial(单线程)、Parallel(吞吐优先,JDK 8 默认)、CMS(已在 JDK 14 移除)、G1(JDK 9 起默认)、ZGC(低延迟)、Shenandoah(OpenJDK 系)。
- Minor / Major / Full GC:Minor 收年轻代;Full GC 是全堆(含元空间)收集,代价最大,线上应尽量为零。
🌿 中级:G1 与 ZGC
G1(Garbage-First),对照 Oracle 官方调优指南:
- 把堆切成大小相等的Region(1MB–32MB,2 的幂),每个 Region 可以是 Eden/Survivor/Old/Humongous;大小 ≥ Region 一半的对象是 Humongous 对象,占连续多个 Region,放在老年代,回收代价高。
- 目标驱动:通过
-XX:MaxGCPauseMillis(默认 200ms)让 G1 “以高概率”达到停顿目标,官方说目标是几百毫秒或更低。它按“收益最高(回收垃圾最多、耗时最短)”优先挑 Region——Garbage-First 的由来。 - 回收周期:Young GC → 老年代占用达到
InitiatingHeapOccupancyPercent(默认 45%)触发并发标记 → 之后进入若干次 Mixed GC(同时回收年轻代和挑选出的一批老年代 Region,选“存活数据少”的,由G1MixedGCCountTarget、G1HeapWastePercent等控制)。Full GC 是 G1 跟不上分配速率时的兜底(单线程/并行 STW 标记整理,很慢)。 - 使用建议:别乱调年轻代大小(
-Xmn、NewRatio会让 G1 放弃自适应),首选只设堆大小与停顿目标。
ZGC:并发标记、并发转移(着色指针 + 读屏障),停顿目标亚毫秒且基本不随堆大小增长,支持从几百 MB 到 TB 级堆。JDK 21 引入分代 ZGC(JEP 439,用 -XX:+UseZGC -XX:+ZGenerational 开启),降低分配停滞、内存与 CPU 开销(JEP 中 Cassandra 测试用约四分之一堆获得四倍吞吐);JDK 23 起分代模式成为默认(JEP 474),非分代被废弃。
| 收集器 | 目标 | 典型场景 | 注意 |
|---|---|---|---|
| Parallel | 最大吞吐 | 离线批处理、对停顿不敏感 | Full GC 停顿长,随堆变大增加 |
| G1 | 吞吐与停顿平衡(默认) | 绝大多数 4GB–几十 GB 在线服务 | Humongous、并发标记赶不上要留意 |
| ZGC(分代) | 亚毫秒停顿 | 大堆、低延迟敏感(网关、交易、推荐) | 吞吐略有让步、CPU 占用略高,JDK 21 需开分代 |
| Serial | 低开销 | 单核小容器、命令行工具 | - |
JDK 默认选择规则:满足“服务器级”机器(多核、内存较大)时选 G1,否则 Serial——所以只给 1 核小容器时你可能跑在 Serial 上,用 -XX:+UseG1GC 或 -XX:+PrintFlagsFinal 确认。
🌳 高级:GC 调优方法论
- 先度量,再动手:打开 GC 日志(JDK 9+ 统一日志
-Xlog:gc*,safepoint:file=gc.log:time,uptime,level,tags:filecount=5,filesize=50m),用 GCViewer/GCEasy 分析;关键指标:停顿 P99、GC 占比(吞吐)、晋升速率、老年代占用趋势、Full GC 次数。 - 常见问题与思路:
- Young GC 太频繁:新对象分配速率过高——先从代码降低分配(大对象、循环里拼接、无谓拷贝),其次扩大堆/年轻代。
- Young GC 停顿长:存活对象多(复制成本高)或跨代引用/RSet 更新重;检查大缓存、本地缓存放到堆内的对象。
- Full GC 频繁:老年代占用高(内存泄漏或缓存过大)→ 看 dump;大对象直接进老年代(Humongous)→ 调
G1HeapRegionSize或拆小对象;并发标记来不及 → 调低InitiatingHeapOccupancyPercent;元空间不足触发 Full GC → 设MaxMetaspaceSize并查类加载泄漏。 - 过早晋升:Survivor 太小导致对象提前进老年代,叫“过早晋升”,日志里 to-space exhausted 是危险信号。
- System.gc() 与
-XX:+DisableExplicitGC:用了 NIO 堆外内存的应用(Netty)依赖System.gc()触发DirectByteBuffer回收,盲目禁用可能导致堆外 OOM;更稳妥是-XX:+ExplicitGCInvokesConcurrent。 - 量化复盘模板(面试很吃这一套):“某服务 P99 从 X ms 抖到 Y ms,GC 日志发现每 N 分钟一次 Mixed GC 停顿 Z ms,原因是某缓存对象常驻老年代+批量刷新造成大量晋升,改为Caffeine 限容量 + 分批刷新后,P99 回到 X ms,Full GC 降为 0”。有数据、有因果、有验证,比背参数强十倍。
十、类加载
🌱 初级:生命周期与类加载器
- 生命周期:加载 → 链接(验证 → 准备 → 解析)→ 初始化 → 使用 → 卸载。准备阶段给静态变量分配内存并赋零值,初始化阶段才执行
<clinit>(静态代码块与静态赋值)。 - 类加载器(JDK 9+):Bootstrap(核心库,C++ 实现)→ Platform(原 Extension)→ Application(classpath)→ 自定义。JDK 8 及以前叫 Bootstrap/Extension/Application。
- 双亲委派:收到加载请求先委派给父加载器,父加载不了才自己加载。好处:避免核心类被篡改(自己写个
java.lang.String不会被加载)、保证类唯一性。
🌿 中级:打破双亲委派与初始化时机
- 类的唯一性 = 加载器 + 全限定名。不同加载器加载同名类,
instanceof会是 false,ClassCastException: X cannot be cast to X就是这么来的(热部署、插件化常见)。 - 打破双亲委派的经典例子:① SPI(JDBC 等):核心库接口由 Bootstrap 加载,实现类在 classpath,用线程上下文类加载器反向加载;② Tomcat:每个 Web 应用有独立 WebAppClassLoader,优先自己加载再委派,实现应用间隔离;③ OSGi / 热部署。
- 初始化触发时机:
new、读写静态字段(非编译期常量)、调用静态方法、反射、子类初始化触发父类、主类。访问编译期常量static final不会触发初始化;通过子类引用父类静态字段只初始化父类。 - 类初始化线程安全:
<clinit>由 JVM 加锁保证只执行一次——静态内部类单例(Holder)的原理;类初始化里死循环或互相等待也会死锁。
🌳 高级:类加载泄漏与线上现象
- Metaspace OOM 排查:动态代理(CGLIB)、Groovy 脚本、反射膨胀、热部署反复创建 ClassLoader 是常见来源。看
jcmd <pid> VM.classloader_stats、-Xlog:class+load/class+unload;如果类数量只增不减,找是谁持有着 ClassLoader(ThreadLocal、线程、静态引用、JDBC Driver 注册)。 - 类卸载条件苛刻:该类所有实例被回收、加载它的 ClassLoader 被回收、Class 对象无引用,三者缺一不可;所以重启应用时 Tomcat 里常见的泄漏警告就是这个原因。
- 启动优化:CDS / AppCDS 共享类元数据,缩短启动、降低内存占用;Spring Boot 3 起对 AOT 与 GraalVM native 有官方支持,属于“冷启动敏感”场景的选项。
十一、OOM 类型与线上排查
🌱 初级:常见 OOM 与含义
| 错误信息 | 含义 | 第一反应 |
|---|---|---|
| Java heap space | 堆里分配失败:容量不足或内存泄漏 | 拿 heap dump,看 Dominator Tree |
| GC Overhead limit exceeded | GC 花了超过 98% 时间却只回收不足 2% | 存活数据几乎占满堆,等同于堆不够或泄漏 |
| Metaspace | 类元数据超过 MaxMetaspaceSize | 查动态类生成/ClassLoader 泄漏 |
| Requested array size exceeds VM limit | 申请的数组超过 JVM 上限 | 检查是否传了异常大的长度 |
| Direct buffer memory | 堆外直接内存耗尽 | 查 Netty/NIO 泄漏,-XX:MaxDirectMemorySize |
| unable to create native thread | OS 不能再创建线程(线程数/进程限制/内存) | 数线程数、看 ulimit -u、查线程泄漏 |
| Out of swap space / native 分配失败 | 本地堆接近耗尽 | 用 NMT 与 OS 工具,看 fatal error 日志 |
以上前几项(heap space、GC overhead、array size、Metaspace、Compressed class space、Out of swap space)与含义均见 Oracle 官方《Troubleshooting Memory Leaks》文档。务必提前在启动参数里加上 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump(必要时再加 -XX:+ExitOnOutOfMemoryError,让实例直接退出被编排系统拉起,避免半死不活)。
🌿 中级:工具链与排查套路
# 1. 找进程与整体资源
jps -l ; top -Hp <pid> # CPU 高:定位线程,printf '%x' 转 nid,再 jstack
# 2. GC 情况
jstat -gcutil <pid> 1000 10 # 关注 O(老年代)%、FGC 次数与 FGCT
# 3. 线程栈(官方推荐 jcmd)
jcmd <pid> Thread.print -l > stack.txt
# 4. 堆直方图(轻量,先看谁占内存最多);注意会触发 Full GC,除非加 -all
jcmd <pid> GC.class_histogram | head -30
# 5. 堆转储(重操作,影响大:暂停时间随堆变大而变长)
jcmd <pid> GC.heap_dump /data/dump/heap.hprof
# 6. 堆外与本地内存
jcmd <pid> VM.native_memory summary # 需启动时开启 NMT
# 7. 持续采样:JFR
jcmd <pid> JFR.start duration=60s filename=/data/rec.jfr settings=profile- CPU 飙高:
top -Hp→ 线程号转十六进制 →jstack;线程名是 “GC Thread/VM Thread” 说明 GC 在忙,转去看 GC 日志;是业务线程则看栈顶(死循环、正则回溯、序列化大对象)。 - Full GC 频繁/堆 OOM:先
jstat -gcutil看老年代是否“回收后仍高位”(泄漏迹象,锯齿逐渐抬升);再class_histogram看是哪类对象最多;再用 MAT 打开 heap dump,看 Dominator Tree 与 Leak Suspects,沿 GC Roots 路径找到谁在持有(静态 Map、缓存、ThreadLocal、监听器、未关闭的连接)。 - 线程数暴涨 / 线程池卡死:
jstack按线程名分组统计,看是不是同一个 IO 点阻塞;找BLOCKED和 “waiting to lock” 链条,jstack 会自动检测 Java 级死锁。 - Arthas:线上不方便重启、不方便加日志时,
thread -n 3(最忙线程)、trace(方法耗时分解)、watch(观察入参出参)、heapdump、vmtool很方便;注意生产使用要遵守权限与变更流程,别在高峰期对热点方法开重型trace。
🌳 高级:把排查变成体系
- 事前:统一 JVM 启动模板(GC 日志、OOM 自动 dump、NMT 按需、容器内存比例);核心指标进监控(JVM 堆各区、GC 次数/时长、线程数、线程池指标、类加载数、直接内存、连接池);给 dump 目录留足磁盘(堆多大,dump 就多大)。
- 事中:先止血再定位——摘流量/限流/扩容/重启,但重启前保留现场:三份间隔 5 秒的 jstack、
jstat输出、必要时 heap dump(大堆 dump 会 STW 很久,先摘流量再 dump)。 - 事后:复盘要有时间线、根因(代码/配置/容量)、修复、防复发措施(告警、压测用例、代码规范)。典型结论例:“用户输入进正则未限长”“本地缓存无容量上限”“线程池无界队列”“连接泄漏”。
- 压测与容量:GC 调优必须基于真实流量形态压测,且要跑足够长时间(至少覆盖若干次 Mixed GC 与晋升周期),10 分钟的压测看不出老年代缓慢泄漏。
- 加分项:能画出“从告警到定位”的决策树(CPU/内存/线程/GC 四个分支各自的第一条命令),面试官问“线上 CPU 100% 怎么办”时,按这个套路 90 秒讲完是很有说服力的。
十二、高频追问速查表
| 问题 | 一句话答案 | 出处/延伸 |
|---|---|---|
| 线程池提交流程 | 核心 → 队列 → 扩到最大 → 拒绝 | ThreadPoolExecutor Javadoc |
| 为什么禁用 Executors | Fixed/Single 用无界队列会 OOM,Cached 无限建线程 | 阿里规范 |
| submit 与 execute 异常差别 | submit 异常封在 Future 里,不 get 就丢 | UncaughtExceptionHandler |
| 线程池大小怎么定 | CPU 型 N+1,IO 型 N×(1+W/C),再压测修正 | 动态调参 |
| synchronized 锁升级 | 无锁→(偏向)→轻量→重量;偏向锁 JDK 15 起默认关闭 | JEP 374 |
| volatile 保证什么 | 可见性 + 禁重排,不保证原子性 | DCL 单例 |
| happens-before 是什么 | 前一操作结果对后一操作可见的偏序规则,不是时间先后 | JMM 规范 |
| CAS 的问题 | ABA、自旋开销、单变量 | AtomicStampedReference / LongAdder |
| AQS 三件套 | volatile state + CLH 队列 + 模板方法 | ReentrantLock / Semaphore |
| 公平锁与非公平锁差别 | 新线程能否插队;公平锁先查 hasQueuedPredecessors | 吞吐 vs 饥饿 |
| ThreadLocal 为什么泄漏 | key 弱引用、value 强引用,线程池线程长存活;要 remove | ThreadLocal Javadoc |
| CompletableFuture 默认线程池 | ForkJoinPool.commonPool(),生产传自定义 Executor | CompletableFuture Javadoc |
| 虚拟线程 pinning | JDK 21 在 synchronized/native 内阻塞会占载体线程;JDK 24 起基本解决 | JEP 444 / JEP 491 |
| 虚拟线程要不要池化 | 不要,每任务一个,限并发用 Semaphore | JEP 444 |
| G1 默认停顿目标 | MaxGCPauseMillis=200ms,按收益优先回收 Region | Oracle G1 调优指南 |
| ZGC 什么时候用 | 大堆、低延迟;JDK 21 开 ZGenerational,JDK 23 起默认分代 | JEP 439 / 474 |
| 双亲委派怎么打破 | SPI 用线程上下文类加载器,Tomcat 每应用独立加载器 | 类加载章节 |
| Metaspace 与永久代 | JDK 8 起元数据放本地内存,上限用 MaxMetaspaceSize | OOM: Metaspace |
| 线上 OOM 怎么办 | 提前配 HeapDumpOnOutOfMemoryError,MAT 看 Dominator Tree | jcmd GC.heap_dump |
| 容器被 OOMKilled 但 Java 无日志 | 进程总内存超 limit(堆外+栈+元空间),用 NMT 排查 | MaxRAMPercentage |
| CPU 100% 怎么排查 | top -Hp → printf %x → jstack;线程若是 GC 线程则看 GC 日志 | 另见事故专栏 |
一句话备忘:并发记“线程池:核心→队列→最大→拒绝,队列必须有界;锁:先不共享,再不可变,再原子类,最后才加锁;volatile 管可见不管原子;虚拟线程给 IO 型服务,JDK 21 防 synchronized pinning”;JVM 记“堆只是进程内存的一部分,G1 是默认、ZGC 管低延迟,OOM 前配好 dump,排查先看 CPU/GC/线程/内存四条线”。
写到这里,我发现自己的复习有个套路:每个知识点都问一句“线上出事我会先看什么”。并发和 JVM 是最能检验这一点的领域——背得出锁升级不稀奇,凌晨三点能在 90 秒内说出“先 top -Hp,再 jstack,再看 GC 日志”才算真的会。愿你的值班群永远安静。
参考资料
- JDK 21 ThreadPoolExecutor Javadoc:任务提交规则、三种排队策略(直接交接/无界/有界)与四种拒绝策略的官方定义。
- JDK 21 CompletableFuture Javadoc:默认执行器为 ForkJoinPool.commonPool()、同步与异步方法的区别、exceptionally/handle 与 orTimeout 等。
- JDK 21 ThreadLocal Javadoc:线程存活期间持有副本引用、线程池下需 remove 的语义说明。
- JEP 444: Virtual Threads:虚拟线程的调度模型、pinning 场景、不要池化、CPU 密集不受益与 ThreadLocal 建议。
- JEP 491: Synchronize Virtual Threads without Pinning:JDK 24 让虚拟线程在 synchronized 中阻塞时可卸载,以及仍会 pinning 的剩余场景。
- JEP 374: Deprecate and Disable Biased Locking:偏向锁自 JDK 15 起默认关闭的原因与影响。
- Oracle JDK 21 G1 垃圾收集器调优指南:Region、Humongous 对象、暂停目标、Mixed 收集选择依据。
- JEP 439: Generational ZGC:JDK 21 分代 ZGC 的目标、开启方式与性能示例。
- JEP 474: ZGC: Generational Mode by Default:JDK 23 起 ZGC 默认分代,非分代模式被废弃。
- Oracle JDK 21 排查内存泄漏:各类 OutOfMemoryError 信息的含义、HeapDumpOnOutOfMemoryError、jcmd/jmap/JFR 的堆转储方法与 NMT。
- jcmd 手册(JDK 21):Thread.print、GC.heap_dump、GC.class_histogram、VM.native_memory、JFR.start 等诊断命令的选项。
一起聊聊