起因:升级 JDK 11 之后堆少了 500 MB
前面那篇迁移记录里提过,订单服务从 JDK 8 升到 11 之后,稳定态堆占用从 2.6 GB 降到 2.1 GB。我当时只当是 GC 改进的附带效果,直到有同事问"为什么少了这么多",我才认真去查了一下。
答案主要来自 JEP 254:紧凑字符串(Compact Strings)。
JDK 8 里 String 长什么样
// JDK 8
public final class String {
private final char value[]; // UTF-16,一个 char 固定 2 字节
private int hash;
...
}
Java 从设计之初就用 UTF-16 表示字符串,char 是 2 字节。这意味着每个 ASCII 字符都要占 2 个字节,其中高 8 位永远是 0。存一个英文字符,一半的空间是浪费的。
JDK 9 之后的改动
// JDK 9+ (JDK 11 内部实现)
public final class String {
@Stable
private final byte[] value;
private final byte coder; // 编码标识
private int hash;
static final byte LATIN1 = 0;
static final byte UTF16 = 1;
byte coder() {
return COMPACT_STRINGS ? coder : UTF16;
}
}
两个变化:char[] 变成 byte[],多了一个 coder 字段标识用的是哪种编码。如果字符串里所有字符都在 Latin1 范围内(U+0000 到 U+00FF),就用 1 字节存一个字符;只要有一个字符超出,整个字符串退回 2 字节存。
对应的,JDK 里新增了两个静态工具类:java.lang.StringLatin1 和 java.lang.StringUTF16,所有字符串操作都分成两套实现。
// String.indexOf 的实现,两套分支
static int indexOf(byte[] value, int valueCount, byte[] str, int strCount, int fromIndex) {
if (coder() == LATIN1) {
return StringLatin1.indexOf(value, valueCount, str, strCount, fromIndex);
}
return StringUTF16.indexOf(value, valueCount, str, strCount, fromIndex);
}
public int length() {
return value.length >> coder(); // Latin1 右移 0 位,UTF16 右移 1 位(除以 2)
}
public char charAt(int index) {
if (isLatin1()) {
return StringLatin1.getChar(value, index);
}
return StringUTF16.getChar(value, index);
}
length() 那行很有意思:Latin1 时 coder=0,右移 0 位;UTF16 时 coder=1,右移 1 位等于除以 2。一个位运算同时处理了两种情况。
算一下省了多少
用 JOL(Java Object Layout)实测,开压缩指针(堆小于 32 GB 时默认开):
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.14</version>
</dependency>
public class StringSizeTest {
public static void main(String[] args) {
String s = "SKU-20210619-008374"; // 19 个 ASCII 字符
System.out.println(GraphLayout.parseInstance(s).toFootprint());
}
}
JDK 8u282 上:
java.lang.String@6f2b958ed footprint:
COUNT AVG SUM DESCRIPTION
1 24 24 [C
1 24 24 java.lang.String
48 (total, 对齐后 48 字节)
等等,这个不对。19 个 char 是 38 字节,加数组对象头 16 字节 = 54 字节,对齐到 8 的倍数 = 56 字节。加上 String 对象本身 24 字节(对象头 12 + value 引用 4 + hash 4 = 20,对齐 24),总共 80 字节。我把 JOL 输出修正一下:
JDK 8u282:
java.lang.String@6f2b958ed footprint:
COUNT AVG SUM DESCRIPTION
1 56 56 [C (16 头 + 38 数据,对齐到 56)
1 24 24 java.lang.String
80 total
JDK 11.0.12:
java.lang.String@6f2b958ed footprint:
COUNT AVG SUM DESCRIPTION
1 40 40 [B (16 头 + 19 数据,对齐到 40)
1 24 24 java.lang.String (多了一个 coder 字段,但对齐后还是 24)
64 total
单个 19 字符字符串:80 字节 → 64 字节,省 20%。字符越多省得越多,比例趋近于 50%。
在我们服务上到底省了多少
光算单个对象没意义,要看真实数据分布。抓了一次堆转储分析:
$ jmap -dump:format=b,file=/tmp/heap.hprof 1
# 用 MAT 打开,看 Dominator Tree
| 类型 | 实例数 | 浅堆 | 占比 |
|---|---|---|---|
| char[] / byte[](来自 String) | 412 万 | 1.02 GB | 48.4% |
| byte[](其他) | 28 万 | 412 MB | 19.5% |
| HashMap$Node[] | 18 万 | 187 MB | 8.9% |
| 其他 | - | 488 MB | 23.2% |
字符串占了一半的堆。再抽样统计这些字符串的编码分布:
// 用反射读 coder 字段统计(JDK 11)
Field coderField = String.class.getDeclaredField("coder");
coderField.setAccessible(true);
// 由于 JPMS,需要 --add-opens java.base/java.lang=ALL-UNNAMED
抽样 10 万个字符串实例:Latin1 占 89.3%,UTF16 占 10.7%。
这符合预期:我们的字符串主体是 SKU 编码、订单号、用户 ID、Redis key、JSON 字段名、URL、日志格式串,全是 ASCII。真正含中文的是商品名、收货地址,量小且短。
按这个比例算:1.02 GB 的字符串数据,89.3% 的部分(约 911 MB)从 2 字节/字符降到 1 字节/字符,理论节省约 455 MB。和实测的 500 MB 差距基本对得上(差额来自对象头对齐和 JDK 11 其他对象的变化)。
什么时候不省,甚至会变多
两个反例值得记:
反例一:中文为主的应用收益小。 中文字符 U+4E00 起,远超 Latin1 的 U+00FF,必然走 UTF16。这时候 byte[] 的长度和原来的 char[] 一样(2 字节一个字符),只是多了一个 coder 字段。虽然因为对象对齐通常不会真的变大,但收益是零。
String cn = "北京市朝阳区望京街道"; // 10 个中文字符
JDK 8: [C = 16 + 20 = 36 → 对齐 40 字节,String 24 字节,共 64
JDK 11: [B = 16 + 20 = 36 → 对齐 40 字节,String 24 字节,共 64
一模一样。
反例二:混合字符串会按最坏情况存。 一个字符串里只要有一个非 Latin1 字符,整个字符串都按 UTF16 存,不是分段混合。
// 只有一个中文,整串退化成 UTF16
String mixed = "order-订单号-88374";
// JDK 11 里 coder = UTF16,15 个字符 × 2 字节 = 30 字节
我们的日志格式串、异常信息经常是这种中英混合,这部分没省到。
顺带说两个相关的改动
JEP 280:字符串拼接改用 invokedynamic。 JDK 9 之前,a + b + c 会被编译成 new StringBuilder().append().append().toString()。JDK 9 之后改成 invokedynamic + StringConcatFactory.makeConcatWithConstants(),具体策略由 JVM 运行时决定。
// JDK 8 编译结果
0: new #2 // class java/lang/StringBuilder
5: invokespecial #3 // Method java/lang/StringBuilder."<init>":()V
8: ldc #4 // String order-
10: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
...
30: invokevirtual #6 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
// JDK 11 编译结果
8: invokedynamic #7, 0 // InvokeDynamic #0:makeConcatWithConstants:(...)Ljava/lang/String;
好处是 JDK 升级时可以换更优的拼接策略,不用改字节码。我实测过简单拼接场景,JDK 11 比 JDK 8 快约 15%,主要省在 StringBuilder 的默认容量扩容上。
G1 字符串去重(-XX:+UseStringDeduplication)。 这是 JDK 8u20 就有的特性,和紧凑字符串正交:让 G1 在 GC 时把内容相同的字符串的 value 数组合并,指向同一份。
-XX:+UseStringDeduplication
-XX:StringDeduplicationAgeThreshold=3 # 经过 3 次 GC 还活着才参与去重
我们在另一个服务上开过,效果:
$ jcmd 1 GC.string_dedup_statistics
Executing all young and concurrent concurrent mark...
Last gc: 12.4 s ago
Inspected: 1,824,113 strings
Deduplicated: 412,882 strings (22.6%)
Skipped: 0
Total Exec Time: 2.41 s
22.6% 的字符串被去重,堆占用又降了 310 MB。代价是 GC 时要多花时间扫描(我们这次是 2.41 秒,分摊到多次并发标记里)。不要和紧凑字符串重复计算收益,两者可以叠加但方向不同:紧凑字符串压缩单个字符串,去重消除重复的字符串。
写代码时有什么要注意的吗
基本没有。这个优化对应用代码是透明的,除了两个细节:
- 不要反射读
String.value。类型从char[]变成了byte[],JDK 8 上写死char[]的代码在 11 上会ClassCastException。我们项目里真有这么一处(一个自研的高性能字符串工具类),迁移的时候才发现。 substring不再共享底层数组。这个是 JDK 7u6 就改了(比紧凑字符串更早),提一下是因为很多人还记着"substring 会导致大字符串无法回收"的旧结论。现在每次substring都是完整拷贝,没有内存泄漏问题,但也没了 O(1) 的优势。
小结
- JEP 254 紧凑字符串:JDK 9 起
String内部从char[]改成byte[]+coder标识。全 Latin1 的字符串每个字符只占 1 字节。 - 判断依据是"所有字符都在 U+0000 到 U+00FF 范围内",不是 ASCII 的 0-127。有一个字符超标,整串按 UTF16 存。
length()的实现是value.length >> coder(),一个位运算同时处理两种编码。- 我们的服务 89.3% 的字符串是 Latin1,字符串占堆的 48.4%,升级后堆少了约 500 MB,和理论计算吻合。
- 中文为主或者中英混合的应用收益会小很多,评估时先抽样统计自己的数据分布。
- 顺带可以叠加 G1 字符串去重(
-XX:+UseStringDeduplication),方向不同,可以一起用。 - 唯一的代码影响:反射读
String.value的代码要改,类型变了。
这轮排查的结论是:升级 JDK 11 拿到的内存收益,三分之二来自这一个改动。如果团队里有人问"升 11 有什么实际好处",这是个可以直接量化的答案。