面试复习资料 列表全部文章
面试复习资料

面试复习:Java 并发与 JVM(初中高级)—— 线程池 / 锁 / AQS / JMM / 虚拟线程 / GC 选型 / 线上排查

按🌱初级/🌿中级/🌳高级三层整理的并发与 JVM 复习笔记:线程池参数与治理、synchronized 锁升级、AQS、volatile 与 JMM、ThreadLocal、CompletableFuture、虚拟线程,以及内存模型、G1/ZGC 选型、类加载、OOM 与线上排查思路。

更多

凌晨两点,值班群里弹出一条告警:“订单服务线程池队列堆积 10 万”。你打开监控,活跃线程数满格、队列满格、CPU 却只有 15%。这画面我见过不止一次——线程都在等,没有人在算。这时候面试八股里那些“核心线程数、阻塞队列、拒绝策略”,突然就成了救命的知识。

这篇把并发和 JVM 放在一起写,因为线上问题永远是两者交织:线程池打满常常是 GC 停顿引起的,Full GC 又常常是并发场景下的对象堆积引起的。怎么用:每个专题按 🌱 初级(是什么)→ 🌿 中级(为什么、怎么选)→ 🌳 高级(怎么落地、出事怎么办)三层组织,每层给标准回答要点、追问链和加分项。文中的默认值和行为都对照了 JDK 21 官方文档,文末有出处。

一、知识地图

并发这条线的核心是“三个性质(原子性、可见性、有序性)+ 一套工具(锁、原子类、并发容器、线程池)”;JVM 这条线的核心是“内存怎么划分、对象怎么死、类怎么来、出事怎么查”。

并发 + JVM 并发:三性质 × 一套工具 JMM 原子性 / 可见性 / 有序性 happens-before volatile / final 锁 synchronized 锁升级 ReentrantLock / AQS CAS / LongAdder 线程池 核心/最大/队列/拒绝 容量估算与监控 优雅关闭 异步与新模型 ThreadLocal CompletableFuture 虚拟线程(21) JVM:结构 → 回收 → 加载 → 排查 运行时数据区 堆 / 元空间 / 栈 直接内存 / 代码缓存 对象布局与逃逸分析 GC 可达性 / 分代 G1 / ZGC / Parallel 选型与参数 类加载 加载/链接/初始化 双亲委派与打破 类卸载 / 元空间 调优与排查 jstat / jcmd / MAT 各类 OOM 定位 CPU 高 / Full GC 频繁

二、线程与线程池

🌱 初级:线程基础与线程池是什么

标准回答要点:

  • 创建线程的方式:继承 Thread、实现 Runnable、实现 Callable 配合 FutureTask、线程池、JDK 21 的虚拟线程。本质上都是 Thread 对象 + 一个任务。
  • 线程状态(6 种,Thread.State):NEW、RUNNABLE、BLOCKED(等 monitor 锁)、WAITING(wait/join/park 无限等)、TIMED_WAITING(带超时)、TERMINATED。
  • start() vs run():start 才会新建线程执行;直接 run 只是普通方法调用;同一线程不能 start 两次。
  • sleep vs wait:sleep 是 Thread 的静态方法,不释放锁;wait 是 Object 的方法,必须在 synchronized 里调用,会释放锁并进入等待集,需 notify 唤醒。
  • 为什么用线程池:复用线程降低创建销毁开销、控制并发数防止资源耗尽、统一管理与监控。

🌿 中级:ThreadPoolExecutor 的运行规则

七个参数:corePoolSize、maximumPoolSize、keepAliveTime + 时间单位、workQueue、threadFactory、handler(拒绝策略)。官方 Javadoc 里的提交规则一定要能默写:

execute(task) 线程数 < core ?是:新建线程直接执行(哪怕有空闲线程) 队列 offer 成功 ?是:入队等待(无界队列永远走这里) 线程数 < max ?是:新建“非核心”线程执行这个任务 拒绝策略Abort / CallerRunsDiscard / DiscardOldest 否 ↓否 ↓否 ↓ 关键推论:先核心 → 再队列 → 队列满才扩到 max → 还满才拒绝。所以“队列越大,越晚扩容”。

  • 无界队列(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() 可预热。
  • submit vs execute: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 是上面大多数同步器的基石。核心三件套:

  1. volatile int state:同步状态。ReentrantLock 里表示重入次数(0 = 空闲);Semaphore 里表示剩余许可;CountDownLatch 里表示剩余计数;ReentrantReadWriteLock 把 32 位拆成高 16 位读锁数、低 16 位写锁重入数。
  2. CLH 变体的双向 FIFO 等待队列:获取失败的线程封装成 Node 入队,LockSupport.park 挂起;前驱释放时唤醒后继。入队用 CAS 保证无锁。
  3. 模板方法:子类实现 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 exceededGC 花了超过 98% 时间却只回收不足 2%存活数据几乎占满堆,等同于堆不够或泄漏
Metaspace类元数据超过 MaxMetaspaceSize查动态类生成/ClassLoader 泄漏
Requested array size exceeds VM limit申请的数组超过 JVM 上限检查是否传了异常大的长度
Direct buffer memory堆外直接内存耗尽查 Netty/NIO 泄漏,-XX:MaxDirectMemorySize
unable to create native threadOS 不能再创建线程(线程数/进程限制/内存)数线程数、看 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
为什么禁用 ExecutorsFixed/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 强引用,线程池线程长存活;要 removeThreadLocal Javadoc
CompletableFuture 默认线程池ForkJoinPool.commonPool(),生产传自定义 ExecutorCompletableFuture Javadoc
虚拟线程 pinningJDK 21 在 synchronized/native 内阻塞会占载体线程;JDK 24 起基本解决JEP 444 / JEP 491
虚拟线程要不要池化不要,每任务一个,限并发用 SemaphoreJEP 444
G1 默认停顿目标MaxGCPauseMillis=200ms,按收益优先回收 RegionOracle G1 调优指南
ZGC 什么时候用大堆、低延迟;JDK 21 开 ZGenerational,JDK 23 起默认分代JEP 439 / 474
双亲委派怎么打破SPI 用线程上下文类加载器,Tomcat 每应用独立加载器类加载章节
Metaspace 与永久代JDK 8 起元数据放本地内存,上限用 MaxMetaspaceSizeOOM: Metaspace
线上 OOM 怎么办提前配 HeapDumpOnOutOfMemoryError,MAT 看 Dominator Treejcmd 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 日志”才算真的会。愿你的值班群永远安静。

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人