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 →,一条命令走完十站。

全景图:十站

容器 · 内存上限 512 MB(第 10 站) JVM 进程 · JDK 21 · G1 堆 256 MB = 256 个 1 MB 的 region 下单线程的栈 局部变量 o 指向堆里的订单 ① 类加载 Order.class 读进元空间 ② JIT 热点代码编译 临时对象不进堆 ③ 分配 Eden 里的 TLAB 指针挪 48 字节 ④ 大对象 byte[2 MB] 占 3 个 Humongous ⑤ Young GC Eden 满了,停一下 活的复制到 Survivor ⑥ 晋升 熬过几次回收 搬进老年代 ⑦ 标记、Mixed 并发认定垃圾 清空那些 region ⑧ 泄漏 回收不掉 Full GC 全停 ⑨ OOM 堆转储、直方图 找出谁占的 ⑩ 容器 堆 + 堆外 > 上限 被内核直接杀掉 new Order(9527…)第一次见到这个类 类的元数据 逃逸的对象才来要内存 ≥ 半个 region走这条路 Eden 的 region 用完了 活着的,年龄 +1 老年代占用过线 ← 清空的 region 回来重新当 Eden:订单 9527 到此被回收 还被 static 集合拉着标记后仍然是活的 大对象直接算老年代,涨得快 Full GC 也腾不出 另一种死法:没有 OOM 异常 泄漏时:6 次 Full GC,255M→255M(实测) 堆外:元空间、代码缓存 线程栈、直接内存
类加载 JIT 与逃逸分析 内存结构、对象布局与分配 垃圾回收 GC 日志与调优 故障排查 容器里的 JVM
上排从左到右是一个对象正常的一生:加载类、编译、分配、年轻代回收、晋升、在老年代被并发标记认定为垃圾,最后那条虚线是它占的 region 被清空、重新拿去当 Eden。往下的两条是出事的支线:大对象和泄漏把老年代撑满,走到 Full GC 和 OOM;最外圈是容器,它不看堆,只看整个进程用了多少内存。点任意一站,跳到下面的分步讲解。

订单 9527 住过哪几种 region

Eden出生的地方 Survivor年龄 1~15 老年代长住 时间 → new:48 字节年龄 0(第 3 站) 短命订单没人引用,随 Eden 清空 复制过来年龄 1(第 5 站) 晋升第 6 站 并发标记没有引用指向它 region 清空Mixed GC(第 7 站) cache.put:被缓存拉着,每次 Young GC 都活下来 cache.remove(9527) 之后 回收的单位是 region,不是单个对象: 活的搬走,剩下的整块作废
对象每搬一次家都是“复制”:Eden → Survivor → 老年代。G1 从不单独释放一个对象,它把 region 里还活着的搬走,然后整个 region 作废。所以回收的成本看的是活对象有多少,不是垃圾有多少。

实测:这十站在 GC 日志里长什么样

做了什么站看到的(原样摘自输出和 GC 日志)
第一次 new Order1[class,load] …Order source: file:/…/target/classes/;java.lang.String source: shared objects file。整个程序加载了约 2100 个类
循环里 new 5000 万个临时 Money2线程实际在堆上分配 0.7 MB;加 -XX:-DoEscapeAnalysis 是 1144.4 MB,纯解释(-Xint)也是 1144.4 MB
JOL 打印一个 Order3Instance size: 48 bytes:mark 8 + class 4 + 字段 36,没有浪费
40 个 2 MB 的 byte[]4Pause Young (Normal) (G1 Humongous Allocation) 251M->139M;Humongous regions: 80->32(每个数组占 3 个 region)
2000 万个短命订单,约 915 MB58 次 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
清空缓存,继续下单7Pause 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 集合只放不删86 次 Pause Full (G1 Compaction Pause),137~190 ms,最后两次都是 255M->255M(256M)
撑到放不下9java.lang.OutOfMemoryError: Java heap space,这时集合里有 4994267 个订单;堆转储 380 MB,写了 0.485 秒;直方图第一行 2501025 120049200 …Order
java -XX:MaxRAM=512m -XX:+PrintFlagsFinal10MaxHeapSize = 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这一站的主干,占了四站
8GC 日志与调优:先定目标,再动参数 懂设计有页4要先懂 GC 在干什么,才读得懂日志;过关题之一
1类加载与双亲委派,SPI 为什么要打破它 懂机制有页5相对独立;元空间泄漏的排查要用到它
2JIT、逃逸分析与常见的性能误区 懂机制有页6相对独立;解释“为什么压测要预热”
9JVM 故障排查:OOM、频繁 Full GC、CPU 飙高 懂设计有页7把前面全用上:靶场的三个雷在这里排;过关题之二
10容器里的 JVM:内存和 CPU 限制 会用有页8放最后:要先分得清堆内和堆外

这张全景没走到的

  • J03 的 8 个知识点都在图上,但有几块只擦了个边:
  • ZGC:场景用的是 G1。ZGC 不分 Eden、Survivor 这样搬家,停顿不随堆变大,在垃圾回收页里讲,也是“下一圈”的场景。
  • SPI 打破双亲委派、类卸载:这里只加载了一个普通的类。JDBC 驱动、Tomcat 的应用隔离怎么绕开“先问上级”,见类加载那一页。
  • CPU 飙高:这条链路全是内存的事。死循环把 CPU 打满是靶场的第三个雷,用 jstack 和 JFR 查,在故障排查那一页做。
  • 容器的 CPU 限制:影响 GC 线程数和 JIT 线程数,容器那一页讲。

补给站

回到全景

复述练习

  1. 合上页面,画出 10 站,标出哪几站在堆里、哪几站在堆外,哪几站是“正常的一生”、哪几站是出事的支线。
  2. 每站用一句话讲它做了什么,再用一句话讲订单 9527 先后住过哪三种 region、每次是怎么搬过去的。
  3. 挑 3 站往下讲两层。比如第 5 站:GC Roots 有哪些?老年代指向年轻代的引用怎么找到?为什么停顿只看活对象的量?

也可以直接讲给我听,说“回到全景”,我对照这张图帮你查漏。

动手:交易靶场这一站

地图上 J03 给靶场的任务是埋三个雷,各用工具排一次,写成排障记录:

  1. 内存泄漏(第 8、9 站):把 ObjLife 的 LEAK 搬进靶场的一个接口,压到 OOM,用直方图和 MAT 找到它。
  2. 大对象引起的频繁 Full GC(第 4 站):导出接口一次 new 几 MB,看 GC 日志怎么变,再把它改成流式。
  3. 死循环把 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 站推出:容器管的是整个进程,不是堆。

下一圈