String
String 源码分析
String 用不可变性换来三样东西:可以安全共享(常量池、拷贝构造直接共享数组)、可以缓存 hashCode、天然线程安全,所以它敢当 HashMap 的 key。
JDK 9 起存储从 char[] 换成 byte[] + coder 标记(Compact Strings),纯 Latin1 内容每字符只占 1 字节,字符串占大头的堆直接省近一半内存。
// 基于 JDK 25 (本地 D:/1ForCode/JAVA_Source), java.lang.String
public final class String // final:不可变的第一道锁,杜绝子类重写方法伪装成"可变字符串"
implements java.io.Serializable, Comparable<String>, CharSequence,
Constable, ConstantDesc {
@Stable // 告诉 JIT:此字段一旦非默认值就永不再变,可参与常量折叠
private final byte[] value; // 真正的存储。final + private + 从不外泄引用 = 不可变的根基
private final byte coder; // value 的解读方式:LATIN1 每字符 1 字节,UTF16 每字符 2 字节
@Stable
private int hash; // Default to 0 // hashCode 缓存。敢缓存的前提是内容不可变
private boolean hashIsZero; // Default to false; // 补丁位:内容恰好算出 hash==0 时靠它记住"已算过"
// ...
static final boolean COMPACT_STRINGS; // 压缩总开关。特意做成静态常量:JIT 折叠后 coder 分支可整体消失
static {
COMPACT_STRINGS = true; // 实际值由 JVM 注入(-XX:-CompactStrings 可关),这里只是占位
}
// ...
public String(String original) { // 拷贝构造直接共享 value 数组:不可变对象无需防御性复制
this.value = original.value;
this.coder = original.coder;
this.hash = original.hash; // 连算好的 hash 一起带走
this.hashIsZero = original.hashIsZero;
}
// ...
public int length() {
return value.length >> coder(); // 字节数 >> coder 得字符数:LATIN1 右移 0 位,UTF16 右移 1 位
}
public char charAt(int index) {
if (isLatin1()) { // 几乎所有方法都是这个套路:按 coder 分发给两套实现之一
return StringLatin1.charAt(value, index);
} else {
return StringUTF16.charAt(value, index);
}
}
// ...
String(byte[] value, byte coder) { // 包内构造:直接持有传入数组不复制,JDK 内部专用的零拷贝通道
this.value = value;
this.coder = coder;
}
byte coder() {
return COMPACT_STRINGS ? coder : UTF16; // 先读静态常量再读实例字段,让 JIT 能把整个判断折叠掉
}
boolean isLatin1() {
return COMPACT_STRINGS && coder == LATIN1;
}
@Native static final byte LATIN1 = 0;
@Native static final byte UTF16 = 1;
}// 基于 JDK 25 (本地 D:/1ForCode/JAVA_Source), java.lang.String
public boolean equals(Object anObject) {
if (this == anObject) {
return true; // 同一对象直接短路,intern 过或来自常量池的字符串常走这条
}
return (anObject instanceof String aString)
&& (!COMPACT_STRINGS || this.coder == aString.coder) // 编码不同即内容必不同:构造时保证了"能压缩必压缩"
&& StringLatin1.equals(value, aString.value); // coder 相同则字节布局相同,UTF16 串也走这个逐字节比较
}
// 源码注释点明:hash / hashIsZero 存在良性数据竞争(benign data race)。多线程可能各算一遍,
// 但计算幂等、输入不可变、每个实例只会写这两个字段之一,所以不加锁不加 volatile 结果也正确
public int hashCode() {
int h = hash;
if (h == 0 && !hashIsZero) { // 两个条件都不成立才真正计算
h = isLatin1() ? StringLatin1.hashCode(value)
: StringUTF16.hashCode(value);
if (h == 0) {
hashIsZero = true; // 结果恰好是 0:记进标记位,本对象下次免算
} else {
hash = h;
}
}
return h;
}// 基于 JDK 25 (本地 D:/1ForCode/JAVA_Source), java.lang.String
public String substring(int beginIndex) {
return substring(beginIndex, length()); // 单参版只是转发,越界检查和复制都在双参版里
}
public String substring(int beginIndex, int endIndex) {
int length = length();
checkBoundsBeginEnd(beginIndex, endIndex, length);
if (beginIndex == 0 && endIndex == length) {
return this; // 截整串直接复用自身:不可变让这种复用永远安全
}
int subLen = endIndex - beginIndex;
return isLatin1() ? StringLatin1.newString(value, beginIndex, subLen) // 复制出新数组。JDK 7u6 起不再共享原数组:
: StringUTF16.newString(value, beginIndex, subLen); // 老实现截 1 个字符也会钉住整个大数组不放
}
public String concat(String str) {
if (str.isEmpty()) {
return this; // 拼空串免分配
}
return StringConcatHelper.doConcat(this, str); // 每次 concat 都产出全新 String——循环拼接必须换 StringBuilder
}
public native String intern(); // 查 JVM 的 StringTable(native 哈希表):池里有等值串就返回池内那个,没有就登记自己原理串讲
以最常见的 map.put(key, value)(key 是 String)走一遍完整链路。
HashMap 先调 key.hashCode():第一次调用时 hash 是 0 且 hashIsZero 为 false,于是按 isLatin1() 分发给 StringLatin1.hashCode(value) 或 StringUTF16.hashCode(value),逐字符累积 h = 31 * h + c,算完写回 hash 字段;之后同一个 key 再怎么查询,hashCode() 都只剩一次字段读。
为什么敢这样无锁缓存?因为写入的值由不可变的 value 唯一决定,两个线程抢着算也只会写入同一个数,是典型的良性竞争,省掉 volatile 和锁反而是这段代码的精髓。
为什么乘数选 31?奇素数分布好,且 31 * i == (i << 5) - i,JIT 能把乘法优化成移位减法。
而 hashIsZero 这个看似多余的 boolean 是 JDK 13 补的坑:内容恰好散列为 0 的字符串(比如 "\u0000" 或某些精心构造的串)在老实现里每次调用都全量重算,可被拿来做拒绝服务攻击的放大器。
哈希定位到桶后发生碰撞,HashMap 转调 key.equals(k)。它的三级短路很讲究:先 == 比引用(字面量和 intern 串在这里就结束了);再 instanceof 带模式变量一步完成类型判断加转型;然后比 coder。
为什么 coder 不同能直接判不等?因为构造路径保证了编码是内容的规范函数:能用 Latin1 表示的内容一定被压成 LATIN1,所以同内容必同 coder,编码不同等价于内容不同。
coder 相同后,两个 value 的字节布局完全一致,直接 StringLatin1.equals 逐字节比即可,UTF16 串也复用这一个方法。
再看两个高频操作。substring 自 JDK 7u6 起用 StringLatin1/StringUTF16.newString 复制出独立数组,把 O(1) 共享改成了 O(n) 复制,代价是性能,换来的是从几 MB 日志里截出的 8 字符 ID 不会把整个大数组钉在堆里。
而 + 拼接从 JDK 9 起由编译器翻译成 invokedynamic,运行期挂到 StringConcatHelper 的具体策略上一次性算好总长度、分配一个终态数组直接填充,比链式 concat 少建所有中间对象;但这套优化只对单个表达式生效,循环体里每轮 s += x 仍是每轮一个新 String,这正是”循环内用 StringBuilder”这条规矩的源码依据(StringBuilder 复用一个可变 byte[],不够就倍增扩容,StringBuffer 只是给同样的方法加了 synchronized)。
设计取舍
- byte[] + coder 替代 char[]:省一半内存,代价是每个方法多一次编码分发;用
COMPACT_STRINGS静态常量前置判断,让 JIT 把分支折叠成零开销。 - hash 缓存接受重复计算、拒绝加锁:多算一次的代价远小于每次读 hash 都过内存屏障。
- substring 用 O(n) 复制换掉 O(1) 共享:牺牲截取性能,根治”小串钉住大数组”的内存泄漏。
- intern 能给海量重复串去重,但 StringTable 容量有限且查表有开销;大规模去重更该考虑 G1 的字符串去重或业务层缓存。
- 拷贝构造
new String(s)共享数组几乎零成本,但也几乎没有使用场景——它只会让<mark>失效,除了演示 陷阱之外别用。