Skip to content
Go back

Compact Strings——JDK 9如何将String内存占用减半?

Compact Strings:JDK 9 如何将 String 内存占用减半?

一句话结论(30s)

Compact Strings 的本质是「只改内部存储、不改外部接口」的优化,因为 JDK 8 用 char[] 每字符固定 2 字节(UTF-16),纯 ASCII 字符串浪费 50% 内存,所以 JDK 9 换成 byte[] + coder 标志位,Latin-1 字符每字符 1 字节实现内存减半。权衡:以新增 coder 分派逻辑为代价换内存,而 coder 是 final 字段被 JIT 内联成单支,运行时没有分支开销。

核心原理(2min)

主流程:构造时判断字符是否全在 Latin-1(0-255)范围,是则 coder=LATIN1(每字符 1 字节)、否则 UTF16(2 字节);charAt/substring/indexOf 等按 coder 分派到 StringLatin1/StringUTF16 静态方法,外部 API 仍返回 char,调用方无感知。coder 是 final 字段,C2 编译器把它视为常量,if (coder()==LATIN1) 被内联为单支,消除分支开销。纯 ASCII 场景(日志/JSON/SQL)内存占用减少约 50%。

底层深入(5-10min)

JDK 8 及之前:char[] 固定 2 字节

// JDK 8
public final class String {
    private final char[] value;  // 每个字符固定 2 字节(UTF-16)
}

一个全是 ASCII 字符的字符串,内存中每个字符也占用 2 字节。纯英文文档 → 内存浪费 50%。

思考:为什么「每个字符固定 2 字节」会浪费 50%?因为 ASCII 实际只需要 1 字节就能表示 0~127,但 char[] 用 2 字节/字符的 UTF-16 存它,等于有一半字节永远是 0(高位空着)。字符串越长、ASCII 占比越高,浪费越接近一半——日志、JSON、SQL 这些几乎纯 ASCII 的海量字符串,正是重灾区。

JDK 9:byte[] + coder 标志位

String.java 中的真实定义(逐字摘自 OpenJDK 源码):

@Stable
private final byte[] value;

/**
 * The identifier of the encoding used to encode the bytes in
 * {@code value}. The supported values in this implementation are
 *
 * LATIN1
 * UTF16
 */
private final byte coder;

/** Cache the hash code for the string */
@Stable
private int hash; // Default to 0

分析:valuebyte[] 存实际字节,coder 标记编码(LATIN1=0UTF16=1)。两个字段都标 @Stable,向 JVM 声明构造后内容不可变、可被信任做常量折叠,这也是后续 JIT 消除 coder 分支的前提。

如果字符串的所有字符都在 Latin-1(0-255)范围内,用 Latin-1 编码(每字符 1 字节);否则用 UTF-16 编码(每字符 2 字节)。coder 标志位告诉 JVM 如何解读 value 字节数组。

思考:Compact Strings 为什么能「省一半内存」?核心是把存储单元从 2 字节 char 换成 1 字节 byte,让纯 Latin-1 字符串每字符只占 1 字节;coder 只是多出来的 1 字节元数据,摊到整个字符串上几乎可忽略。它并不是「所有字符串都减半」,而是「能走 Latin-1 的减半、含中文的仍 UTF-16」——按需选择编码,正是它精妙的地方。

如何保持向后兼容?

所有涉及字符的方法(charAtsubstring 等)通过两个辅助类的静态方法分派:

public char charAt(int index) {
    if (isLatin1()) {
        return StringLatin1.charAt(value, index);
    } else {
        return StringUTF16.charAt(value, index);
    }
}

boolean isLatin1() {
    return COMPACT_STRINGS && coder == LATIN1;
}

byte coder() {
    return COMPACT_STRINGS ? coder : UTF16;
}

@Native static final byte LATIN1 = 0;
@Native static final byte UTF16  = 1;

分析:真实实现比简化版多一层 isLatin1()——它先看 COMPACT_STRINGS 静态开关再比 coder,关闭压缩时 JIT 把 COMPACT_STRINGS 折叠成 false、直接走 UTF16 单支;LATIN1/UTF16@Native 标注为 native 常量。

StringLatin1.charAt() 把 1 字节转为 char(扩宽),StringUTF16.charAt() 从 2 字节读取。外部接口不变——返回的还是 char,调用方不知道内部的 byte[]

思考:为什么要费劲把 charAt 分派到两个类,而不是让调用方自己判断?因为目标是「只改内部、不改外部」——如果外部 API 变了,所有依赖 String 的代码都要改,Compact Strings 就不可能落地。用两个辅助类的静态方法在内部消解编码差异,对外仍返回 char,才能让用户零改动享受内存减半。

哪些方法需要感知 coder?

所有操作字符索引的方法都依赖 codercharAtindexOfsubstringlength(Latin-1 下 length = value.length,UTF-16 下 length = value.length / 2)。新增方法都能感知两种编码,但外部 API 完全不变。

思考:为什么 length() 在 Latin-1 下是 value.length、UTF-16 下是 value.length / 2?因为 value.length 是字节数不是字符数:Latin-1 每字符 1 字节,字节数即字符数;UTF-16 每字符 2 字节,字符数 = 字节数 ÷ 2。这也说明 coder 不只是「省内存」的标记,还是所有索引运算换算字符与字节的基准。

JIT 如何优化掉 coder 判断

coderfinal 字段,首次读取后 JIT 判定为常量。if (coder() == LATIN1) 的分支被 JIT 内联为单支——C2 编译器看到 coder 值不变,走单路径,消除分支开销。所以运行时不存在”每个字符都要判断一次 coder”的性能损耗。

思考coder 明明是个字段,JIT 凭什么把它当常量消除分支?因为它是 final,构造后永不改变,C2 在编译时一旦观测到具体值(0 或 1),就把 if (coder() == LATIN1) 折叠成固定走某一支,另一支的代码直接不生成。所以「多了一个 coder 判断」的成本只在解释执行阶段有,JIT 编译后归零。

内存收益

纯 ASCII 字符串(如日志、JSON、SQL 语句)内存占用减少 ~50%。混合字符(如中英混合)的字符串在包含中文时仍用 UTF-16,但大量纯英文的 JSON key 和日志内容享受了全额节省。

思考:为什么「中英混合」字符串整个都走 UTF-16,而不是每个字符单独选编码?因为编码是按整个字符串决定的——只要有一个字符超出 Latin-1,coder 就置 UTF16,所有字符统一 2 字节。逐字符混合编码会引入复杂的索引偏移计算,得不偿失;字符串级判定才是实现简单与内存节省的最佳平衡点。

总结

Compact Strings 的设计精髓:只改内部存储,不改外部接口。byte[] 替代 char[] + coder 分派 + JIT 内联消除分支,用户代码零改动,纯 ASCII 场景内存占用减半。

章末提问

Q1:Compact Strings 的核心思想是什么? 结论:只改内部存储、不改外部接口。因为 JDK 8 的 char[] 每字符固定 2 字节、纯 ASCII 浪费 50%,JDK 9 换成 byte[] + coder,让 Latin-1 字符每字符 1 字节,用户代码零改动即可受益。

Q2:coder 标志位有什么用?怎么消除它带来的开销? 结论:coder 标记 byte[] 用 Latin-1(每字符 1 字节)还是 UTF-16(2 字节)解读。因为它是 final 字段,C2 编译器把它当常量,把 if (coder()==LATIN1) 内联成单支,运行时没有分支开销。

Q3:什么样的字符串享受不到内存减半? 结论:含非 Latin-1 字符(如中文、emoji)的字符串。因为编码按整个字符串决定,只要有一个字符超出 0~255,coder 就是 UTF16、每字符仍 2 字节,只有纯 ASCII/Latin-1 字符串才能减半。

Q4:JVM 怎么决定一个字符串走 Latin-1 还是 UTF-16? 结论:构造时扫描字符是否全落在 Latin-1(0~255)范围。因为压缩是构造期一次性判定并固化到 coder 字段,后续所有方法都按这个 coder 分派,不存在运行时逐字符判断。

Q5:charAt 为什么要分派到 StringLatin1 / StringUTF16 两个类? 结论:为了内部消解两种编码的差异、对外保持 char 返回值不变。因为外部 API 一旦改变,所有依赖 String 的代码都要跟着改,Compact Strings 就不可能无痛落地。


Share this post on:

Previous Post
IntegerCache——为什么Integer 127 == Integer 127是true而128不是?
Next Post
ByteBuffer——HeapByteBuffer与DirectByteBuffer的本质区别