news 2026/10/27 15:28:55

JVM StringTable拆解:字符串常量池、intern与G1去重

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JVM StringTable拆解:字符串常量池、intern与G1去重

前两天帮朋友看一个服务的内存问题,堆一共给了 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 一份可以直接抄的检查清单

把上面这些整理成一个可执行的顺序,遇到字符串相关的内存问题时按这个走。

步骤操作判断依据
1jmap -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”——要么是枚举值,要么是固定话术,要么是字段名。坚持了两年多,回过头看,凡是当时写不出这句注释的地方,后来基本都出了问题。这个习惯没什么技术含量,但确实管用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 15:27:09

从零构建自动化测试体系:技术选型与实战指南

1. 项目概述&#xff1a;自动化测试从理论到实践的完整闭环 三年前接手一个日活百万级的金融APP测试任务时&#xff0c;我经历了手工测试团队每天加班到凌晨的噩梦。直到引入自动化测试方案后&#xff0c;回归测试时间从72小时压缩到4小时&#xff0c;这就是自动化测试的威力。…

作者头像 李华
网站建设 2026/10/27 15:25:48

NPOI Excel公式处理原理与开箱即用实战指南

1. 项目概述&#xff1a;为什么在NPOI里处理Excel公式不是“填个字符串”那么简单 你有没有遇到过这种情况&#xff1a;用NPOI往Excel单元格里写入一个 "SUM(A1:A10)" &#xff0c;保存后打开文件&#xff0c;单元格里显示的不是计算结果&#xff0c;而是赤裸裸的文…

作者头像 李华
网站建设 2026/10/27 15:25:25

KVM 部署 Windriver Linux 实战:镜像形态判断、引导配置与避坑指南

1. 项目背景与整体设计思路拆解KVM 上跑 Linux 发行版这件事&#xff0c;做过的人都知道&#xff0c;装 Ubuntu、CentOS 这类主流系统基本是"下一步下一步"的节奏&#xff0c;镜像丢进去、virt-install 敲一行、等几分钟就完事。但换成 Windriver Linux 这类面向嵌入…

作者头像 李华
网站建设 2026/10/27 15:25:23

基于CNN的路面坑洼检测技术实现与优化

1. 项目背景与核心价值 马路坑洼识别是城市道路维护中的一项基础但重要的工作。传统的人工巡检方式效率低下且成本高昂&#xff0c;尤其在大型城市路网中几乎难以实现全面覆盖。基于CNN卷积神经网络的深度学习方案&#xff0c;能够通过摄像头采集的路面图像自动识别坑洼缺陷&am…

作者头像 李华
网站建设 2026/10/27 15:25:22

对比学习技术解析:经典与分组方法实践对比

1. 对比学习技术概述对比学习&#xff08;Contrastive Learning&#xff09;作为自监督学习的重要分支&#xff0c;近年来在计算机视觉、自然语言处理等领域展现出强大的特征学习能力。其核心思想是通过构造正负样本对&#xff0c;让模型学习到"相似样本在特征空间中靠近&…

作者头像 李华
网站建设 2026/10/27 15:24:08

PDF RAG实战:PyMuPDF+Qwen-VL双通道解析图文混合文档

PDF RAG 这词儿这两年都快被说烂了&#xff0c;但真上手做过的都知道&#xff0c;大部分开源方案拿扫描件或者图文混排的 PDF 一测就露馅。文本抽取乱、表格错位、图表信息全丢&#xff0c;最后检索出来的东西根本没法用。我自己在折腾企业合同、产品手册、技术文档这批资料时&…

作者头像 李华