JAVA 后端架构 · J03 JVM 与调优 · 场景全景
一个对象从 new 到被回收
用户 1024 下单,代码里一行 new Order(9527, …)。这 48 个字节先落在年轻代,熬过几次回收后搬进老年代,被缓存放手以后,在一次并发标记里被认定为垃圾,所在的那块内存被清空。如果它被一个只放不删的集合拉着,就永远回收不掉,堆被一点点占满,最后是 Full GC 和 OOM。这张全景回答:一个对象的一生走过哪 10 站,每一站是 JVM 的哪个部件在干活,哪几站会出事,以及 J03 的 8 个知识点各管哪一站。
所在:Java 地图 J03 · JVM 与调优上一站:一个下单请求在线程池里AI 主线用到:站 01、站 04、站 05整条链路在本机真跑过 实测
场景:订单 9527 的 48 个字节
- 谁
- 交易靶场的订单服务。用户 1024 买 2 件 88 号机械键盘,金额 598.00 元,生成订单 9527。
- JVM
- JDK 21,G1 收集器,堆固定 256 MB(
-Xms256m -Xmx256m),G1 把它切成 256 个 1 MB 的 region。 - 发生了什么
- 订单先被 new 出来,放进缓存(活得久),后来从缓存移除(该死了)。同一个进程里还有 2000 万个短命订单、几个 2 MB 的导出缓冲区,和一个只放不删的 static 集合。
- 期望
- 不用的对象被回收,停顿短到用户感觉不到;出事时能在 5 分钟内说出是哪个对象占的内存。
实测 正常时每次 Young GC 不到 1 ms,清空缓存后老年代从 199 MB 回到 4 MB。泄漏时 6 次 Full GC 各停 137~190 ms、一个字节都没腾出来,然后 OutOfMemoryError: Java heap space;类直方图第一行就是 250 万个 Order。
实测环境:本机 2 核 ARM,JDK 21.0.12,一个不带 Spring 的小程序 ObjLife,开 -Xlog:gc* 和 -Xlog:class+load,完整跑了一次;关掉逃逸分析、纯解释各补跑一次。
这几行代码,各在第几站起作用
左边的号是这一行触发的那一站。
1Order o = new Order(9527, 1024, 88, 2, 59800, "CREATED");3// ↑ 第一次用到 Order 先加载类;再在 Eden 里要 48 字节2Money m = new Money(cents); return m.cents();// ↑ m 没逃出方法:JIT 编译后根本不进堆4byte[] buf = new byte[2 * MB]; // 导出用的缓冲区:大对象6cache.put(o.id, o); // 被缓存拉着:活得久,会晋升7cache.remove(9527L); // 没人引用了:等并发标记来认定8static List<Order> LEAK = …; LEAK.add(o); // 只放不删
完整代码在 study-labs:java/java/pano-j03-object-new-to-gc →,一条命令走完十站。
全景图:十站
订单 9527 住过哪几种 region
实测:这十站在 GC 日志里长什么样
| 做了什么 | 站 | 看到的(原样摘自输出和 GC 日志) |
|---|---|---|
第一次 new Order | 1 | [class,load] …Order source: file:/…/target/classes/;java.lang.String source: shared objects file。整个程序加载了约 2100 个类 |
循环里 new 5000 万个临时 Money | 2 | 线程实际在堆上分配 0.7 MB;加 -XX:-DoEscapeAnalysis 是 1144.4 MB,纯解释(-Xint)也是 1144.4 MB |
JOL 打印一个 Order | 3 | Instance size: 48 bytes:mark 8 + class 4 + 字段 36,没有浪费 |
40 个 2 MB 的 byte[] | 4 | Pause Young (Normal) (G1 Humongous Allocation) 251M->139M;Humongous regions: 80->32(每个数组占 3 个 region) |
| 2000 万个短命订单,约 915 MB | 5 | 8 次 Young GC 一共 13 ms,例如 Pause Young (Normal) (G1 Evacuation Pause) 154M->2M(256M) 0.586ms |
| 100 万个订单放进缓存 | 6 | 老年代 2 MB → 127 MB;4 次 Young GC 共 117 ms,其中一次 (Evacuation Failure) 205M->127M 61.937ms |
| 清空缓存,继续下单 | 7 | Pause Young (Concurrent Start) → Concurrent Mark Cycle 209.813ms(其中 Pause Remark 1.480ms、Pause Cleanup 0.161ms)→ Pause Young (Prepare Mixed) 0.879ms → Pause Young (Mixed) 0.938ms。老年代 199 MB → 4 MB |
| static 集合只放不删 | 8 | 6 次 Pause Full (G1 Compaction Pause),137~190 ms,最后两次都是 255M->255M(256M) |
| 撑到放不下 | 9 | java.lang.OutOfMemoryError: Java heap space,这时集合里有 4994267 个订单;堆转储 380 MB,写了 0.485 秒;直方图第一行 2501025 120049200 …Order |
java -XX:MaxRAM=512m -XX:+PrintFlagsFinal | 10 | MaxHeapSize = 134217728(128 MB,也就是 MaxRAMPercentage = 25);ThreadStackSize = 2040(每个线程约 2 MB 栈,ARM64) |
第 7 站那行有运气成分:补跑的那一次,同样的时间点老年代只从 203 MB 降到 113 MB,因为并发标记和 Mixed GC 什么时候轮到,由 G1 自己按占用和停顿目标决定。堆只有 256 MB,所以 Full GC 才一百多毫秒;生产上几十 GB 的堆,一次 Full GC 是秒级。
一步一步走
1类加载:第一次 new,先把 Order.class 读进来
JVM 执行到 new Order 时发现这个类还没加载。应用类加载器先问上级(平台类加载器、启动类加载器)“你们有没有”,都没有,才自己从 classpath 读出 Order.class,校验、解析成类的元数据放进元空间(堆外),再在堆里建一个 Class 对象代表它。之后每个 Order 对象头里的类指针都指向这份元数据。
实测 Order.class.getClassLoader().getName() 是 app,String.class.getClassLoader() 是 null(启动类加载器)。类加载日志:…Order source: file:/…/target/classes/,而 java.lang.String source: shared objects file(JDK 自带的类从共享归档里直接映射,不用再解析)。
为什么要它类是按需加载的,没用到的类不占内存;先问上级,保证 java.lang.String 这种核心类只有一份,谁也冒充不了。
往下一层类加载与双亲委派,SPI 为什么要打破它 · 元空间在哪:JVM 内存结构
常出的事不停生成新类(动态代理、脚本引擎、反复热部署)又卸不掉,元空间一直涨,最后 OutOfMemoryError: Metaspace。同一个类被两个类加载器各加载一次,互相转换报 ClassCastException。
2JIT 与逃逸分析:热点代码被编译,有些对象根本不进堆
方法刚开始由解释器一条条执行字节码。调用次数和循环次数过了阈值,JIT 编译器把它编译成机器码放进代码缓存(堆外):先用 C1 快速编译,更热了再用 C2 做深度优化。C2 会做逃逸分析:priceOf 里那个 Money 没有被返回、没有存进字段、没有传给别的线程,JIT 就把它拆成一个局部的 long(标量替换),这次 new 不会真的发生。
实测 循环 5000 万次 new Money:线程在堆上实际只分配了 0.7 MB(编译完成之前的那一小段)。关掉逃逸分析或纯解释执行,都是 1144.4 MB,正好是 5000 万 × 24 字节。
为什么要它大部分代码只跑几次,不值得花时间编译;真正热的少数代码编译后快几十倍。只编译热点,是启动速度和峰值性能之间的折中。
往下一层JIT、逃逸分析与常见的性能误区
常出的事以为“new 很贵”,手写对象池,反而把短命对象变成长命对象,加重第 6、7 站的负担。服务刚启动的前几十秒还在解释执行,这时压测或者流量直接打上来,响应时间比稳定后差很多。
3分配:在 Eden 里把指针往后挪 48 字节
每个线程在 Eden 里有一小块自己专用的缓冲区(TLAB)。new Order 就是把这块缓冲区的指针往后挪 48 字节,不用加锁;TLAB 用完了再去 Eden 领一块新的。然后写对象头:8 字节的 mark word(放哈希、锁状态、GC 年龄)加 4 字节的压缩类指针(指回第 1 站的元数据),字段清零,最后执行构造方法。变量 o 在线程的栈帧里,它只是一个指向堆的地址。
实测 JOL:0 8 (object header: mark) 0x…01 (non-biasable; age: 0)、8 4 (object header: class)、12 4 int skuId、16 8 long id …… Instance size: 48 bytes。JVM 把 4 字节的 skuId 排到了类指针后面的空隙里。
为什么要它分配是最频繁的操作,必须便宜到接近栈上分配;每个线程一块缓冲区,就免掉了多线程抢同一个指针。
往下一层new 一个对象的全过程:分配、对象头与内存布局(下一圈) · 上一圈:对象头、栈和堆
常出的事低估对象的大小:一个只有一个 int 的对象也要 16 字节,Long、HashMap.Node 这些包装和节点常常比数据本身还大。堆超过 32 GB,压缩指针失效,同样的对象突然变大(内存结构页的坑)。
4大对象:不进 Eden,直接占连续的 Humongous region
导出订单时 new byte[2 * MB]。G1 规定,大小达到 region 一半(这里是 512 KB)的对象是大对象,不走第 3 站的路,而是直接找几个连续的空 region,算作老年代的一部分。2 MB 的数组加上 16 字节的数组头,一个 region 放不下,两个也差 16 字节,所以占了 3 个。
实测 40 个这样的数组期间,日志里出现 Pause Young (Normal) (G1 Humongous Allocation) 251M->139M(256M) 0.589ms 和 Humongous regions: 80->32:找不到连续空 region 时,分配大对象本身就会触发一次 GC。
为什么要它大对象在年轻代里来回复制太贵,G1 干脆不复制它。代价是它一出生就算老年代,而且占的最后一个 region 剩下的空间浪费掉了。
往下一层垃圾回收:分代、可达性、G1、ZGC(大对象的坑) · 内存结构页:Humongous
常出的事靶场的第二个雷:接口里反复 new 大数组(导出、大 JSON、一次查出几万行),老年代涨得飞快,并发标记一轮接一轮,跟不上就是 Full GC。修法是把大块拆小、流式处理,或者调大 -XX:G1HeapRegionSize。
5Young GC:Eden 满了,只把活的搬走
Eden 的 region 用完时,所有业务线程停下(Stop-The-World)。G1 从 GC Roots 出发找活着的对象:各线程栈里的引用、静态字段,以及老年代里指向年轻代的引用(靠每个 region 的记忆集记着,不用扫整个老年代)。活着的被复制到 Survivor region,年龄加 1;Eden 的 region 整块作废。2000 万个短命订单里,每次只有还留在数组里的那 1024 个是活的。
实测 约 915 MB 的短命订单:8 次 Young GC 一共 13 ms。GC(4) Pause Young (Normal) (G1 Evacuation Pause) 154M->2M(256M) 0.586ms:152 MB 的垃圾,半毫秒。
为什么要它绝大多数对象很快就没人用了。把新对象集中放在一起,回收时只处理少数活着的,花的时间和垃圾有多少无关。
往下一层垃圾回收:分代、可达性、G1、ZGC(记忆集) · 分代
常出的事以为 Young GC 的停顿跟堆大小或垃圾量成正比。它跟活对象的量成正比:下一站一次有上百万个订单活下来,同样是 Young GC,停了 61.9 ms。
6晋升:熬过几次回收,搬进老年代
订单被 cache.put 以后一直有引用指着,每次 Young GC 都活下来、年龄加 1(年龄记在 mark word 里,只有 4 位,最大 15)。年龄到了阈值,或者 Survivor 放不下,它就被复制到老年代的 region。老年代不参加普通的 Young GC,订单在这里可以住很久。
实测 100 万个订单进缓存后,老年代从 2 MB 涨到 127 MB。4 次 Young GC 共 117 ms,其中 GC(9) Pause Young (Normal) (G1 Evacuation Pause) (Evacuation Failure) 205M->127M(256M) 61.937ms:活对象太多,没有足够的空 region 接它们。
为什么要它已经活了很久的对象多半还会继续活。把它们搬出年轻代,以后的 Young GC 就不用一遍遍复制它们。
往下一层内存结构页:分代与晋升(带晋升实验台) · 垃圾回收
常出的事过早晋升:流量突增或 Survivor 太小,本来活几百毫秒就死的对象被挤进老年代,老年代里堆满“刚晋升就死”的垃圾,第 7 站被迫频繁工作。
7并发标记和 Mixed GC:订单 9527 在这里被回收
cache.remove(9527) 之后,没有任何引用指向这个订单了,但它还躺在老年代里。老年代占用超过阈值(初始是堆的 45%,G1 会自己调整)时,某次 Young GC 顺带启动并发标记:GC 线程和业务线程同时跑,把整个堆里活着的对象标出来,中间只有两次很短的停顿(Remark、Cleanup)。标完以后 G1 知道每个老年代 region 里垃圾占多少,接下来的几次 Mixed GC 在回收年轻代的同时,挑垃圾最多的几个老年代 region 一起回收:活的搬走,region 清空。订单 9527 的 48 个字节到此消失,那块 region 回去重新当 Eden。
实测 Pause Young (Concurrent Start) 218M->128M 0.876ms → Concurrent Mark Cycle 209.813ms(Pause Remark 1.480ms、Pause Cleanup 0.161ms)→ Pause Young (Prepare Mixed) 0.879ms → Pause Young (Mixed) 0.938ms。第二轮的 Pause Remark 137M->28M:整块都是垃圾的 region 在 Remark 时就直接收走了。老年代 199 MB → 4 MB。
为什么要它老年代很大,停下来一次性整理要很久。并发标记把最费时的“找活对象”挪到停顿之外;Mixed GC 每次只收一部分 region,把单次停顿控制在目标(默认 200 ms)以内。
往下一层垃圾回收:G1 的并发标记与 Mixed GC · 停顿预测 · SATB
常出的事对象“该死了”和“被回收”之间隔着一整个并发周期,监控上老年代不会马上降。分配太快、并发标记跑不赢时,G1 来不及腾出空 region,退化成第 8 站的 Full GC。
8泄漏和 Full GC:标记过了,它还是活的
另一批订单被放进 static List<Order> LEAK,再也没人删。静态字段是 GC Root,从它出发永远能走到这些订单,所以每次标记它们都是“活的”。老年代越来越满:Young GC 开始报 Evacuation Failure(没有空 region 可以复制过去),最后 G1 只能做 Full GC:所有业务线程停下,全堆标记、整理。可是活的就是活的,整理完还是满的。
实测 GC(32) Pause Young … (Evacuation Failure) 253M->254M(256M) 36.216ms,然后 6 次 Pause Full (G1 Compaction Pause):255M->225M 137.201ms、255M->252M 142.643ms …… 最后两次 255M->255M(256M) 145.059ms、190.237ms。
这一站要学的读 GC 日志。箭头两边的数字是回收前后的堆占用:154M->2M 是健康的;255M->255M 说明问题不在 GC 参数,是有东西回收不掉。这时候加堆、换收集器、调停顿目标都没用。
往下一层GC 日志与调优:先定目标,再动参数 · 垃圾回收页:退化成 Full GC 的坑
常出的事靶场的第一个雷。线上表现是:接口每隔几秒卡一下,卡的时间越来越长,CPU 被 GC 线程吃满。看到 Full GC 就先调参数,是最常见的弯路。
9OOM 与排查:谁占的内存
Full GC 之后还是放不下新对象,JVM 抛出 java.lang.OutOfMemoryError: Java heap space。排查就是回答一个问题:堆里什么对象最多,谁拉着它们。两件工具:类直方图(jcmd <pid> GC.class_histogram,按类统计个数和字节数)先看是谁;堆转储(配好 -XX:+HeapDumpOnOutOfMemoryError,OOM 时自动写出)再用 MAT 沿着引用链往上找到 GC Root。
实测 OOM 时 LEAK 里有 4994267 个订单;堆转储 380 MB,0.485 秒写完。泄漏途中跑的直方图第一行:2501025 120049200 labs.…Order,120049200 ÷ 2501025 = 48 字节,和第 3 站对上。跑直方图本身触发了一次 Pause Full (Heap Inspection Initiated GC) 83.038ms。
为什么要它这是地图上的过关题:OOM 以后 5 分钟内定位到是哪个对象占的。直方图回答“是什么”,堆转储回答“被谁拉着”。
往下一层JVM 故障排查:OOM、频繁 Full GC、CPU 飙高 · 泄漏的一个常见来源:ThreadLocal 与内存泄漏
常出的事没配堆转储参数,进程一重启现场就没了。在线上对一个几十 GB 的堆跑直方图或转储,会把服务停住几秒到几十秒,要先摘流量。
10容器:它不看堆,只看整个进程
前九站说的都是堆。JVM 进程用的内存还包括:元空间(第 1 站)、代码缓存(第 2 站)、每个线程的栈、直接内存(NIO、Netty 用的堆外缓冲区)、GC 自己的数据结构。容器的内存上限管的是这些加在一起。两种相反的错:不设 -Xmx,JVM 默认只拿容器内存的四分之一当堆,512 MB 的容器只有 128 MB 堆,白白频繁 GC;把 -Xmx 设得和上限一样大,堆外部分一涨,整个进程超限,被内核直接杀掉。
实测 java -XX:MaxRAM=512m -XX:+PrintFlagsFinal -version:MaxHeapSize = 134217728(128 MB),MaxRAMPercentage = 25.000000,ThreadStackSize = 2040(K)。本机没有容器,被杀的现象没有复现,按文档:状态是 OOMKilled,退出码 137。
为什么要它被内核杀掉时没有 OOM 异常、没有堆转储、日志里什么都没有,只能从容器的状态看出来。它和第 9 站是两种完全不同的“内存不够”,排查方向也不同。
往下一层容器里的 JVM:内存和 CPU 限制 · 直接内存 · 线程栈
常出的事Pod 隔一段时间重启一次,应用日志干干净净。先看是不是 OOMKilled,再用 -XX:NativeMemoryTracking=summary 加 jcmd VM.native_memory 看堆外各部分各占多少。
知识点地图
知识点名照抄学习地图。下钻按依赖排:被别人用到的先学。前三个已经有页面,这一次是“下一圈”:带着这张全景回去重学,补上地图要的深度。
| 站 | 知识点 | 状态 | 下钻顺序和理由 |
|---|---|---|---|
| 1、2、3、10 | 内存结构:栈、堆、元空间、直接内存 懂机制 | 有页 · 下一圈 | 1地基:每一站都在问“这块东西放在哪个区” |
| 3 | 对象创建、对象头与内存布局 懂机制 | 有页 · 下一圈(1、2 合成一圈) | 2对象头里的年龄、类指针,是第 5、6、9 站的前提 |
| 4、5、6、7 | 垃圾回收:分代、可达性、G1、ZGC 懂设计 | 有页 | 3这一站的主干,占了四站 |
| 8 | GC 日志与调优:先定目标,再动参数 懂设计 | 有页 | 4要先懂 GC 在干什么,才读得懂日志;过关题之一 |
| 1 | 类加载与双亲委派,SPI 为什么要打破它 懂机制 | 有页 | 5相对独立;元空间泄漏的排查要用到它 |
| 2 | JIT、逃逸分析与常见的性能误区 懂机制 | 有页 | 6相对独立;解释“为什么压测要预热” |
| 9 | JVM 故障排查:OOM、频繁 Full GC、CPU 飙高 懂设计 | 有页 | 7把前面全用上:靶场的三个雷在这里排;过关题之二 |
| 10 | 容器里的 JVM:内存和 CPU 限制 会用 | 有页 | 8放最后:要先分得清堆内和堆外 |
这张全景没走到的
补给站
- 第 1 站:类加载与双亲委派:SPI 为什么要打破它,第 5 个下钻,两个 Order、支付渠道 SPI 和元空间泄漏实测都在里面。
- 第 2 站:JIT、逃逸分析与常见的性能误区,第 6 个下钻,预热曲线、八种写法进不进堆、热点 NPE 丢堆栈实测都在里面。
- 第 8 站:GC 日志与调优:先定目标,再动参数,第 4 个下钻,交易靶场大促和泄漏实测都在里面。
- 第 9 站:JVM 故障排查:OOM、频繁 Full GC、CPU 飙高,第 7 个下钻,交易靶场三个雷(泄漏到 OOM、大导出频繁 Full GC、一分钱优惠券死循环)的实测排查都在里面。
- 第 10 站:容器里的 JVM:内存和 CPU 限制,第 8 个下钻,cgroup 里实测默认堆和 GC 怎么定、OOMKilled 复现、直接内存封顶后变成 JVM 自己报错,都在里面。
- 已有的相邻页面:ThreadLocal 与内存泄漏(一种具体的泄漏)、WeakHashMap 与四种引用、系统压测(预热)。
回到全景
复述练习
- 合上页面,画出 10 站,标出哪几站在堆里、哪几站在堆外,哪几站是“正常的一生”、哪几站是出事的支线。
- 每站用一句话讲它做了什么,再用一句话讲订单 9527 先后住过哪三种 region、每次是怎么搬过去的。
- 挑 3 站往下讲两层。比如第 5 站:GC Roots 有哪些?老年代指向年轻代的引用怎么找到?为什么停顿只看活对象的量?
也可以直接讲给我听,说“回到全景”,我对照这张图帮你查漏。
动手:交易靶场这一站
地图上 J03 给靶场的任务是埋三个雷,各用工具排一次,写成排障记录:
- 内存泄漏(第 8、9 站):把
ObjLife的 LEAK 搬进靶场的一个接口,压到 OOM,用直方图和 MAT 找到它。 - 大对象引起的频繁 Full GC(第 4 站):导出接口一次 new 几 MB,看 GC 日志怎么变,再把它改成流式。
- 死循环把 CPU 打满(没走到的那块):用
top -H加 jstack,或者 JFR,找到那个线程和那一行。
预测题
1. 把堆从 256 MB 加到 1 GB,第 8、9 站会怎样?
OOM 来得更晚,但一定会来:LEAK 只放不删,多大的堆都会被填满。而且 Full GC 要整理的活对象变成了 4 倍,每次停顿更长。加堆只对“活对象本来就这么多、堆确实小了”有用,对泄漏只是推迟。
从第 8、9 站推出:255M->255M 说明回收不掉,和堆大小无关。
2. 第 6 站把缓存里的 100 万个订单换成 1000 万个短命订单(放进去马上删),Young GC 的停顿会变长还是变短?
变短。Young GC 的时间主要花在复制活对象上,放进去马上删的订单在下一次 GC 时已经没人引用,不用复制。第 6 站那次 61.9 ms 是因为有上百万个对象同时活着要搬;第 5 站回收 152 MB 垃圾只用了 0.586 ms。
从第 5、6 站推出:停顿看活对象的量,不看垃圾的量。
3. 第 4 站的缓冲区从 2 MB 改成 400 KB,日志里什么会消失?
G1 Humongous Allocation 和 Humongous regions 的变化会消失。400 KB 小于 region 的一半(512 KB),它变回普通对象,在 Eden 里分配,用完在下一次 Young GC 里随 Eden 一起作废,不再直接进老年代。
从第 3、4、5 站推出:大对象的门槛是 region 大小的一半。
4. 关掉逃逸分析(-XX:-DoEscapeAnalysis),第 5 站的 Young GC 次数会怎样?用户会感觉到吗?
次数变多:第 2 站那 5000 万个临时对象原本不进堆,现在要多分配 1144 MB,按每次 Young GC 回收约 150 MB 算,多出七八次。但每次都不到 1 ms(它们全是马上就死的垃圾),用户基本感觉不到。真正的损失是分配和 GC 占掉的 CPU,吞吐会低一些。
从第 2、5 站推出:实测 0.7 MB 对 1144.4 MB。
5. 容器上限 512 MB,-Xmx480m,Tomcat 跑着 200 个线程。服务跑一阵子就重启,应用日志里没有任何异常。最可能是什么?先看哪里?
最可能是被内核杀掉了。堆最多 480 MB,200 个线程的栈最多可以用到几百 MB(按本机默认每个 2 MB 预留,实际用到多少算多少),再加元空间、代码缓存、直接内存,很容易超过 512 MB。先看容器状态是不是 OOMKilled、退出码是不是 137;再用 Native Memory Tracking 看堆外各部分。修法是给堆外留出余量,比如堆只占上限的 60%~75%。
从第 1、2、10 站推出:容器管的是整个进程,不是堆。
下一圈
同一个订单,换成 ZGC
G1 靠“停下来复制”,停顿随活对象变多而变长。ZGC 在业务线程运行的同时搬对象,靠染色指针和读屏障保证读到的地址是对的。同样的十站里,第 5、6、7 站完全变了样,难在“对象正在被搬的时候怎么读它”。
真正的服务:Spring Boot 在 512 MB 的容器里
几千个类、几十个线程、Netty 的直接内存,堆外部分不再是零头。难在把进程内存拆成堆、元空间、线程栈、直接内存各自的账,并且在容器里复现一次 OOMKilled。这是 J03 学完以后靶场的全景。