Appearance
字符串常量池
String Pool(字符串常量池)是 String#intern()、new String("a") == "a" 这类面试题的总源头,也是「JDK7 常量池从 PermGen 搬到堆」这个经典演进的落点。本篇把池的动机、进池时机、位置演进、内部实现、观察手段与性能边界一次讲透,最后回答一个实战问题:要不要自建字符串池。内存区域背景见 Java内存结构,Metaspace 细节见 堆外内存Metaspace深入理解,弱引用语义见 强引用、软引用、弱引用、虚引用。
1 为什么要有字符串常量池
构造一个 Java 对象不便宜,还要占用 GC 资源,缓存池就是为了缓解这个问题。Java 里早有先例——基本类型包装类的缓存:
| 类型 | 缓存区间 |
|---|---|
| Integer / Long / Short | -128 ~ 127(Integer 可用 -XX:AutoBoxCacheMax 或 java.lang.Integer.IntegerCache.high 扩大) |
| Byte | 全部(-128 ~ 127) |
| Character | 0 ~ 127 |
| Float / Double | 无(浮点值无限多,缓存无意义) |
String 不是基本类型,但使用频率极高:假设 "Java" 这个字面量在应用里出现一百次,我们不希望堆里因此躺着一百个等值对象。String Pool 就是 JVM 层面的全局去重缓存。
能这样缓存的前提是不可变:Integer、Long、String 全是不可变类,共享是安全的。这也是「String 为什么设计成 final」的标准答案素材之一。
实现上,包装类缓存用的是数组;String Pool 用的是 JVM 内部的 HashTable——数组 + 链表的结构,和我们平时用的 HashMap 类似,但不可动态扩容,这一点决定了它后面所有的性能话题。
2 什么时候进池、什么时候出池
两条进池路径:
- 字面量:代码里用双引号写出的字符串,随类的加载进入 String Pool(严谨说这是 lazy 的,HotSpot 在首次使用时才解析进池,不是类加载瞬间全部驻入);
String#intern():池中没有则登记一条记录并返回池中引用;已有则直接返回池中引用。
java
String a = "javadoop"; // 字面量,进池
String b = new String("javadoop"); // 在堆中新建对象,"javadoop" 本身已在池中
String c = b.intern(); // 返回池中引用
a == b // false:b 是堆中新对象
a == c // true:c 取的是池中引用出池没有特殊通道:只要池中的 String 对象对 GC Roots 不可达,就可以被回收——池持有的是引用,不是免死金牌。但要注意反面:池中对象过多时 YGC 会变长,因为 YGC 需要扫描 String Table;历史上就有 String.intern() 滥用导致 YGC 时间持续上涨的著名案例。
3 池在哪:跟着 JDK 版本搬家
位置演进是面试最爱考的点,也是最容易答错的点:
- Java 6 及以前:String Pool 在 PermGen 中。PermGen 大小固定(-XX:MaxPermSize=N),设多大都是死的,大量 intern 直接可能
OutOfMemoryError: PermGen,所以那个年代的调优建议是「小心使用 intern」; - Java 7 起:String Pool 移到堆中。堆虽然也固定大小,但调优时只需统筹调堆,池的生死和普通对象一样交给 GC;
- Java 8:PermGen 被彻底移除、Metaspace 上位。注意:String Pool 留在堆里,没有跟着去 Metaspace——这是高频错误答案。Metaspace 只放 class metadata。
顺带一提,因为池从 Java 7 起在堆中,intern() 对首次出现的字符串可以直接记录堆中现有对象的引用,不必像 Java 6 那样往 PermGen 复制一份——同一份代码在 6 与 7+ 上的 == 判定结果可能不同,这是 intern 类笔试题的分水岭。
4 内部实现:一张不许扩容的哈希表
String Pool 的核心是一张固定 bucket 数的 HashTable:
- Java 6 早期 bucket 数固定 1009;后来可用 -XX:StringTableSize=N 配置;
- Java 7u40 起默认提升为 60013,Java 8 沿用,对绝大多数应用够用;
- 1009、60013 都是质数——质数个 bucket 更容易散列均匀;
- 表不可扩容,但会 rehash:JVM 发现散列严重不均时会重排,链长随之下降。
不可扩容意味着:若代码大量调用 intern() 而表偏小,单 bucket 链表会变长,读写退化成 O(链长)。所以「会在运行期产生海量不同字符串并 intern」的应用(如动态拼出的 key),才需要手动调大 StringTableSize。
5 观察与压测:链长决定性能
JVM 提供 -XX:+PrintStringTableStatistics,在进程退出时打印池统计(没有运行期实时查询的官方接口):
StringTable statistics:
Number of buckets : 60003 = 480024 bytes, avg 8.000
Number of entries : 4000774 = 96018576 bytes, avg 24.000
Number of literals : 4000774 = 1055252184 bytes, avg 263.762
Total footprint : = 1151750784 bytes
Average bucket size : 66.676
Maximum bucket size : 84关注两行:Average bucket size(平均链长)与 Maximum bucket size(最长链)。上面这份是往池里 intern 了 400 万长字符串后的数据,平均链长 66.7。
压测结论(400 万长字符串 intern,2G 堆):
bash
java -Xms2g -Xmx2g com.example.StringTest
java -Xms2g -Xmx2g -XX:StringTableSize=400031 com.example.StringTest把表扩大到 40 万 bucket 后平均链长降到 10 左右,插入耗时几乎无损——String Pool 的性能就是链表长度的性能。链长在个位数时不用折腾,默认 60013 够用;确有海量 intern 场景再调。
6 要不要自建 String Pool:不要
有人担心 JVM 池不可控,想用 HashMap 自建。正确姿势必须上弱引用,否则池本身就造成泄漏:
java
private static final WeakHashMap<String, WeakReference<String>> pool
= new WeakHashMap<>(1024);
private static String manualIntern(final String str) {
final WeakReference<String> cached = pool.get(str);
if (cached != null) {
final String value = cached.get();
if (value != null) {
return value;
}
}
pool.put(str, new WeakReference<>(str));
return str;
}同样 2G 堆持续灌数据:String#intern() 大约撑到 3000 万条才开始大量 Full GC;自建的 manualIntern 到 1400 万条就先撑不住了。结论很干脆:不要自建 String Pool——JVM 内置实现更省、更快、还能被 GC 正确感知,自建没有任何收益。
面试追问链
- new String("abc") 创建了几个对象?→ 最多两个:堆中的新对象 + 「abc」若尚未进池则随字面量进池的那个引用对象;
- intern() 的 JDK6/7+ 差异?→ 6 在 PermGen 里复制一份并返回;7+ 池在堆中,可直接记录首次出现的堆对象引用,== 结果可能不同;
- 常量池在 Metaspace 里吗?→ 不在。Java 7 起就在堆中,Java 8 之后也没动,Metaspace 只放类元数据;
- StringTableSize 是什么?→ 池的 bucket 数,Java 6 是 1009,7u40+ 默认 60013,都取质数;只扩 bucket 数、不动态扩容但会 rehash;
- intern() 会引发什么 GC 问题?→ 池中对象多则 YGC 需扫描 String Table,YGC 时长被拉高;
- 为什么 String 能被池化?→ 不可变,共享无副作用;这也回答了「String 为什么 final」;
- 怎么观察池的健康度?→ -XX:+PrintStringTableStatistics 看退出时的平均/最大链长,链长个位数即健康。
面试官视角小结
这道题的区分度在于「演进」二字。只背「常量池存字符串」是末位;能说出「Java 6 在 PermGen、7 搬进堆、8 之后也没去 Metaspace」已属合格;再补上「池是固定 bucket 的 HashTable、性能看链长、60013 是质数」就开始超出预期;若能主动聊到「intern 滥用拉长 YGC」的真实案例与「不要自建池」的实测结论,就是实战型信号了。最差的回答是把常量池说进 Metaspace,或认为池中对象永不回收——前者说明没跟踪过 JDK 演进,后者说明没理解 GC Roots。可与 强引用、软引用、弱引用、虚引用 的弱引用语义互相印证着答。
来源:javadoop《Java 字符串常量池介绍》(https://javadoop.com/post/string)——本文为研读消化后的重写笔记,原文版权归 javadoop,引用请遵原文。注:原文评论区勘误过「Java 8 常量池迁至 Metaspace」的早期表述,本文按勘误后的正确口径(Java 7 起在堆、8 后仍留堆未去 Metaspace)撰写,见正文第 2 节。