Java 27 发布有一周多了,9 月 15 日 GA。作为非 LTS 版本,很多团队的反应大概是"25 还没捂热,27 与我无关"。这个判断大体没错,生产环境确实应该锚在 25 上。但"不升级"和"没变化"是两回事——27 有三个改动不等你升级就已经定了调子,它们全是默认值:对象头默认压缩、TLS 1.3 默认带上后量子算法、所有环境默认 G1。
我手头正好有台挺"寒酸"的 Linux 小机器,1 个核,不到 2G 内存。这个配置有个特殊意义:从 JDK 9 开始,JVM 的 GC 选择规则是"单核或内存小于 1792MB 就退回 Serial"——我这台机器常年落在这个规则里。所以三个默认值的变化,在我这儿都能直接看出差别。 装的过程没什么可说的,Temurin 两套二进制,25.0.4.1 和 27+35,解压即用。下面按我发现问题的顺序说。
先看对象头:16 字节变 8 字节
紧凑对象头是三个默认值里最"物理"的一个,JEP 534,也是 Project Lilliput落地三部曲的最后一步:JDK 24 实验特性,JDK 25 转正,JDK 27 默认开启。 验证工具我用了 JOL(Java Object Layout),打印一个空对象的内存布局。JDK 25 上是这样:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x001720d8
12 4 (object alignment gap)
Instance size: 16 bytes
8 字节 mark word,4 字节压缩类指针,加上 4 字节对齐凑整,一个什么都不装的 Object 占 16 字节。同样的代码放到 27 上:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0017740000000009 (Lilliput)
Instance size: 8 bytes
16 字节变 8 字节,对齐损失直接归零。JOL 在 mark word 那行打了个 (Lilliput) 标记——类指针被塞进了 mark word 里,两头并一头。看到自己平时熟悉的 new Object() 被内部项目代号标记出来,还挺有意思的。
不过 JOL 只能说明"单个对象小了",实际收益得看量。我写了个笨办法:固定 640MB 堆,往 List 里疯狂塞 new Object() 直到 OOM,数一下塞了多少个。为了避免 GC 行为差异干扰,两版都显式指定 Serial GC,只留对象头这一个变量:
JDK 25:31,151,587 个对象,每对象 20 字节(8B mark + 4B class指针 + 4B 引用 + 4B 对齐),实占 624MB
JDK 27:31,151,587 个对象,每对象 12 字节(8B mark + 4B 引用),实占 375MB
两边倒在同一个位置,对象数一个不差。先解释一下为什么 375MB 就会 OOM:这个实验的死亡触发点不是"堆被对象填满",而是 List 底层那个引用数组的扩容——3100 多万个引用的数组再按 1.5 倍翻一倍,一下子要划出近 300MB 的连续空间。25 那边是 624MB 的对象加上 188MB 的旧数组,真把堆塞满了;27 这边对象只占 375MB,但扩容要的那块连续空间照样给不出来,死在同一个节点上。所以这组数据比的不是"谁先塞满",而是"同样多的对象谁占得少":同样的 640MB,实占从 624MB 降到 375MB,约省 40% 。这个收益对缓存类服务、大集合、对象池这类"堆里全是小对象"的场景最实在。
有一个连带的小坑要提:紧凑头普及后,UseCompressedClassPointers 这个老参数在 JDK 25 就被标记弃用,27 里正式废弃。如果你的启动脚本里还写着它,升级时会直接被拒。我翻 release notes 才注意到这一点,属于"不改代码但改参数"的暗坑。
握手已经悄悄换算法了,前提是两头都是 27
TLS 的默认值变化更隐蔽,也是三个里最容易被忽略的:JEP 527 把后量子混合密钥交换接进了 TLS 1.3,并且默认排在候选组的第一位。
背景一句话就够:现在的加密流量可能被截获囤着,等量子计算机成熟后再解密,所以 IETF 搞了混合方案——传统 ECDHE 和 ML-KEM 各算一半,破解其中任何一个都不够。Java 这边是分三步走的:21 给了 KEM API,24 落了 ML-KEM 算法本体,27 把它接进 TLS 并默认启用。27 这一步的变化是:客户端的握手候选列表里,X25519MLKEM768 排到了最前面,什么代码都不用改。
关键问题是:默认开了新算法,老客户端会不会被拒之门外? 我用 JDK 自带的 SSLSocket 写了个几十行的握手探针,自签证书,四种组合各连一遍:
server=25 client=25 → 协商 x25519(老组合,无 MLKEM)
server=27 client=25 → 协商 x25519(27 服务端对老客户端回落,握手成功)
server=25 client=27 → 协商 x25519(27 客户端面对老服务端回落,握手成功)
server=27 client=27 → 协商 X25519MLKEM768(后量子生效)
四种组合全部握手成功,加密套件都是 TLS_AES_256_GCM_SHA384。27 的客户端日志里能看到它把新组排在了最前面:
"named groups": [X25519MLKEM768, x25519, secp256r1, secp384r1,
secp521r1, x448, ffdhe2048, ffdhe3072, ffdhe4096]
对面是 27 就用后量子,对面是老的就用 x25519,双向兼容都验证过了,服务端和客户端任何一头先升级都不会炸。如果你显式设置过 jdk.tls.namedGroups 或调用过 SSLParameters.setNamedGroups,那默认列表的变化影响不到你,但也意味着你享受不到——这种情况建议把列表对齐一下。
顺带一提,对比两版日志时我还看到一个不起眼的变化:27 的默认列表把 ffdhe6144 和 ffdhe8192 两个大参数 DHE 组拿掉了,release notes 里对应 JDK-8373426 这条。反正大多数连接走的都是 ECDHE 系,这个变化几乎无感,但你要是真依赖超大 DHE 组,这里会静默少掉选项,属于不查日志根本发现不了的类型。
G1:在这台机器上,它真的慢了
GC 的默认值变化,在我这台 1 核小机器上反应最直白:JEP 523 让 G1 成为所有环境的默认收集器,不再区分 server 和非 server。差异一目了然:
JDK 25:bool UseSerialGC = true {ergonomic}
JDK 27:bool UseG1GC = true {ergonomic}
两版都是 JVM 自己挑的,规则不同,选择不同。JEP 的理由也充分:这几年 G1 全面进化,JEP 522 砍了同步开销,官方测试显示 G1 在各种堆尺寸上都已经追平 Serial,与其让"单核小内存"偷偷选 Serial,不如统一行为,好理解也好排查。 动机成立,但我还是想看看官方那句"性能不应显著退化"在我这个极端环境下的真实表现。于是把刚才那个塞对象程序改用各自默认 GC 跑了一遍:
JDK 25 默认(Serial):2.7 秒跑完
JDK 27 默认(G1) :7.3 秒跑完
慢了约 2.6 倍。单核加小堆,正是 G1 区域化管理开销最吃亏的场景。这个负载本身就是极端的分配压力测试,日常业务不会这么极端,但趋势是真实的——JEP 523 的 Risks 一节其实自己写了:受限环境下有些应用用 Serial 仍然最优,这种情况可以显式指定回去。
所以这一段的结论不是"27 的默认变差了",而是:如果你的服务跑在 1 核小容器或者最低配 VPS 上,升级前值得拿真实流量对比一下,不行就加回 -XX:+UseSerialGC,一行参数的事。本地大机器上测不出差别,恰恰是这类默认值变化最阴的地方——它只在你看不见的环境里生效。
写在最后
三个默认值里,对象头和 TLS 基本是白捡的——前者直接给堆省了 40% 的内存,后者把量子时代的路提前铺了一段,都不用改一行代码。G1 那个则更像对"小机器"的重新定价,JVM 不再认为小环境配用最省的收集器——官方赌 G1 已经够好,大多数场景确实,只是我这台 1 核的没赌赢。
生产环境继续锚 25 没问题,但建议把 27 扔进 CI 先跑着,顺便把 UseCompressedClassPointers 这类老参数从启动脚本里清一清。至于这台小机器,我把 G1 留着没动,反正它也不跑什么正经业务——就当替大家踩个雷。