String的intern()原理:字符串常量池的内存布局实验
String的intern()原理:字符串常量池的内存布局实验
适读人群:想搞清楚JVM内存的Java开发者 | 阅读时长:约14分钟 | 文章类型:JVM原理+内存实验
开篇故事
有次Code Review,看到一段处理大量短字符串的代码,老赵在循环里每个字符串后面都加了.intern():
for (String city : cities) {
map.put(city.intern(), count);
}我问他:你为什么加intern()?
他说:节省内存,防止重复字符串占用太多堆空间。
我说:你测过用了之后内存有没有变化吗?
他说:没有,但网上说intern()能节省内存。
我说:这要看情况。你知道JDK6和JDK7以后intern()的行为是不一样的吗?你知道常量池在JDK7之后从PermGen搬到了堆里吗?你知道大量调用intern()反而可能导致常量池OOM吗?
老赵愣了。
这个话题比很多人想象的复杂。String.intern()背后涉及JVM的字符串常量池设计,而这个设计在JDK6到JDK8之间发生了根本性变化。今天我们做几个实验,把内存布局说清楚。
一、字符串的三种创建方式
先把基础概念理清:
// 方式1:字符串字面量(编译期常量)
String s1 = "hello";
// 编译时,"hello"被放入class文件的常量池
// 类加载时,JVM把"hello"放入运行时字符串常量池(JDK7+在堆中)
// s1直接指向常量池中的对象
// 方式2:new String
String s2 = new String("hello");
// 堆中创建一个新的String对象
// 注意:"hello"这个字面量本身仍然会放入常量池,但s2指向的是堆中新对象
// 方式3:运行时拼接
String s3 = new String("hel") + new String("lo");
// s3指向堆中的一个String对象,不在常量池中
// (编译器优化:如果两个操作数都是字面量,会在编译期合并为"hello")1.1 JDK6 vs JDK7+ 的常量池位置变化
| 版本 | 常量池位置 | 大小限制 |
|---|---|---|
| JDK6及以前 | PermGen(永久代) | -XX:MaxPermSize,默认64MB-256MB |
| JDK7 | 堆(Heap) | 受-Xmx限制 |
| JDK8+ | 堆(Heap),PermGen废除 | 受-Xmx限制 |
这个变化直接影响intern()的行为,后面会重点说。
二、intern()的工作机制
JDK6和JDK7+的关键区别:
- JDK6:
intern()会在PermGen复制字符串,有内存浪费 - JDK7+:
intern()只在堆中记录引用,不复制对象,这个优化让intern()真正有意义
三、内存布局实验
3.1 基础实验:==和intern()的关系
package com.laozhang.string;
/**
* 字符串常量池内存布局实验
* 以下所有实验均基于JDK8+(常量池在堆中)
*
* 注意:这些==比较在JDK6上结果可能不同
*/
public class StringInternExperiment {
/**
* 实验1:字面量 vs new String
*/
public static void experiment1() {
String s1 = "hello"; // 常量池对象
String s2 = "hello"; // 同一个常量池对象
String s3 = new String("hello"); // 堆中新对象
System.out.println(s1 == s2); // true(同一常量池对象)
System.out.println(s1 == s3); // false(不同对象)
System.out.println(s1 == s3.intern()); // true(intern()返回常量池对象)
System.out.println(s1.equals(s3)); // true(内容相等)
}
/**
* 实验2:运行时拼接与intern() - JDK7+的关键实验
*
* 这个实验是理解JDK7+常量池变化的核心
*/
public static void experiment2() {
// s1是运行时拼接的字符串,不在常量池中
String s1 = new String("hel") + new String("lo");
// JDK7+:intern()发现常量池中没有"hello",
// 直接在常量池中保存s1这个堆对象的引用(不复制!)
// 返回的就是s1本身
String s2 = s1.intern();
// 因此:s1和s2指向同一个对象
System.out.println(s1 == s2); // true(JDK7+),false(JDK6)
// "hello"字面量:此时常量池已经有了(就是s1),所以s3也指向s1
String s3 = "hello";
System.out.println(s1 == s3); // true(JDK7+),false(JDK6)
System.out.println(s2 == s3); // true
}
/**
* 实验3:先有字面量,再intern()
* 注意和实验2的区别:字面量和intern()的顺序
*/
public static void experiment3() {
// 先有字面量,常量池中已经有"world"了
String s1 = "world";
// 运行时拼接,不在常量池
String s2 = new String("wor") + new String("ld");
// intern():常量池中已有"world"(就是s1),返回s1的引用
String s3 = s2.intern();
System.out.println(s1 == s3); // true(返回已有的常量池对象)
System.out.println(s2 == s3); // false(s2在堆中,s3指向常量池s1)
}
/**
* 实验4:intern()的内存节省效果量化
* 场景:大量重复的城市名字符串
*/
public static void experiment4() {
String[] cities = {"北京", "上海", "广州", "深圳", "北京", "上海", "广州"};
// 不用intern():每次new String都创建新对象(如果从数据库读取)
String[] withoutIntern = new String[cities.length];
for (int i = 0; i < cities.length; i++) {
withoutIntern[i] = new String(cities[i]); // 模拟从数据库读取
}
// 用intern():相同内容的字符串共享同一对象
String[] withIntern = new String[cities.length];
for (int i = 0; i < cities.length; i++) {
withIntern[i] = new String(cities[i]).intern();
}
// 验证:相同城市名是否是同一对象
System.out.println("withoutIntern:北京1==北京2?" +
(withoutIntern[0] == withoutIntern[4])); // false
System.out.println("withIntern:北京1==北京2?" +
(withIntern[0] == withIntern[4])); // true
// 内存节省量:据我测试(JDK17,OpenJDK)
// 一个中文字符串对象约占48-56字节(对象头+引用+char数组)
// 1000万个"北京"字符串:约480MB
// intern()后:只需要1个对象,约56字节 + 1000万个引用(~80MB) = 约80MB
// 节省约400MB
}
public static void main(String[] args) {
System.out.println("=== 实验1 ===");
experiment1();
System.out.println("=== 实验2 ===");
experiment2();
System.out.println("=== 实验3 ===");
experiment3();
System.out.println("=== 实验4 ===");
experiment4();
}
}3.2 intern()的性能测试
package com.laozhang.string;
import java.util.HashMap;
import java.util.Map;
/**
* String.intern()性能测试与最佳实践
* 包含:何时用intern()有意义,何时适得其反
*/
public class InternPerformanceTest {
/**
* 性能测试:intern()的调用开销
* intern()内部是一个native方法,底层走hash查找
*
* 据我测试(JDK17,M2 Pro):
* 字符串字面量直接使用:~2ns/op
* String.intern()(已在常量池中):~15ns/op
* String.intern()(不在常量池中):~45ns/op
*
* 所以intern()是有开销的!不是免费的!
*/
public static void benchmarkIntern() {
int N = 1_000_000;
String base = "testString";
// 测试:每次new String然后intern()
long start = System.nanoTime();
for (int i = 0; i < N; i++) {
String s = new String(base).intern();
}
long internTime = System.nanoTime() - start;
// 测试:直接使用字面量
start = System.nanoTime();
for (int i = 0; i < N; i++) {
String s = base; // 直接引用
}
long directTime = System.nanoTime() - start;
System.out.printf("intern()开销: %.1fns/op%n", (double) internTime / N);
System.out.printf("直接引用开销: %.1fns/op%n", (double) directTime / N);
}
/**
* 最佳实践:什么时候用intern()
* 场景:大量从数据库/网络读取的重复枚举型字符串
* 比如:城市名、状态码、标签等
*/
public static class CityStatsCollector {
private final Map<String, Long> cityStats = new HashMap<>();
// 这种场景适合用intern():城市名是有限集合(几百个),
// 但可能从大量记录里重复读到,每次读到都是new String
public void addRecord(String cityFromDB, long count) {
// intern():保证相同城市名共享同一个String对象
// HashMap.put的key比较会用==短路,减少equals调用
String city = cityFromDB.intern();
cityStats.merge(city, count, Long::sum);
}
// 不适合用intern()的场景:动态生成的唯一字符串
// 比如:UUID、用户输入的自由文本
// 这些字符串几乎不会重复,intern()只会增加常量池负担
public void addUserInput(String uniqueText, long count) {
// 这里不加intern()!uniqueText几乎不重复,intern()没有价值
cityStats.merge(uniqueText, count, Long::sum);
}
}
/**
* 危险!大量调用intern()导致常量池OOM
* 这就是老赵那个案例的潜在风险
*/
public static void demonstrateOOMRisk() {
// 模拟不断产生新的字符串并intern()
// JDK8+:常量池在堆中,受-Xmx限制,不会触发PermGen OOM
// 但常量池里的字符串对象会被GC(和普通对象一样,GCRoot可达则不回收)
// 危险在于:intern()过的字符串如果没有被引用,会被GC,但频繁intern()也有开销
// 最大的风险其实在JDK6:PermGen不被GC(或很少被GC),
// 大量intern()会把PermGen撑满
// JDK7+这个问题好多了,但仍然要避免对动态唯一字符串调用intern()
System.out.println("intern()使用建议:");
System.out.println("1. 只对有限枚举值集合的字符串用intern()(城市名、状态码等)");
System.out.println("2. 对动态唯一字符串(UUID、用户输入)不要用intern()");
System.out.println("3. 热路径上的intern()调用要压测,有约15-45ns开销");
System.out.println("4. JDK6上要特别注意PermGen空间");
}
public static void main(String[] args) {
benchmarkIntern();
demonstrateOOMRisk();
}
}四、踩坑实录
坑1:JDK6升级JDK7后,代码行为变了
报错现象:一段在JDK6上运行正常的字符串比较逻辑,升级到JDK7后,==比较结果变了。
根本原因:就是前面实验2描述的情况。JDK6里intern()在PermGen创建副本,和堆中的对象一定是不同的引用(==返回false)。JDK7里intern()直接引用堆中对象,两者是同一个引用(==返回true)。
具体解法:
// 不要用==比较字符串内容,永远用equals()
// 即使你认为intern()之后可以用==,也是脆弱的假设
// 错误(依赖==比较字符串)
if (cityFromDB.intern() == "北京") { // JDK6/7+行为可能不同
// ...
}
// 正确
if ("北京".equals(cityFromDB)) { // 永远可靠
// ...
}坑2:误以为+运算符拼接的字符串会自动进常量池
报错现象:在压测时发现一段字符串拼接的代码内存占用异常高,以为拼接后的字符串在常量池里了(只会有一份),实际上每次都是新对象。
根本原因:只有编译期能确定的字面量拼接才会被编译器优化为单个字符串并放入常量池。运行时拼接产生的字符串在堆中,不在常量池。
具体解法:
// 编译期确定 -> 放入常量池(编译器合并为"helloworld")
String s1 = "hello" + "world"; // 常量池中只有一个"helloworld"
// 运行时拼接 -> 在堆中,不在常量池
String part1 = "hello";
String s2 = part1 + "world"; // 堆中的新对象(part1是变量,编译期不确定)
// final修饰的变量:编译期确定,会进常量池
final String PART = "hello";
String s3 = PART + "world"; // 等价于"hello" + "world",进常量池
// 需要进常量池:显式调用intern()
String s4 = (part1 + "world").intern(); // 主动intern坑3:用String对象做锁,不同字符串对象导致同步失效
报错现象:用字符串做细粒度锁(按用户ID加锁),压测时发现锁根本没起作用,数据出现并发问题。
根本原因:synchronized(strObj)是对象锁,同内容的不同String对象,锁住的是不同对象,相当于没锁。
具体解法:
// 错误:每次new String("userId-123")都是不同对象,锁不同
String lockKey = new String("user-" + userId);
synchronized (lockKey) { // 每次synchronized的是不同对象!!
// ...
}
// 正确做法1:intern()保证相同内容是同一对象
String lockKey = ("user-" + userId).intern();
synchronized (lockKey) { // 相同userId -> 相同对象 -> 有效锁
// ...
}
// 注意:这种用法要确保intern()的字符串集合是有限的(比如userId数量有限)
// 正确做法2(更推荐):用ConcurrentHashMap<String, Lock>实现细粒度锁
// 避免intern()的副作用
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
ConcurrentHashMap<String, ReentrantLock> lockMap = new ConcurrentHashMap<>();
ReentrantLock lock = lockMap.computeIfAbsent("user-" + userId, k -> new ReentrantLock());
lock.lock();
try {
// 临界区
} finally {
lock.unlock();
}五、总结与延伸
String.intern()在JDK7+的行为与JDK6完全不同:不再复制对象,而是在常量池里记录堆对象的引用。 这个变化让intern()的内存节省效果更真实,但也让基于intern()的==比较更容易踩坑。永远用equals()比较字符串内容。intern()有调用开销(约15-45ns),不是免费的。 只在"有限集合的重复字符串"场景下使用(城市名、状态码、枚举值),对动态唯一字符串(UUID、用户输入)用了反而是负优化。
JDK7把常量池从PermGen迁到堆,是JVM历史上一个重要的改进。 从此常量池里的字符串受GC管理,OOM风险从PermGen转移到了堆,而堆的大小通常远大于以前的PermGen限制。如果你还在JDK6(希望不是),要特别注意大量intern()打满PermGen的问题。
