【JVM原理详解】20-String常量池与intern深度解析
String 常量池与 intern 深度解析引言String 是 Java 中使用最频繁的对象类型也是设计最特殊的类之一。从面试经典题new String(‘ab’) 创建了几个对象到生产环境的 OOM 排查String 常量池都是一个绕不开的知识点。String 常量池的位置在 JDK 6 到 JDK 7 之间发生了重大变化这一变化直接影响了intern()方法的行为也改变了 String 拼接的优化策略。本篇将从常量池的位置变迁出发深入解析intern()的原理、经典面试题的本质以及 String 拼接的编译期优化。一、String 常量池的位置变迁1.1 什么是 String 常量池String 常量池String Pool / String Table是 JVM 用来存储字符串常量的一块特殊内存区域。它的核心作用是避免重复创建相同内容的字符串对象从而节省内存。publicclassStringPoolDemo{publicstaticvoidmain(String[]args){// 字面量方式字符串放入常量池Strings1hello;Strings2hello;// s1 和 s2 指向常量池中同一个对象System.out.println(s1s2);// trueSystem.out.println(s1.equals(s2));// true}}当使用字面量hello创建字符串时JVM 首先在常量池中查找是否已存在内容为 “hello” 的字符串。如果存在直接返回引用如果不存在在常量池中创建新对象并返回引用。这样s1和s2指向的是同一个对象。1.2 JDK 6永久代中的常量池在 JDK 6 及之前String 常量池位于**永久代PermGen**中。永久代是方法区的一种实现与 Java 堆是分开的两块内存区域。------------------------------------------------------------------ | JDK 6 内存结构 | ------------------------------------------------------------------ | | | ----------------------- ------------------------ | | | Java 堆 (Heap) | | 永久代 (PermGen) | | | | | | | | | | ----- ----- | | ------------------ | | | | | Eden| | S0 | | | | String 常量池 | | | | | ----- ----- | | | hello world | | | | | ----- | | ------------------ | | | | | S1 | | | | 类元数据 | | | | | ----- | | | 运行时常量池 | | | | | ----------------- | | ------------------ | | | | | Old Gen | | | | | | | ----------------- | | -XX:PermSize64m | | | | | | -XX:MaxPermSize82m | | | ----------------------- ------------------------ | | | ------------------------------------------------------------------ | 问题永久代大小固定intern 过多字符串会抛 OOM: PermGen | ------------------------------------------------------------------JDK 6 的问题永久代大小固定且较小默认最大 82MB大量使用intern()会导致java.lang.OutOfMemoryError: PermGen space。这在 Web 应用中尤其常见——每个 HTTP 请求可能产生大量动态字符串。1.3 JDK 7移入 Java 堆从 JDK 7 开始String 常量池被移到了Java 堆中。这是一个重要的架构调整。------------------------------------------------------------------ | JDK 7 内存结构 | ------------------------------------------------------------------ | | | --------------------------------------- | | | Java 堆 (Heap) | | | | | | | | ----- ----- | | | | | Eden| | S0 | | | | | ----- ----- | | | | ----- | | | | | S1 | ------------------ | | | | ----- | String 常量池 | | | | | --------- | hello world | | | | | | Old Gen ------------------ | | | | --------------------------------- | | | --------------------------------------- | | | | ------------------------ | | | 元空间 (Metaspace) | JDK 8 取代永久代 | | | - 类元数据 | - 使用本地内存 | | | - 运行时常量池 | - -XX:MaxMetaspaceSize | | ------------------------ | | | ------------------------------------------------------------------ | 优势常量池可随堆扩展受 GC 管理降低 OOM 风险 | ------------------------------------------------------------------移入堆中的好处空间更大堆可以按需扩展受限于物理内存不再受永久代固定大小的限制。GC 可回收常量池中的字符串成为普通堆对象可以被 GC 回收。无引用的字符串不会永久驻留。intern 更安全可以更放心地使用intern()优化不至于轻易 OOM。# JDK 8 查看字符串表大小java-XX:PrintStringTableStatistics-version# 输出示例# SymbolTable statistics:# Number of buckets : 60013 (avg4)# Number of entries : 240052# ...# 调整 StringTable 桶数量默认 60013JDK 11 可调-XX:StringTableSize10000031.4 为什么不放到元空间JDK 8 移除永久代、引入元空间Metaspace来存储类元数据但 String 常量池并没有跟随迁移到元空间。原因是String 对象是 Java 对象需要被 GC 管理元空间使用本地内存、生命周期与类绑定不适合存放可回收的实例对象。堆中的常量池可以利用现有 GC 机制无需为元空间额外开发回收逻辑。二、intern() 方法原理2.1 intern() 的语义String.intern()是一个 native 方法其作用是返回字符串的规范表示。如果常量池中已包含等于此 String 对象的字符串由equals(Object)判断则返回常量池中的字符串否则将此 String 对象添加到常量池中并返回引用。publicclassInternDemo{publicstaticvoidmain(String[]args){// new String 在堆中创建对象不在常量池Strings1newString(hello);// intern() 检查常量池Strings2s1.intern();// 常量池中的 hello字面量在编译期已入池Strings3hello;System.out.println(s1s2);// falseJDK 7System.out.println(s2s3);// true}}2.2 intern() 在 JDK 6 中的行为JDK 6 中调用intern()时如果常量池没有该字符串JVM 会复制字符串对象到永久代的常量池中并返回永久代中对象的引用。------------------------------------------------------------------ | JDK 6 intern() 行为 | ------------------------------------------------------------------ | | | String s1 new String(hello); // 堆中对象 | | | | 堆: 永久代常量池: | | ----------- ----------- | | | s1 对象 | | hello | (字面量 | | | hello | ----------- 编译期入池) | | ----------- ^ | | | | | s1.intern() → 复制到永久代? 否已存在返回永久代引用 | | | | | String s2 s1.intern(); s2 -------- | | | | s1 ! s2 堆对象 ≠ 永久代对象 | ------------------------------------------------------------------2.3 intern() 在 JDK 7 中的行为JDK 7 中常量池在堆中。调用intern()时如果常量池没有该字符串JVM不再复制对象而是直接存储堆中对象的引用。------------------------------------------------------------------ | JDK 7 intern() 行为 | ------------------------------------------------------------------ | | | String s1 new String(hello); // 堆中对象 | | | | Java 堆: | | ----------- 常量池(也在堆中): | | | s1 对象 | ----------- | | | hello |---| 引用 | (存储的是 s1 的地址) | | ----------- ----------- | | ^ | | | | | String s2 s1.intern(); s2 指向 s1 同一个对象 | | | | 特殊场景如果 s1 是 new String(hello) | | 字面量 hello 在编译期已入池intern 返回的是常量池已有对象 | ------------------------------------------------------------------这个复制 vs 存引用的差异是 JDK 6 与 JDK 7 行为不同的根本原因。后面会通过经典面试题详细展示。三、经典面试题new String(“ab”) 创建几个对象3.1 分析过程StringsnewString(ab);这道题的答案是1 个或 2 个取决于常量池中是否已存在 “ab”。拆解分析字面量 “ab”在编译期ab作为字面量被放入 Class 文件的常量池中。在类加载阶段更准确地说是解析阶段或首次使用时字面量 “ab” 会被放入 String 常量池。这是第一个对象。new String(…)new关键字在堆中创建一个新的 String 对象其内容是 “ab”。这是第二个对象。------------------------------------------------------------------ | new String(ab) 创建对象分析 | ------------------------------------------------------------------ | | | 步骤1: 类加载时字面量 ab 进入 String 常量池 | | 常量池: [ String ab ] ← 对象 #1 | | | | 步骤2: 执行 new String(ab) | | 堆: [ String ab ] ← 对象 #2 (s 指向这个) | | | | 结果创建了 2 个对象 | | s 常量池中的 ab ? false | | | | 如果 ab 已在常量池中如之前用过则只创建 1 个对象 | ------------------------------------------------------------------3.2 代码验证publicclassNewStringAnalysis{publicstaticvoidmain(String[]args){StringsnewString(ab);// s 是堆中的新对象// ab 是常量池中的对象Stringliteralab;System.out.println(sliteral);// falseSystem.out.println(s.intern()literal);// true}}3.3 进阶new String(“a”) new String(“b”) 创建几个对象StringsnewString(a)newString(b);这个更复杂字面量a进入常量池 → 对象 #1字面量b进入常量池 → 对象 #2new String(a)在堆中创建对象 → 对象 #3new String(b)在堆中创建对象 → 对象 #4拼接编译器会使用 StringBuilderJDK 8或 invokedynamicJDK 9最终生成一个新的 String “ab” → 对象 #5注意拼接结果 “ab” 是动态生成的不会自动进入常量池。只有s.intern()才会将其放入常量池。publicclassStringConcatAnalysis{publicstaticvoidmain(String[]args){StringsnewString(a)newString(b);Stringliteralab;// JDK 7 中s.intern() 首次调用// 常量池中没有 ab拼接结果未入池将 s 的引用存入常量池// s.intern() 返回 s 本身System.out.println(sab);// falseab 此时才入池是另一个对象// 如果先调用 s.intern()// s.intern();// System.out.println(s ab); // JDK7 true, JDK6 false}}四、JDK 6 vs JDK 7 intern 行为差异这是 intern 最经典的差异场景也是高频面试题。4.1 经典代码publicclassInternDifference{publicstaticvoidmain(String[]args){// 第1行拼接生成 计算机软件堆中对象不在常量池Strings1newString(计算机)newString(软件);// 第2行intern 首次调用Strings2s1.intern();// 第3行字面量Strings3计算机软件;System.out.println(s1s2);// JDK6: false JDK7: trueSystem.out.println(s1s3);// JDK6: false JDK7: true}}4.2 JDK 6 的执行过程------------------------------------------------------------------ | JDK 6 执行过程 | ------------------------------------------------------------------ | | | 第1行: s1 new String(计算机) new String(软件) | | - 计算机 入常量池(永久代) | | - 软件 入常量池(永久代) | | - s1 指向堆中拼接结果 计算机软件 | | | | 第2行: s2 s1.intern() | | - 常量池中没有 计算机软件 | | - 复制 s1 到永久代常量池 | | - s2 指向永久代中的副本 | | - s1(堆) ≠ s2(永久代) | | | | 第3行: s3 计算机软件 | | - 常量池已有第2步复制进去的 | | - s3 指向永久代中的副本 | | - s2 s3 | | | | 结果: s1 ! s2, s1 ! s3 | ------------------------------------------------------------------4.3 JDK 7 的执行过程------------------------------------------------------------------ | JDK 7 执行过程 | ------------------------------------------------------------------ | | | 第1行: s1 new String(计算机) new String(软件) | | - 计算机 入常量池(堆) | | - 软件 入常量池(堆) | | - s1 指向堆中拼接结果 计算机软件 | | | | 第2行: s2 s1.intern() | | - 常量池中没有 计算机软件 | | - 不复制直接存储 s1 的引用到常量池 | | - s2 s1返回的就是 s1 本身 | | | | 第3行: s3 计算机软件 | | - 常量池已有存的是 s1 的引用 | | - s3 指向 s1 | | - s3 s1 | | | | 结果: s1 s2, s1 s3 | ------------------------------------------------------------------4.4 差异总结场景JDK 6JDK 7intern() 不存在时的行为复制对象到永久代存储堆中对象的引用intern() 返回值永久代中的对象堆中原始对象首次s1 s1.intern()falsetrue拼接场景常量池中的字符串独立对象可能是堆对象的引用根本原因JDK 6 的常量池在永久代存储的必须是永久代中的对象所以需要复制。JDK 7 的常量池在堆中存储引用即可无需复制。五、String 拼接优化5.1 编译期优化常量拼接publicclassCompileTimeConcat{publicvoiddemo(){// 编译期已知直接优化为 abcStrings1abc;// 等价于Strings2abc;System.out.println(s1s2);// true}}使用javap -c查看字节码可以发现a b c在编译期就被优化为abc没有 StringBuilder 或 invokedynamic 的调用。5.2 JDK 8StringBuilder 优化变量拼接在 JDK 8 中会被编译器优化为 StringBuilderpublicclassJdk8Concat{publicvoiddemo(){Stringaa;Stringbb;Stringcab;// 变量拼接}}// 编译后的等价代码JDK 8// String c new StringBuilder().append(a).append(b).toString();字节码验证javap-cJdk8Concat.class# 0: ldc #2 // String a# 2: astore_1# 3: ldc #3 // String b# 5: astore_2# 6: new #4 // class java/lang/StringBuilder# 9: dup# 10: invokespecial #5 // Method java/lang/StringBuilder.init:()V# 13: aload_1# 14: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;# 17: aload_2# 18: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;# 21: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;# 24: astore_35.3 JDK 9invokedynamic 优化JEP 280 在 JDK 9 中引入了invokedynamic来优化字符串拼接取代了 StringBuilder 方案。publicclassJdk9Concat{publicvoiddemo(){Stringaa;Stringbb;Stringcab;// invokedynamic}}JDK 9 的字节码javap-cJdk9Concat.class# 0: ldc #2 // String a# 2: astore_1# 3: ldc #3 // String b# 5: astore_2# 6: aload_1# 7: aload_2# 8: invokedynamic #4, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;# 13: astore_3invokedynamic 的优势减少对象创建不再每次拼接都创建 StringBuilder 对象。** JIT 优化空间更大**makeConcatWithConstants是一个引导方法JIT 可以根据实际类型做更激进的内联优化。预计算大小可以直接计算最终字符串大小一次性分配 char[]/byte[]避免 StringBuilder 的扩容。不同类型策略根据拼接参数的类型组合选择最优的拼接策略。5.4 循环中的拼接陷阱publicclassLoopConcatTrap{publicvoidbadPractice(){// JDK 8: 每次循环创建一个 StringBuilder// 循环 10000 次创建 10000 个 StringBuilder 对象Stringresult;for(inti0;i10000;i){resultresulti;// 编译为 new StringBuilder().append(result).append(i).toString()}}publicvoidgoodPractice(){// 手动使用 StringBuilder只创建一个对象StringBuildersbnewStringBuilder();for(inti0;i10000;i){sb.append(i);}Stringresultsb.toString();}}注意即使 JDK 9 使用 invokedynamic循环中拼接仍然不高效——每次循环都会生成新的 String 对象。在循环拼接场景手动使用StringBuilder仍然是最优实践。六、intern() 的实战应用6.1 大量重复字符串的内存优化publicclassInternOptimization{// 场景从数据库读取大量记录status 字段只有几种值// 不 intern: 每条记录的 status 都是一个独立 String 对象// intern 后: 所有相同 status 共享一个对象publicvoidprocessRecords(ListRawRecordrecords){ListRecordresultnewArrayList();for(RawRecordraw:records){RecordrnewRecord();// intern 后ACTIVE/INACTIVE/PENDING 只存一份r.setStatus(raw.getStatus().intern());result.add(r);}}}6.2 intern 的性能权衡publicclassInternTradeoff{// intern 的好处节省内存比较速度快 比 equals 快// intern 的代价intern() 调用本身有开销查找/入池// 适合 intern 的场景// 1. 字符串种类少、重复多如状态码、枚举名// 2. 需要频繁比较相等性// 3. 字符串生命周期长如缓存 key// 不适合 intern 的场景// 1. 字符串几乎不重复如 UUID、时间戳// 2. 字符串是临时的用完即弃// 3. 字符串数量极大且种类多会膨胀常量池}6.3 调整 StringTable 大小# JDK 11 可以调整 StringTable 的桶数量# 默认 60013对于大量 intern 的应用可以调大以减少哈希冲突-XX:StringTableSize1000003# 查看当前 StringTable 统计java-XX:PrintStringTableStatistics-version实践要点不要盲目 internintern()在 JDK 7 虽然更安全但仍有性能开销。只对少量不同值、大量重复的场景使用如状态码、分类标签。对 UUID、时间戳等几乎不重复的字符串使用 intern 反而增加开销。JDK 版本敏感intern 的行为在 JDK 6 和 JDK 7 不同。如果你的代码需要在 JDK 6 上运行虽然已很少见不要依赖 intern 返回原始对象的引用。循环拼接用 StringBuilder无论 JDK 版本循环内的字符串拼接都应手动使用 StringBuilder。JDK 9 的 invokedynamic 优化了简单拼接但循环场景仍需手动优化。常量池不是免费的常量池中的字符串虽然可以被 GC 回收JDK 7但回收条件较苛刻需要 Full GC 或并发标记。大量 intern 动态字符串仍可能导致常量池膨胀。可通过-XX:PrintStringTableStatistics监控。equals 比较更安全除非确定两个 String 都来自常量池否则始终用equals()而非比较。intern 后用是一种优化手段但增加了代码的脆弱性。StringTableSize 调优对于大量使用 intern 的应用如解析大量重复 key 的 JSON适当调大StringTableSize可以减少哈希冲突提升 intern 性能。小结String 常量池在JDK 6 位于永久代JDK 7 移入 Java 堆这一变迁降低了 OOM 风险也改变了 intern 的行为。intern()的作用是确保字符串在常量池中存在并返回引用。JDK 6 会复制对象JDK 7 存储引用这是两者行为差异的根源。new String(ab)创建1 或 2 个对象常量池中的字面量如果不存在 堆中的 new 对象。String 拼接优化经历了三个阶段编译期常量折叠→JDK 8 的 StringBuilder→JDK 9 的 invokedynamic。intern()适合少量不同值、大量重复的场景如状态码、枚举名不适合 UUID、时间戳等不重复的动态字符串。循环内的字符串拼接无论 JDK 版本都应手动使用StringBuilder。至此对象与内存模块的四篇文章全部完成。从对象的创建、内存布局、访问定位到 String 常量池我们完整梳理了 Java 对象在 JVM 中的生命周期起点。后续模块将进入垃圾收集与内存管理的世界。