文章收录专栏:Java 核心原理全解:源码・并发・面试实战
引子
String是 Java 里用得最多的类,也是最容易 “灯下黑” 的类:new String("abc")到底几个对象?s1.intern() == s1到底 true 还是 false?JDK 9 为什么把char[]换成byte[]?JDK 7 的substring又藏着什么内存泄漏事故?今天一次讲透 String 的底层存储、常量池机制、拼接原理、内存模型与经典方法变迁,面试背熟、生产避坑两不误。
一、String 的核心特性:final + 不可变
final类、内部存储数组final、对外无 setter,所有 “修改” 操作都返回全新对象。不可变 ⇒天生线程安全,可多线程安全共享。
二、底层存储:char [] → byte [] + coder
JDK 8 及以前:
private final char value[]; // 每字符固定 2 字节,纯 ASCII 也浪费JDK 9 起(JEP 254 Compact Strings):
private final byte[] value; // 存储字节 private final byte coder; // 0 = LATIN1(1字节/字符),1 = UTF16(2字节/字符)- 全在 Latin-1 范围 → LATIN1,每字符 1 字节;否则 UTF16,2 字节;
- 方法分
StringLatin1/StringUTF16两套;LATIN1 下哈希、indexOf 计算更快; - 可用
-XX:-CompactStrings关闭(关闭后全走 UTF16); - 主流场景平均省一半内存。
⚠️length()返回char 数不是逻辑字符数:emoji 等增补字符以代理对表示,1 个逻辑字符占 2 个 char。数真实字符用s.codePointCount(0, s.length())—— 参数是起止索引(beginIndex, endIndex),且该方法需遍历、有代价,日常用length()即可。
三、字符串常量池
3.1 本质与引用性质
全局唯一的表(HashTable 结构),存字符串对象的引用。存的是强引用不是弱引用;但 GC 时 JVM 会专门扫描 StringTable,对除池外无其他 GC Roots 的 String 回收并清理对应条目 —— 这是特殊强引用 + GC 清理策略,不是弱引用语义。
3.2 位置变迁(必考)
| 版本 | 位置 |
|---|---|
| JDK 6 及以前 | 方法区(永久代) |
| JDK 7 | 移到堆 |
| JDK 8+ | 永久代→元空间,池仍在堆 |
调优:-XX:StringTableSize设桶数(JDK 7 前约 1009、JDK 8 起约 60013,较新版本支持动态扩容、参数近于初始桶数);-XX:+PrintStringTableStatistics打印统计排查 intern 滥用。
3.3 三个 “常量池” 别混淆
Class 文件常量池(每类一个)、运行时常量池(每类一个,元空间)、字符串常量池(全局唯一,堆)。
3.4 intern () 的两个陷阱
陷阱一:版本差异——JDK 6 intern 复制进永久代(两份);JDK 7+ 直接复用堆对象引用(一份)。
陷阱二:intern()==s的成立条件—— 只有调用前池中不存在该字符串才 true;只要池中已存在就必 false:
String s1 = new String("abc"); // 字面量 "abc" 已入池 s1.intern() == s1; // false!返回池引用 // 前提:程序执行前从未加载过字面量 "abc" String s2 = new String(new char[]{'a','b','c'}); s2.intern() == s2; // true(JDK7+),池原本没有四、new String (“abc”) 到底几个对象?(分版本)
| 场景 | JDK 6 | JDK 7+ |
|---|---|---|
| 池中无 “abc” | 2 个:永久代池对象 + 堆对象(两份独立实例) | 2 个:堆内池对象 + 堆内 new 对象 |
| 池中已有 “abc” | 1 个堆对象 | 1 个堆对象 |
String s1 = "abc"; // 1 个池对象 String s2 = new String("abc"); // 只创建 1 个堆对象 s1 == s2 // false s1 == "abc" // trueJDK 7+ 的 “池对象” 本身在堆上;计数默认只算 String 实例,若把底层数组也算作对象则数量更多。
五、字符串拼接:常量折叠 vs StringBuilder vs invokedynamic
- 常量折叠:
"a"+"b"+"c"编译期变"abc",零开销;final String x="a"; x+"b"也是编译期折叠入池(高频变体); - JDK 8 变量拼接:编译为
new StringBuilder().append().toString(); - JDK 9+(JEP 280):javac不再硬编码 StringBuilder,改
invokedynamic+StringConcatFactory,运行期策略选优(精确预分配 / 预估容量 / BC_SB=ByteCode StringBuilder); - 误区澄清:JEP 280 只是编译期不再强制 StringBuilder,运行期策略仍可能内部使用 StringBuilder;
- 变量拼接结果都是堆中新对象,只有常量折叠才指向常量池;
- 反常识澄清:JDK 9+ 的
StringBuilder.toString()依然是复制数组(源码注释 “Create a copy, don’t share the array”),不存在 “共享 byte [] 零拷贝”—— 真正的优化来自预分配策略,不是 toString; - 循环
+=是 O (n²),必须 StringBuilder。
六、equals /hashCode 底层
- equals 完整流程:
==引用 →instanceof→ 比coder编码 →比长度→ 逐字节对比; - hashCode:
s[0]*31^(n-1)+...+s[n-1],用 31(奇素数 +(h<<5)-h优化);hash字段默认 0 懒计算,空字符串""恒为 0、不重复计算;哈希恰为 0 的非空字符串会失去缓存收益。
七、String 与 StringBuilder:别再混用
| 类 | 可变性 | 线程安全 | 场景 |
|---|---|---|---|
| String | 不可变 | 安全 | 常量、key |
| StringBuilder | 可变 | 不安全 | 单线程拼接(推荐) |
| StringBuffer | 可变 | 安全 | 多线程共享(极少) |
- 构造容量:无参 16、
String(str)= len+16、带参按指定; - 扩容:JDK 8 为
旧*2+2(ensureCapacity取 max),JDK 9+ 结合 coder 调整;扩容要拷贝数组 → 预分配; - toString 结果不入池:
String s = new StringBuilder().append("a").append("b").toString(); s == "ab" // false s.equals("ab") // true八、常量池与 JVM 内存模型
堆:String 对象(byte[]+coder)、静态变量(JDK7+)、字符串常量池(JDK7+) 元空间:类元数据、运行时常量池(每类一份) 栈:局部变量引用- 引用路径:一个被 intern 的 String 有两条引用路径—— 栈上局部变量 和 StringTable 条目,都指向堆中同一个对象;StringTable 是堆中的哈希表,不是独立 “内存层”;
- 静态变量在堆的 Class 镜像,不在方法区;
- 池可被 GC 回收,但别滥用 intern;需可控去重用
Map/Set自管。
九、为什么 String 不可变?—— 以及它的边界
- 线程安全;2. 哈希稳定;3. 池复用省内存;4. 参数安全;5. 可缓存 hashCode。
边界:反射可setAccessible修改内部value(JDK 8 改char[];JDK 9+ 改byte[]且必须同步改coder,否则错乱);JDK 16+ 默认强封装拦截,需--add-opens java.base/java.lang=ALL-UNNAMED放行。仅研究用,生产禁用。
十、12 个生产坑点
- 循环
+→ O(n²); new String("abc")双对象;- 误用
==比内容(应equals); - 密码存储:
char[]可清零但堆 dump 仍见明文、只能降险不能杜绝,叠加 PBKDF2/BCrypt、禁入日志; - intern 滥用 → StringTable 冲突、内存上涨;
- 误区:JDK9 后内部还是
char[]→ 是byte[]; - 误区:常量池在元空间 → 在堆;
- 误区:静态变量在方法区 → 在堆;
- StringBuilder 不预分配 → 频繁拷贝;
- 单线程用 StringBuffer → 无谓同步;
- JDK6
substring:复用大 char [] + offset/count,小片段钉住大数组 → 内存泄漏(JDK7u6 修复); - 高频
String.format→ 性能差。
十一、经典方法变迁与进阶优化
substring(高频追问):JDK 6 复用原始完整char[]、仅调 offset/count 标记区间,大字符串截小片段 → 大数组无法回收 →内存泄漏;JDK 7u6 起复制新数组,牺牲性能换安全。
G1 字符串去重(JEP 192):-XX:+UseStringDeduplication,仅 G1 生效,ZGC/Shenandoah 不支持;GC 时让内容相同的 String 共享底层数组,与 intern 无关、不走 StringTable。
总结(记忆金句)
- 存储:JDK 9
byte[]+coder;常量池:JDK 7 起在堆、存引用、强引用 + GC 扫描清理; - 对象数:池中无 = 2(JDK7+ 都在堆),池中有 = 1;
- intern:
==s仅 “池中原本没有” 时 true; - 拼接:常量折叠(含 final)/ JDK8 StringBuilder / JDK9+ invokedynamic;toString 不入池且始终复制;
- substring:JDK 7u6 由共享改复制;比内容务必
equals。