Java - JVM 篇

8.0 开篇:JVM 到底在管什么

先记住一个总类比,后面所有概念都往它上面挂:

JVM = 一家餐厅

  • 类加载 = 把菜谱从总部调过来、验货、上架
  • 方法区 = 菜谱库(所有员工共享)
  • 堆 = 后厨仓库(所有食材,大家共用,需要保洁)
  • 虚拟机栈 = 每个厨师的操作台(一人一张,互不干扰)
  • 程序计数器 = 操作台上的一张便签:这道菜做到第几步了
  • GC = 保洁阿姨,专门清理不再使用的食材
  • JIT = 老师傅:同一道菜做多了,就不再翻菜谱,直接肌肉记忆

为什么面试必问 JVM:它决定了三件事——程序能跑多大(内存)、跑多久会卡(GC)、出问题怎么查(工具)。业务代码写得再好,一次 Full GC 停 5 秒,用户就跑了。

学习地图(建议按这个顺序读):

内存长什么样 → 对象怎么放进去 → 垃圾怎么找 → 垃圾怎么清
→ 清垃圾的几套人马(收集器)→ 类怎么进来 → 代码怎么变快 → 出问题怎么查

版本坐标(2026-10,别再用 JDK 8 的资料答):

事项现状
默认收集器G1(JDK 9 起)
CMSJDK 14 已移除
永久代JDK 8 已移除,换成元空间
ZGCJDK 15 转正,JDK 23 起默认分代,JDK 24 移除非分代模式
紧凑对象头JDK 25 正式(JEP 519),对象头从 12/16 字节降到 8 字节
GC 日志JDK 9+ 统一用 -Xlog:gc*,-XX:+PrintGCDetails 已废弃

8.1 运行时数据区:内存长什么样

一张图记住

┌─────────────────────────────────────────────┐
│                 线程共享区                    │
│  ┌───────────────────────────────────────┐  │
│  │  堆 Heap                               │  │
│  │  新生代(Eden + S0 + S1)/ 老年代      │  │
│  └───────────────────────────────────────┘  │
│  ┌───────────────────────────────────────┐  │
│  │  方法区 / 元空间 Metaspace              │  │
│  │  类元信息、运行时常量池、静态变量        │  │
│  └───────────────────────────────────────┘  │
├─────────────────────────────────────────────┤
│                 线程私有区                    │
│  每线程:程序计数器 PC                       │
│  每线程:虚拟机栈(栈帧 × N)                │
│  每线程:本地方法栈                          │
└─────────────────────────────────────────────┘
        外加:直接内存(堆外,不受 -Xmx 管)

逐个拆解

区域存什么线程会 OOM 吗餐厅类比
程序计数器 PC当前字节码行号私有❌ 唯一不会 OOM 的区域便签:做到第几步
虚拟机栈栈帧(局部变量表、操作数栈、动态链接、返回地址)私有✅ StackOverflowError / OutOfMemoryError厨师的操作台,每调一个方法就摆一层
本地方法栈native 方法调用私有✅请外援厨师的操作台
堆对象实例、数组共享✅ OutOfMemoryError: Java heap space后厨仓库
方法区/元空间类结构、方法字节码、常量池、静态变量共享✅ Metaspace菜谱库
直接内存DirectByteBuffer 等堆外内存共享✅ Direct buffer memory仓库门外的露天堆场

三个必须记住的细节

1. 栈帧里有什么

public int add(int a, int b) {
    int c = a + b;
    return c;
}

每次调用 add,压入一个栈帧:

部分作用
局部变量表参数 + 局部变量(a、b、c),以 Slot 为单位
操作数栈执行字节码时的工作区,a + b 先把 a、b 压栈再相加
动态链接指向运行时常量池中该方法的引用
方法返回地址方法结束后回到哪一行继续执行

StackOverflowError 的典型原因:递归没有终止条件。默认栈大小 -Xss 通常 512KB ~ 1MB,每层栈帧占几十到几百字节,几万层就爆了。

2. 为什么程序计数器不会 OOM

它只存一个行号,每线程固定一小块,生命周期和线程一样。JVM 规范里它是唯一没有规定 OOM 情况的区域。

3. 永久代为什么被干掉

JDK 7 及以前方法区用永久代实现(在堆里),容量固定、调优困难,还容易出现 PermGen space OOM——尤其是有大量动态生成类(CGLIB、JSP、反射)的应用。

JDK 8 换成元空间:方法区移到本地内存,默认不设上限(受物理内存限制),只受 -XX:MaxMetaspaceSize 约束。菜谱库从”店内的小书架”搬到了”店外的仓库”,不再和食材抢地方。

4. 字符串常量池在哪

  • JDK 6:永久代
  • JDK 7+:堆
  • JDK 8+:随永久代一起,仍在堆里

这就是 intern() 行为变化的原因:JDK 7 之后 intern() 返回的是堆里已有的引用,不再复制到永久代。


8.2 对象的一生

创建过程(new 到底做了什么)

1. 类加载检查     → 这个类加载过吗?没有就先加载
2. 分配内存       → 在堆里划一块地
3. 零值初始化     → 所有字段先置 0 / null(所以对象字段有默认值)
4. 设置对象头     → 写入 Mark Word、类型指针
5. 执行 <init>    → 构造器、实例块、父类构造器
6. 返回引用       → 交给调用方

⚠️ 注意第 3 步和第 5 步的顺序:先零值初始化,再执行构造器。这就是为什么构造器里能直接读 int 字段得到 0,而局部变量必须先赋值。

内存怎么分配:两种方式

方式适用场景原理
指针碰撞堆内存规整(GC 后有压缩)一个指针指向”已用”和”空闲”的分界,分配就是指针往后挪
空闲列表堆内存不规整(标记-清除后)维护一张空闲块表,找一块够大的

类比:指针碰撞 = 停车场从前往后依次停;空闲列表 = 停车场随便停,墙上挂个空位表。

并发分配:TLAB

多线程同时 new,指针碰撞会打架。解决方案是 TLAB(Thread Local Allocation Buffer):

每个线程先向仓库领一小块专属区域,自己的小件放自己这块地,不用每次都排队找仓库管理员。用完了再领下一块。

  • 默认开启,大小由 JVM 自适应(-XX:TLABSize 可调)
  • 大对象不会走 TLAB,直接在 Eden 分配

对象在内存里长什么样

┌──────────────────────┐
│   Mark Word(8 字节) │  hashCode、GC 年龄、锁状态
├──────────────────────┤
│  类型指针(4 字节)    │  指向方法区的类元信息(压缩指针)
├──────────────────────┤
│  实例数据             │  int、引用等字段
├──────────────────────┤
│  对齐填充             │  补齐到 8 字节的整数倍
└──────────────────────┘

Mark Word 是锁升级的核心(多线程篇讲过):

状态存储内容
无锁hashCode + 分代年龄 + 偏向标志
偏向锁线程 ID + epoch
轻量级锁指向栈中锁记录的指针
重量级锁指向 Monitor 的指针

JDK 25 的紧凑对象头(JEP 519):把 Mark Word 和类型指针压缩进 8 字节,小对象的内存占用明显下降,缓存命中率更高。面试提一句就是加分项。

指针压缩:32GB 的由来

64 位 JVM 里引用本来是 8 字节,太浪费。开启 -XX:+UseCompressedOops(默认开启)后变成 4 字节,但只能寻址 2³² × 对齐粒度:

堆小于 32GB 时指针压缩生效,超过 32GB 就失效——这就是”堆不要设成 32GB 以上,宁可用多个小堆”这个经验的来源。32~48GB 区间是最尴尬的:堆大了,指针也变大了,性价比反而下降。

对象一定在堆上吗

不一定。 经过逃逸分析后,如果对象不会逃出方法,JIT 可以做:

  • 标量替换:把对象拆成几个局部变量,直接放栈/寄存器
  • 栈上分配:本质是标量替换的结果,对象根本不进堆
  • 锁消除:对象只被一个线程访问,synchronized 直接去掉
public int sum() {
    Point p = new Point(1, 2);   // p 没逃逸
    return p.x + p.y;            // 优化后等价于 return 1 + 2
}

类比:小件食材不用进仓库上架,直接放在手边——用完就没了,保洁都不用管。 这也是为什么”所有对象都在堆上”这句面试答案,要补一句”JIT 优化后可能有栈上分配”。


8.3 垃圾回收(上):怎么找出垃圾

两种找法

算法原理问题采用
引用计数每个对象记一个被引用次数,归零就是垃圾循环引用永远清不掉❌ JVM 不用(Python 用)
可达性分析从 GC Roots 出发遍历,走不到的对象就是垃圾需要 STW 保证一致性✅ JVM 用

类比:引用计数像”每本书借出时记一笔”,还回来划掉,但两个人互相借对方的书,两本都永远显示”借出中”。 可达性分析像从大门进入大楼,能走到的房间都有人用;走不到的房间,不管里面东西怎么互相引用,一律清空。

GC Roots 有哪些

  1. 虚拟机栈中局部变量表引用的对象(正在执行的方法参数、局部变量)
  2. 方法区中静态属性引用的对象
  3. 方法区中常量引用的对象(字符串常量池等)
  4. 本地方法栈中 JNI 引用的对象
  5. 活跃线程本身
  6. synchronized 持有的锁对象
  7. JVM 内部引用(基本类型 Class、常驻异常、系统类加载器)

记住一个判断标准:只要还能从”正在运行的程序”直接摸到,就不是垃圾。

四种引用:垃圾的”优先级”

引用类型回收时机类比典型用途
强引用 new永不主动回收,除非不可达家人:只要还在,就不走普通对象
软引用 SoftReference内存不足时才回收应急储备:平时留着,缺钱再卖本地缓存
弱引用 WeakReference下次 GC 必回收一次性用品:看到就扔ThreadLocal 的 key、WeakHashMap
虚引用 PhantomReference回收时收到通知,无法通过它拿对象影子:只能知道”人没了”堆外内存回收(Cleaner)
// ThreadLocal 的内存泄漏就藏在这里
ThreadLocalMap.Entry extends WeakReference<ThreadLocal<?>>
// key 是弱引用(会被回收),value 是强引用(不会)
// → 线程池里线程不结束,value 永远清不掉 → 必须手动 remove()

垃圾什么时候开始收

类型触发条件范围
Minor GC / Young GCEden 区满新生代
Major GC / Old GC老年代满老年代
Mixed GCG1 特有,老年代占用达到阈值(默认 45%)新生代 + 部分老年代
Full GC元空间不足、晋升失败、System.gc()、G1 的兜底整个堆 + 方法区

⚠️ 不要显式调用 System.gc():它只是建议,JVM 可能真的做一次 Full GC。线上可以加 -XX:+DisableExplicitGC 屏蔽。很多第三方库(尤其是 NIO 的 DirectByteBuffer)会调它,需要甄别。

finalize() 为什么被废弃

  • 执行时机不确定,可能永远不执行
  • 让对象”复活”,逻辑复杂
  • 拖慢 GC(需要额外队列和线程)

JDK 9 标记废弃,JDK 18 标记为 deprecated for removal。替代方案是 Cleaner(java.lang.ref.Cleaner)或 try-with-resources。


8.4 垃圾回收(下):怎么清理

三种基础算法

算法做法优点缺点类比
标记-清除标记垃圾 → 直接清掉简单内存碎片把空房间的东西搬走,房间还是碎的
标记-复制存活对象复制到另一半 → 清空当前半区无碎片、快浪费一半空间把有用的东西全搬进新仓库,旧仓库直接清空
标记-整理标记 → 存活对象向一端移动 → 清理边界外无碎片、不浪费移动成本高把有用的东西码到仓库一头,另一头整体清空

分代收集:三种算法各司其职

核心假设:绝大多数对象朝生夕死(弱代假设)。所以:

区域特点算法比例
新生代对象大量死亡,存活少复制(Eden : S0 : S1 = 8 : 1 : 1)占堆约 1/3
老年代对象活得久,存活多标记-整理(或标记-清除)占堆约 2/3

一次 Minor GC 的过程:

Eden 满
 → 把 Eden + 当前 Survivor 里的存活对象复制到另一块 Survivor
 → 清空 Eden 和原 Survivor
 → 存活对象年龄 +1
 → 年龄达到阈值(默认 15)→ 晋升老年代

为什么 Survivor 要两块:复制算法需要一块空的”目标区”。两块 Survivor 轮流当”当前”和”目标”,始终有一块是空的。所以新生代实际可用空间是 90%(Eden 80% + 一块 Survivor 10%)。

并发标记的难题:三色标记

GC 线程和应用线程同时跑时,对象的引用关系还在变。用三种颜色描述状态:

颜色含义
白色还没访问到(最终可能是垃圾)
灰色自己访问了,但引用的对象还没访问完
黑色自己和所有引用都访问完了

漏标发生在两个条件同时满足时:

  1. 黑色对象新增了一条指向白色对象的引用
  2. 灰色对象删掉了到该白色对象的所有引用

类比:你已经检查完房间 A(涂黑),宣布”这间没东西了”;结果有人从 A 里搬了个箱子到 B,又把 B 里通向箱子的路标拆了——最后箱子被当成垃圾扔掉。

两种解决方案:

方案做法使用者
增量更新记录新增的引用,标记结束后重新扫描CMS
原始快照 SATB记录删除前的引用,按开始时的快照标记G1、Shenandoah

对应到代码里就是写屏障(write barrier)——每次引用赋值时插一段小逻辑,像小区门口的登记本。

Stop The World(STW)

GC 时必须暂停应用线程,否则对象在标记过程中乱跑,结果不可信。

类比:保洁要清点仓库,必须让大家先停手,不然刚数过的货架又被搬进新货。

收集器的进化史,本质就是一部”缩短 STW”的历史:

Serial   → 全停,单线程
Parallel → 全停,多线程(吞吐优先)
CMS      → 大部分并发,但碎片化、有 Concurrent Mode Failure
G1       → 分区域、可预测停顿
ZGC      → 亚毫秒级停顿,几乎与堆大小无关

8.5 垃圾收集器:几套人马

总览

收集器分代并发停顿适用现状
Serial新生代❌ 串行长客户端、小堆仍在
ParNew新生代❌ 并行中配合 CMS随 CMS 边缘化
Parallel Scavenge新生代❌ 并行中吞吐量优先仍在
Serial Old老年代❌ 串行长小堆兜底仍在
Parallel Old老年代❌ 并行中吞吐优先仍在
CMS老年代✅ 并发短低延迟JDK 14 移除
G1整堆分 Region✅ 并发可预测默认,中大堆主力
ZGC整堆✅ 并发< 1ms超大堆、极低延迟JDK 15 转正,23 默认分代
Shenandoah整堆✅ 并发低低延迟非 HotSpot 默认
Epsilon———压测、短命程序JDK 11

G1:现在的默认

核心思想:把堆切成大小相等的 Region(1~32MB,2 的幂),每个 Region 动态扮演 Eden / Survivor / Old / Humongous(大对象)。

类比:不再是”三个固定的大仓库”,而是”一栋楼里很多小房间”,每次只挑垃圾最多、回收收益最高的几个房间清理(G1 的 “Garbage First” 由此得名)。

特性说明
可预测停顿-XX:MaxGCPauseMillis=200,G1 按这个目标挑 Region
Mixed GC同时回收新生代 + 部分老年代(按收益排序)
Remembered Set记录跨 Region 引用,避免全堆扫描
SATB并发标记用原始快照,漏标靠写屏障记录
Humongous≥ Region 一半的大对象,直接进连续 Region
Full GC 兜底并发失败时会退化成串行 Full GC(要尽量避免)

调优参数:

-XX:+UseG1GC
-XX:MaxGCPauseMillis=200          # 目标停顿,不是硬保证
-XX:G1HeapRegionSize=4m           # Region 大小,通常不用手动设
-XX:InitiatingHeapOccupancyPercent=45  # 触发并发标记的堆占用阈值
-XX:G1NewSizePercent=5
-XX:G1MaxNewSizePercent=60

ZGC:为超大堆和极低延迟

关键技术作用
染色指针(Colored Pointers)把标记信息存在指针的高位,不用额外对象头
读屏障(Load Barrier)读取引用时检查并修正指针,实现并发重定位
并发标记 / 并发转移 / 并发重映射几乎所有阶段都与应用线程并发
分代(JDK 21+,23 起默认)区分新生代/老年代,回收更精准
-XX:+UseZGC
-Xmx16g
-Xlog:gc*:file=gc.log

注意:ZGC 的吞吐量通常低于 G1(读屏障有开销),不适合极致吞吐场景。它的战场是”堆几十 GB、要求每次 GC 停顿 < 1ms”的在线服务。

怎么选(面试可直接答)

小堆(< 4G)、单核、客户端         → Serial
吞吐优先(批处理、离线计算)        → Parallel
通用在线服务(默认)                → G1
超大堆 / 严格低延迟(金融、实时)    → ZGC
特殊:想完全关掉 GC 做压测          → Epsilon

GC 日志怎么看

-Xlog:gc*:file=gc.log:time,level,tags    # JDK 9+ 统一日志

日志里重点看:

  • Pause Young (Normal) / Pause Young (Concurrent Start):Young GC 次数与耗时
  • Pause Remark / Pause Cleanup:并发标记的停顿点
  • Pause Full:Full GC,重点告警对象
  • Heap after GC:回收后占用,持续上涨说明有内存泄漏
  • Metaspace:类加载过多(动态代理、热部署)时重点看

8.6 内存分配与回收策略

五条规则

规则说明参数
对象优先在 Eden 分配绝大多数对象在新生代”出生即死”—
大对象直接进老年代避免 Eden/Survivor 间大量复制-XX:PretenureSizeThreshold
长期存活对象进老年代年龄达到阈值-XX:MaxTenuringThreshold=15
动态年龄判定Survivor 中同龄对象总和 > 一半,直接晋升—
空间分配担保Minor GC 前检查老年代剩余空间是否够-XX:+HandlePromotionFailure(旧)

新生代默认比例

-Xmn1g                       # 新生代总大小
-XX:SurvivorRatio=8          # Eden : 单个 Survivor = 8 : 1
-XX:MaxTenuringThreshold=15  # 晋升年龄

也就是说:新生代 1G → Eden 800M、S0 100M、S1 100M。

一个完整的分代示例

1. new 对象 → Eden
2. Eden 满 → Minor GC:存活对象 → S0,年龄=1
3. Eden 又满 → Minor GC:Eden + S0 存活 → S1,年龄+1
4. 年龄到 15(或动态年龄判定)→ 晋升老年代
5. 老年代占用达到阈值 → 并发标记 → Mixed GC(G1)
6. 老年代也回收不动 → Full GC

常见 OOM 类型对照

报错区域常见原因
Java heap space堆内存泄漏、一次查太多数据、缓存无上限
GC overhead limit exceeded堆GC 花了 98% 时间却只回收不到 2%,即将 OOM
Metaspace元空间动态生成类过多(CGLIB、反射、脚本引擎)
unable to create new native thread栈/线程线程数过多,或 ulimit -u 限制
Direct buffer memory直接内存Netty/NIO 堆外内存未释放,或 -XX:MaxDirectMemorySize 太小
Requested array size exceeds VM limit堆数组长度超过 Integer.MAX_VALUE - 2

8.7 类加载机制:菜谱怎么进店

类的生命周期

加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载
 └──────────── 连接 ────────────┘
阶段做什么餐厅类比
加载读字节码,生成 Class 对象把菜谱从总部取回来
验证检查字节码合法、安全验货,防止假菜谱
准备静态变量分配内存、赋零值先把调料瓶摆好(空的)
解析符号引用 → 直接引用把”生抽”翻译成货架编号
初始化执行 <clinit>(静态变量赋值 + 静态块)按菜谱真正开始备料

⚠️ 高频陷阱:准备阶段赋的是零值,不是代码里写的值。 static int a = 10; 在准备阶段 a = 0,到初始化阶段才变成 10。 但 static final int A = 10; 是编译期常量,直接进常量池,甚至不会触发类初始化。

类加载器

加载器加载什么实现
Bootstrap ClassLoaderJAVA_HOME/lib 核心类(java.lang.*)C++ 实现,Java 里拿到是 null
Platform ClassLoaderJDK 平台模块(JDK 9 前叫 Extension)Java 实现
Application ClassLoader应用 classpathJava 实现
自定义 ClassLoader指定路径、热部署、加密类继承 ClassLoader,重写 findClass

双亲委派模型

protected Class<?> loadClass(String name, boolean resolve) {
    // 1. 自己加载过吗?
    Class<?> c = findLoadedClass(name);
    if (c == null) {
        // 2. 交给父加载器
        c = parent.loadClass(name);
        // 3. 父加载器搞不定,自己找
        if (c == null) c = findClass(name);
    }
    return c;
}

类比:新员工要查一份资料,先问直属领导,领导再问上级,一直问到总部;总部没有,才回到自己这一层找。 好处:

  1. 安全:你写一个 java.lang.String 也不会被加载,永远用 JDK 的
  2. 避免重复:同一个类在内存里只有一份

什么时候会打破双亲委派

场景怎么破为什么
SPI(JDBC 等)Thread.currentThread().getContextClassLoader()核心类要加载实现类,但实现类在应用 classpath,父加载器看不到子加载器的类
Tomcat每个 webapp 一个 WebappClassLoader,先自己后父类不同应用要隔离同名类、不同版本库
OSGi网状委派模块化热插拔
热部署 / 插件自定义加载器 + 卸载旧类重新加载修改后的类
JDK 9 模块化按模块图加载,不再是树模块依赖关系

SPI 那个最常考:JDBC 的 DriverManager 在 java.base 模块里,由 Bootstrap 加载;它要加载 MySQL 驱动,但驱动在应用 classpath。于是它借用当前线程的上下文类加载器,绕过了双亲委派的限制。

类初始化的 6 种主动引用

  1. new、读取/设置静态字段、调用静态方法
  2. 反射调用(Class.forName)
  3. 初始化子类时,父类先初始化
  4. 包含 main 方法的主类
  5. MethodHandle 解析结果
  6. 接口的 default 方法被实现类调用时,接口初始化

被动引用(不会初始化):

  • 通过子类引用父类的静态字段 → 只初始化父类
  • 定义数组 User[] arr = new User[10] → 不初始化 User
  • 引用 static final 常量 → 编译期已进常量池

8.8 编译与执行:代码怎么变快

解释 vs 编译

解释执行 = 逐句翻译,说一句翻一句,启动快、执行慢。 编译执行 = 整段翻好再执行,启动慢、执行快。

JVM 两者都用:先解释执行,热点代码再 JIT 编译。

热点探测

计数器触发场景
方法调用计数器方法被反复调用
回边计数器循环体反复执行

分层编译(默认开启)

层级编译方式特点
0解释执行启动快
1C1 简单优化快
2C1 + 少量 profiling过渡
3C1 + 完整 profiling收集类型信息
4C2 深度优化峰值性能最高

类比:新菜先按菜谱做(解释),做多了进入”熟练”(C1),再过一阵子变成”闭着眼睛都能做”(C2)。如果某天食材变了(类型变化),可能退化回解释执行——这就是去优化(Deoptimization)。

JIT 常见的优化手段

优化说明面试考点
方法内联把小方法直接展开到调用处最重要的优化,其他优化的前提
逃逸分析判断对象是否逃出方法栈上分配、锁消除
标量替换把对象拆成字段“对象一定在堆上吗”的答案
锁消除单线程访问的锁直接去掉StringBuffer 局部变量
循环展开减少循环控制开销—
常量折叠编译期算好常量表达式"a" + "b"

JIT 的代价

  • 预热:刚启动时是解释执行,吞吐低。压测要跑够时间(通常几分钟)再看稳定值。
  • Code Cache:JIT 编译结果放在 -XX:ReservedCodeCacheSize 里,满了会停用 JIT(只解释执行),性能骤降。
  • 去优化:运行时类型变化导致已编译代码失效。

AOT 与 GraalVM Native Image(2026 现状)

JITAOT / Native Image
启动慢(数百 ms ~ 数秒)极快(数十 ms)
峰值性能高(久跑之后)略低
内存较高低
动态性完整(反射、动态代理)受限,需要配置/编译期处理
适用长驻服务Serverless、CLI、边缘

Spring Boot 3 起支持 AOT 处理与 Native Image。但大多数业务后端仍在用 JIT —— 启动时间是分钟级的部署流程里不是瓶颈,而动态性更值钱。


8.9 排查工具与故障剧本

工具箱

工具用途
jps列出 Java 进程
jstat -gcutil <pid> 1000每秒打印 GC 统计(最常用)
jmap -heap <pid> / jmap -histo <pid>堆信息 / 对象直方图
jmap -dump:format=b,file=heap.hprof <pid>导出堆快照
jstack <pid>线程栈
jcmd <pid> GC.heap_info统一入口,JDK 9+ 推荐
JFR低开销飞行记录仪,JDK 11 开源,生产可用
JMC / VisualVM图形化分析
MAT堆转储分析(支配树、泄漏嫌疑)
Arthas线上诊断:dashboard、thread、watch、trace

剧本一:CPU 100%

# 1. 找到占 CPU 的 Java 进程
top

# 2. 找到进程里占 CPU 的线程(-H 显示线程)
top -Hp <pid>

# 3. 线程 ID 转 16 进制(jstack 里是 hex)
printf "%x\n" <tid>

# 4. 抓线程栈,搜索 nid=0x<hex>
jstack <pid> > stack.log
grep -A 30 "nid=0x<hex>" stack.log

常见结论:死循环、频繁 Full GC(看 jstat)、大量线程上下文切换、正则回溯爆炸。

剧本二:OOM

# 启动参数必须加:OOM 时自动 dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump/

# 分析
jmap -dump:format=b,file=heap.hprof <pid>
# 用 MAT 打开 → Leak Suspects → Dominator Tree

看什么:哪个类的实例最多、谁持有它们的引用(GC Root 路径)、是不是缓存没有上限、是不是一次性 select * 查了百万行。

剧本三:内存泄漏 / 频繁 GC

jstat -gcutil <pid> 1000 20
# 关注:O(老年代占用)是否持续上涨、FGC 次数是否在增加
  • 老年代回收后仍持续上涨 → 大概率泄漏
  • Young GC 很频繁但每次回收后很低 → 可能是 Eden 太小或对象创建速率过高
  • Full GC 频繁但回收效果差 → 检查大对象、元空间、直接内存

常用参数速查

-Xms4g -Xmx4g                    # 堆初始/最大,生产建议设成一样
-Xmn2g                           # 新生代
-Xss1m                           # 线程栈
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump/
-Xlog:gc*:file=/data/logs/gc.log:time,level,tags
-XX:+ExitOnOutOfMemoryError      # OOM 后自杀,让编排系统重启

容器里的坑

问题说明
JDK 8u191 之前不认 cgroup容器限制 2G,JVM 却按宿主机 64G 算堆,直接被 OOMKilled
JDK 10+ 容器感知-XX:+UseContainerSupport 默认开启
堆占比默认 MaxRAMPercentage=25,容器里通常要调到 60~75
非堆内存堆外、元空间、线程栈、Code Cache 都不在 -Xmx 里,容器 limit 要留余量
# 容器推荐写法
-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=70

2026 年跑在 K8s 里,别写死 -Xmx,用百分比让 JVM 自己读 cgroup limit。同时给 resources.limits.memory 留出 25%~30% 给堆外。


8.10 面试速查

问题一句话答案
JVM 内存区域有哪些线程私有:PC、虚拟机栈、本地方法栈;共享:堆、方法区(元空间);另有直接内存
哪个区域不会 OOM程序计数器
永久代和元空间区别JDK 8 起方法区移到本地内存的叫元空间,默认无上限,避免 PermGen OOM
对象创建过程类加载检查 → 分配内存 → 零值初始化 → 设对象头 → 构造器
对象一定在堆上吗不一定,逃逸分析后可能栈上分配/标量替换
指针压缩为什么和 32G 有关4 字节引用最多寻址 2³² × 8 字节 = 32GB,超过就失效
怎么判断对象是垃圾可达性分析,从 GC Roots 出发走不到的就是垃圾
GC Roots 有哪些栈中局部变量、静态变量、常量、JNI 引用、活跃线程、锁对象
四种引用强、软(内存不足回收)、弱(下次 GC 回收)、虚(回收通知)
为什么不用引用计数循环引用无法回收
三种 GC 算法标记-清除(碎片)、标记-复制(浪费空间)、标记-整理(移动成本)
新生代为什么用复制存活对象少,复制成本低;Eden:S0:S1 = 8:1:1
三色标记漏标黑→白新增引用 + 灰→白删除引用;增量更新 / SATB 解决
G1 和 CMS 区别G1 分 Region、可预测停顿、整体标记-整理局部复制;CMS 已移除
ZGC 为什么快染色指针 + 读屏障,标记/转移/重映射几乎全并发,停顿 < 1ms
默认收集器JDK 9+ 是 G1
双亲委派先问父加载器,父加载不了才自己加载;保证安全和唯一
怎么打破双亲委派重写 loadClass;SPI 用线程上下文类加载器;Tomcat 应用优先
类初始化时机new、静态字段/方法、反射、子类初始化、main 类、MethodHandle
准备阶段赋什么值零值;static final 常量在编译期进常量池
JIT 优化手段方法内联、逃逸分析、标量替换、锁消除、常量折叠
CPU 100% 怎么查top → top -Hp → printf %x → jstack 搜 nid
OOM 怎么查-XX:+HeapDumpOnOutOfMemoryError → MAT 分析支配树
线上频繁 Full GC 怎么排查jstat -gcutil 看老年代趋势 → dump → 找大对象/泄漏 → 看元空间和直接内存

下一篇:网络与 IO 篇——TCP 握手挥手、HTTP/2 与 HTTP/3、五种 IO 模型、epoll、零拷贝和 Netty。