主题 03JVM 内存与对象
前两章讲的是「你写出来的代码」,这一章讲代码跑起来之后内存里发生了什么。
它之所以排在第三,是因为它是一个放大器:同一个「递归会不会炸」「OOM 怎么排查」的问题,
懂 JVM 的人答出来是三层,不懂的人只有一句「内存不够」。
而且这一章和算法直接打通——你写的每一个递归、每一个 new int[n],都在这一章的地盘上。
3.1为什么第三站是 JVM
JVM 是八股里最容易「背得很熟、一问就露」的一块。原因是大多数人背的是名词表 (堆、栈、方法区、双亲委派),没背因果关系。面试官只要往下追一层,名词表就撑不住了:
| 你听到的问题 | 它其实在问 | 说错的代价 |
|---|---|---|
| 「这个方法递归 10 万层怎么就挂了?」 | 栈帧与栈大小 | 答「内存不够」→ 立刻被追问「哪个内存」,答不上就结束 |
| 「一个对象在内存里占多少字节?」 | 对象头 + 对齐 | 说「看字段加起来」→ 说明没看过真实布局 |
| 「软引用和弱引用什么区别?」 | 四种引用强度 | 混着说 → 等于承认只背过名字 |
| 「双亲委派为什么要打破?」 | 类加载器隔离 | 只会背定义、举不出 Tomcat 和 JDBC 的例子 |
| 「线上 OOM 你怎么查?」 | 区分错误类型 + 工具链 | 张口就说「加内存」→ 这是最掉分的一句 |
所以这一章和上一章一个写法:每个结论都先给一段能跑出来的证据,再给一句话总结。
下面所有数字都是同一个 JvmDemo 类在 JDK 17(Windows x64)上跑出来的原样输出,
你照着敲完能一字不差地复现——这一点很重要,因为 JVM 的很多参数在不同平台、不同版本上不完全一样,
「我实测过」和「我看别人说」在面试里是两种可信度。
JvmDemo,五个演示各放在一个静态方法里,
用命令行参数选择跑哪个(java JvmDemo stack / header / refs / loader / oom)。
这样你可以只跑一节,也可以全跑。
3.2五个运行时数据区:先分清谁共享、谁会炸
「JVM 内存模型」这个说法其实有两个东西,很容易混:运行时数据区(内存怎么分块)和 Java 内存模型 JMM(多线程下可见性怎么保证)。这一节讲前者,后者在并发主题里讲。 先把这张表记住,它后面所有问题的地图:
| 区域 | 线程 | 存什么 | 撑爆了报什么 |
|---|---|---|---|
| 程序计数器 PC | 私有 | 当前线程执行到哪条字节码的行号 | 唯一不会 OOM 的区域 |
| 虚拟机栈 | 私有 | 栈帧:局部变量表、操作数栈、动态链接、返回地址 | StackOverflowError |
| 本地方法栈 | 私有 | native 方法调用(Object.hashCode 之类底层实现) |
StackOverflowError |
| 堆 | 共享 | 几乎所有对象实例和数组 | OutOfMemoryError: Java heap space |
| 方法区(JDK 8+ 叫 Metaspace) | 共享 | 类元信息、字段方法描述、运行时常量池 | OutOfMemoryError: Metaspace |
三个必须记准的细节:
- 字符串常量池不在方法区了。JDK 7 起搬到了堆里。所以 「方法区存字符串常量池」这句话在 JDK 8 之后是错的,问 JDK 版本就是在考这一点。
- Metaspace 用的是本地内存,不是堆。JDK 8 用 Metaspace 替掉永久代,最大的变化是
它默认没有上限(受物理内存限制),所以「永久代溢出」这个报错文本已经从 JDK 8 消失了。
要限制得自己加
-XX:MaxMetaspaceSize。 - 直接内存不在上面五个里。NIO 的
DirectByteBuffer分配在堆外, 但它归属堆里的一个 Java 对象管理。溢出时报OutOfMemoryError: Direct buffer memory, 而且它不受-Xmx约束——这是「明明堆没满却 OOM」的经典原因。
StackOverflowError)是调用太深,跟对象多少无关;
堆溢出是对象太多,跟调用深度无关。把这两个混为一谈,是最常见的第一个失分点。
一张图把五个区串起来
┌──────────── 所有线程共享 ────────────┐
│ 堆(Heap) │
│ new 出来的对象、数组、字符串常量池 │ ← -Xmx 管这里
├──────────────────────────────────────┤
│ Metaspace(方法区,本地内存) │
│ 类元信息、常量、静态变量 │ ← -XX:MaxMetaspaceSize
└──────────────────────────────────────┘
线程 1 线程 2
┌──────────────┐ ┌──────────────┐
│ PC 寄存器 │ │ PC 寄存器 │ ← 私有,不 OOM
├──────────────┤ ├──────────────┤
│ 虚拟机栈 │ │ 虚拟机栈 │ ← -Xss 管这里
│ ├ frame(n) │ │ ├ frame(n) │ 溢出 = StackOverflowError
│ ├ ... │ │ ├ ... │
├──────────────┤ ├──────────────┤
│ 本地方法栈 │ │ 本地方法栈 │
└──────────────┘ └──────────────┘
注意箭头方向:对象永远在堆里,栈里放的是指向堆的引用。 这也是第 2 章「值传递」的物理基础——方法传参传的是栈里那个 4 字节(压缩后)的地址副本。
3.3栈帧:递归到底多深会炸(实测 22876 层)
先看一段必然溢出的代码。它没有任何终止条件,靠「什么时候炸」来告诉我们栈有多深:
public class JvmDemo {
static int depth = 0;
static void recurse() {
depth++;
recurse(); // 没有终止条件,必然栈溢出
}
public static void main(String[] args) {
try {
recurse();
} catch (StackOverflowError e) {
System.out.println("溢出时递归深度 = " + depth);
}
}
}
在本机 JDK 17 上跑,输出是:
[3.3] 溢出时递归深度 = 22876
两万两千多层。为什么不是十万?因为每次调用都要在栈上压一个栈帧, 默认栈大小只有 1 MB(Windows x64 的 HotSpot 默认值),除下来大约每帧 46 字节。 下面把「帧变大」和「栈变大」两件事分别测一遍,结论就清楚了。
第一组:帧变大 → 深度变浅
同样的递归,方法里多声明 3 个 long 局部变量,这三个变量会进局部变量表,帧就胖了:
static int depth2 = 0;
static void recurseFat() {
long a = 1, b = 2, c = 3; // 占栈空间,会进局部变量表
depth2++;
recurseFat();
System.out.print(a + b + c); // 让变量真的被用到,防止编译器优化掉
}
// 溢出时输出:
// [3.3] 带 3 个 long 局部变量时深度 = 7360
| 方法 | 溢出深度 | 说明 |
|---|---|---|
recurse() | 22876 | 帧里基本只有返回地址 |
recurseFat() | 7360 | 多 3 个 long,深度掉到 1/3 |
第二组:栈变大 → 深度变深
用 -Xss 改栈大小,同一个方法跑三次:
java -Xss256k JvmDemo stack
java JvmDemo stack # 默认 1 MB
java -Xss4m JvmDemo stack
| -Xss | recurse() | recurseFat() | 相对默认 |
|---|---|---|---|
| 256k | 3543 | 1570 | 栈是 1/4,深度约 1/6.5 |
| 1m(默认) | 22876 | 7360 | 基准 |
| 4m | 221639 | 37422 | 栈是 4 倍,深度约 9.7 倍 |
一个必须知道的坑:catch 住之后栈也快没了
StackOverflowError 是 Error 不是 Exception,但它能被 catch。
问题在于,你 catch 到时栈已经耗到只剩最后一点空间,此刻再调用一个稍微深一点的方法,会立刻再炸一次:
try {
recurse();
} catch (StackOverflowError e) {
// 危险:这里栈空间几乎为零
// doSomethingDeep(); // 大概率再抛一次 StackOverflowError
System.out.println(depth); // 打印这种浅调用是安全的
}
工程上的正确做法是不要在 catch 里做复杂逻辑,只记录状态然后让线程结束。 真实场景里这类代码常见于递归解析 JSON / 递归遍历目录树——如果数据是攻击者可控的, 一个精心构造的深层嵌套就能把服务打挂,所以正经的解析器都会加深度上限。
这一节和算法题的直接关系
你的递归深度 = 树的深度 = 栈的帧数。两万层听起来很多,但在算法题里很容易撞上:
| 场景 | 递归深度 | 会不会炸 |
|---|---|---|
| 力扣 51 N 皇后(N=9) | 9 | 永远不会 |
| 力扣 104 二叉树最大深度(平衡树,1 万个节点) | 约 14 | 不会 |
| 力扣 104 同上,但树退化成一条链 | 10000 | 接近上限 |
| 力扣 206 反转链表递归版(5 万个节点) | 50000 | 本地必炸 |
所以刷题时有个规律:凡是递归版能过、迭代版也能过的题,面试官更想看迭代版—— 不是因为他喜欢炫技,而是因为生产环境里链表长度不受你控制。这也是 链表专题和递归与回溯里反复强调 「能用循环就别递太深」的原因。
3.4对象在堆里长什么样:12 字节的头
「一个 new Object() 占多少字节?」这个问题的标准答案是 16 字节:
12 字节对象头 + 4 字节填充(对齐)。为什么是 12 而不是 16 的头?因为压缩指针。
光背结论没用,我们直接把字段偏移打出来看。
用 Unsafe 看字段的真实偏移
import java.lang.reflect.Field;
public class JvmDemo {
static class Point {
int x; // 4 字节
int y; // 4 字节
long z; // 8 字节
boolean flag; // 1 字节
}
static void demoHeader() throws Exception {
// Unsafe 的唯一实例藏在私有静态字段里,反射拿出来
Field f = sun.misc.Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
sun.misc.Unsafe u = (sun.misc.Unsafe) f.get(null);
for (String name : new String[]{"x", "y", "z", "flag"}) {
long off = u.objectFieldOffset(Point.class.getDeclaredField(name));
System.out.println(name + " 偏移 = " + off);
}
}
}
实测输出(默认参数,压缩指针开启):
x 偏移 = 12
y 偏移 = 24
z 偏移 = 16
flag 偏移 = 28
这四行信息量很大,逐条读:
- 第一个字段从 12 开始 → 对象头就是 12 字节。Mark Word 8 字节(哈希码、GC 年龄、锁标志位都塞在这里), 类型指针 4 字节(压缩后指向 Metaspace 里的类元信息)。这就是为什么是 12 不是 8。
- 字段不是按声明顺序摆的。声明顺序是 x、y、z、flag,实际是 x(12)、z(16)、y(24)、flag(28)。
z是long必须 8 字节对齐,JVM 会插空把它提前,y被挤到 24。 所以「按字段大小累加算对象大小」这个算法是错的。 - 最后还有对齐填充。
flag在 28,占 1 字节,对象实际用到 29 字节, 但整个对象必须补齐到 8 的倍数(-XX:ObjectAlignmentInBytes,默认 8),所以这个对象是 32 字节。
压缩指针:两个开关,不是一个
很多人以为加 -XX:-UseCompressedOops 就能看到 16 字节的头。实测:
| 启动参数 | x 偏移 | 说明 |
|---|---|---|
| 默认 | 12 | 头 12 字节(8 + 4) |
-XX:-UseCompressedOops | 12 | 没变!因为类型指针压缩是另一个开关 |
-XX:-UseCompressedOops -XX:-UseCompressedClassPointers | 24 | 头变 16(8+8),z 对齐到 16,x 退到 24 |
UseCompressedOops 压缩的是对象引用
(你栈里那个指向堆的指针),UseCompressedClassPointers 压缩的是对象头里的类型指针。
只关前者时,引用变 8 字节,但头里的 klass 指针还是 4 字节,所以头仍是 12。
两者在堆小于 32GB 时默认都开——这也是为什么「堆开到 32G 以上性能会掉」这个说法存在:
指针压缩失效,引用和头都变宽,同样的数据占更多内存、缓存命中率下降。
为什么算法工程师也要知道这个
因为它解释了第 1 章那句「工程里几乎只用 ArrayList」的内存侧原因。同样是存 100 万个 int:
| 结构 | 单个元素开销 | 总量 |
|---|---|---|
int[1000000] | 4 字节 | 约 4 MB(+ 16 字节数组头) |
LinkedList<Integer> | 头 12 + 引用 4 + next 4 + prev 4 + 值 4 → 对齐到 32 字节,加 Integer 对象 16 字节 | 约 48 MB,12 倍 |
HashMap<Integer, Integer>(100 万对) | Node 对象 32 字节 + 两个 Integer 各 16 字节 + 数组槽位 | 约 80 MB,20 倍 |
而且数组是连续内存,CPU 一次读一个缓存行(64 字节)能带进 16 个 int;
链表节点散布在堆的各处,每次 next 都可能是一次缓存未命中。
所以「复杂度一样但快 10 倍」不是玄学,是内存布局。
Unsafe 代码用 javac 编译时一定会打三行
「Unsafe 是内部专用 API,可能会在未来版本中被删除」的警告(@SuppressWarnings 消不掉它)。
这是警告不是错误,class 文件正常生成、程序正常跑。想彻底消掉得加
-XDignore.symbol.file,但那是 javac 的内部参数,别写进正式工程。
面试里如果被问「怎么获取对象大小」,正确回答是「生产上用 JOL 的 GraphLayout.parseInstance(obj).totalSize(),
Unsafe 只适合写验证代码」。
3.5四种引用:不是概念题,是缓存设计的工具
强、软、弱、虚这四个词,背下来只要三十秒,但面试官问它一定是想听你在哪用过。 先看实测,区别立刻分明:
static void demoRefs() {
byte[] strong = new byte[1024 * 1024]; // 强引用
WeakReference<byte[]> weak = new WeakReference<>(new byte[1024]);
SoftReference<byte[]> soft = new SoftReference<>(new byte[1024]);
System.gc(); // 主动请求一次 GC
try { Thread.sleep(100); } catch (InterruptedException ignored) { }
System.out.println("strong 仍可达 = " + (strong != null));
System.out.println("weak.get() = " + weak.get());
System.out.println("soft.get() = " + (soft.get() != null));
}
输出:
[3.5] strong 仍可达 = true
[3.5] weak.get() = null
[3.5] soft.get() 非空 = true
关键在第三行:软引用在内存充足时不会被回收。要让它消失,得制造内存压力。 下面把堆限制到 64MB,先放一个 8MB 的软引用,然后往里硬塞:
static void demoSoftPressure() {
SoftReference<byte[]> soft = new SoftReference<>(new byte[8 * 1024 * 1024]);
System.out.println("分配前 soft.get() 非空 = " + (soft.get() != null));
List<byte[]> hold = new ArrayList<>();
int i = 0;
try {
while (i < 100) {
hold.add(new byte[1024 * 1024]); // 每次 1MB
i++;
}
} catch (OutOfMemoryError e) {
// 忽略,我们只关心软引用有没有被清
}
System.out.println("堆压力后(已塞 " + i + " MB)soft.get() = " + soft.get());
}
用 java -Xmx64m JvmDemo soft 跑:
[3.5] 分配前 soft.get() 非空 = true
[3.5] 堆压力后(已塞 31 MB)soft.get() = null
[3.5] hold 仍持有 31 MB
软引用被清掉了,而 hold 里那 31MB 强引用一个都没少。
这就是四种引用的本质区别:不是「能不能拿到」,而是「GC 在什么条件下敢动它」。
| 类型 | 回收时机 | 典型用途(记住用途,别只背定义) |
|---|---|---|
| 强引用 | 只要可达就永不回收 | new 出来的都是;内存泄漏的元凶 |
软引用SoftReference | 内存不足时才回收 | 内存敏感的缓存。实测:塞满堆才清 |
弱引用WeakReference | 下次 GC 必回收 | ThreadLocalMap 的 key、WeakHashMap、Guava 的弱键缓存 |
虚引用PhantomReference | 随时可能回收,get() 永远返回 null |
只用来在对象被回收时收到通知(配合 ReferenceQueue),管理堆外内存 |
ThreadLocal 为什么要用弱引用?这是最常被追问的一环
ThreadLocalMap 里的 Entry 结构是 key 用弱引用、
value 用强引用。为什么这么设计?
- 如果 key 是强引用:
ThreadLocal对象被你的代码丢弃后,Map 里还死死引用着它, key 和 value 永远删不掉,线程池里的线程活多久就泄漏多久。 - 用弱引用:
ThreadLocal被丢弃后,下次 GC 就把 key 清了,Entry 变成key == null的「僵尸条目」。 - 但 value 还在——因为 Entry 对象本身被 Map 的数组强引用着。
所以还得靠
ThreadLocal的get/set/remove里顺手做「探测式清理」 (expungeStaleEntry)把这些僵尸条目扫掉。 - 结论:弱引用只是把「永久泄漏」降级成「泄漏到下一个人调用 get/set 为止」,
线程池场景下必须手动
remove()。这就是为什么所有编码规范都强制try { ... } finally { threadLocal.remove(); }。
System.gc() 只是建议,
JVM 可以不理;如果加了 -XX:+DisableExplicitGC 就完全无效。
所以「靠 System.gc() 保证回收」的代码是不可靠的。
② 别用软引用做业务缓存。软引用什么时候被清由 GC 决定,你是不可控的,
缓存被清空后会瞬间把压力全打到数据库(缓存击穿)。现代做法是 Caffeine / Guava 的
maximumSize + 过期时间,把淘汰策略抓在自己手里。
3.6双亲委派:默认规则,不是铁律
先把它跑出来看。一个类被加载时,到底经过哪几个加载器:
static void demoLoader() {
ClassLoader app = JvmDemo.class.getClassLoader();
System.out.println("JvmDemo 的加载器 = " + app);
System.out.println(" parent = " + app.getParent());
System.out.println(" parent's parent = " + app.getParent().getParent());
System.out.println("String 的加载器 = " + String.class.getClassLoader());
}
实测输出:
[3.6] JvmDemo 的加载器 = jdk.internal.loader.ClassLoaders$AppClassLoader@4e25154f
[3.6] parent = jdk.internal.loader.ClassLoaders$PlatformClassLoader@7adf9f5f
[3.6] parent's parent = null
[3.6] String 的加载器 = null
两个 null 是关键:Bootstrap ClassLoader 是 C++ 写的,Java 里根本没有它的对象,
所以 getParent() 到顶返回 null,String 由它加载也返回 null。
| 加载器 | 加载什么 | 在 JDK 17 里的实现类 |
|---|---|---|
| Bootstrap(启动) | java.lang.*、rt 模块的核心类 | C++ 实现,Java 里是 null |
| Platform(平台) | JDK 里的非核心模块(JDBC、XML 等) | ClassLoaders$PlatformClassLoader |
| App(应用) | classpath 上的类,包括你的代码和依赖 jar | ClassLoaders$AppClassLoader |
双亲委派的动作:收到加载请求时,先委派给父加载器, 父加载器再往上委派,一直到 Bootstrap;只有父加载器说「我这儿没有」,才自己加载。
为什么需要它(两个理由,都要能说)
- 安全。你自己写一个
java.lang.String放进 classpath,双亲委派会让请求先到 Bootstrap,发现核心类库里已经有了,直接返回正版,你那个冒牌货永远加载不到。 没有这条规则,任何人都能替换核心类。 - 避免重复加载、保证类的唯一性。同一个类被不同加载器加载会变成两个不同的类
(
Class对象的相等判断里包含加载器)。双亲委派保证了核心类全局只有一份。
为什么要「打破」它(这才是加分项)
双亲委派是 ClassLoader.loadClass() 的默认实现,不是 JVM 强制的。
重写这个方法就能改规则,现实中有三个经典场景:
| 场景 | 怎么破的 | 不破会怎样 |
|---|---|---|
| Tomcat(Web 容器) | 每个 webapp 一个 WebappClassLoader,先自己加载、找不到再委派父类 |
两个应用用同一个 jar 的不同版本,会互相覆盖 |
| JDBC SPI | 用线程上下文类加载器(TCCL)反向委派:
Thread.currentThread().getContextClassLoader() |
DriverManager 由 Bootstrap 加载,它看不到 classpath 上的 MySQL 驱动 |
| 热部署 / OSGi | 自定义加载器 + 每次重新加载,旧类靠换加载器来「丢弃」 | 类无法卸载(类的生命周期绑在加载器上),改代码必须重启 |
loadClass 的默认实现,可以被重写——
Tomcat 为了应用隔离用「先自己后父」破了它,JDBC 为了跨加载器找驱动用 TCCL 反向委派。」
这段话里既有定义又有两个例子,比背十行定义管用。
3.7三种 OOM 怎么分:别一上来就加内存
先把堆撑爆一次,看看它长什么样。用 -Xmx32m 把堆限制到 32MB,然后不断塞 1MB 的数组:
static void demoHeapOOM() {
List<long[]> list = new ArrayList<>();
try {
while (true) {
list.add(new long[128 * 1024]); // 128K 个 long = 1 MB
}
} catch (OutOfMemoryError e) {
System.out.println("栈里累计持有 " + list.size() + " 个数组后 OOM");
System.out.println("异常信息 = " + e.getMessage());
}
}
java -Xmx32m JvmDemo oom 的输出:
[3.7] 栈里累计持有 15 个数组后 OOM
[3.7] 异常信息 = Java heap space
32MB 的堆只塞进 15MB 就满了,剩下的被新生代、Survivor、以及元数据占掉。
注意最后那句 Java heap space——这就是排查的起点,
因为不同区域的溢出,报错文本完全不同,处理方式也完全不同:
| 错误信息 | 爆的是哪个区 | 先查什么 |
|---|---|---|
Java heap space | 堆 | 谁在占内存:jmap -histo:live <pid> 看对象排行,或 dump 出来用 MAT 看引用链 |
GC overhead limit exceeded | 堆(GC 太频繁) | 98% 时间在 GC 却只回收不到 2%,通常还是堆里堆了不该堆的东西 |
Metaspace | 元空间 | 类太多:动态代理 / CGLIB / 热部署反复加载类。用 jstat -gc 看 MC/MU |
unable to create native thread | 线程数 / 栈 | 线程太多或 -Xss 太大。看 ulimit -u、线程数、每个线程栈大小 |
Direct buffer memory | 堆外直接内存 | NIO / Netty 的 ByteBuf 没释放。-XX:MaxDirectMemorySize 限制 |
线上排查的四板斧(顺序别乱)
jps -l # 1. 找到进程 pid
jstat -gcutil <pid> 1000 # 2. 每秒看一次:Eden/Old 占比、FGC 次数、GC 耗时
jmap -histo:live <pid> | head # 3. 看谁的对象最多(会触发一次 Full GC,线上慎用)
jstack <pid> > stack.txt # 4. 看线程在干嘛:死锁、线程池满、卡在哪个方法
① 先加
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump。
绝大多数 OOM 现场,你赶到时进程已经重启了,没 dump 就只能猜。
② jmap -histo:live 和 jmap -dump 都会触发 STW,
在大堆上可能停顿几秒到几十秒。线上要先摘流量,或者对副本做。
这两条说出来,比背十个工具名更能证明你干过。
一个常见误区:「内存不够就加 -Xmx」
如果是内存泄漏(对象该回收却一直活着),加 -Xmx 只是把爆炸时间往后推,
GC 每次要扫的对象更多,停顿反而更长。判断方法很简单:看 Full GC 之后老年代的占用能不能降下来。
降不下来 = 泄漏,加内存没用;能降下来 = 确实不够,加内存合理。这一句话是面试官最想听的。
3.8本章速记卡与自测
这一章的数字比概念重要——能报出具体数值的答案,听起来就是实测过的。下面这六条要能张口就来。
| # | 八股问题 | 一句话答案 | 最可能的追问 |
|---|---|---|---|
| 1 | JVM 运行时数据区有哪些? | PC、虚拟机栈、本地方法栈(线程私有)+ 堆、方法区/Metaspace(共享)。 | 哪个不会 OOM?→ PC,唯一一个。 |
| 2 | 栈溢出和堆溢出怎么区分? | 栈溢出是调用太深(StackOverflowError),堆溢出是对象太多(OutOfMemoryError)。 |
默认栈多深?→ 1MB,实测约 2.3 万层。 |
| 3 | new Object() 占多少字节? |
16 字节:对象头 12(Mark Word 8 + 类型指针 4,压缩后)+ 4 填充对齐。 | 为什么是 12 不是 8?→ 多了类型指针。 |
| 4 | 四种引用的区别? | 强(永不)/ 软(内存不足时)/ 弱(下次 GC 必收)/ 虚(随时,只用来收通知)。 | ThreadLocal 为什么用弱引用?→ 否则 key 永久泄漏。 |
| 5 | 双亲委派是什么?为什么要打破? | 先委派父加载器,保证核心类不被篡改、类全局唯一。Tomcat 用「先自己后父」做应用隔离, JDBC 用 TCCL 反向委派。 | Bootstrap 在 Java 里怎么表示?→ null。 |
| 6 | 线上 OOM 怎么排查? | 先看错误信息判断是哪个区,再查 Full GC 后老年代能不能降下来,决定是泄漏还是不够。 | 没 dump 怎么办?→ 提前加 HeapDumpOnOutOfMemoryError。 |
自测:能答上来再离开本章
- 把
recurseFat()里的三个long换成一个String[]数组(长度 10), 预测溢出深度会变大还是变小,然后实测验证你的判断。 - 不查资料,说出
Point那个类为什么是 32 字节,并解释y为什么在 24 而不是 20。 - 为什么只加
-XX:-UseCompressedOops时对象头还是 12 字节? - 写一个必现的
Metaspace溢出(提示:动态生成类),并说明它和堆溢出的报错有什么不同。 - 线程池里用了
ThreadLocal但没remove(),泄漏的是 key 还是 value?为什么? - 一条 5 万节点的链表,递归反转会不会炸?给出两种修法。