关于 String 有两个流传很广的说法:JDK 9 之后引入了 Compact Strings,纯拉丁字符的字符串用 1 个字节存一个字符,含其他字符才用 2 个字节;String 是不可变的。这次用本机的 OpenJDK 17 逐个验证:用反射读出 String 内部的 value(byte[])和 coder 字段,看不同内容的字符串到底怎么存;用 Instrumentation 精确量出一个字符串占多少字节,开关 -XX:-CompactStrings 对比;再用反射直接改 value 里的一个字节,看"不可变"到底是被谁保证的,以及改了之后 hashCode 缓存、HashMap、字面量常量池会发生什么。最后是一个分步演示,每一帧都是真实程序在每个操作之后读出的字节。
上一篇:HashMap 扩容全过程:第 13 个 put 才触发,节点按 hash 的一位拆成两桶,预设容量省掉 18 次扩容
真实命令 · shell $ java -version $ javac -d build MemAgent.java StringExp.java && (cd build && jar cfm agent.jar ../manifest.txt MemAgent.class) $ java -javaagent:build/agent.jar --add-opens java.base/java.lang=ALL-UNNAMED -cp build StringExp $ java -XX:-CompactStrings -javaagent:build/agent.jar --add-opens java.base/java.lang=ALL-UNNAMED -cp build StringExp
openjdk version "17.0.20.1" 2026-08-18 OpenJDK Runtime Environment Temurin-17.0.20.1+1 (build 17.0.20.1+1) OpenJDK 64-Bit Server VM Temurin-17.0.20.1+1 (build 17.0.20.1+1, mixed mode, sharing) java.version = 17.0.20.1,OpenJDK 64-Bit Server VM,aarch64,jvm 参数: [-javaagent:build/agent.jar, --add-opens=java.base/java.lang=ALL-UNNAMED]
MemAgent 是一个只有几行的 Java Agent,把 Instrumentation 存下来供实验调用 getObjectSize(它返回的是单个对象的浅层大小,不包含引用的对象,所以 String 对象和它的 byte[] 要分开量再相加)。读取 String 的私有字段用反射:
真实命令 · java Field VALUE = String.class.getDeclaredField("value"); // private final byte[] value Field CODER = String.class.getDeclaredField("coder"); // private final byte coder:0 = LATIN1,1 = UTF16 VALUE.setAccessible(true); CODER.setAccessible(true);
在 JDK 17 里,这两行 setAccessible(true) 默认会失败,需要 --add-opens java.base/java.lang=ALL-UNNAMED。不加这个参数的真实报错是:
真实命令 · shell $ java NoOpens.java
java.lang.reflect.InaccessibleObjectException: Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module @4b53f538
"" length() = 0 coder = 0 value.length = 0 value = [] "abc" length() = 3 coder = 0 value.length = 3 value = [61 62 63] "é" length() = 1 coder = 0 value.length = 1 value = [e9] "€" length() = 1 coder = 1 value.length = 2 value = [ac 20] "中" length() = 1 coder = 1 value.length = 2 value = [2d 4e] "abc中" length() = 4 coder = 1 value.length = 8 value = [61 00 62 00 63 00 2d 4e] "😀" length() = 2 coder = 1 value.length = 4 value = [3d d8 00 de]
coder = 0(LATIN1)时每个字符一个字节:"abc" 的 value 是 61 62 63 三个字节;"é"(U+00E9,在 0–255 之内)仍是 1 个字节。"€"(U+20AC)和 "中"(U+4E2D)超出了这个范围,coder = 1(UTF16),每个字符 2 个字节,并且在这台机器上是小端序:"中" 是 2d 4e,低位字节在前。最关键的是 "abc中":整个字符串的 coder 是 1,value 有 8 个字节,前面三个 ASCII 字符也被存成了 61 00 62 00 63 00,也就是一个非拉丁字符会让整个字符串的存储翻倍。"😀" 是 U+1F600,超出基本平面,length() 是 2(两个 UTF-16 代码单元),value 是 4 个字节 3d d8 00 de,也就是一对代理项。空字符串的 coder 是 0,value 长度 0。
关掉 Compact Strings(-XX:-CompactStrings)之后同一组字符串:
"" length() = 0 coder = 1 value.length = 0 value = [] "abc" length() = 3 coder = 1 value.length = 6 value = [61 00 62 00 63 00] "é" length() = 1 coder = 1 value.length = 2 value = [e9 00] "€" length() = 1 coder = 1 value.length = 2 value = [ac 20] "中" length() = 1 coder = 1 value.length = 2 value = [2d 4e] "abc中" length() = 4 coder = 1 value.length = 8 value = [61 00 62 00 63 00 2d 4e] "😀" length() = 2 coder = 1 value.length = 4 value = [3d d8 00 de]
所有字符串(包括空串)的 coder 都变成了 1,"abc" 的 value 变成了 6 个字节。所以 coder 是 Compact Strings 这个特性的开关体现,不是字符串本身固定的属性。
字符串 coder String 对象 byte[] 数组 合计 长度 0,全是 ASCII 0 24 16 40 长度 1,全是 ASCII 0 24 24 48 长度 1,最后一个是「中」 1 24 24 48 长度 10,全是 ASCII 0 24 32 56 长度 10,最后一个是「中」 1 24 40 64 长度 100,全是 ASCII 0 24 120 144 长度 100,最后一个是「中」 1 24 216 240 长度 1000,全是 ASCII 0 24 1016 1040 长度 1000,最后一个是「中」 1 24 2016 2040
String 对象本身固定 24 字节(对象头、value 引用、缓存的 hash、coder 等字段加对齐),byte[] 数组是 16 字节的头加上数据再对齐到 8 的倍数:长度 100 的 ASCII 字符串数据是 100 字节,数组 120 字节,合计 144 字节;只要有一个「中」,数据翻成 200 字节,数组 216 字节,合计 240 字节,多了 96 字节(约 67%)。长度 1000 时是 1040 对 2040,接近 2 倍。长度 1 或 10 时因为数组头占比大,差距小一些:长度 10 是 56 对 64 字节。
用 -XX:-CompactStrings 关掉之后:
字符串 coder String 对象 byte[] 数组 合计 长度 0,全是 ASCII 1 24 16 40 长度 1,全是 ASCII 1 24 24 48 长度 1,最后一个是「中」 1 24 24 48 长度 10,全是 ASCII 1 24 40 64 长度 10,最后一个是「中」 1 24 40 64 长度 100,全是 ASCII 1 24 216 240 长度 100,最后一个是「中」 1 24 216 240 长度 1000,全是 ASCII 1 24 2016 2040 长度 1000,最后一个是「中」 1 24 2016 2040
关掉之后,ASCII 字符串也和带「中」的一样大:长度 100 是 240 字节、长度 1000 是 2040 字节,Compact Strings 对纯拉丁字符串大约省了一半的数据空间。这些数字是这个 JVM(64 位、压缩指针默认开启)下的浅层大小,不是所有 JVM 配置通用的。我最开始试过用 Runtime.totalMemory() - freeMemory() 在 GC 之后估算,但同样的两批字符串两次估出来差了两成以上(119.9 字节对 148.0 字节每个,这次输出我没有保存),说明这种办法在 SerialGC 下不稳定,所以改用了 Instrumentation。
修改前: s = hello,copy = hello,sub = ello copy 与 s 共享同一个 value 数组: true;sub 与 s 共享: false 修改后: s = jello,copy = jello,sub = ello 别的方法里再取一次同一个字面量: literalHello() = jello,与 s 是同一个对象: true 新建一个内容为 jello 的字符串 fresh:s.equals(fresh) = true hashCode 缓存: s.hashCode() = 99162322(修改前是 99162322),fresh.hashCode() = 101009364 HashMap 里放着 s:map.get(s) = 1,map.get(fresh) = null(s.equals(fresh) 明明是 true)
这一段的每一行都值得拆开看:
所以,String 的不可变不是 JVM 在内存层面强制的:它靠的是 value 字段是 private final、类里没有任何修改内容的公开方法,再加上 JDK 9 之后的模块封装(不加 --add-opens 反射根本进不去,见上面的报错)。一旦有代码绕过这些保护,共享同一个数组的对象、常量池里的字面量、已经缓存的 hashCode 都会一起出问题。这也是"字符串可以放心当 HashMap 的 key、可以安全地在线程间共享"这个前提的真实成立条件。这个实验只是为了理解"不可变"的实现方式,不要在生产代码里这样做。
下面的演示依次执行 6 条语句:s0 = "abc",s1 = s0.concat("é"),s2 = s1.concat("中"),s3 = s2.substring(0, 3),s4 = new String(s0),s5 = s0.substring(1)。每一帧的字节、coder、底层数组编号都是真实程序在每一步之后用反射读出的。可以看到:s1 仍然是 LATIN1;s2 追加「中」后整个字符串变成 UTF16,前面的字符被整体重新编码(value 从 4 字节变成 10 字节);s3 从 UTF16 的 s2 里截出 "abc",因为都在 0–255 之内,结果又回到 LATIN1;s4 与 s0 的数组编号相同(共享);s5 是新数组。