Administrator
发布于 2021-11-10 / 4356 阅读
32

String 在 JDK 9 之后为什么改用 byte 数组

起因:升级 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.StringLatin1java.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 GB48.4%
byte[](其他)28 万412 MB19.5%
HashMap$Node[]18 万187 MB8.9%
其他-488 MB23.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 有什么实际好处",这是个可以直接量化的答案。

参考