ZGC 自适应堆(JEP 546)的实现在北京时间 10 月 21 日合入了 OpenJDK 主线,也就是 JDK 28 的开发线,目前还没有进入任何官方 EA 构建。我把同一个 Spring Boot 应用关进 1G 内存的 cgroup 跑了三遍:默认 ZGC 的堆上限只有 256M,存活集往 320M 涨的时候就 OOM 退出了;固定 768M 堆能跑完,cgroup 内存峰值 889M;只加 -XX:+ZAdaptiveHeapSizing、什么堆参数都不写,也能跑完,峰值 750M,负载回落后堆自己缩回 300M 上下。代价是 GC 更勤快了,周期数从 77 次涨到 258 次,进程 CPU 时间从 13 秒涨到 17 秒。
用的是哪个 JDK
截至北京时间 10 月 22 日上午,jdk.java.net/28 上最新的还是 Build 20(2026/10/15),里面没有这个开关。JEP 545 和 JEP 546 在 openjdk.org 上的状态都是 Targeted,目标版本 JDK 28,两者共用一份实现:JDK-8380289 是这两个 JEP 共同的实现子任务,现已 Resolved(Fixed),openjdk/jdk#33243 以 commit d544fca 合入主线,时间是北京时间 10 月 21 日 22:09,仓库里还没有 jdk-28+21 的标签。JDK 28 本身要到 2027 年 3 月才正式发布。
所以这次的 JDK 是我从主线源码自己编的,源码 HEAD 是 bf83d326,包含 d544fca,版本串如下:
openjdk version "28-internal" 2027-03-23
OpenJDK Runtime Environment (build 28-internal-d544fca-local)
OpenJDK 64-Bit Server VM (build 28-internal-d544fca-local, mixed mode, sharing)
下面所有数字都来自这个自编译版本,没有在官方 EA 上跑过。应用是 Spring Boot 4.1.1,内嵌 Tomcat 11.0.24,宿主机 8 核、16G 内存。
JEP 545(ZGC 启动和预热提速)和 JEP 546 两份 JEP 给的用法都是 java -XX:+UseZGC -XX:+ZAdaptiveHeapSizing。JEP 545 管的是 2M 初始堆、激进扩堆、并发预触碰和大页,JEP 546 管的是按负载和资源调整堆大小。在这个构建里,三组配置的 gc,init 日志都有 Memory Heating: Enabled 和 Large Page Support: Enabled (Transparent, JVM enforced),不加开关也一样;2M 的初始堆则只在打开开关后出现。启动和预热时间这次没有测,下面只看堆大小。
怎么限的内存
这台机器没有 Docker,也不是 systemd 启动的,systemd-run 用不了。我直接用 cgroup v2:每组实验新建一个 cgroup,memory.max 设成 1G,memory.swap.max 设成 0,再把 java 进程放进去。
#!/bin/sh
# 用法: CG=<组名> MEM=<上限> incg.sh <命令...>
# 新建一个 cgroup v2 组,设内存上限并关掉 swap,再把命令放进去运行
CG=${CG:-jep546}; MEM=${MEM:-1G}
U=$(id -u); G=$(id -g)
sudo mkdir -p /sys/fs/cgroup/$CG
echo $MEM | sudo tee /sys/fs/cgroup/$CG/memory.max >/dev/null
echo 0 | sudo tee /sys/fs/cgroup/$CG/memory.swap.max >/dev/null
exec sudo sh -c 'echo $$ > /sys/fs/cgroup/'$CG'/cgroup.procs && exec setpriv --reuid='$U' --regid='$G' --init-groups "$@"' sh "$@"
JVM 认出了这个限制,ZGC 的初始化日志写的是 Memory: 1024M。
打开开关,堆参数全变了
先在 1G 的 cgroup 里看几组 -XX:+PrintFlagsFinal:
$ java -XX:+UseZGC -XX:+PrintFlagsFinal -version
size_t InitialHeapSize = 8388608 {product} {ergonomic}
size_t MaxHeapSize = 268435456 {product} {ergonomic}
size_t SoftMaxHeapSize = 241591910 {manageable} {ergonomic}
bool ZAdaptiveHeapSizing = false {product} {default}
double ZGCIntensity = 0.000000 {manageable} {default}
$ java -XX:+UseZGC -XX:+ZAdaptiveHeapSizing -XX:+PrintFlagsFinal -version
size_t InitialHeapSize = 2097152 {product} {ergonomic}
size_t MaxHeapSize = 16779313152 {product} {ergonomic}
size_t SoftMaxHeapSize = 0 {manageable} {ergonomic}
bool ZAdaptiveHeapSizing = true {product} {command line}
double ZGCIntensity = 5.000000 {manageable} {ergonomic}
$ java -XX:+UseZGC -XX:+ZAdaptiveHeapSizing -XX:MaxRAMPercentage=75 -XX:+PrintFlagsFinal -version
size_t InitialHeapSize = 2097152 {product} {ergonomic}
size_t MaxHeapSize = 805306368 {product} {ergonomic}
size_t SoftMaxHeapSize = 0 {manageable} {ergonomic}
bool ZAdaptiveHeapSizing = true {product} {command line}
double ZGCIntensity = 5.000000 {manageable} {ergonomic}
$ java -XX:+UseZGC -XX:+ZAdaptiveHeapSizing -Xms768m -Xmx768m -XX:+PrintFlagsFinal -version
[0.000s][warning][gc] Adaptive heap sizing was enabled, but heap size is fixed. Disabling adaptive heap sizing.
size_t InitialHeapSize = 805306368 {product} {command line}
size_t MaxHeapSize = 805306368 {product} {command line}
size_t SoftMaxHeapSize = 0 {manageable} {ergonomic}
bool ZAdaptiveHeapSizing = false {product} {command line, ergonomic}
double ZGCIntensity = 0.000000 {manageable} {ergonomic}
不开开关时,最大堆是 1G 的 25%,也就是 256M。容器里默认堆只给四分之一,之前那篇Spring Boot 塞进 512M 容器,堆默认只有 128M讲的就是这个默认值。
打开开关后,初始堆只有 2M,SoftMaxHeapSize 变成 0,ZGCIntensity 默认是 5。MaxHeapSize 是 16779313152 字节,这是宿主机的 16G,不是 cgroup 的 1G。主线代码里,没显式写 MaxRAMPercentage 时,这个上限按整机物理内存算;写了 -XX:MaxRAMPercentage=75,就按容器的 1G 算出 768M。-Xms 和 -Xmx 设成一样,开关会被直接关掉,并打一条 warning。
不过真跑起来,堆的上限并不是 16G。初始化日志和每次 GC 的日志是这么写的:
[0.005s][info ][gc,init] Memory: 1024M
[0.006s][info ][gc,init] Max Capacity: 16002M
[0.006s][info ][gc,init] Heap Sizing: Adaptive (Implicit Boundaries)
[0.038s][info ][gc,heap ] GC(0) Y: Max Capacity: 1024M(100%)
应用里用 MemoryMXBean 读到的最大堆也是 1024M。
负载怎么造的
应用启动后自动跑三个阶段:存活集先保持 64M 跑 20 秒,再涨到 320M 跑 30 秒,最后回到 64M 跑 40 秒。另外两条线程一共以每秒 300MB 的速度分配 64KB 的短命数组。每 2 秒打一行堆的 used、committed,进程 RSS,以及累计 GC 周期数,跑完打印进程 CPU 时间后退出。端口用 18090。
server.port=18090
spring.main.banner-mode=off
logging.pattern.console=%d{HH:mm:ss.SSS} %-5level %logger{0} - %msg%n
# 存活集MB:持续秒数,逗号分隔
workload.phases=64:20,320:30,64:40
workload.alloc-mb-per-sec=300
workload.threads=2
核心代码在一个 @Component 里,ApplicationReadyEvent 之后启动:
private void runPhases() {
try {
for (String p : phases.split(",")) {
String[] kv = p.trim().split(":");
int mb = Integer.parseInt(kv[0]);
int sec = Integer.parseInt(kv[1]);
targetLiveMb.set(mb);
adjustLive(mb);
log.info("phase live={}M for {}s", mb, sec);
Thread.sleep(sec * 1000L);
}
} catch (InterruptedException ignored) {
} finally {
running.set(false);
sample0();
var os = (com.sun.management.OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
log.info("done processCpu={}s", os.getProcessCpuTime() / 1_000_000_000L);
System.exit(SpringApplication.exit(ctx, () -> 0));
}
}
private synchronized void adjustLive(int mb) {
while (live.size() > mb) {
live.remove(live.size() - 1);
}
while (live.size() < mb) {
live.add(new byte[1 << 20]);
}
}
// 每条线程按限速分配 64KB 的短命数组
private void churn() {
long bytesPerSec = (long) allocMbPerSec * (1 << 20) / threads;
int chunk = 64 * 1024;
long start = System.nanoTime();
long allocated = 0;
while (running.get()) {
byte[] b = new byte[chunk];
b[ThreadLocalRandom.current().nextInt(chunk)] = 1;
sink = b;
allocated += chunk;
long expectedNanos = allocated * 1_000_000_000L / bytesPerSec;
long ahead = expectedNanos - (System.nanoTime() - start);
if (ahead < -100_000_000L) {
// 落后超过 100ms 就重新计时,避免停顿之后猛补分配
start = System.nanoTime();
allocated = 0;
} else if (ahead > 1_000_000) {
try {
Thread.sleep(ahead / 1_000_000);
} catch (InterruptedException e) {
return;
}
}
}
}
private void sample0() {
var heap = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage();
long cycles = 0;
for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
if (gc.getName().contains("Cycles")) {
cycles += gc.getCollectionCount();
}
}
log.info("live={}M heapUsed={}M heapCommitted={}M rss={}M gcCycles={}",
targetLiveMb.get(), heap.getUsed() >> 20, heap.getCommitted() >> 20, rssMb(), cycles);
}
限速那段一开始没有“落后就重新计时”的分支。结果第二阶段线程被拖慢以后,第三阶段会猛补欠下的分配量,GC 周期数一下子翻了十几倍,看起来像是 GC 出了问题,其实是负载本身的毛病。加上这个分支以后才是下面这组数据。
三组对比
JDK_HOME 指向自编译出来的 JDK。三组都加了 -XX:+ExitOnOutOfMemoryError,GC 日志用 -Xlog:gc*,gc+heap=debug,gc+load=debug 写到文件,每组用一个新的 cgroup,跑完读 memory.events 里的 oom_kill 和 memory.peak:
J=$JDK_HOME/bin/java
run(){ name=$1; shift; echo "### $name: $*"
CG=jep546-$name MEM=1G ./incg.sh $J "$@" -XX:+ExitOnOutOfMemoryError \
-Xlog:gc*,gc+heap=debug,gc+load=debug:file=logs/$name-gc.log:uptime,level,tags \
-jar target/zgc-heap-demo-0.0.1.jar > logs/$name-app.log 2>&1
echo "rc=$?"; d=/sys/fs/cgroup/jep546-$name
grep oom_kill $d/memory.events; echo "cgroup memory.peak=$(( $(cat $d/memory.peak) >> 20 ))M"; }
run default -XX:+UseZGC
run fixed -XX:+UseZGC -Xms768m -Xmx768m
run adaptive -XX:+UseZGC -XX:+ZAdaptiveHeapSizing
### default: -XX:+UseZGC
rc=3
oom_kill 0
cgroup memory.peak=395M
### fixed: -XX:+UseZGC -Xms768m -Xmx768m
rc=0
oom_kill 0
cgroup memory.peak=889M
### adaptive: -XX:+UseZGC -XX:+ZAdaptiveHeapSizing
rc=0
oom_kill 0
cgroup memory.peak=750M
三组都没有被 cgroup 的 OOM killer 杀掉。默认那组是 JVM 自己的堆不够,存活集还没涨到 320M 就退出了:
08:42:33.003 INFO Workload - start maxHeap=256M committed=166M phases=64:20,320:30,64:40 alloc=300MB/s threads=2
08:42:33.021 INFO Workload - phase live=64M for 20s
Terminating due to java.lang.OutOfMemoryError: Java heap space
退出前 GC 日志里已经有 22 次 Allocation Stall,最后一次是 [22.130s][info ][gc ] GC(145) Major Collection (Allocation Stall)。另外两组的 GC 日志里一次 Allocation Stall 都没有。
固定 768M 那组,committed 从头到尾钉在 768M,RSS 一直在 900M 上下:
08:42:54.899 INFO Workload - start maxHeap=768M committed=768M phases=64:20,320:30,64:40 alloc=300MB/s threads=2
08:43:08.915 INFO Workload - live=64M heapUsed=270M heapCommitted=768M rss=904M gcCycles=12
08:43:30.932 INFO Workload - live=320M heapUsed=520M heapCommitted=768M rss=912M gcCycles=33
08:44:22.972 INFO Workload - live=64M heapUsed=542M heapCommitted=768M rss=913M gcCycles=75
08:44:24.957 INFO Workload - done processCpu=13s
自适应那组,堆跟着负载走。第一阶段从 492M 一路缩到 360M,存活集一涨就扩到 606M,然后回落到 500M 上下,最后一阶段缩到 300M 上下,最低 292M:
08:44:26.608 INFO Workload - start maxHeap=1024M committed=308M phases=64:20,320:30,64:40 alloc=300MB/s threads=2
08:44:28.613 INFO Workload - live=64M heapUsed=180M heapCommitted=492M rss=639M gcCycles=8
08:44:46.626 INFO Workload - live=320M heapUsed=208M heapCommitted=360M rss=510M gcCycles=38
08:44:50.629 INFO Workload - live=320M heapUsed=416M heapCommitted=606M rss=772M gcCycles=53
08:45:14.646 INFO Workload - live=320M heapUsed=390M heapCommitted=504M rss=673M gcCycles=152
08:45:24.653 INFO Workload - live=64M heapUsed=176M heapCommitted=522M rss=691M gcCycles=177
08:45:36.662 INFO Workload - live=64M heapUsed=226M heapCommitted=308M rss=477M gcCycles=204
08:45:54.674 INFO Workload - live=64M heapUsed=134M heapCommitted=306M rss=475M gcCycles=253
08:45:56.755 INFO Workload - done processCpu=17s
把每 2 秒的采样汇总一下:
| 配置 | 结果 | 堆 committed | 进程 RSS | cgroup 峰值 | GC 周期 | 进程 CPU |
|---|---|---|---|---|---|---|
-XX:+UseZGC |
堆 OOM 退出 | 上限 256M | 最高 371M | 395M | 退出前 130 | 未跑完 |
-Xms768m -Xmx768m |
跑完 | 768M | 897M 到 913M | 889M | 77 | 13 秒 |
-XX:+ZAdaptiveHeapSizing |
跑完 | 292M 到 606M | 453M 到 772M | 750M | 258 | 17 秒 |
同一台机器上把固定堆和自适应两组又各跑了一遍:固定堆峰值还是 889M,GC 周期 78 次;自适应那组峰值 730M,堆在 264M 到 568M 之间,GC 周期 230 次。两组峰值的差距每次会有十几兆的浮动。
RSS 比 committed 多出来的 150M 左右是堆外内存,元空间、线程栈、代码缓存这些。之前用 NMT 拆过这笔账,见堆才给了 256M,进程却占了 550M,用 NMT 把这笔账算清楚。自适应堆看的是整个容器的内存负载,这部分也算在里面。
日志里它怎么做决定
加上 gc+load 和 gc+heap=debug,每次 GC 都会打出它看到的资源和它定的目标。存活集刚涨到 320M 的时候:
[20.558s][info ][gc,load ] GC(35) y: Container: System Memory Load: 48.6%, Process Memory Load: 44.0%, Heap Memory Load: 29.3%
[20.558s][info ][gc,heap ] GC(35) y: Process GC CPU Overhead: 15.4%, Target Process GC CPU Overhead: 14.1%
[20.558s][info ][gc,heap ] GC(35) y: GC Intensity: 5.6, Resource Pressure Scaling: 1.1
[21.574s][info ][gc,heap ] GC(39) y: Heap Increase 40M (13.3%)
[21.586s][info ][gc,heap ] GC(40) Y: Heap Increase 80M (23.5%)
[21.615s][info ][gc,heap ] GC(41) y: Heap Increase 42M (10.0%)
[21.662s][info ][gc,heap ] GC(40) O: Heap Increase 106M (22.9%)
GC 的 CPU 开销 15.4% 超过了 14.1% 的目标,它就扩堆,0.1 秒里连着扩了五次。负载回落以后反过来,每次缩几兆:
[58.773s][info ][gc,heap ] GC(175) y: Heap Decrease 4M (0.8%)
[60.242s][info ][gc,heap ] GC(177) y: Heap Decrease 4M (0.9%)
[61.068s][info ][gc,heap ] GC(178) y: Heap Decrease 4M (0.9%)
整个运行过程里这样的 Heap Increase 和 Heap Decrease 一共 189 行。初始化日志写着 Uncommit Delay: 300s,但 committed 从 522M 缩到 308M 只用了 12 秒左右,RSS 也从 691M 跟着降到了 477M。
运行中调 ZGCIntensity
JEP 546 里说,ZGCIntensity 默认是 5,调低了 GC 少干活、堆更大,调高了 GC 多干活、堆更小,强度和实际 CPU 占用的对应关系故意不写死。在 d544fca 这个 commit 里,它被声明成 manageable 的 double 型参数,所以可以用 jcmd 在运行中改。
我让存活集一直保持 64M 跑 90 秒,第 30 秒改成 10,第 60 秒改成 1:
JH=$JDK_HOME
CG=jep546-intensity MEM=1G ./incg.sh $JH/bin/java -XX:+UseZGC -XX:+ZAdaptiveHeapSizing -XX:+ExitOnOutOfMemoryError \
-Xlog:gc*,gc+heap=debug,gc+load=debug:file=logs/intensity-gc.log:uptime,level,tags \
-jar target/zgc-heap-demo-0.0.1.jar --workload.phases=64:90 > logs/intensity-app.log 2>&1 &
sleep 3; PID=$(pgrep -f 'workload.phases=64:90' | tail -1)
sleep 27; echo "$(date +%T) set 10"; $JH/bin/jcmd $PID VM.set_flag ZGCIntensity 10
sleep 30; echo "$(date +%T) set 1"; $JH/bin/jcmd $PID VM.set_flag ZGCIntensity 1
$JH/bin/jcmd $PID VM.flags -all | grep -E ' ZGCIntensity '
08:46:56 set 10
956104:
Command executed successfully
08:47:26 set 1
956104:
Command executed successfully
double ZGCIntensity = 1.000000 {manageable} {management}
应用这边的采样:
08:46:51.988 INFO Workload - live=64M heapUsed=176M heapCommitted=326M rss=479M gcCycles=45
08:46:55.991 INFO Workload - live=64M heapUsed=200M heapCommitted=324M rss=477M gcCycles=55
08:46:59.994 INFO Workload - live=64M heapUsed=144M heapCommitted=260M rss=414M gcCycles=69
08:47:08.000 INFO Workload - live=64M heapUsed=180M heapCommitted=252M rss=406M gcCycles=102
08:47:24.010 INFO Workload - live=64M heapUsed=176M heapCommitted=252M rss=406M gcCycles=168
08:47:28.013 INFO Workload - live=64M heapUsed=182M heapCommitted=290M rss=444M gcCycles=185
08:47:32.016 INFO Workload - live=64M heapUsed=390M heapCommitted=500M rss=654M gcCycles=192
08:47:36.019 INFO Workload - live=64M heapUsed=254M heapCommitted=550M rss=704M gcCycles=197
08:47:56.033 INFO Workload - live=64M heapUsed=472M heapCommitted=540M rss=694M gcCycles=220
改成 10 之后,堆从 324M 收到 252M,每 4 秒要跑 16 次左右 GC 周期。改成 1 之后,堆涨到 500M 以上,每 4 秒只跑 5 次左右。GC 日志里的目标开销也跟着变:
[30.290s][info ][gc,heap ] GC(56) y: GC Intensity: 11.1, Resource Pressure Scaling: 1.1
[58.353s][info ][gc,heap ] GC(171) y: Process GC CPU Overhead: 14.9%, Target Process GC CPU Overhead: 27.4%
[67.346s][info ][gc,heap ] GC(194) y: GC Intensity: 1.1, Resource Pressure Scaling: 1.1
[71.355s][info ][gc,heap ] GC(198) y: Process GC CPU Overhead: 4.5%, Target Process GC CPU Overhead: 2.9%
默认强度 5 时目标开销在 14% 左右,改成 10 后变成 27.4%,改成 1 后降到 2.9%。日志里打出来的强度是设定值乘上 Resource Pressure Scaling 之后的数,所以显示的是 11.1 和 1.1。
这次测过的组合就是这些:自编译的 28-internal-d544fca-local,Spring Boot 4.1.1,cgroup v2 限 1G、关 swap,存活集 64M 和 320M,分配速率每秒 300MB。
一起交流
分享你的思考,让讨论更进一步。