Java 后端八股精讲一问一答,背得下也答得出 速查 追问链路 ⌂ 全部教程

主题 03JVM 内存与对象

前两章讲的是「你写出来的代码」,这一章讲代码跑起来之后内存里发生了什么。 它之所以排在第三,是因为它是一个放大器:同一个「递归会不会炸」「OOM 怎么排查」的问题, 懂 JVM 的人答出来是三层,不懂的人只有一句「内存不够」。 而且这一章和算法直接打通——你写的每一个递归、每一个 new int[n],都在这一章的地盘上。

学习目标
能画出五个区、说出对象 12 字节头、分清三种 OOM
前置要求
写过递归、见过 OutOfMemoryError 或 StackOverflowError
本章产出
1 个能跑出全部结论的验证程序 + 5 张速记卡
建议学时
3 小时(含动手敲一遍)

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

三个必须记准的细节:

  1. 字符串常量池不在方法区了。JDK 7 起搬到了堆里。所以 「方法区存字符串常量池」这句话在 JDK 8 之后是错的,问 JDK 版本就是在考这一点。
  2. Metaspace 用的是本地内存,不是堆。JDK 8 用 Metaspace 替掉永久代,最大的变化是 它默认没有上限(受物理内存限制),所以「永久代溢出」这个报错文本已经从 JDK 8 消失了。 要限制得自己加 -XX:MaxMetaspaceSize。
  3. 直接内存不在上面五个里。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
-Xssrecurse()recurseFat()相对默认
256k35431570栈是 1/4,深度约 1/6.5
1m(默认)228767360基准
4m22163937422栈是 4 倍,深度约 9.7 倍
为什么不是严格的正比?因为栈大小 ≠ 可用帧空间。栈里还要放保护页(guard page)、 而且 JIT 编译前后同一个方法的帧大小会变(解释执行和编译执行用的栈布局不一样)。 所以别在面试里说「栈 1MB 就能递归 N 层」这种精确数值, 说「量级在两万层左右,深递归要改写成迭代」就足够了——这反而是懂行人的说法。

一个必须知道的坑: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

这四行信息量很大,逐条读:

  1. 第一个字段从 12 开始 → 对象头就是 12 字节。Mark Word 8 字节(哈希码、GC 年龄、锁标志位都塞在这里), 类型指针 4 字节(压缩后指向 Metaspace 里的类元信息)。这就是为什么是 12 不是 8。
  2. 字段不是按声明顺序摆的。声明顺序是 x、y、z、flag,实际是 x(12)、z(16)、y(24)、flag(28)。 z 是 long 必须 8 字节对齐,JVM 会插空把它提前,y 被挤到 24。 所以「按字段大小累加算对象大小」这个算法是错的。
  3. 最后还有对齐填充。flag 在 28,占 1 字节,对象实际用到 29 字节, 但整个对象必须补齐到 8 的倍数(-XX:ObjectAlignmentInBytes,默认 8),所以这个对象是 32 字节。

压缩指针:两个开关,不是一个

很多人以为加 -XX:-UseCompressedOops 就能看到 16 字节的头。实测:

启动参数x 偏移说明
默认12头 12 字节(8 + 4)
-XX:-UseCompressedOops12没变!因为类型指针压缩是另一个开关
-XX:-UseCompressedOops -XX:-UseCompressedClassPointers24头变 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 用强引用。为什么这么设计?

  1. 如果 key 是强引用:ThreadLocal 对象被你的代码丢弃后,Map 里还死死引用着它, key 和 value 永远删不掉,线程池里的线程活多久就泄漏多久。
  2. 用弱引用:ThreadLocal 被丢弃后,下次 GC 就把 key 清了,Entry 变成 key == null 的「僵尸条目」。
  3. 但 value 还在——因为 Entry 对象本身被 Map 的数组强引用着。 所以还得靠 ThreadLocal 的 get/set/remove 里顺手做「探测式清理」 (expungeStaleEntry)把这些僵尸条目扫掉。
  4. 结论:弱引用只是把「永久泄漏」降级成「泄漏到下一个人调用 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 上的类,包括你的代码和依赖 jarClassLoaders$AppClassLoader

双亲委派的动作:收到加载请求时,先委派给父加载器, 父加载器再往上委派,一直到 Bootstrap;只有父加载器说「我这儿没有」,才自己加载。

为什么需要它(两个理由,都要能说)

  1. 安全。你自己写一个 java.lang.String 放进 classpath,双亲委派会让请求先到 Bootstrap,发现核心类库里已经有了,直接返回正版,你那个冒牌货永远加载不到。 没有这条规则,任何人都能替换核心类。
  2. 避免重复加载、保证类的唯一性。同一个类被不同加载器加载会变成两个不同的类 (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本章速记卡与自测

这一章的数字比概念重要——能报出具体数值的答案,听起来就是实测过的。下面这六条要能张口就来。

#八股问题一句话答案最可能的追问
1JVM 运行时数据区有哪些? PC、虚拟机栈、本地方法栈(线程私有)+ 堆、方法区/Metaspace(共享)。 哪个不会 OOM?→ PC,唯一一个。
2栈溢出和堆溢出怎么区分? 栈溢出是调用太深(StackOverflowError),堆溢出是对象太多(OutOfMemoryError)。 默认栈多深?→ 1MB,实测约 2.3 万层。
3new 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。

自测:能答上来再离开本章

  1. 把 recurseFat() 里的三个 long 换成一个 String[] 数组(长度 10), 预测溢出深度会变大还是变小,然后实测验证你的判断。
  2. 不查资料,说出 Point 那个类为什么是 32 字节,并解释 y 为什么在 24 而不是 20。
  3. 为什么只加 -XX:-UseCompressedOops 时对象头还是 12 字节?
  4. 写一个必现的 Metaspace 溢出(提示:动态生成类),并说明它和堆溢出的报错有什么不同。
  5. 线程池里用了 ThreadLocal 但没 remove(),泄漏的是 key 还是 value?为什么?
  6. 一条 5 万节点的链表,递归反转会不会炸?给出两种修法。
一句话记忆(主题 03):这一章的所有结论都能归到两句话—— 「栈管调用深度,堆管对象多少」(决定了你报什么错、怎么查), 「对象有 12 字节的头,字段要按对齐摆」(决定了内存怎么算、为什么数组比链表快)。 记住这两句,剩下的四种引用、双亲委派、OOM 分类都只是它们的分支。