在大多数Java应用的堆转储(Heap Dump)分析报告里,char[]和String对象常年霸占内存占用榜首。更糟糕的是,其中相当一部分字符串的内容是完全相同的,它们本可以共享同一个实例,却因为各自独立创建而白白消耗了堆内存。JDK从7u40开始提供-XX:+StringDeduplication这样的全局手段,但对大多数仍在使用JDK8或者希望精确控制的团队来说,String.intern()依然是解决字符串重复问题最直接的工具。

字符串常量池到底长什么样
要理解intern的作用,得先弄清楚字符串常量池(String Pool,HotSpot内部叫StringTable)的真实结构。它本质上是一个固定容量的哈希表,每个桶里挂着一个引用,指向堆中的String对象。注意这里的关键点:常量池本身存的只是引用,真正的String对象从JDK7开始就位于普通的Java堆里,而JDK6及之前它位于永久代。
这个位置差异影响非常大。在JDK6中,调用intern()时,如果池中不存在该字符串,JVM会把整个字符串复制一份到永久代,再返回永久代中那个副本的引用。而到了JDK7,HotSpot做了改进:首次intern时只是把当前堆中这个String对象的引用记录到StringTable里,不再复制对象。这一变化让intern的成本大幅降低,也让它第一次真正具备了在生产环境大规模使用的条件。
另一个值得关注的参数是StringTable的桶数量。JDK8默认是60013个桶,可以通过-XX:StringTableSize=1000003来调整。如果程序里需要intern海量字符串,桶太少会导致哈希冲突加剧,查找退化成链表遍历,性能会明显下降。对于高吞吐场景,把桶数量调到百万级别是常见做法。
intern方法的行为与验证实验
intern()的语义很简洁:如果常量池中已经存在与当前字符串内容相同的对象,就返回池中那个对象的引用;如果不存在,就把当前对象登记进池并返回它。配合==比较可以直观验证这一行为,下面这段代码建议分别在JDK6和JDK8环境下跑一遍,结果会截然不同。
public class InternDemo {
public static void main(String[] args) {
// 情况一:new 出来的对象在堆上,常量池中已有编译期常量 "hello"
String a = new String("hello");
String b = "hello";
System.out.println(a == b); // false,a指向堆中独立对象
System.out.println(a.intern() == b); // true,intern后返回池中同一引用
// 情况二:经典面试题,JDK6与JDK7+结果不同
String c = new StringBuilder("ja").append("va").toString();
System.out.println(c.intern() == c);
// JDK6:false(intern把对象复制到永久代,返回的是副本)
// JDK7+:true(池中没有"java"的堆引用记录时,直接登记c本身)
String d = new StringBuilder("计算机").append("软件").toString();
System.out.println(d.intern() == d); // JDK7+ 为 true
}
}情况二的差异来源在于:JDK7+中intern首次登记时不再复制对象,而是把堆中这个对象的引用放进池子,所以返回的就是它自己。而JDK6中intern返回的是永久代里的副本,自然不相等。理解了这个机制,才能准确预测生产代码中引用到底指向哪里。
实战:用intern压缩重复字符串带来的内存膨胀
来看一个典型场景:解析外部数据源时,字段值高度重复。比如订单导入文件里的城市名、商品类目名,原始数据可能有几百万行,但城市可能只有几千个。如果不做处理,每行都会new出独立的String对象,堆内存会快速膨胀。用MAT(Memory Analyzer Tool)分析这类应用的堆转储,经常能看到同一个字符串内容存在几十万个实例。
处理方式很简单,在解析完成后对重复率高的字段调用一次intern:
import java.util.ArrayList;
import java.util.List;
public class CityDedup {
static class Order {
String orderId;
String city; // 重复率极高的字段
Order(String orderId, String city) {
this.orderId = orderId;
// 关键一行:登记进常量池,相同内容只保留一份
this.city = city.intern();
this.orderId = orderId;
}
}
public static void main(String[] args) {
String[] cities = {"北京", "上海", "广州", "深圳", "杭州", "成都"};
List<Order> orders = new ArrayList<>();
for (long i = 0; i < 3_000_000; i++) {
// 模拟解析外部数据:每次都产生新的String对象
String raw = new String(cities[(int)(i % cities.length)]);
orders.add(new Order("ORDER-" + i, raw));
}
// 不使用intern时约保留300万个city对象
// 使用intern后city对象收敛为6个,节省数百MB内存
System.out.println("订单数量: " + orders.size());
}
}实测数据很有说服力:三百万条订单,城市字段不加处理时占用约200MB堆内存(每个对象包含对象头、hash缓存和char[]或byte[]引用),intern之后这部分内存几乎可以忽略不计,只剩6个共享实例。同时GC收益也很明显——存活对象数量从三百万级降到个位数级,Minor GC的标记和复制开销随之大幅下降。
还可以从字节码角度验证intern确实只影响运行期行为。编译期字面量本来就会进入class文件的常量池并在类加载时登记,而new String(...)和StringBuilder.toString()产生的对象是运行期在堆上新建的,intern的作用正是把这些运行期对象拉回共享轨道。用javap -v反编译可以看到字面量在Constant Pool中的记录,而运行期创建的对象不会出现在那里。
什么时候该用,什么时候是灾难
intern不是万能药,用错场景反而会带来严重问题。适合使用的判断标准很清晰:字符串内容重复率高、生命周期长(随应用存活的缓存类数据)、数量可控。典型例子包括字典表字段、枚举值的字符串形式、配置项、协议中的固定命令字等。
不适合的场景同样要牢记。第一类是内容几乎不重复的字符串,比如用户输入的评论、UUID、时间戳字符串,intern它们没有任何收益,反而白白撑大StringTable。第二类是海量且生命周期短的数据,intern后引用长期挂在池中,相当于制造了慢速内存泄漏。第三类要注意,JDK6及更早版本上,intern会触发永久代分配,大量调用可能导致OutOfMemoryError: PermGen space,这是老系统升级JDK前必须评估的风险。
如果拿不准重复率,可以先做测算再动手:对堆转储用MAT的Duplicate Classes或直方图功能统计目标字符串的实例数,如果同一内容的实例数达到几万以上,intern基本稳赚;如果每个内容只有一两个实例,那就完全没必要。另外,对于JDK8u20以上的版本,如果重复字符串来源分散、不好在代码里统一处理,开启G1的-XX:+UseG1GC -XX:+StringDeduplication让JVM在后台自动去重,也是一条可行的替代路线,代价是只合并底层数组而不合并String对象本身,效果略逊于显式intern。
最后提醒一点,intern调用本身有锁和哈希查找开销,在极端高频路径上调用要谨慎。更稳妥的模式是在数据入口处统一做一次intern,而不是在业务逻辑的每个角落反复调用。把去重收敛在边界层,既保证了内存收益,也让代码意图更清晰。
String.intern()字符串常量池JVM内存优化修改时间:2026-09-10 22:44:52