JVM 的题最后都落到「日志里那句话」
内存分区、分代回收、类加载这些概念背下来不难,难的是线上真出事时知道先看什么:OOM 后面那句文案、CPU 高时那个十六进制线程号、老年代涨不涨。关键结论都给出真跑出来的数字。
01概念落到数字上空对象 16 字节、栈深两万多、老年代涨到多少,都是这台机器上真跑出来的。
02盯住两条线索OOM 的冒号后面那句话,和 jstack 里那个 nid。这两条能解决八成问题。
03和前端经验对上前端也要看堆快照和长任务,思路一样:先定位「谁在涨」,再找「为什么涨」。
1
运行时数据区:哪些是线程私有的
私有的一人一份,共享的大家一起抢
五块内存,私有的是程序计数器、虚拟机栈、本地方法栈;共享的是堆和方法区(JDK 8 之后叫元空间,挪到了本地内存)。堆是 GC 的主战场,栈不会 OOM 只会 StackOverflow——这一句能挡住一半的追问。
输出== 堆(所有线程共享)==
上限 8148 MB
已提交 512 MB
已使用 0 MB
== 非堆(元空间 + 代码缓存等)==
上限 不限制
已使用 2 MB
== 各个内存池(分代划分)==
CodeHeap 'non-nmethods' Non-heap memory 已用 1 MB
Metaspace Non-heap memory 已用 0 MB
CodeHeap 'profiled nmethods' Non-heap memory 已用 0 MB
Compressed Class Space Non-heap memory 已用 0 MB
G1 Eden Space Heap memory 已用 0 MB
G1 Old Gen Heap memory 已用 0 MB
G1 Survivor Space Heap memory 已用 0 MB
CodeHeap 'non-profiled nmethods' Non-heap memory 已用 0 MB
== 线程与类 ==
可用核数 16
活着的线程 7
已加载类 810
虚拟机 OpenJDK 64-Bit Server VM
线程私有(一人一份)
- 程序计数器:记着当前线程执行到哪条字节码。唯一一块不会 OOM 的区域,也是唯一定义了 OutOfMemoryError 之外行为的区域。
- 虚拟机栈:每个方法调用压一个栈帧,装局部变量表、操作数栈、动态链接、返回地址。请求太深就
StackOverflowError,-Xss调大小。 - 本地方法栈:给 native 方法用的,HotSpot 里和虚拟机栈合并实现了,所以平时感觉不到它存在。
线程共享(大家一起抢)
- 堆:所有对象实例和数组,GC 的主战场。分新生代(Eden + 两块 Survivor)和老年代,
-Xms/-Xmx控制。 - 方法区 / 元空间:类元信息、常量、静态变量。JDK 8 之前是永久代(在堆里,受
-XX:MaxPermSize限制),之后换成元空间(在本地内存,受-XX:MaxMetaspaceSize限制)。这也是为什么 JDK 8 之后String.intern()放进堆里了。 - 直接内存:NIO 的
DirectByteBuffer用的,不在堆里,但受本机内存限制,也会抛OutOfMemoryError: Direct buffer memory。
面试怎么答线程私有的是程序计数器、虚拟机栈和本地方法栈,共享的是堆和方法区。方法区 JDK 8 之后叫元空间,从堆里挪到了本地内存,所以不再受 -Xmx 限制,改用 MaxMetaspaceSize。堆溢出抛 OutOfMemoryError,栈溢出抛 StackOverflowError,这是两种不同的错误,栈那个调 -Xss,堆那个调 -Xmx。还有一个容易漏的:NIO 的直接内存不在堆里,报的是 Direct buffer memory。
2
一个对象在堆里占多少字节
对象头 12 字节,然后按 8 对齐
HotSpot 里一个对象 = 对象头(Mark Word 8 字节 + 类型指针 4 字节,指针压缩开着)+ 实例数据 + 对齐填充。所以空对象是 16 字节,多一个 int 变 24(因为 20 要补到 24),多一个 long 也是 24。字段顺序会被重排,为的是尽量少补白。
输出用「前后已用堆的差值 / 对象个数」估单个对象大小,N = 2000000
(每个对象都通过 next 链着,保证不会被提前回收)
对象形态 单个字节数
空对象 + 一个引用 16
再加一个 int 24
再加一个 long 24
再加四个 int 32
结论:对象头 12 字节(标记字 8 + 类型指针 4,指针压缩开时),
字段按 8 字节对齐补白,所以 12+4+4=20 会补到 24。
- 对着数字推一遍:只有引用 → 12 + 4 = 16,刚好对齐;加 int → 12 + 4 + 4 = 20,补到 24;加 long → 12 + 4 + 8 = 24,刚好;加四个 int → 12 + 4 + 16 = 32,刚好。这三行数字本身就说明了「补白」是怎么回事。
- Mark Word 里装了什么:hashCode、GC 分代年龄、锁状态标志、偏向线程 ID。它是复用的——同一块 8 字节,在无锁时存 hash 和年龄,在重量级锁时变成指向 monitor 的指针。这也是为什么「调用过 hashCode() 之后偏向锁会失效」。
- 指针压缩为什么能省一半:64 位机器上引用本该 8 字节,但 JVM 把对象地址按 8 字节对齐,于是只需存「第几个 8 字节」,4 字节就能寻址 32GB。
-XX:+UseCompressedOops,堆小于 32GB 时默认开。一旦堆超过 32GB 它必然关闭,对象变大、内存利用率反而下降——这就是「堆不要超过 32G」这个经验值的来源。 - 字段会被重排:JVM 会把 long/double 等 8 字节字段放前面、int 中间、byte 最后,尽量填满 8 字节的槽,减少补白。所以你在代码里写的字段顺序,跟内存里的布局不是一回事。
- 为什么关心这个:一个
Long包装类占 24 字节,一个long只占 8 字节。百万级的数据结构里混用包装类型,内存能差三倍,直接反映在 GC 压力和缓存命中率上。
面试怎么答对象在堆里是对象头加实例数据加对齐填充。对象头在 64 位、指针压缩开着的时候是 12 字节:8 字节 Mark Word 存 hashCode、GC 年龄和锁状态,4 字节类型指针指向类元信息。所以空对象 16 字节,加一个 int 是 20 但要对齐到 24,加一个 long 是 24。字段还会被 JVM 重排以减少补白。指针压缩能把引用从 8 字节压到 4 字节,但堆超过 32G 就会失效,这是「堆别超 32G」这个经验的根源。
3
一个方法调用,在栈里留下了什么
看懂字节码,就知道栈帧里放了什么
每次方法调用压入一个栈帧,里面四样东西:局部变量表(存参数和方法内局部变量)、操作数栈(做运算的临时区)、动态链接(指向常量池里本方法的引用)、返回地址。方法返回时这个栈帧整个弹出,所以局部变量跟着没了——这就是「栈上分配」这个说法的来源(其实是 JIT 的标量替换,见第 13 节)。
javap -c -p Frame 的部分输出 static int twice(int, int);
Code:
0: iload_0
1: iload_1
2: iadd
3: istore_2
4: iload_2
5: iconst_2
6: imul
7: ireturn
public static void main(java.lang.String[]);
Code:
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: iconst_3
4: iconst_4
5: invokestatic #13 // Method twice:(II)I
8: invokevirtual #19 // Method java/io/PrintStream.println:(I)V
11: return
- 逐条读一遍
twice:iload_0把第 0 个局部变量(参数 a)压到操作数栈;iload_1压 b;iadd弹出两个相加、把结果压回去;istore_2把栈顶存进第 2 个局部变量槽(就是 sum,第 0、1 槽被参数占了);再iload_2、iconst_2、imul、ireturn。整段就是在「局部变量表」和「操作数栈」之间倒腾数据。 - 局部变量表的槽位是复用的:静态方法第 0 槽就是第一个参数;实例方法第 0 槽固定是 this。这也是为什么
this能在方法里直接用——它就是一个普通局部变量。 - invokestatic / invokevirtual 是两种分派:静态方法和私有方法用
invokestatic/invokespecial,编译期就定死了;普通虚方法用invokevirtual,运行期按实际类型查虚方法表,这就是多态的实现方式。接口方法还有个invokeinterface。 - 为什么栈深度超了是 StackOverflow 而不是 OOM:栈是提前分配好大小的(
-Xss,默认 1MB),压不进去就是「溢出了」,不会去要新内存。堆不够才是OutOfMemoryError。 - 看字节码的实用场景:确认
String拼接有没有被优化、确认 lambda 是不是生成了invokedynamic、确认switch走的是 tableswitch 还是 lookupswitch、确认自动装箱发生在哪一行。javap -c -p是排查这些的最快手段。 - 和前端对照:JS 的调用栈是引擎自己管的,你只能在
new Error().stack里看到函数名和行号;Java 因为字节码是公开格式,能直接看到每条指令和栈操作,理解成本低得多。
面试怎么答每次方法调用会压入一个栈帧,里面有局部变量表、操作数栈、动态链接和返回地址。看 javap 的输出最清楚:iload 系列把局部变量压到操作数栈,iadd 弹出两个算完再压回去,istore 把栈顶存回局部变量槽。静态方法第 0 槽是第一个参数,实例方法第 0 槽固定是 this。invokestatic 和 invokespecial 是编译期定死的,invokevirtual 和 invokeinterface 是运行期按实际类型分派,这就是多态。栈大小是固定的,超了抛 StackOverflowError;堆不够才是 OutOfMemoryError。
4
类加载的五个阶段,和「什么时候才真的初始化」
准备阶段赋零值,初始化阶段才赋值
五个阶段:加载 → 验证 → 准备 → 解析 → 初始化。准备阶段只给静态变量赋零值(int 给 0、引用给 null),真正的赋值在初始化阶段执行
<clinit> 时才做。所以一个静态变量如果写了 static int A = 5,中间有一个时刻它的值确实是 0。输出1 main 开始,准备 new Child()
2 父类静态字段赋值
3 父类静态块
4 子类静态字段赋值
5 子类静态块
6 父类实例初始化块
7 父类构造方法
8 子类实例初始化块
9 子类构造方法
--- 再 new 一个,静态部分不会再执行 ---
6 父类实例初始化块
7 父类构造方法
8 子类实例初始化块
9 子类构造方法
- 整体顺序一句话:父类静态 → 子类静态 → 父类实例(块 + 构造)→ 子类实例(块 + 构造),各只执行一次的是静态部分。第二段输出就是证据:静态部分完全没再出现。
- 静态部分内部按代码书写顺序:我把
static int P = ...写在static { }前面,所以先 2 后 3;把两行对调,输出就会跟着对调。这是个很常见的坑——静态块里提前用了后面才赋值的静态变量,拿到的是零值。 - 实例部分也是这个规则:初始化块和字段赋值按书写顺序执行,最后才轮到构造方法体。所以构造方法里看到的值,是前面这些都跑完之后的。
- 什么时候触发初始化:
new、读或写静态字段(final 常量除外,编译期就塞进调用方了)、调静态方法、反射Class.forName(不带 initialize=false)、初始化子类时先初始化父类、作为启动类。反过来,只声明数组引用、只访问父类的静态字段,都不会触发。 - 面试常考的父子陷阱:
Child.P这种「通过子类访问父类静态字段」,只会初始化父类,子类压根不初始化。Child[] arr = new Child[3]也只创建数组对象,不初始化 Child。
面试怎么答类加载分加载、验证、准备、解析、初始化五个阶段。要注意准备阶段只给静态变量赋零值,真正的赋值在初始化阶段执行 clinit 时才做。执行顺序是父类静态、子类静态、父类实例、子类实例,静态部分只执行一次,而静态部分内部按代码书写顺序执行,所以静态块里用了后面才赋值的变量会拿到零值。触发初始化的典型动作是 new、访问静态字段或方法、反射 Class.forName、初始化子类。反过来通过子类访问父类静态字段只初始化父类,创建数组也不初始化元素类型。
5
双亲委派,和它被打破的三次★
先问爹,爹不行才自己来
收到加载请求时,先交给父加载器,一路问到顶;父加载器说加载不了,才自己动手。好处有两个:核心类库不会被自己写的同名类替换(安全),同一个类在各级加载器里只加载一次(不重复)。打破它的三个经典场景是 JDBC 的 SPI、Tomcat 的应用隔离、以及热部署/OSGi。
输出== 当前类的加载器链 ==
1 jdk.internal.loader.ClassLoaders$AppClassLoader@6d06d69c
2 jdk.internal.loader.ClassLoaders$PlatformClassLoader@33c7353a
最顶层是 bootstrap,它在 Java 里表示为 null
== 不同类的加载器 ==
String <- null (null = bootstrap)
LoaderDemo <- jdk.internal.loader.ClassLoaders$AppClassLoader@6d06d69c
javax.sql.DataSource <- jdk.internal.loader.ClassLoaders$PlatformClassLoader@33c7353a (平台类加载器,管非核心的 JDK 模块)
== 验证双亲委派:自己写的 findClass 什么时候才被调用 ==
spy.loadClass("java.lang.String") 实际加载者: null (交给了 bootstrap,findClass 没被调)
-> findClass 被调用: com.example.NotExist(说明父加载器找不到)
一路问到顶都没有,才轮到自己的 findClass
== 谁加载,决定类的身份 ==
spy 加载的 String 就是 String.class 本身吗: true (都委派到 bootstrap,所以是同一个 Class 对象)
反过来,同一个字节码被两个不同加载器加载,JVM 里就是两个类,互相不能强转
这就是 Tomcat 能让两个应用各带一份同名 jar 互不干扰的原理
- JDK 9 之后的三个内置加载器:Bootstrap(C++ 实现,加载
java.base等核心模块,在 Java 里表现为 null)、Platform(原扩展类加载器,管javax.sql这类非核心 JDK 模块)、App(加载 classpath 上的应用类)。 - 打破一:JDBC 的 SPI。
DriverManager在java.base里,由 bootstrap 加载,可它要加载的 MySQL 驱动在 classpath 上、只有 App 加载器认识。父加载器要请求子加载器,于是引入线程上下文类加载器(TCCL),用Thread.currentThread().getContextClassLoader()反向委派。这也是为什么框架里到处是 TCCL。 - 打破二:Tomcat 的应用隔离。每个 webapp 一个
WebappClassLoader,它先自己找,找不到才问父(和双亲委派相反),为的是让两个应用各带一份不同版本的 Spring,互不干扰。但java.*仍然强制走 bootstrap,防止你把核心类换掉。 - 打破三:热部署与 OSGi。要替换一个类就得换掉它的加载器——类身份是「加载器 + 全限定名」,所以热部署的做法是整个 ClassLoader 丢掉重建,而不是「重新加载同一个类」。OSGi 更进一步,做成了网状委派。
- 自定义加载器的实际用途:加载加密的 class 文件(
findClass里解密)、从非标准位置加载(数据库、网络)、做类隔离(中间件里跑多个版本的同名依赖)。写的时候重写findClass而不是loadClass——重写 loadClass 就等于把双亲委派整个拆了。
面试怎么答双亲委派是收到加载请求先往上问,父加载器加载不了才自己来。它保证核心类不被篡改,也保证同一个类只被加载一次。打破它有三次:第一次是 JDBC 这种 SPI,DriverManager 在核心库里却要加载业务 classpath 上的驱动实现,于是用线程上下文类加载器反向委派;第二次是 Tomcat,每个应用一个加载器,先自己找再问父,实现版本隔离,但 java 开头的类仍然强制走 bootstrap;第三次是热部署和 OSGi,靠换掉整个类加载器来替换类。自定义加载器要重写 findClass 而不是 loadClass,否则就把双亲委派拆了。
6
怎么判断一个对象该回收了
引用计数过不了循环引用这一关
主流 JVM 用可达性分析:从一组 GC Roots 出发往下遍历,遍历不到的对象就可回收。GC Roots 包括虚拟机栈里引用的对象、静态变量引用的对象、常量引用的对象、JNI 引用的对象、被 synchronized 持有的对象。不用引用计数,因为它解决不了循环引用。
输出强引用,gc 前: true
弱引用,gc 前: true
软引用,gc 前: true
虚引用,gc 前 get 到: null (永远是 null)
---- System.gc() 之后 ----
强引用: true <- 还在
弱引用: false <- 没了
软引用: true <- 通常还在,内存没紧张
虚引用入队了吗: true <- 回收时放进队列
四种引用强弱: 强 > 软 > 弱 > 虚
WeakHashMap 的 key 就是弱引用,key 一被回收整条 entry 自动消失
- 为什么不用引用计数:两个对象互相引用,计数永远不归零,谁也不是垃圾——但整个环其实都是垃圾。C++ 的 shared_ptr 就栽在这上面,得靠程序员手动 break 环或者用 weak_ptr。可达性分析从根上一刀切下去,环里的节点没有外部入口,一起回收。
- 四种引用对应四种生命策略:强引用宁可 OOM 也不回收;软引用在 OOM 之前回收,适合做缓存(比如本地图片缓存,内存紧了自动让位);弱引用只要 GC 就回收,适合「不必可少」的关联数据(
ThreadLocalMap的 key、WeakHashMap);虚引用只为收到回收通知,配合DirectByteBuffer释放堆外内存。 - 循环引用在 Java 里不是问题:这是和 C++、和 Swift 的 ARC 最大的区别。
a.next = b; b.next = a;之后如果没人引用 a 和 b,两个一起被回收,不需要手动断开。 - 不可达也不一定马上死:对象要经历两次标记,第一次不可达后判断要不要执行
finalize(),要执行的会进 F-Queue 里再复活一次机会。所以永远不要在 finalize 里做释放资源这种事,它执行时机不确定,甚至可能不执行。JDK 9 起已标记废弃。 - GC Roots 里最容易漏的一条:「局部变量表里引用的对象」。所以一个方法还没返回时,它用的对象一定不会被回收;反过来,一个已经返回的方法,它的局部变量槽可能还被复用前的值占着——这也是为什么在长方法里
obj = null曾经是个优化技巧。
面试怎么答主流 JVM 用可达性分析:从 GC Roots 出发遍历引用链,够不着的对象就可以回收。GC Roots 主要有虚拟机栈里引用的对象、方法区里静态变量和常量引用的对象、JNI 引用的对象、以及被锁持有的对象。不用引用计数是因为它处理不了循环引用,两个对象互指时计数永远不归零,而它们在 Java 里其实都是垃圾,可达性分析能从根上一刀切干净。另外引用分四种强度,强引用的宁可 OOM 也不收,软引用在 OOM 前收适合做缓存,弱引用一 GC 就收,虚引用只为回收通知,主要给 DirectByteBuffer 释放堆外内存用。
7
对象什么时候会进老年代
熬过几轮 Young GC 才够资格
三条路进老年代:年龄到阈值(默认 15,每熬过一次 Young GC 加一)、大对象直接进(超过
-XX:PretenureSizeThreshold,只对 Serial/ParNew 有效)、动态年龄判定(Survivor 里同龄对象总和超过一半,就从这个年龄往上的都直接晋升)。还有一条:Survivor 装不下,直接溢出到老年代。输出(-Xmx512m,具体数字每次略有不同)这段用 -Xmx512m 跑,堆小一点才看得清涨落
== 一开始 ==
Eden 已用 2 MB
老年代 已用 0 MB | 上限 512 MB
Young GC 累计 0 次 | Old/Full GC 累计 0 次
== 造了约 31 MB 垃圾之后 ==
Eden 已用 10 MB
老年代 已用 18 MB | 上限 512 MB
Young GC 累计 1 次 | Old/Full GC 累计 0 次
== 造了约 93 MB 垃圾之后 ==
Eden 已用 27 MB
老年代 已用 24 MB | 上限 512 MB
Young GC 累计 3 次 | Old/Full GC 累计 0 次
== 造了约 156 MB 垃圾之后 ==
Eden 已用 94 MB
老年代 已用 24 MB | 上限 512 MB
Young GC 累计 3 次 | Old/Full GC 累计 0 次
== 造了约 218 MB 垃圾之后 ==
Eden 已用 160 MB
老年代 已用 24 MB | 上限 512 MB
Young GC 累计 3 次 | Old/Full GC 累计 0 次
Eden 一再被打满又清空,老年代基本不动 —— 朝生夕灭的对象活不过一次 Young GC
== 按住 31 MB 不放,再反复造垃圾 ==
Eden 已用 93 MB
老年代 已用 69 MB | 上限 512 MB
Young GC 累计 9 次 | Old/Full GC 累计 0 次
老年代涨了:这批对象熬过多次 Young GC,年龄到阈值就被晋升
还活着 31 MB
- 前半段看什么:Eden 从 10MB 涨到 160MB 又回落,Young GC 从 1 次到 3 次,老年代只在 24MB 附近微动。这就是「绝大多数对象都是朝生夕灭」的直观证据,也是分代收集能成立的前提——只需要扫一小块新生代,就能回收掉绝大部分垃圾。
- 后半段看什么:先按住 31MB 不让它死,再反复造垃圾把 Young GC 推到 9 次。这批对象每熬过一次 GC 年龄加一,到阈值(G1 默认也是 15,但实际常在 Survivor 装不下时提前晋升)就搬进老年代,于是老年代从 24 涨到 69。
- 为什么老年代一开始就有 24MB:JVM 启动时加载的大量类元数据、字符串常量、以及前几次 GC 里侥幸活下来的对象。所以「老年代已用」从来不是从 0 开始的,排查时看趋势别看绝对值。
- 动态年龄判定这个规则要背:Survivor 里所有同龄对象加起来超过 Survivor 空间的一半,那么年龄大于等于这个年龄的对象直接晋升,不用等到 15。这就是为什么把 Survivor 调太小会「莫名其妙地频繁 Full GC」——对象还没来得及死就被推去老年代了。
- 大对象那条路:需要连续内存的大数组(典型是超大
HashMap的初始容量、一次查出几十万行的结果集)直接进老年代,绕开新生代。G1 里叫 humongous 对象,超过 Region 一半就算,它直接占连续的几个 Region。频繁创建这种对象会让老年代快速膨胀,是「莫名其妙 Full GC」的另一大来源。
面试怎么答对象进老年代主要有四条路。第一是年龄到阈值,默认 15,每熬过一次 Young GC 加一岁。第二是大对象直接进,G1 里是超过 Region 一半的 humongous 对象,典型是超大数组。第三是动态年龄判定,Survivor 里同龄对象总和超过一半,这个年龄以上的就都晋升。第四是 Survivor 装不下时溢出到老年代。这也解释了两个常见事故:Survivor 调太小会频繁晋升导致老年代涨得快,频繁创建超大数组会直接打老年代。
8
收集器演进:从串行到 G1★
每一代都是为了解决上一代的停顿问题
主线是「停顿越来越短」:Serial 单线程 STOP THE WORLD → Parallel 多线程但还停 → CMS 让标记和清理跟用户线程并发 → G1 把堆切成 Region 做可预测停顿 → ZGC 用染色指针把停顿压到 1ms 以内。但吞吐和停顿是一对矛盾,低延迟的代价永远是吞吐和 CPU。
Serial单线程,年轻代复制、老年代标记整理,全程 STW。客户端模式默认,几十兆堆够用。
→
Parallel多线程版 Serial,优化目标是吞吐量。JDK 8 服务端默认,适合批处理。
→
CMS第一次做到标记和清理与用户线程并发,停顿短。缺点:标记清除产生碎片、并发模式失败会退化成 Serial Old、还有浮动垃圾。JDK 9 废弃,14 移除。
→
G1堆切成大小相等的 Region,按「垃圾最多的优先」回收(Garbage First),可以指定目标停顿时间。JDK 9 起默认。
→
ZGC染色指针 + 读屏障,标记、移动、重定位几乎全并发,停顿稳定在 1ms 以内,堆大到 16TB 也能扛。JDK 15 转正。
输出== 当前 JVM 用的垃圾收集器 ==
G1 Young Generation
负责的区域: G1 Eden Space, G1 Survivor Space, G1 Old Gen
已收集次数: 0,累计耗时 0 ms
G1 Old Generation
负责的区域: G1 Eden Space, G1 Survivor Space, G1 Old Gen
已收集次数: 0,累计耗时 0 ms
== 它在 JDK 17 里是默认的 ==
JDK 8 默认 Parallel,JDK 9 起默认 G1,JDK 21 引入分代 ZGC 但仍以 G1 为默认
== 堆的边界 ==
初始 512 MB | 已提交 512 MB | 上限 8148 MB
从「已提交」到「上限」之间是可以继续向 OS 要的余量
== 每个内存池现在的样子 ==
G1 Eden Space | 上次 GC 后已用 0 MB | 此刻已用 0 MB
G1 Old Gen | 上次 GC 后已用 0 MB | 此刻已用 0 MB
G1 Survivor Space | 上次 GC 后已用 0 MB | 此刻已用 0 MB
- CMS 为什么被淘汰:三个硬伤。一是标记-清除不整理,跑久了全是碎片,大对象分配不下就只能提前触发 Full GC;二是并发模式失败——预留的老年代空间不够用户线程继续分配,CMS 还没标完就得退化成 Serial Old 单线程全停,那一下停顿能到秒级;三是浮动垃圾,并发标记期间新产生的垃圾这一轮收不掉。
- G1 的关键改动:不再按物理分代切,而是把堆切成 1~32MB 的 Region(默认约 2048 个),每个 Region 动态扮演 Eden / Survivor / Old。回收时优先挑垃圾最多的 Region(这就是 Garbage First 的名字来源),并且可以按
-XX:MaxGCPauseMillis指定的目标停顿来挑选这次回收哪几个 Region。 - G1 也有 Full GC:并发标记跟不上分配速度时会退化成串行的 Full GC,那一下可能几十秒。所以线上看到 G1 的了 Full GC,通常意味着有内存泄漏或者堆给太小,不是「G1 没调好」。
- 停顿、吞吐、内存是三角:停顿短 → 每轮回收的 Region 少 → 回收跟不上 → 触发更频繁的回收 → 吞吐下降。所以「把 MaxGCPauseMillis 调到 5ms」不会让系统变快,只会让 GC 更频繁。
- 怎么选:堆 4GB 以内、在意延迟 → G1;堆很大(几十 GB)、延迟要求到毫秒级、CPU 核数够 → ZGC;纯批处理、只在意总耗时 → Parallel。别为了「新」而上 ZGC,它的读屏障有固定开销,小堆收益不明显。
面试怎么答演进的主线是缩短停顿。Serial 单线程全停,Parallel 多线程但还是全停、优化吞吐,CMS 第一次让标记和清理跟用户线程并发,G1 把堆切成 Region 按垃圾多少优先回收、并能指定目标停顿,ZGC 用染色指针加读屏障把停顿压到 1 毫秒内。CMS 被淘汰是因为标记清除有碎片、并发模式失败会退化成单线程全停、还有浮动垃圾。G1 是 JDK 9 之后的默认,但它并发跟不上时也会退化 Full GC,线上真出现通常说明堆太小或有泄漏。选择上堆不大在意延迟用 G1,堆几十 G 且要毫秒级停顿用 ZGC,批处理用 Parallel。
9
G1 和 ZGC 到底差在哪★
一个是「尽量短」,一个是「基本不停」
G1 的目标是把停顿控制在你能接受的范围内(比如 200ms),它是「可预测」;ZGC 的目标是停顿与堆大小无关,永远 1ms 以内,它是「几乎不停」。代价是 ZGC 需要更多 CPU(读屏障开销)和更大的堆余量(并发期间还要分配),而且小堆上收益看不出来。
G1 的设计取舍
- 分 Region 回收,每次只挑一部分,用
-XX:MaxGCPauseMillis当预算来挑——停顿可控但不确定,堆越大越难压住。 - 用记忆集(RSet)记录「谁引用了我这个 Region 的对象」,避免全堆扫描。RSet 本身要占内存,通常 5%~10%。
- 对象移动是「复制」,需要 STW 做根处理,所以哪怕是很短的停顿,也还是有停顿。
ZGC 的设计取舍
- 染色指针:把标记位直接放进 64 位指针的高位,对象引用本身就带着状态,不需要额外标记位图。
- 读屏障:每次读引用时插一小段代码检查指针状态,需要时修正——好处是标记和移动都能并发做,代价是每次读都多一点开销。
- 并发移动 + 转发表:对象搬走后旧地址留一个转发记录,读屏障会自动换算到新地址,所以用户线程几乎不用停。
- G1 什么时候会输:堆特别大(几十上百 G)且对象生命周期混杂时,它的记忆集(RSet)维护成本会变得明显,而且「优先回收垃圾最多的 Region」在极端情况下会让某次回收挑不到足够的回收价值,停顿目标压不住。
- ZGC 什么时候会输:堆只有几 G 的时候,读屏障的固定开销占比高,吞吐反而不如 G1;CPU 核数少(比如 2 核的 Pod)时并发线程抢不到资源,标记跟不上分配,会出现「分配停顿」(Allocation Stall)。
- 真实选择顺序:先看延迟预算。要求 200ms 以内、堆 16G 以下 → G1 足够;要求 10ms 以内、堆很大、机器核数够 → ZGC;只是跑批、在意总耗时 → Parallel。别一上来就上 ZGC。
- 两个都要注意的:一是对象分配速率,二是存活对象大小。分配速率高(每秒几百 MB)时任何收集器都会频繁触发回收;存活对象多(老年代常年 5G 以上)时每次回收的复制/标记成本都高。这两点靠调收集器是救不回来的,得改代码或加机器。
- 怎么验证选对了:不是看平均停顿,是看 P99 停顿和停顿频率。开
-Xlog:gc*:file=gc.log:time,uptime,跑一天,用 GCViewer 或者直接统计Pause行的分位数。低于 10ms 的停顿对业务基本无感,超过 200ms 就会被上游感知。
面试怎么答G1 和 ZGC 的差别是「可控停顿」和「几乎不停」。G1 靠切 Region 按垃圾多少优先回收,用 MaxGCPauseMillis 给回收定预算,所以停顿是「可预测但不确定」,堆越大越难压。ZGC 靠染色指针和读屏障,让标记和移动都能并发,停顿跟堆大小无关,稳定在 1ms 内。代价是 ZGC 的读屏障有固定 CPU 开销,还需要额外堆余量,小堆和低核数环境下反而更差,会出现分配停顿。所以我的判断标准是延迟预算:200ms 以内、十几 G 堆用 G1 就够,要 10ms 以内且堆很大、机器核数够才上 ZGC。验证看 P99 停顿和停顿频率,不看平均值。
10
参数、容器感知与出事时的现场
会看生效值,比会背默认值有用
参数不用背全,但这几个必须张口就来:
-Xms/-Xmx、-Xss、-XX:MetaspaceSize、-XX:MaxGCPauseMillis、-XX:+HeapDumpOnOutOfMemoryError。更重要的是知道怎么确认它真的生效了——下边这段代码读的就是「此刻进程里实际生效的值」,不是文档里的默认值。输出(-Xmx512m -Xss512k)JVM: OpenJDK 64-Bit Server VM 17.0.13+11
== 真实生效的参数(不是默认值,是这台进程此刻的值)==
MaxHeapSize 536870912
InitialHeapSize 534773760
MaxMetaspaceSize -1
ThreadStackSize 512
UseG1GC true
UseCompressedOops true
MaxRAMPercentage 25.0
ActiveProcessorCount -1
MaxHeapSize = 536870912 就是 -Xmx512m,ThreadStackSize = 512 就是 -Xss512k;
ThreadStackSize 单位是 KB,显示 0 表示没显式指定,走平台默认(通常 1MB)。
这段是用 -Xmx512m -Xss512k 跑的,所以上面两个数就是被改过之后的生效值。
MaxRAMPercentage 是 JDK 10 起容器里按内存百分比算堆上限的依据,默认 25%。
ActiveProcessorCount = -1 表示没手动指定,自动探测(本机 16 核)。
- 这几个参数要能背下来:
-Xms / -Xmx堆初始和上限(生产上设成一样,避免运行期反复扩缩);-Xss每线程栈大小;-XX:MetaspaceSize / MaxMetaspaceSize元空间;-XX:MaxGCPauseMillisG1 的目标停顿;-XX:+HeapDumpOnOutOfMemoryError配-XX:HeapDumpPath出事时留现场;-XX:+PrintGCDetails(JDK 9+ 换成-Xlog:gc*)。 - 容器里最容易踩的坑:JDK 8u191 之前不认 cgroup 内存限制,Pod 限 2G 它按宿主机 64G 算,堆开到 16G,一跑就被 OOMKilled。之后虽有
UseContainerSupport,也用MaxRAMPercentage而不是-Xmx——这样镜像里的默认值能随容器规格自动适配。 - 堆不是越大越好:一是超过 32G 指针压缩失效(第 2 节),二是堆大了 Full GC 一次就很长,三是给堆留太多会让堆外内存和系统页缓存不够。8~16G 是个常见的舒服区间。
- OOM 时要有现场:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump必须加,否则只能靠复现。dump 文件通常跟堆一样大,记得挂持久卷并加清理策略,不然能把磁盘写满。 - 会看生效值比会背默认值重要:面试官问「你线上用什么参数」,能说出来怎么确认的(比如这个
HotSpotDiagnosticMXBean、或者jinfo -flags <pid>、-XX:+PrintFlagsFinal)比背数字有说服力。
面试怎么答G1 的目标是停顿可预测,靠切 Region 和按垃圾多少优先回收,用 MaxGCPauseMillis 当预算;ZGC 的目标是停顿与堆大小无关,靠染色指针加读屏障让标记和移动都能并发,停顿稳定在 1 毫秒内。代价是 ZGC 的读屏障有固定 CPU 开销,还需要额外堆余量,小堆上看不出收益,所以几十 G 以下还是 G1 划算。实际配参数时我会把 Xms 和 Xmx 设成一样避免抖动,容器里用 MaxRAMPercentage 让堆随规格自适应,并且一定加 HeapDumpOnOutOfMemoryError 留现场。
11
OOM 不止一种,报错文案就是线索★
看冒号后面那句话,直接定方向
OutOfMemoryError 后面那句提示就是诊断方向:Java heap space 是堆里对象太多或太大;Metaspace 是类加载太多(动态代理、热部署);unable to create native thread 是线程太多;Direct buffer memory 是堆外内存没释放;GC overhead limit exceeded 是 GC 忙了半天没回收出空间。输出(-Xmx64m,为了几秒内就撑爆)1) 数组长度超限 -> OutOfMemoryError: Requested array size exceeds VM limit
2) 堆装不下 -> OutOfMemoryError: Java heap space
3) 慢慢涨到撑爆 -> OutOfMemoryError: Java heap space
撑爆前一共 hold 了 6 个 8MB 数组,约 48 MB
注意:OutOfMemoryError 是 Error,catch (Exception e) 抓不到,
而且 catch 住之后对象往往还没被回收,能不能继续跑要看现场。
- 六种常见文案对应六个方向:
Java heap space查堆里的对象(jmap -histo看谁最多);Metaspace查动态生成的类(反序列化框架、CGLIB、Groovy 脚本、热部署没卸载干净);unable to create native thread查线程数(是不是线程池没上限,或者-Xss太大);Direct buffer memory查 NIO 的堆外内存;GC overhead limit exceeded说明 98% 的时间在 GC 却只回收了不到 2%,几乎是内存泄漏的确诊;Requested array size exceeds VM limit是长度太离谱。 - 为什么 1) 和 2) 不同:
Integer.MAX_VALUE个 long 已经是 16GB,但更关键的是「单个数组的长度上限」是 VM 的约束(大约是Integer.MAX_VALUE - 8),超过就直接拒绝,连尝试分配都不做。所以调-Xmx也救不了 1)。 - catch 住 OOM 之后不能当没事:这个 Error 抛出时堆已经满了,后续任何一次分配都可能再炸,而且线程可能处于半完成状态。正确的做法是记日志、dump、然后让进程退出,交给 K8s 重启。硬撑着继续对外服务,通常会造成更大面积的错误。
- 三种 OOM 的排查顺序:先看文案分方向 → 看监控确认是「阶梯式上涨」(泄漏,一定要看增长斜率)还是「一根尖刺」(大对象或突发流量)→ 泄漏就 dump 比对(隔段时间 dump 两次,用 MAT 看 dominator tree),尖刺就查那次请求的参数。
- 容器里的 OOMKilled 不是这个:被 K8s 杀掉时进程没有任何 Java 异常,日志戛然而止,
kubectl describe pod里能看到OOMKilled。这通常是「堆 + 堆外 + 元空间 + 线程栈」总和超过 limit,而-Xmx只约束了堆那部分。
面试怎么答OutOfMemoryError 后面那句话就是方向。Java heap space 是堆里的对象太多,用 jmap histo 或 dump 看谁占着;Metaspace 是动态生成的类太多,常见于 CGLIB、反射、热部署没卸载干净;unable to create native thread 是线程数到顶,通常是线程池无上限或者 Xss 太大;Direct buffer memory 是 NIO 堆外内存没释放;GC overhead limit exceeded 基本可以确诊内存泄漏,因为 98% 的时间在 GC 却收不回 2%。另外要区分容器层面的 OOMKilled,那个进程没有 Java 异常,日志直接断,是堆加堆外再加线程栈的总和超了 limit。
12
线上 CPU 100%,先找线程再找代码★
top -Hp 拿线程号,jstack 拿栈
四步:
top 找到进程 → top -Hp <pid> 找到最耗 CPU 的线程 → 把线程号转成十六进制 → jstack <pid> 里搜 nid=0x那个十六进制,栈顶就是它正在干的事。这套流程用 Java 代码也能复刻,本质就是「隔一段时间采两次每个线程的 CPU 时间,取差值排序」。输出模拟线上 CPU 100%:一个线程狂算,其余在睡
做法:隔一段时间采两次每个线程的 CPU 时间,取差值,排出最热的
采样窗口 1015 ms,各线程的 CPU 占用:
线程名 占比 状态
狂算线程 98% RUNNABLE
线上这么看:top -Hp <pid> 找到占 CPU 的线程号,转成 16 进制,
再 jstack <pid> | grep -A 20 nid=0x<那个十六进制>,就能看到它卡在哪行代码。
CPU 高但吞吐低
- 死循环 / 正则回溯:栈顶固定在同一行,多次采样都一样。典型是
Pattern写了个灾难性回溯的表达式、或者重试逻辑没退避。 - 频繁 Full GC:CPU 高但业务线程都在等其他。用
jstat -gcutil <pid> 1000看 FGC 列是不是在涨,是的话问题在内存不在 CPU。 - 线程数过多导致上下文切换:
top里sy(内核态)占比高、vmstat 1里cs每秒几十万,说明在疯狂切换而不是在算。
内存一直涨但没 OOM
- 先看是不是真泄漏:
jstat -gcutil多看几次,老年代用了一直不降、Full GC 后也降不下来,就是泄漏;反之是缓存没设上限。 - dump 两次比对:
jmap -dump:live,format=b,file=a.hprof <pid>,隔十分钟再 dump 一次,用 MAT 的「Compare Basket」看哪些对象在增长。一次 dump 只能看「谁大」,两次才能看「谁在长」。 - 不 dump 也能初筛:
jmap -histo:live <pid> | head -30一秒出结果,能看到哪个类的实例数和字节数最多。注意live会触发一次 Full GC,生产上慎用。
面试怎么答CPU 100% 的排查就四步:top 找进程,top -Hp 找线程,线程号转十六进制,jstack 里搜 nid 找到栈。关键是这个十六进制转换,很多人卡在这。如果栈顶一直停在同一个方法,基本就是死循环或者正则灾难性回溯;如果业务线程都在等而 CPU 还是高,那要先用 jstat 排除频繁 Full GC。内存问题的排查是另一样:先看老年代在 Full GC 后能不能降下来区分泄漏和缓存,然后隔一段时间 dump 两次用 MAT 比对,一次 dump 只能看谁大,两次才能看谁在长。
13
JIT、逃逸分析与标量替换
为什么「没分配对象」但代码里明明 new 了
Java 代码里的
new,不一定真的在堆上分配。JIT 编译时做逃逸分析,如果发现这个对象没逃出方法(没被返回、没被存到字段、没传给别的方法),就能把它拆成几个局部变量——这叫标量替换,对象压根不存在。所以「小对象多」不等于「GC 压力大」。输出造 30000000 个 16 字节的小对象,两种写法对比 GC 次数
这段要跑十几秒,因为第二轮真的在分配内存
不逃逸(对象只在循环体里用): GC 次数增加 0
逃逸(塞进 static volatile 字段): GC 次数增加 4
keep = 449999985000000(加这个是为防止 JIT 把整个循环删掉)
第一轮几乎没有 GC,就是标量替换起作用:对象被打散成两个局部变量 x、y,
压根没在堆上出现。这是 JIT 的功劳,跟代码写法有关但你看不见。
- 三千倍差距的来源:同样是三千万次
new,不逃逸的那轮 GC 一次没触发(对象被拆成两个 int 放在寄存器或栈上),逃逸的那轮触发了 4 次 Young GC(真的在堆上分配了 480MB)。这不是「优化技巧」,是「理解 JIT 之后不写出反优化代码」。 - JIT 的触发方式:方法被调用足够多次(
-XX:CompileThreshold,C1 约 1500 次、C2 约 10000 次)之后,热点代码被编译成机器码,这时代码里的对象分配就可能被消掉。所以这类优化只在「跑热了之后」生效,冷启动和大促刚扩容的那几分钟是享受不到的——这解释了为什么「刚发布时慢,跑一会儿就快了」。 - 锁消除也是同一套分析:
synchronized(new Object())这种锁对象根本不可能被别的线程拿到,JIT 直接把锁去掉。所以「局部对象加锁」不用担心性能,但反过来也别指望它有任何同步效果。 - 常见反优化写法:把对象塞进成员变量、放进集合、作为返回值返回、传给外部方法——只要有一个出口让它跑了,逃逸分析就失败。所以「为了省 GC 把对象复用成成员变量」往往是反向优化,既没省下反而带来线程安全问题。
- 别为这个改代码结构:标量替换是 JIT 的活,不是你的活。正确的顺序是先把代码写清楚,真遇到 GC 压力再上 profiler 看是谁在分配,最后才考虑要不要动结构。为了「让 JIT 好优化」而把代码写歪,是本末倒置。
面试怎么答JIT 在把热点方法编译成机器码时会做逃逸分析,如果发现这个新对象没被返回、没存进字段、没传给别的方法,就能把它拆成几个局部变量,这就是标量替换,对象根本不在堆上出现。我实测过三千万次 new,不逃逸的写法 GC 零次,塞进 static volatile 字段的写法触发四次 Young GC。所以小对象多不等于 GC 压力大。要注意这个优化只在代码跑热之后生效,刚发布的窗口期享受不到。另外锁消除是同一套分析,局部对象的 synchronized 会被直接去掉。
14
速答清单
39 条,考前扫一遍
这一节没有新知识,只有能直接说出口的短答案。盖住右边先自己答,答不上来的做个记号,第二轮只看记号。
01JVM 内存分哪几块?
线程私有:程序计数器、虚拟机栈、本地方法栈;共享:堆、方法区(JDK 8 后叫元空间)。外加直接内存。
02哪块内存不会 OOM?
程序计数器。它是唯一不抛 OutOfMemoryError 的区域。
03栈溢出和堆溢出分别抛什么?
栈抛 StackOverflowError(调 -Xss),堆抛 OutOfMemoryError: Java heap space(调 -Xmx)。
04永久代和元空间的区别?
永久代在堆里、受 -XX:MaxPermSize 限制;元空间在本地内存、受 -XX:MaxMetaspaceSize 限制。JDK 8 换的。
05一个空对象占多少字节?
16 字节。对象头 12(Mark Word 8 + 类型指针 4,指针压缩开着)+ 0 字段 + 补白到 8 的倍数。
06Mark Word 里存什么?
hashCode、GC 分代年龄、锁状态标志、偏向线程 ID。同一块 8 字节复用。
07为什么堆建议不超过 32G?
超过 32G 指针压缩失效,引用从 4 字节变 8 字节,对象变大、内存利用率反而下降。
08栈帧里有什么?
局部变量表、操作数栈、动态链接、返回地址。
09实例方法局部变量表第 0 槽放什么?
this。静态方法第 0 槽就是第一个参数。
10invokevirtual 和 invokestatic 的区别?
前者运行期按实际类型查虚方法表(多态),后者编译期就定死了。
11类加载的五个阶段?
加载、验证、准备、解析、初始化。
12准备阶段做了什么?
给静态变量赋零值。真正的赋值在初始化阶段执行 clinit 时才做。
13什么操作会触发类初始化?
new、访问静态字段或方法、Class.forName、初始化子类。读 final 常量、创建数组不触发。
14类初始化的执行顺序?
父类静态 → 子类静态 → 父类实例块 → 父类构造 → 子类实例块 → 子类构造,静态部分只执行一次。
15双亲委派是什么?
加载请求先交给父加载器,父加载器加载不了才自己加载。保证核心类不被替换、类不重复加载。
16JDK 9 之后有哪几个内置加载器?
Bootstrap(核心模块,Java 里是 null)、Platform(原扩展)、App(classpath)。
17双亲委派被打破的典型场景?
JDBC 的 SPI(用线程上下文类加载器反向委派)、Tomcat 应用隔离(先自己找)、热部署和 OSGi。
18自定义类加载器该重写哪个方法?
findClass。重写 loadClass 等于把双亲委派整个拆了。
19怎么判断对象可以回收?
可达性分析,从 GC Roots 遍历,够不着就可回收。不用引用计数,因为处理不了循环引用。
20GC Roots 有哪些?
虚拟机栈里引用的对象、静态变量和常量引用的对象、JNI 引用的对象、被锁持有的对象。
21四种引用强弱顺序?
强 > 软 > 弱 > 虚。软引用在 OOM 前回收、弱引用一 GC 就收、虚引用只为回收通知。
22对象什么时候进老年代?
年龄到阈值(默认 15)、大对象直接进、动态年龄判定(同龄总和超 Survivor 一半)、Survivor 溢出。
23为什么 Survivor 调太小会频繁 Full GC?
对象来不及死就被动态年龄判定推去老年代,老年代快速填满。
24分代收集为什么有效?
绝大多数对象朝生夕灭,只扫新生代就能回收绝大部分垃圾,不用每次全堆扫。
25CMS 为什么被淘汰?
标记清除有碎片、并发模式失败会退化成单线程全停、有浮动垃圾。JDK 14 移除。
26G1 的核心设计?
堆切成 Region,按垃圾多少优先回收,可指定目标停顿 MaxGCPauseMillis。
27G1 会有 Full GC 吗?
会。并发标记跟不上分配速度时退化,那一下可能几十秒,通常意味着堆太小或有泄漏。
28ZGC 为什么停顿这么短?
染色指针把标记信息放进指针高位,读屏障让标记和移动都能并发,停顿与堆大小无关。
29OutOfMemoryError 常见有哪几种?
heap space、Metaspace、unable to create native thread、Direct buffer memory、GC overhead limit exceeded。
30GC overhead limit exceeded 说明什么?
98% 的时间在 GC 却只回收不到 2%,基本可以确诊内存泄漏。
31CPU 100% 怎么查?
top 找进程,top -Hp 找线程,线程号转十六进制,jstack 里搜 nid 找栈。
32内存泄漏怎么确认和定位?
jstat 看 Full GC 后老年代降不下来确认泄漏,隔十分钟 dump 两次用 MAT 比对增长的对象。
33什么是指针压缩?
对象地址按 8 字节对齐,只存「第几个 8 字节」,4 字节就能寻址 32G。堆超 32G 自动关闭。
34JIT 什么时候开始优化?
方法调用到阈值(C1 约 1500 次、C2 约 10000 次)后编译成机器码,所以刚发布时享受不到。
35逃逸分析和标量替换是什么?
对象没逃出方法时,JIT 把它拆成几个局部变量,不在堆上分配,GC 压力为零。
36锁消除是什么?
锁对象不可能被别的线程拿到时,JIT 直接把 synchronized 去掉,同一套逃逸分析。
37容器里 OOMKilled 和 Java OOM 的区别?
OOMKilled 是 K8s 杀的,进程没有 Java 异常、日志直接断;Java OOM 有异常栈。前者是堆加堆外加线程栈总和超 limit。
38为什么 Xms 和 Xmx 要设成一样?
避免运行期反复扩缩容带来的抖动和额外 GC。
39-XX:MaxGCPauseMillis 调小会让系统变快吗?
不会。只是让每轮回收的 Region 更少、回收更频繁,吞吐反而下降。