bigywater.tech
Java 内部原理 · 字符串

String 内部:coder 决定 1 字节还是 2 字节,「不可变」只是约定——反射改一个字节,字面量、HashMap 全被改了

关于 String 有两个流传很广的说法:JDK 9 之后引入了 Compact Strings,纯拉丁字符的字符串用 1 个字节存一个字符,含其他字符才用 2 个字节;String 是不可变的。这次用本机的 OpenJDK 17 逐个验证:用反射读出 String 内部的 value(byte[])和 coder 字段,看不同内容的字符串到底怎么存;用 Instrumentation 精确量出一个字符串占多少字节,开关 -XX:-CompactStrings 对比;再用反射直接改 value 里的一个字节,看"不可变"到底是被谁保证的,以及改了之后 hashCode 缓存、HashMap、字面量常量池会发生什么。最后是一个分步演示,每一帧都是真实程序在每个操作之后读出的字节。

144 → 240
长度 100 的 ASCII 字符串加一个「中」之后每个字符串占的字节数
7 步
分步演示,每一帧都是反射读出的 String.value 真实字节

上一篇: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

真实验证:coder 按「内容」自动选择,拉丁字符 1 字节,其他字符 2 字节,一个字符就能让整个字符串翻倍

真实实测 · 本机执行输出
""     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 这个特性的开关体现,不是字符串本身固定的属性。

真实验证:一个字符串占多少字节,ASCII 是 144 字节,加一个「中」变成 240 字节

真实实测 · 本机执行输出
字符串                          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。

真实验证:「不可变」是 API 层的约定,反射改一个字节,字面量和别的变量全都变了

真实实测 · 本机执行输出
修改前: 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、可以安全地在线程间共享"这个前提的真实成立条件。这个实验只是为了理解"不可变"的实现方式,不要在生产代码里这样做。

分步演示:concat、substring、new String 之后,value 的真实字节

下面的演示依次执行 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 是新数组。

String 内部的 value 与 coder:每一步之后用反射读出的真实字节(OpenJDK 17,aarch64 小端)
新建的字符串的字节

    结论

    参考与说明

    • 环境:本机 OpenJDK 17.0.20.1(Temurin),aarch64(小端),macOS。coder、value 是 JDK 内部字段,名称和语义随版本变化,本文只对这个版本成立。
    • 实验源码 StringExp.java、MemAgent.java、NoOpens.java 和生成演示数据的 StringDemo.java 都在本机的实验目录里,数据全部来自它们的真实输出。
    • 没有验证:JDK 8 及以前(char[] 存储,没有 Compact Strings);G1 的字符串去重(-XX:+UseStringDeduplication);大端机器上 UTF16 的字节序;字符串常量池(intern)的内存开销;StringBuilder 的存储方式。
    • 相关文章:字符串 + 拼接编译成了什么、String.hashCode() 真实公式、String 代理对(同一系列,本文只说明 String 自己的存储)。
    京ICP备2026067502号-1京公网安备11010502063487号