前两天帮朋友看一个服务的内存问题,堆一共给了 8G,跑了两天老年代就顶到 90%,但 CPU 和 QPS 都平稳。拉了个堆转储丢进分析工具,排名第一的是char[],占了将近 3.2G,再往下看引用链,一大片内容高度相似的字符串——用户的收货地址、订单备注、还有拼接出来的跳转链接。这些东西绝大多数都是运行时拼出来的,既不是常量,也没人主动往池子里放,但它们的生命周期和整个订单对象绑在一起,迟迟不释放。顺着这条线往下挖,问题最终落到了 JVM 的StringTable上:它到底是什么结构、字面量和intern()分别走哪条路、字符串常量在垃圾回收里扮演什么角色、表大小设小了会有什么后果。这些点在八股文里被讲烂了,可真到线上,很多人对它的理解还停留在“字符串常量池就是个池子”这个层面。这篇就把 StringTable 从内存位置、底层结构、入池路径、容量调优、回收时机到字符串去重,一层层拆开讲清楚,适合已经写过不少 Java、但对 JVM 内部细节还比较模糊的同学,也适合正在排查内存问题的工程师直接拿去对照。
1. 先把 StringTable 放回 JVM 的内存地图里
很多人第一次接触“字符串常量池”是在讲 Class 文件结构的时候,那里有个constant_pool,于是很自然地把两者画上等号。实际上这是三个不同层级的东西,混在一起讲,后面所有的困惑都从这里开始。
1.1 三个“常量池”不是一回事
Class 文件里的那个叫静态常量池,它是磁盘上的字节序列,是编译期产物。你在代码里写String s = "hello";,编译器会把"hello"这个字面量以CONSTANT_String_info的形式塞进 Class 文件的常量池里,再配一个CONSTANT_Utf8_info存实际的字符序列。它躺在.class文件里,还没进内存,谈不上任何运行时行为。
类被加载进 JVM 之后,Class 文件常量池里的内容会被解析到运行时常量池,这时候它在方法区(JDK 8 之后是元空间)。运行时常量池比静态常量池“活”得多,它支持动态添加,比如String.intern()就可能往里塞东西——注意这里说的是“逻辑上”,实际的字符串对象本身在 JDK 7 之后已经不在方法区了。
真正的主角是字符串常量池,也就是 StringTable。它是 HotSpot 内部一个全局的、被专门特化过的哈希表,用来保存“已经被驻留(interned)的字符串引用”,作用只有一个:保证内容相同的字符串字面量在堆里只存在一份实例,从而省内存、让引用比较有意义。你可以把它理解成一个全局的、带锁的、只增不删(除非 GC 介入)的字典,key 和 value 其实都是同一个堆里的 String 对象的引用。
这三者的关系用一句话概括:Class 文件常量池是源码里的“字符串清单”,运行时常量池是加载后的“符号表”,StringTable 是运行期的“字符串去重字典”。层级完全不同。
1.2 它在哪块内存:从永久代搬到堆的那次大迁移
StringTable 的位置在历史上挪过一次,这是很多老面试题的根子。
JDK 6 及以前,StringTable 位于永久代(PermGen)里,字符串对象本身也在永久代。永久代的大小由-XX:MaxPermSize控制,默认只有几十兆,稍微多一点动态生成的字符串就会触发java.lang.OutOfMemoryError: PermGen space。当年无数人踩过这个坑,往intern()里灌了一堆数据,服务第二天早上就挂了。
从 JDK 7 开始,StringTable 和字符串对象一起被搬到了堆上。这个改动带来的最直接后果是:字符串终于和其他对象一样,受堆大小的管理,也能被正常地垃圾回收。永久代 OOM 这个经典故障模式基本消失了,代价是字符串现在要和老年代抢地盘——也就是我朋友那个服务遇到的问题。
JDK 8 彻底移除了永久代,改成元空间,但 StringTable 仍然留在堆里,位置没再变过。所以你现在用jmap -histo看到的java.lang.String和[B(JDK 9+)或[C(JDK 8 及以前),统计到的是堆里的对象,StringTable 只是持有它们的引用。
这个位置变迁直接影响了一个行为:intern()在 JDK 6 和 JDK 7 之后语义不同。后面讲intern()的时候会专门展开。
1.3 底层就是一张特化的哈希表,别拿 HashMap 类比
HotSpot 里的 StringTable 定义在symbolTable.hpp和stringTable.cpp中,核心结构是一个Hashtable,由“桶数组 + 单向链表”组成。它和java.util.HashMap有本质区别:没有树化、没有负载因子、没有自动扩容,桶的数量在 JVM 启动时就定死了,运行期不会自己变大。
这个设计取舍很明确——StringTable 的操作发生在类加载、常量解析、intern()调用这些路径上,频率极高,每增加一次扩容逻辑就多一份复杂度和并发风险。HotSpot 选择把“多大的桶数组”这个决策权交给使用者,用启动参数-XX:StringTableSize=N指定,默认值在 JDK 6 是 1009,从 JDK 7u40 往后一直到现在(JDK 8 / 11 / 17)都是 60013。
60013 这个数不是随便取的,它是个质数。质数桶数量能让哈希值分布更均匀,减少碰撞。你可以自己验证:
java -XX:+PrintFlagsFinal -version | grep StringTableSize输出里会有一行uintx StringTableSize = 60013。注意这个参数有个下限约束,HotSpot 内部要求不小于 1009,设得比这还小会直接导致 JVM 启动失败,报StringTable size of X is invalid; must be between 1009 and ...。
桶数组里每个桶存的是一个链表头,每个 entry 大约占 16 字节(在开启压缩指针的 64 位环境下)加上桶指针本身 8 字节。60013 个桶的基础开销大约是 480KB,这个成本不高,但如果你把它调到几百万,光空桶就要吃掉几十兆——所以调大之前得先确认字符串池确实是瓶颈。
一个经常被忽略的点:StringTable 的 entry 里存的是指向 String 对象的引用,不是字符数据本身。字符数据在 String 内部的byte[](JDK 9+ 的紧凑字符串)或char[](JDK 8 及以前)里。所以 StringTable 本身的膨胀和字符串内容的膨胀是两个独立的维度,排查时要分开看。
2. 字面量和 intern():字符串进池的两条路径
搞清楚结构之后,接下来最关键的问题是:一个字符串到底在什么时机、以什么方式进入 StringTable?这里只有两条路,一条是 JVM 自动走的,一条需要你手动触发,两条路的代价和副作用完全不同。
2.1 字面量的入池发生在 ldc 那一条指令上
写String s = "hello";的时候,编译出来的字节码里是ldc #2,这个#2指向运行时常量池里的一个CONSTANT_String_info。执行到这条指令时,JVM 会做这样几件事:
先看这个CONSTANT_String_info是否已经解析过。没解析过的话,取出里面的 Utf8 字符序列,调用 StringTable 的intern逻辑去查表。查到就直接返回已有对象的引用;查不到就在堆里创建一个新的 String 对象,把它放进表里,然后返回。
注意这里有个容易误解的地方:字面量创建的 String 对象是在堆上,不是在元空间里。很多人以为“常量池里的字符串不在堆里”,这是被 JDK 6 时代的记忆带偏了。用一段代码验证最直接:
public class WhereIsIt { public static void main(String[] args) { String a = "hello"; String b = "hello"; System.out.println(a == b); // true,同一个对象 } }a == b为true,说明两次ldc拿到的是同一个对象引用,第二次没有新建。这个“同一个对象”就在堆里,被 StringTable 引用着。
还有一个细节值得说:ldc的解析结果是缓存在运行时常量池的那个槽位上的。也就是说,同一个类里的同一个字面量,只有第一次执行ldc会走查表逻辑,后面都是直接取缓存。所以字面量的开销主要集中在类加载和首次执行阶段,热路径上没有额外的哈希查找成本。
2.2 intern() 在 JDK 6 和 JDK 7 之后的语义分叉
String.intern()是这一块最容易出错的地方,因为它在不同版本下行为不一样,而网上的资料新旧混杂。
JDK 6 的实现逻辑是:查 StringTable,查到了返回表里的引用;查不到就把当前这个 String 对象复制一份到永久代,把副本放进表里,返回副本的引用。这意味着什么?意味着调用完intern()之后,你手上的原对象和表里的对象是两个不同的实例,==比较的结果必然是false。
JDK 7 之后改成:查 StringTable,查到了返回表里的引用;查不到就把当前这个 String 对象本身的引用记录到表里,返回原对象。不再复制。所以在 JDK 7+ 上,如果你的字符串是第一次被 intern,intern()的返回值就是你调用它的那个对象,==为true。
这个差异在经典题上体现得淋漓尽致:
public class InternDemo { public static void main(String[] args) { String s = new StringBuilder("ja").append("va").toString(); System.out.println(s.intern() == s); } }这段代码在 JDK 7+ 上输出false。原因不是intern()复制了对象,而是"java"这个字符串在 JVM 启动、类加载的过程中早就被放进 StringTable 了(比如System类里的相关常量)。s.intern()查到已有条目,返回的是那个早已存在的对象,自然和s不是同一个。把字符串换成一个运行期间才第一次出现的组合,比如"计算机" + "软件"这种,结果就变成true了。
这个例子说明,判断intern()的结果不能只看版本,还要看这个字符串在之前的运行过程中有没有被“无意间”驻留过。调试这类问题时,别靠猜,直接打印==结果最实在。
2.3 new String、拼接、StringBuilder,入池情况逐个拆
实际代码里字符串的来源五花八门,每一种进池的行为都不同,列个表看得最清楚。
| 写法 | 堆上创建几个对象 | 是否进 StringTable | 说明 |
|---|---|---|---|
"abc" | 0 或 1 | 是 | 已在池中则复用,否则新建并放入 |
new String("abc") | 1 或 2 | 字面量部分进池 | new出来的对象本身不进池 |
"a" + "b"(编译期常量) | 0 或 1 | 是 | 编译器直接折叠成"ab" |
a + b(运行时变量) | 1 到多个 | 否 | JDK 8 用 StringBuilder,结果不进池 |
sb.toString() | 1 | 否 | 新对象,不进池 |
str.intern() | 0 或 1 | 视情况 | JDK 7+ 不复制,只登记引用 |
new String("abc")这一行值得单独讲。如果"abc"之前没出现过,这一行会创建两个对象:一个是ldc触发的、进池的那个,另一个是new出来的、不进池的那个。所以“new String 创建两个对象”这个说法在特定前提下才对,如果"abc"早就在池里了,就只创建一个。面试里问这个,答“两个”其实是不严谨的。
运行时拼接的行为在 JDK 9 之后也变了。JDK 8 及以前,a + b编译成new StringBuilder().append(a).append(b).toString();JDK 9 引入了invokedynamic加StringConcatFactory,由运行时生成更高效的拼接策略。但不管是哪种,拼接结果都不会自动进 StringTable。这一点非常关键——绝大多数“字符串吃内存”的问题,根源就在这里:运行时拼出来的字符串永远是新的对象,池子里没有,也不会自动去重。
3. 用实验把 StringTable 的行为钉死
光看文字描述容易记混,尤其是intern()那几个变体。我习惯的做法是搭一个最小可观测环境,把每个结论都跑一遍,顺便把观测命令摸熟。这一节的内容你完全可以照着在高版本的 JDK 上复现。
3.1 先把观测工具配齐
不需要装什么额外的东西,JDK 自带的几个就够了。
第一个是启动参数-XX:+PrintStringTableStatistics,它会在 JVM 退出时打印 StringTable 的统计信息。这个开关成本极低,排查时建议常开——反正只在退出时输出一次。
第二个是jcmd,服务运行中也能查。用法是jcmd <pid> VM.stringtable,加-verbose能看到每个条目的内容,但条目一多输出会很长,生产环境慎用,容易把磁盘写满。
第三个是jmap -histo:live <pid>,看堆里各类对象的实例数和占用。String 的数量能直接反映池子和运行时字符串的规模。
第四个是java -XX:+PrintFlagsFinal -version | grep -i stringtable,确认当前 JVM 的表大小设置。
把这几个命令记熟,后面所有实验都能靠它们验证。
3.2 三组对照实验,把结论跑出来
实验一:字面量复用与 new 的区别。
public class Exp1 { public static void main(String[] args) { String a = "alpha"; String b = "alpha"; String c = new String("alpha"); System.out.println(a == b); // true System.out.println(a == c); // false System.out.println(a == c.intern()); // true } }结果毫无悬念,但值得注意最后一行:c.intern()返回的是池里已有的那个a,所以为true。如果这是 JDK 6,c.intern()会返回永久代里的副本,和a其实也不是一个——注意 JDK 6 里a本身也在永久代,所以结果仍然是true,但那是另一套机制下的巧合。
实验二:运行时拼接加 intern 的版本差异。
public class Exp2 { public static void main(String[] args) { String s = new String("1") + new String("1"); s.intern(); String t = "11"; System.out.println(s == t); } }JDK 8 上输出true。整条链路是这样的:s是 StringBuilder 拼出来的堆对象,此时池里没有"11";s.intern()把s的引用登记进 StringTable;接着ldc "11"查表命中,直接返回s,赋给t。所以s == t。
把s.intern()那一行去掉,结果就变成false。因为ldc "11"查表没命中,新建了一个进池,和s无关。
在 JDK 6 上跑同样的代码,即使有s.intern()那一行,结果也是false——因为intern()把内容复制到了永久代,t拿到的是副本,s还是原来堆里那个。
实验三:看表大小对冲突的影响。
写一个循环,往里塞 100 万个不同的intern字符串,分别在-XX:StringTableSize=1009和-XX:StringTableSize=1000003下跑,记录耗时。我自己在一台普通开发机上测过,前者比后者慢一个数量级还多,原因是桶太少,链表退化成近似线性查找。这个实验不算严谨的基准测试,但足够说明问题:StringTableSize 是链表长度,不是容量上限,塞再多也不报错,只是越来越慢。
3.3 Pipe 统计输出,重点看哪几个数
-XX:+PrintStringTableStatistics的典型输出长这样:
StringTable statistics: Number of buckets : 60013 = 480104 bytes Number of entries : 48213 = 771408 bytes Number of literals : 219034 = 14289472 bytes Total footprint : = 15550984 bytes四个数字各有含义,别只看最后一个。
Number of buckets就是-XX:StringTableSize的值,除非你手动改过,否则是 60013。
Number of entries是当前表里有多少个条目,也就是有多少个被驻留的字符串还活着。这个数字会随 GC 波动,因为死掉的字符串对应的条目会被清理。
Number of literals是累计创建过的字面量数量估计,包含了已经在池里的和曾经进来过的。这个值和 entries 的差距能反映出字符串的存活率——差距特别大说明字符串创建销毁很频繁,可能有问题。
Total footprint是表本身的结构开销加字符串对象的总占用估算,注意它不包含字符串内部的byte[]或char[]数组,那部分要在堆直方图里看。这一点经常被误解,有人看到 footprint 才 15MB 就觉得没问题,其实底下的数组可能占了几百兆。
我的习惯是把这个输出和jmap -histo的[B、java.lang.String两行放在一起看,两边对得上才说明没有遗漏。
4. 容量、回收与去重:三个容易踩的坑
StringTable 的调优空间其实不大,就一个-XX:StringTableSize参数。但围绕回收和去重,有几处认知偏差会直接导致线上决策失误。
4.1 StringTableSize 该不该调,怎么定值
先说结论:绝大多数应用不需要动它。60013 个桶对于普通业务系统足够用,因为大多数应用里被驻留的字符串也就几万个量级。
什么情况下需要调大?判断依据是Number of entries和Number of buckets的比例。如果 entries 长期超过 buckets 的 2 到 3 倍,平均链长就到了 2 到 3,哈希冲突开始明显,这时才值得考虑。典型的场景有两类:
一类是大量使用intern()做手工去重的系统,比如解析日志、处理海量配置项、做协议解析的应用。我之前见过一个解析设备上报协议的服务,把每个字段名都intern了一遍,池子里长期有几十万个条目,默认桶数下 CPU 有很大一部分耗在链表遍历上,调到 100 万之后明显好转。
另一类是动态生成大量类、或者用脚本引擎的场景。类加载过程中大量CONSTANT_String_info会进池,表太小时冲突严重,会拖慢启动。
反过来,如果 entries 远小于 buckets,说明桶大量空置,这时候表已经够用,再调大只是浪费内存——每个空桶 8 字节,调到 1000 万就是 80MB 的纯浪费,而且这个内存是常驻的,不参与回收。
取值上不用拘泥于质数,但建议用质数,HashTable 的桶分布对质数更友好。常用的值有 1000003、2000003、4000037 这几个。改完之后重启观察PrintStringTableStatistics的 entries 数字,再决定要不要继续加。
注意:
-XX:StringTableSize是启动参数,不支持运行期动态调整。要改就得重启。
4.2 池里的字符串什么时候会被回收
这是被问得最多的一个点,也是最容易答错的一个。
StringTable 里的条目本身不是强引用意义上的“永久存活”。JVM 在 GC 时会把 StringTable 当作根集合的一部分去遍历,但遍历的目的是更新引用和清理死条目,不是把所有条目都当作活跃对象。如果某个被驻留的 String 对象只被 StringTable 引用、外部没有任何强引用指向它,那么这次 GC 就会把它的 entry 从表里摘掉,对象本身也随之被回收。
也就是说,intern()不是内存泄漏,它不会让字符串永久驻留。这一点和很多人的直觉相反。
具体到分代回收:Young GC 时,如果 StringTable 里的某个字符串对象正好在年轻代,GC 需要更新或清理这个引用,这也是为什么 StringTable 在分代收集器下会被频繁访问。代码层面,HotSpot 通过StringTable::unlink_or_oops_do做这件事,逻辑是遍历表、判断条目是否可达、不可达就摘除、可达但地址变了就更新指针。
举个能直接验证的例子:
public class GcDemo { public static void main(String[] args) throws Exception { for (int i = 0; i < 1_000_000; i++) { ("str-" + i).intern(); } System.gc(); Thread.sleep(3000); } }配合-XX:+PrintStringTableStatistics,你能看到进循环前 entries 很小,循环结束后暴涨,System.gc()之后又掉回来。掉回来的幅度取决于当时有多少字符串已经被判定为不可达。
这个特性有一个实际用途:把intern()当作一种“弱去重”手段。处理一批短期数据时,如果同一个字符串会被反复创建,用intern()归并可以显著降低内存,而且不用担心它永久占着不放——数据一处理完,引用一断,GC 自然会清理。
但要注意反面:intern()本身是有成本的,涉及哈希计算和表锁竞争。在高并发路径上大量intern相同的字符串,反而可能因为争抢锁把性能拖垮。我倾向于在批处理、离线任务、解析类场景里用它,在核心链路上慎用。
4.3 G1 的字符串去重,不是万能药
JDK 8u20 起,G1 收集器提供了一个字符串去重功能-XX:+UseStringDeduplication。它的思路和intern()完全不同:不去碰 StringTable,而是针对同内容的char[]/byte[]数组做合并——多个 String 对象各自持有一个内容相同的数组时,让它们指向同一个数组实例,从而省下数组的内存。
它有几个前提条件,缺一不可:
第一,只能在 G1 上开启,其他收集器(包括 ZGC、Shenandoah 的早期版本)都不支持。开之前先确认用的是-XX:+UseG1GC。
第二,只对经历过多次 GC 存活下来的对象生效,由-XX:StringDeduplicationAgeThreshold控制,默认 3。意思是新创建的字符串不会被立刻处理,得先熬过几次 Young GC。这个设计是为了避免对朝生夕死的短命字符串做无用功。
第三,去重是并发执行的,发生在 STW 阶段之外的线程里,所以不会明显增加暂停时间,但会消耗额外的 CPU。这一点在 CPU 本来就吃紧的机器上要权衡。
第四,JDK 9 之后 String 内部改成了byte[]加一个coder字段,去重的粒度变成了 byte 数组。如果两个字符串一个是 Latin-1 编码一个是 UTF-16 编码,即使内容看起来一样,也不会被合并。
我朋友那个服务最后就是靠这个解决的——把-XX:+UseG1GC -XX:+UseStringDeduplication加上去,几天后老年代的byte[]下降了差不多 40%,没改一行业务代码。但这是运气好,因为他的字符串是“内容相同、对象不同”的典型形态。如果字符串内容本来就各不相同,去重一点用都没有,还是得从业务层面想办法——比如把长字段的存储改成引用、或者在入库前做一次规范化。
还有一点值得提:-XX:+PrintStringDeduplicationStatistics能看到去重的效果统计,包括检查了多少个字符串、合并了多少个数组、节省了多少字节。上线前先跑一段时间看数据,比拍脑袋开参数靠谱。
5. 一次真实的排查:字符串是怎么把老年代吃掉的
这一节把前面所有知识点串成一个完整的排查过程。这套思路我在不止一个项目上用过,照着走基本能把字符串类的内存问题定位清楚。
5.1 从直方图到引用链,定位“谁在占着不放”
第一步永远是看直方图。jmap -histo:live <pid>或者从堆转储文件里看,重点盯三行:java.lang.String、[B(或[C)、java.util.HashMap$Node。如果前两个加起来占了一半以上,基本方向就定了。
第二步是搞清楚这些字符串是“池里的”还是“普通对象”。池里的字符串特征是:数量稳定、内容重复率高、通常有大量字面量特征(比如固定的枚举值、配置键名)。普通对象的特征是:数量随时间起伏、内容和业务数据相关。区分方法很简单,看PrintStringTableStatistics里的 entries 是不是和直方图里 String 的总数接近——接近说明大部分是池里的,差得远说明池子不是主因。
第三步是抓引用链。在堆分析工具里随便挑几个占内存的大 String,看它的 GC Root 路径。这一步最关键,它直接告诉你“是谁在持有这些字符串”。常见的几种情况:
被一个全局的静态 Map 持有——典型的缓存没设上限,Map 只增不删。 被 ThreadLocal 持有,而线程池里的线程长期存活——ThreadLocal 的 value 一直挂在 Thread 上,线程不死就不释放。 被数据库连接池或框架的元数据缓存持有——比如某些 ORM 会把 SQL 模板和参数名缓存起来。 被一次批量任务的中间结果持有——任务没结束,整批数据都在。
我朋友那次是第三种和第四种的混合:订单对象里塞了完整地址字符串,地址里又包含用户填的备注,备注里经常是重复的“请放快递柜”“不要打电话”这类话术。几十万个订单对象各持一份,内容高度重复。这种情况用intern()批量归一化是最直接的解法——因为话术的取值空间其实很小,池子能压到几十个条目。
5.2 intern 用错的两个典型场景
intern()好用,但用错的地方特别多,我列两个见过最多的。
第一个是拿intern()当“字符串缓存”用,不做任何边界控制。比如有人写了这么个工具方法:任何从外部读进来的字符串都先intern()一下。如果外部数据是用户输入或者设备上报,取值空间近乎无限,那 StringTable 会被撑到几百万条,哈希冲突严重,查找退化成线性扫描,同时这些条目在 GC 时都要被遍历一遍,反而增加了 GC 开销。这种情况下intern()不但没省内存,还增加了 CPU 和 GC 时间。
判断标准很简单:只有取值空间有限且重复率高的字符串才值得 intern。枚举值、状态码、字段名、固定话术,这些都是好例子。用户填的地址、随机生成的 ID、时间戳拼接的键,这些都不是。
第二个是以为intern()之后原对象就能被回收了。这完全是误解。intern()不改变你手上那个变量的引用,它只是往表里登记了一份。如果原来的 String 对象还被业务对象持有,该占的内存一点没少。真正省内存的前提是:原对象被释放,只剩池里那一份被复用。所以正确的用法是拿到intern()的返回值去替换原来的引用,而不是调用完就扔。
// 错误写法:返回值被丢弃,原对象还在 String raw = readFromDb(); raw.intern(); // 正确写法:用池里的引用替换掉原引用 String normalized = readFromDb().intern();这两行的差别在数据量大时非常明显。前者等于白干,后者才真正让重复内容归并到一份。
5.3 一份可以直接抄的检查清单
把上面这些整理成一个可执行的顺序,遇到字符串相关的内存问题时按这个走。
| 步骤 | 操作 | 判断依据 |
|---|---|---|
| 1 | jmap -histo:live看 String 和数组占比 | 超过 40% 就重点查字符串 |
| 2 | 开-XX:+PrintStringTableStatistics重启观察 | entries 与 buckets 的比例决定是否调表 |
| 3 | 堆转储里随机抽大字符串看引用链 | 找出真正的持有者 |
| 4 | 判断内容重复率 | 高重复才考虑 intern 或去重 |
| 5 | 评估取值空间是否有限 | 无限空间不要 intern |
| 6 | 试开UseStringDeduplication看统计 | 只在 G1 下有效,注意 CPU 开销 |
| 7 | 从业务层做规范化 | 治本,但改动量大 |
顺序不要乱,很多人一上来就想着开去重参数,结果发现字符串内容根本不重复,白折腾。先定位持有者,再决定手段,这个顺序能省掉大量试错时间。
还有一个小技巧:如果怀疑是某个新上线的功能引入的,可以用jcmd <pid> VM.stringtable -verbose对比上线前后 entries 的增长情况。entries 涨得特别快而业务量没变,基本就是新代码在大量创建字符串。这个手段比翻代码快得多。
最后分享一个我自己的习惯。凡是要写intern()的地方,我都会在旁边加一行注释说明“为什么这里能 intern”——要么是枚举值,要么是固定话术,要么是字段名。坚持了两年多,回过头看,凡是当时写不出这句注释的地方,后来基本都出了问题。这个习惯没什么技术含量,但确实管用。