Ohhnews

分类导航

$ cd ..
Jetbrains Blog原文

节省的字节去哪了?IntelliJ IDEA 紧凑对象头实测

#intellij idea#jvm#紧凑对象头#内存优化#垃圾回收

AI 智能体或许能让代码生产得更快,但并不会让运行成本更低。在管理数百万个对象的应用中,内存分配和垃圾回收仍然至关重要。JVM 一直在从多个方面应对这些成本:ZGC 致力于降低垃圾回收停顿,Project Leyden 解决启动和预热问题,Project Lilliput 则减少每个对象头占用的空间。在 Java 27 中,HotSpot 默认启用紧凑对象头(Project Lilliput,JEP 534)。

合成基准测试经常展示更小的对象占用空间,但这一变化对于庞大的真实 JVM 应用究竟带来了什么?我们直接在 IntelliJ IDEA 中评估了其影响。

什么是紧凑对象头?

每个 Java 对象都带有一个对象头。它标识对象的类,并保存或编码用于锁定、身份哈希和垃圾回收的状态。由于每个实例都要承担这项开销,缩小对象头就成了内存优化中颇具吸引力的目标。

在我们测试的 64 位 HotSpot 配置中,常规实例头占用 12 字节:8 字节的标记字(mark word)和 4 字节的类指针。紧凑布局将两者压缩进单个 8 字节字中。

这是否意味着应用总内存会立即减少 10%–20%?不一定。下面,我们将从原始对象布局和堆转储一路追踪到进程常驻内存以及交互式 IDE 工作负载,展示紧凑对象头的影响,并指出节省在哪些环节真正实现,又在哪些环节消失。

我们如何在 IntelliJ IDEA 中测试紧凑对象头

我们测量了三种资源需求逐步提高的配置:

  • 无项目(仅打开欢迎界面)。
  • 小型项目:Spring Petclinic,约 3,370 行 Java/Kotlin 代码。
  • 大型项目:IntelliJ IDEA monorepo,约 2,650 万行代码。

这些代码库规模相差近四个数量级,提供了显著不同的索引配置和内存占用。对于两种项目配置,我们都运行了一个自动化脚本,反复打开 Java 源文件、插入和删除注释、解析代码引用、保存更改并关闭编辑器。

我们使用运行在 JBR 25.0.4(JetBrains IDE 使用的 Java 运行时)上的 IntelliJ IDEA 2026.2.3,并显式开启和关闭紧凑对象头。在每次开/关对比中,构建、项目快照、工作负载和 -Xmx 值保持不变。我们在其他方面空闲的主机上对每种配置运行多次,并交替执行开/关顺序,以尽量减少缓存预热和顺序偏差。

常驻集大小(RSS)

我们先从进程看起来很大时用户最容易注意到的主要指标开始:RSS(常驻集大小)。

RSS 统计映射到进程的物理 RAM 页。这包括私有内存页和共享内存页,例如动态库。由于共享页会计入每个映射它们的进程,RSS 并不代表 IDE 独占持有的 RAM 量。

我们使用 ps 命令对主 IDE 进程进行采样。下表列出了每次运行三个阶段中位数的平均值(单位 MiB),方括号中给出了最低和最高的运行中位数。

条件对象头关闭,MiB对象头开启,MiB差值
欢迎界面(-Xmx2048m)1,226.9 [1,213.6–1,247.8]1,266.4 [1,238.8–1,294.1]+39.5 MiB(+3.22%)
Petclinic 活跃(-Xmx2048m)3,576.4 [3,442.8–3,647.1]3,531.7 [3,436.3–3,625.3]−44.7 MiB(−1.25%)
Monorepo 活跃(-Xmx4096m)7,352.6 [7,351.3–7,353.4]7,376.9 [7,339.8–7,426.1]+24.3 MiB(+0.33%)

结论:在相同的 -Xmx 限制下,紧凑对象头并未在整体 RSS 上带来统计显著或一致的下降。

这是否意味着紧凑对象头没有实际收益?要回答这个问题,我们需要深入堆内部,弄清紧凑对象头实际如何工作,而不是假设进程内存会全面下降。

衡量对象占用空间

进程 RSS 包含的内容远不止存活的 Java 堆对象。常驻页可能包括已提交堆内未分配的空余空间、类元数据、JIT 编译代码、线程栈、本地缓冲区和 JVM 数据结构、共享库以及其他内存映射文件。因此,仅减少对象头,未必会让进程 RSS 相应降低相同字节数(甚至可能根本看不到明显变化)。

为了单独衡量结构性节省,我们比较了对象占用空间——同一组捕获对象在每种对象头布局模型下会占用多少字节。

对于你的项目,可以直接在 IntelliJ IDEA 中探索这一点。它的内存转储会按类显示实例数量、浅层大小和保留大小,让你看到堆中被什么占用。从 2026.3 EAP 开始,它还使用 JOL 的布局模型提供考虑紧凑对象头的估算。

下表对比了相同捕获堆在模型中的占用空间。

捕获对象群体实例数常规模型紧凑模型节省内存
欢迎界面2,587,924141.533 MiB132.619 MiB8.914 MiB(6.3%)
Petclinic,对象头关闭9,335,990472.304 MiB442.633 MiB29.671 MiB(6.3%)
Petclinic,对象头开启9,314,900470.790 MiB441.179 MiB29.610 MiB(6.3%)
Monorepo,对象头关闭19,102,676924.818 MiB848.823 MiB75.995 MiB(8.2%)
Monorepo,对象头开启19,980,536953.401 MiB873.060 MiB80.341 MiB(8.4%)

在这里,节省变得可见:固定对象群体的模型将欢迎界面和 Petclinic 对象减少了约 6.3%,将 monorepo 对象减少了 8.2%–8.4%。虽然系统级 RSS 掩盖了这一效果,但堆内部的内存节省是真实的。

为什么收益并不固定

为什么从对象头中移除 4 字节,并没有转化为每个对象统一的 4 字节节省?因为两个大小相同的堆可以包含非常不同的对象,而对象头只是起作用的变量之一。

实例还会存储基本类型字段和引用。数组会存储长度和元素数据。它们的数量、类型和引用宽度都会影响对象的整体大小。在我们的对比中,字段类型和引用宽度保持不变,但更改对象头可能会移动字段偏移量并改变填充。其工作原理如下:

默认情况下,HotSpot 会将每个对象的浅层大小向上取整到 8 字节的倍数。这会吸收对象头减少的 4 字节:一个带有一个 int 的对象在两种布局下都保持 16 字节(12 + 4 字节,或 8 + 4 字节再加 4 字节填充)。相比之下,一个空的 Object 会从 16 字节缩小到 8 字节;一个带有两个 int 字段的对象会从 24 字节缩小到 16 字节,如下图所示:

[LOADING...]

对象内部也可能存在填充。字段有各自的对齐要求;它们并非都从 8 字节边界开始。在这里建模的 HotSpot 布局中,普通字段偏移量遵循其存储宽度:byte/boolean 为 1 字节,short/char 为 2 字节,int/float 和压缩引用为 4 字节,long/double 为 8 字节。这与将整个对象取整到 8 字节大小是两回事,也不是 Java 语言层面的保证。

看一下 Long.value。在 12 字节对象头之后,其 8 字节字段从偏移量 16 开始,留下 4 字节空隙。在 8 字节对象头之后,它可以从偏移量 8 开始。整个对象从 24 字节缩小到 16 字节。HotSpot 可以打包或重排字段,因此确切偏移量仍取决于类和 JVM 的布局规则。

[LOADING...]

数组也表现出同样的模式:byte[0] 保持 16 字节,而 byte[1] 从 24 字节缩小到 16 字节。当堆包含数百万种不同形状时,总节省取决于这种混合构成,而不仅仅是对象数量。

[LOADING...]

我们在同一 JBR 上使用 Instrumentation.getObjectSize,在显式关闭和开启紧凑对象头的情况下,将这些示例与实际浅层大小进行了核对。浅层大小统计对象头、字段和填充,但不统计通过引用可达的任何对象。我们还验证了压缩引用和对齐。以下是 JBR 25.0.4 报告的结果:

形状对象头关闭对象头开启节省
空 Object16 B8 B8 B
一个 int 字段16 B16 B0 B
两个 int 字段24 B16 B8 B
Integer16 B16 B0 B
Long24 B16 B8 B
Node 形状:int + 三个引用32 B24 B8 B
byte[0]16 B16 B0 B
byte[1]24 B16 B8 B
byte[4]24 B16 B8 B
byte[8]24 B24 B0 B
int[4]32 B32 B0 B
Object[1000]4,016 B4,016 B0 B

从表中可以得出一个简单的结论:更小的对象头并不会让每个对象都变小。总堆内存的减少完全取决于应用的对象群体构成。## 实际收益:分配、GC 与吞吐量

即使进程的 RSS 在固定的 -Xmx 下没有下降,对象大小减少是否会在运行时的其他地方带来好处?我们考察了四种影响:

  • 相同工作流分配的总兆字节数是否更少?
  • 垃圾回收器运行频率是否更低,或消耗的周期是否更少?
  • 由于上述变化或更好的 CPU 缓存局部性等影响,脚本化 IDE 操作是否会变快?
  • 应用能否在更小的 -Xmx 下可靠执行?

对于前三个问题,我们在相同 -Xmx 下比较了关闭和启用紧凑对象头的情况:Petclinic 为 2,048 MiB,Monorepo 为 4,096 MiB。

JVM 分配的字节数减少了吗?

对于每个项目,我们的基准测试脚本重复同一组操作:打开一个 Java 文件、插入并删除注释、解析引用、保存并关闭编辑器。这包括并发的后台工作和探测工作,因此它并不是仅针对这些编辑操作的分配汇总。

如果你想分析自己代码中的分配热点,IntelliJ IDEA 的 Memory Allocations 分析器可以跟踪调用路径和时间线。与 IntelliJ IDEA 的堆转储工具一样,它原生支持对紧凑对象头建模,因此无论目标 JVM 版本如何,你都可以获得应用的真实数据。

项目脚本化操作次数未启用紧凑对象头,MiB启用紧凑对象头,MiB总减少量
Petclinic75671.2 [655.4–683.1]612.3 [605.4–621.0]−58.9 MiB(−8.8%)
Monorepo3845,241.6 [5,118.0–5,341.3]4,558.2 [4,486.8–4,598.7]−683.4 MiB(−13.0%)

在相同工作负载下,紧凑对象头减少了总分配量的 8.8% 到 13.0%。

垃圾回收器的工作量减少了吗?

分配量降低可能意味着垃圾回收器的工作量减少。我们比较了项目打开/索引期间 JVM 的 GC 事件数和回收耗时,随后比较了脚本化操作期间的 GC 暂停时间。

项目与阶段指标未启用紧凑对象头启用紧凑对象头差值
Petclinic,打开/索引GC 事件数90.7 [82–98]82.3 [63–95]−8.4 次(−9.3%)
Petclinic,打开/索引GC 耗时,毫秒758.3 [733–788]698.0 [595–783]−60.3 毫秒(−8.0%)
Petclinic,打开/索引记录的 GC CPU,秒5.9 [5.6–6.1]5.3 [4.8–5.9]−0.6 秒(−10.2%)
Petclinic,脚本化操作GC 耗时,毫秒24.3 [10–50]13.0 [0–25]−11.3 毫秒(−46.5%)
Monorepo,打开/索引GC 事件数560.0 [546–574]530.7 [527–534]−29.3 次(−5.2%)
Monorepo,打开/索引GC 耗时,毫秒11,322 [10,944–11,823]11,001 [10,965–11,061]321 毫秒(−2.8%)
Monorepo,打开/索引记录的 GC CPU,秒88.9 [86.5–92.0]85.7 [85.3–85.9]−3.2 秒(−3.6%)
Monorepo,脚本化操作GC 耗时,毫秒92.0 [77–107]72.0 [36–106]−20.0 毫秒(−21.7%)

在 Monorepo 打开/索引期间,每次启用紧凑对象头的运行记录的 GC 事件数都少于使用传统对象头的运行。在这些比较中,平均 GC 耗时和记录的 GC CPU 也更低。

IDE 操作变快了吗?

这最终是开发者最关心的问题。对于相同操作,我们比较了总耗时、操作延迟的第 95 百分位以及进程级 CPU 利用率。

项目指标未启用紧凑对象头启用紧凑对象头差值
Petclinic累计操作时间,秒3.682 [3.632–3.710]3.568 [3.423–3.775]−0.114 秒(−3.1%)
Petclinic单次操作 p95,毫秒60.63 [59.51–61.72]59.11 [52.81–63.48]−1.52 毫秒(−2.5%)
Petclinic进程总 CPU,秒12.92 [12.66–13.18]12.90 [12.62–13.09]−0.02 秒(−0.2%)
Monorepo累计操作时间,秒16.768 [16.220–17.246]16.751 [15.565–17.422]−0.017 秒(−0.1%)
Monorepo单次操作 p95,毫秒69.06 [68.56–69.44]67.46 [64.48–69.53]−1.60 毫秒(−2.3%)
Monorepo进程总 CPU,秒52.73 [51.95–53.24]51.55 [49.58–52.84]−1.18 秒(−2.2%)

性能数据呈小幅正向趋势,表明有适度加速。

-Xmx 可以降到多低?

如果紧凑对象头能在相同堆容量中容纳更多实例,那么在负载失败之前,我们可以把 -Xmx 降到多低?

我们逐步降低 -Xmx,直到索引和编辑器场景无法完全可靠地完成。一次通过意味着短场景能够完成,即使它变得很慢。

下表显示了每个项目和对象头布局下,能够完成每一次重复测试的最低测试设置。

项目对象头布局有效 -Xmx,MiB活动 RSS,MiB打开/索引,秒打开/索引 GC,秒固定工作量耗时,秒
Petclinic紧凑3762,528.2 [2,465.4–2,558.2]67.0 [60.0–71.0]29.6 [22.9–32.6]4.74 [4.42–5.09]
Petclinic传统4002,529.3 [2,508.9–2,580.7]69.5 [64.0–70.0]31.9 [25.6–33.3]5.18 [4.75–6.76]
Monorepo紧凑15684,718.8 [4,658.6–4,837.0]344.0 [344.0–348.0]36.5 [36.4–39.6]17.8 [17.8–17.8]
Monorepo传统18885,063.1 [4,804.2–5,097.9]341.0 [338.0–344.0]27.9 [27.8–33.5]16.3 [16.2–17.4]

注意:这些代表测试工作负载能够持续完成的边界阈值——并非建议的生产配置。

对于 Petclinic,紧凑对象头让我们将最小可用堆从 400 MiB 降至 376 MiB(降低 6%)。对于 Monorepo,最低上限从 1,888 MiB 降至 1,568 MiB——节省了 320 MiB(17%)。

结论

无需修改一行应用代码就能让现有应用更高效的升级并不多见。紧凑对象头通过减少数百万个对象所承担的内存税来实现这一点。除了启动和垃圾回收方面的改进之外,它们也是 Java 在运行时效率方面更广泛工作的一部分——在我们生成代码比以往任何时候都多的时代,增强了 JVM。

当你在自己的应用中采用紧凑对象头时,实际收益将取决于对象组合及其对齐方式。许多对象会占用更少空间,这通常意味着更低的分配波动、更小的 GC 压力以及更低的可行堆上限。然而,它并不保证进程 RSS 会立即出现同等幅度的下降,也不保证整体执行都会更快。请分别测量这些指标,不要仅仅因为对象头应该更小就降低 -Xmx。

你可能已经在 IntelliJ IDEA 中使用该功能。IntelliJ Platform 的变更已在 2026.2 中到来,而 Java 27 默认启用紧凑对象头。一旦迁移到新的 Java 版本,就无需任何手动配置——考虑到最近落地了如此多的运行时工作,这非常值得一做。

你希望我们接下来探讨什么?你想看更多关于 Java 性能、较新 Java 特性的文章,还是两者都要?欢迎在评论中告诉我们!