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
分析:value 用 byte[] 存实际字节,coder 标记编码(LATIN1=0、UTF16=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」——按需选择编码,正是它精妙的地方。
如何保持向后兼容?
所有涉及字符的方法(charAt、substring 等)通过两个辅助类的静态方法分派:
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?
所有操作字符索引的方法都依赖 coder:charAt、indexOf、substring、length(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 判断
coder 是 final 字段,首次读取后 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 就不可能无痛落地。