资讯动态

JDK8+字符串常量池:从永久代到堆,intern语义与内存排查全解析

发布时间:2026/10/16 11:12:37 来源:尧图企业网站定制
前阵子帮某项目排查一个线上服务的内存问题堆 dump 一打开好家伙存活对象里 char[] 占了将近六成顺着引用链往上看全是 String。做了个简单的去重统计40 万条字符串实际只有 3 万多种。这问题的根源绕不开 JDK8 字符串常量池的机制变化。今天就把这块掰开揉碎讲清楚包括字符串常量池在 JVM 里的真实位置、创建 String 时的驻留行为、编译器对拼接做了什么、以及线上内存该从哪条链路入手排查。这篇文章适合两类人一类是被new String 创建几个对象intern 到底会不会复制这类问题困扰的 Java 开发者另一类是线上内存出现膨胀但不知道怎么定位的运维或后端同学。读完你会对字符串的底层驻留机制有一个完整认识也能直接拿排查链路去落地产线操作。1. 字符串常量池从哪里来又搬到了哪里1.1 池子不是一开始就在堆里的很多刚接触 JVM 的人默认字符串常量池就在堆里其实这是 JDK7 之后才发生的事情。更早的 JDK6 及以前字符串常量池是放在方法区里的方法区在 HotSpot 虚拟机中的落地实现是“永久代”。永久代是一个独立的物理区域有相对固定的大小上限通过-XX:MaxPermSize控制。我印象很深早年做中间件压测时只要业务代码里大量调用String.intern()或者动态生成不带边界的字符串字面量就特别容易打出java.lang.OutOfMemoryError: PermGen space。那个年代 PermGen 区域本身就小默认几十 MB字符串一多直接顶满。而且永久代里对象的回收效率不高背着不合理的字符串对象很难被清理掉。这就带来第一个问题如果一个池子的位置选在了容量受限、回收成本高的区域它的天花板就非常低。字符串又是业务代码里用得最频繁的对象类型任何缓存、配置加载、数据拼接都可能产生大量实例。于是把字符串常量池挪出永久代就成了当时 JVM 演进中一件顺理成章的事。1.2 从永久代搬到堆JDK8 之后的位置更明确JDK7 开始这个设计变了字符串常量池从永久代迁移到了 Java 堆中。到了 JDK8永久代被彻底移除换成了元空间Metaspace元空间使用的是本地内存不再占用堆内存但字符串常量池并没有从此跟着住进元空间它依然安稳地待在堆里。这一点是社区里最容易记混的地方因为提到 JDK8 大家第一反应都是永久代被“元空间取代了”顺手把字符串常量池也想象成搬进了元空间。正确的关系是这样的版本方法区实现字符串常量池位置常见 OOM 形态JDK6 及以前永久代永久代内PermGen spaceJDK7永久代逐步退化Java 堆Java heap spaceJDK8元空间本地内存Java 堆Java heap spaceJDK8 的字符串常量池本质上就是 Java 堆中的一张字符串哈希表StringTable这张表记录了被驻留的字符串对象的引用。对象实体在堆里哈希表只是维护一堆引用关系所以它不再是某个固定大小的独立区域而是和其他普通对象一起参与堆的分配与回收。这个迁移带来两个非常关键的变化。第一常量池里的字符串对象不再是被永久代独角兽保护的“特赦对象”而是一旦没有外部引用就会被普通 GC 正常回收。第二因为字符串对象本体就在堆中intern 的时候不需要再复制一份字符串数据到永久代而是可以直接把堆中已有的对象引用放进表里这就直接改变了 intern 方法在 JDK6 和 JDK8 之间的返回语义。1.3 池子住进堆里对开发者的实际影响我们可以做个类比永久代时代的字符串常量池像一个单位的小档案室容量小进档案室的材料都得重新“誊写”一份交上去而且档案室几乎不做清理。JDK8 的字符串常量池则像一个共享的大书库书还是那本书书库里只登记一个编号指着书放哪如果没人借阅了书也会被定期清理出去。对开发者来说最直接的影响有两点。一是同样一段代码在不同 JDK 上跑出来的对象引用关系可能不一样。说“可能”是因为大多数业务代码根本不关心比较字符串自然感受不到差异。一旦你开始研究 intern、锁字符串这类操作就会发现版本差异极其明显。二是排查内存问题时不能再笼统地说“常量池爆了”。在 JDK8 里你看到的是堆内存里 String 对象和 char[] 数组占比异常具体原因可能是业务对象上挂了大量独立字符串也可能是有人不小心把本该限量驻留的字符串全部 intern 了。要分清这两类情况就得回到字符串对象是怎么被创建和驻留的这个问题上。2. String 诞生的三种方式分别往池子里放什么2.1 字面量与 new String经典“创建几个对象”先看一段几乎每个 Java 面试都会聊到的代码String a abc; String b abc; String c new String(abc); System.out.println(a b); // true System.out.println(a c); // false System.out.println(a c.intern()); // truea b为 true是因为字符串字面量abc走的是“加载类时/首次执行时驻留”的路径JVM 会把字面量对应的 String 对象引用放进字符串常量池后续再出现相同的字面量直接复用池中的引用。这也是课堂上最常讲的一句字面量字符串会被自动 intern。new String(abc)就复杂一点。它至少会涉及两部分一是编译期在运行时常量池里出现的abc字面量二是运行时通过 new 在堆里创建的一个全新 String 对象。如果执行这行代码之前字符串常量池里还没有abc那么 JVM 会先把字面量对应的字符串对象驻留进池子然后再 new 一个独立对象返回给 c。此时一共会有两个 String 对象一个进了池子一个挂在 c 上。如果池子里已经有abc了那 new 的时候只新增一个堆对象不重复驻留。所以网上最严谨的说法是“new String 字面量”创建 1 个或 2 个对象取决于池子是否命中。c.intern() 之后返回的是池中那个既有 String 的引用所以a c.intern()是 true。这里有个容易忽略的细节abc这个字面量对应的 String 对象本身也是堆里的一个对象只不过它的引用被登记在 StringTable 里。而 c 指向的是另一个没有进表的对象。两个对象都是堆对象差别只是“是否被常量池登记引用”。2.2 intern() 在 JDK8 的真实语义引用入池而不是复制数据到了 intern 这一层版本差异就藏不住了。JDK6 及以前如果池中不存在相同内容的字符串intern 方法会把当前字符串对象的内容“复制”一份放到永久代的常量池里然后返回那个新副本的引用。换句话说堆中的原始对象和池中的副本是互相独立的两个对象。JDK7 之后完全换了玩法如果池中不存在相同内容的字符串JVM 直接把当前这个堆上 String 对象的引用登记到 StringTable 里然后返回这个引用本身。不再复制字符串内容不再折腾第二份对象。这个差异用一个经典实验就能证明。这个实验也是网上流传很广的一个题很多人第一次看到会怀疑自己 JDK 是不是装错了String s1 new String(1) new String(1); s1.intern(); String s2 11; System.out.println(s1 s2);在 JDK6 上打印 false在 JDK8 上打印 true。原因拆开来看new String(1) new String(1)在 JDK8 里底层是用 StringBuilder 拼出来的一个字符串内容是11但这个拼接结果并不是字面量所以没有自动驻留。s1 调用 intern 时池子里没有11于是 JDK8 直接把 s1 这个对象的引用放进了 StringTable。随后执行字面量11JVM 发现池子里已经有这个内容了就返回了 s1 的引用。因此 s1 和 s2 指向同一个对象比较结果是 true。JDK6 则不同intern 会把 s1 的内容复制一份到永久代s2 拿到的是永久代里新副本的引用和堆里的 s1 不是同一个对象所以 false。同一个代码块两个 JDK 版本两种结果这是字符串常量池迁移后最典型的语义变化。2.3 从 intern 看 JDK8 的对象生命周期继续说这个实验的另一个关键点s1 这个对象在 intern 之后StringTable 里会一直保存着对它的强引用。即便后续没有任何业务代码再引用 s1它也不会被 GC 回收因为哈希表里还登记着这条记录。这就是 JDK8 字符串驻留的“永久性”问题。JDK8 虽然不会复制数据但也不是说驻留是免费的每 intern 一个新内容StringTable 就多一条强引用这条强引用指向的对象和它内部的 char[]JDK9 是 byte[]数据会一直存活。所以我在实际项目中经常跟同事说一句话intern 操作要做到心里有数驻留的是有穷集合还是无穷集合。如果驻留的是一个有穷集合比如状态码、错误码、枚举描述那这个池子规模是可控的。如果你在循环里对一批用户输入的自由文本挨个调用 intern那等于往池子里不断塞新条目这些字符串再也不会被回收内存上涨是必然结果。3. 拼接、常量传播和编译期折叠别被“优化”反噬3.1 编译器把哪些拼接折叠成了常量字符串常量池不只是运行时的机制它和编译器也有很强的联动。javac 在处理字符串拼接时如果参与拼接的所有元素在编译期都能确定值它会直接帮你把拼接结果算出来生成一个合成后的字符串常量。比如final String prefix 订单; final String suffix 号; String orderKey prefix suffix;这里的prefix和suffix都是 final 常量变量它们是被 JLS 定义的“编译期常量”。编译器可以在 javac 阶段直接算出订单号然后把orderKey这个局部变量的字节码改成加载常量订单号。运行时只会往字符串常量池里驻留一条订单号。但如果你把 final 去掉哪怕前缀和后缀的值确实没变过编译期也不会做折叠。因为普通变量在编译期无法确定其最终运行值编译器必须老老实实生成运行时拼接的逻辑。这种“看起来一样底层路径完全不一样”的差异是最容易被忽视的第一个坑。用 javap 看字节码是最直观的。对非 final 变量的拼接你会看到类似这样的调用序列new StringBuilder ldc // 加载第一个字符串 invokevirtual append ldc // 加载第二个字符串 invokevirtual append invokevirtual toString也就是说运行时拼接不是“直接算个结果”而是要 new 一个 StringBuilder然后 append、append、toString最后才能得到一个字符串对象。3.2 循环拼接中的隐性膨胀字符串拼接最大的坑在循环里。很多新手写过这样的代码String result ; for (int i 0; i 100000; i) { result i; }在字节码层面等价于每次迭代都 new 一个 StringBuilder把上一轮结果 append 进去再把当前数字 append 进去最后 toString 生成一个新对象赋给 result。100000 次循环意味着 100000 个 StringBuilder 临时对象和 100000 个中间 String 对象这些对象的 char[] 都是一次性分配、用完即弃。不是说 JIT 不会做优化但代码层面这种写法会给 JIT 增加不必要的压力。正确做法要么用显式 StringBuilder要么直接把循环逻辑改造成不会重复复制字符串的形态。JDK9 虽然把字符串拼接的底层实现换成了 invokedynamic 加运行时策略不再强制依赖 StringBuilder但循环内反复拼接导致的中间对象问题依然存在只是形态略有变化。这里有个网上容易混淆的说法“JDK8 里字符串拼接会自动优化成 StringBuilder”。这句话不够精确。javac 确实会为多处拼接生成一个 StringBuilder 并连续调用 append但没有聪明到自动帮你把循环里的 String 变量改写掉。所谓优化是把一句表达式里的连续合并成一个 StringBuilder而不是帮你重塑整个代码块的数据流。3.3 业务中隐藏的拼接膨胀点比显式更难发现的是框架层、工具层里的拼接。举个例子某段业务日志里大量使用了log.info(用户ID: userId 订单状态: status 耗时: cost);表面上看只是日志但如果这个日志在循环里、在链路上高频调用并且最终被采集框架缓冲到内存那么即使字符串拼接很轻量产生的 String 和 char[] 也会堆积在内存队列里。线上 dump 里经常能看到大量这种短命但数量巨大的 String。还有一类是 Map 的 Key 拼接。比如把user_ userId作为 Redis Key 或者在本地 Map 里做 Key。每次执行都会生成一个新 String即使内容一样如果不经过驻留Key 就不是同一个对象。如果这个 Map 生命周期很长这些 Key 会一直占用内存。解决思路不能说“那我把 Key 全部 intern”而要回到业务本身Key 的数量是不是有界的如果是可以考虑预生成常量如果不是先想想能不能用组合对象替代字符串拼接而不是依赖常量池救场。4. 线上内存排查链如何定位字符串膨胀的源头4.1 第一步堆 dump 看 char[] 和 String 的分布回到开头那个案例。当时某服务总内存从 4GB 一路涨到 12GB 还压不住GC 日志里 Full GC 频繁每次回收后老年代占用率只降不到 15%。直觉告诉我堆里有一批无法被回收的对象而且大概率是字符串。定位过程从堆 dump 开始。低峰期执行jmap -dump:live,formatb,file/data/heapdump.hprof pid注意live选项会先触发一次 Full GC 再 dump生产环境务必先评估影响最好是配合发布窗口操作。dump 文件出来之后用 Memory Analyzer 这类工具打开先看支配树里的最大对象。当时结果非常典型排在前面的是char[]其次是java.lang.String再往后才是业务对象。然后直接跑一段 OQL 按字符串内容聚合示意如下SELECT s.toString(), COUNT(*) AS cnt FROM instanceof java.lang.String s GROUP BY s.toString() ORDER BY cnt DESC结果一目了然排名前三的字符串内容全部来自内部场景生成的状态描述文案40 万个 String 实例去重后只有 3 万多种内容重复率接近 92%。这段内存完全是可以被共享的却每个人都持有一份独立副本。堆 dump 里还要重点看一眼引用链如果某些 String 对象被一个哈希表结构引用而这张表看起来又不像业务代码里的普通容器那基本就是 StringTable 了。这种情况比业务代码持有更麻烦因为业务对象持有还能通过业务逻辑释放StringTable 里的引用除非 JVM 卸载类或池子重建否则会一直存在。4.2 第二步区分“重复持有”和“被动驻留”排查字符串膨胀最核心的是分清楚内存是被“重复持有”还是被“主动驻留”占掉的。重复持有是指每个对象都保存了自己的一份字符串副本内容一样但互相独立。这种问题的解法是共享或缓存。被动驻留是指字符串被 intern 进 StringTableStringTable 持有强引用永久不释放。这种问题的解法是治理 intern 行为本身。两者在堆 dump 中的表现有差异。重复持有的 char[] 通常分布在各业务对象之下引用链显示为业务对象 - String - char[]。被动驻留的字符串引用链会指向 StringTable你会在 GC 根节点引用里看到字符串表。如果你发现大量字符串挂在 StringTable 底下重点检查代码里哪些地方调用了 intern以及调用频率、内容基数。还有一种常见情况是两者叠加业务代码在循环里对固定枚举集合调用了 intern本来集合有界却因为每次循环传入的是重新拼接的新字符串对象导致池中被塞进大量内容相同但对象不同的副本这里要注意intern 的内容相同是会命中池子的不会产生新条目。真正的问题是传入内容的基数不受控或者每次传入的都带时间戳、随机数这类不可枚举字段。4.3 第三步用 StringTable 统计判断驻留规模是否合理除了看堆 dumpJDK 还提供直接查看 StringTable 状态的入口。启动 JVM 时带上-XX:PrintStringTableStatistics进程退出时会打印一张表包含桶数量、条目数量、平均链长等。也可以在运行中用 jcmd 触发jcmd pid VM.stringtable输出会显示类似StringTable statistics: Number of buckets : 65536 Number of entries : 1843200 Average length : 28.1一个关键指标是平均链长。字符串哈希表是“桶 链表”的结构桶数量固定后条目越多平均链长越长查找性能越差。如果你发现 entries 数量级远超桶数量说明有两个方向要处理一是代码里驻留了过多字符串二是在允许的情况下调大-XX:StringTableSize。当时我处理的案例里entries 有 180 多万桶数量是 65536平均链长 28这个哈希表读写的耗时已经是可感知的了。再深挖代码发现某个工具类在每次请求进来时都会对一堆过程性字符串执行 intern而这些内容有很大一部分在业务上根本不需要复用。那个问题的最终修复并不是调参数而是把 intern 调用从请求链路里删掉只对真正需要长期复用的固定集合做驻留。调参只是缓解症状删掉错误用法才是治本。5. 字符串驻留的正确姿势与调优参数5.1 什么场景值得用 intern()经历过前面的排查基本原则已经很清楚了除非你能穷举字符串的可能取值否则不要轻易 intern。我用这个标准判断了以下几个常见场景。适合 intern 的典型场景是有限状态集合。比如订单状态INIT、PAY_SUCCESS、PAY_FAILED、CLOSED错误码集合渠道标识配置中心的固定 Key。这些集合的数量是明确的可能几百个或几千个通过 intern 可以让这些字符串在 JVM 内只保留一份对象。不适合 intern 的场景则是自由文本。用户名、昵称、地址、备注、日志内容、接口响应体这些内容的基数趋近于无穷intern 一个就有永久占一块内存的风险必须用其他方案替代。一个更好的组合思路是内部系统里对这种有穷集合直接定义成枚举或静态常量根本不需要运行时依赖 intern 去去重。静态常量在类加载时就会有一个明确的 String 对象引用业务代码引用同一个常量自然就只有一份数据。intern 真正的价值存在于“运行时得到字符串内容但又希望跨调用共享”的场景比如一些框架里动态拼出的固定集合 Key。5.2 StringTableSize 和 StringDeduplication 怎么用如果业务上确实有大量字符串驻留需求且经过评估内容基数可控可以考虑调大 StringTable 的桶数量来控制查找开销。-XX:StringTableSize控制的是 StringTable 的哈希桶数量。和历史版本相比JDK8 后续版本的默认值已经提高了很多但如果你项目里驻留了几十万条字符串默认值可能依然不够。设置的原则是期望的唯一字符串数量 / 桶数量 不要太大一般希望平均链长在 2 到 5 以内。举个例子如果预期最多有 50 万个不同字符串会被驻留桶数量可以设置成 1_000_003 这样的质数。为什么推荐质数哈希表用取模法计算桶索引时质数能让哈希值分布更均匀减少取模后的碰撞。千万别随手写个 1_000_000虽然也能跑但和质数相比分布质量要差一些。另一个方向是被动的字符串去重参数JDK8u20 之后在 G1 收集器下可以开启-XX:UseG1GC -XX:UseStringDeduplication这个参数做的事和 intern 完全不同。它不改变字符串对象引用而是在 GC 扫描存活对象时对达到年龄阈值默认 3的 String 对象做内容筛选如果发现多个字符串对象的 char[] 内容相同就尝试让它们共享同一个 char[]从而回收部分多余的字符数组。StringDeduplication 适合的场景是“堆中存在大量重复字符串但代码层面不方便统一改为常量”。它的优势是改造成本低只需要加 JVM 参数局限是只对熬过多轮 GC 仍存活的对象起效短生命周期字符串在 GC 前就被回收了去不去重都无所谓。同时它要求触发条件依赖 G1 的标记机制JDK8 需要显式开启 G1 才能使用JDK9 默认 G1 才更方便。我见过有人同时开启 intern 和 StringDeduplication这两者并不冲突。intern 是代码主动把对象“登记”到 StringTableStringDeduplication 是 GC 阶段被动地让相同内容的 char[] 合并。业务代码该做的是限制 intern 的内容边界JVM 参数做的是兜底优化。5.3 我的避坑清单最后按惯例汇总一份踩坑记录这些都是在实际项目中付出过代价的经验。第一不要用字符串常量池去规范业务数据模型。数据模型的设计靠类型、枚举、值对象不靠 intern 碰运气。让所有调用方都用同一个字符串常量应该在编码层面确保而不是运行到 intern 再去碰运气。第二不要拿 intern 后的字符串做锁对象。以前真见过有人synchronized (str.intern())做并发控制看起来很优雅实际一旦某个不受控制的字符串也被 intern锁的粒度就会被其他代码意外放大。更别提如果 intern 的是自由文本StringTable 里会不断新增条目内存问题会再次找上门。第三正则表达式、JSON 序列化这些高频工具内部也会产生大量临时字符串它们不会主动进入字符串常量池但会作为短命对象堆积在年轻代。这部分问题更适合去调整 GC 策略或对象缓存而不是分析常量池。第四JDK9 的 String 内部从 char[] 改成了 byte[]这会影响单字符串内存占用但不会改变字符串常量池的驻留机制。你在 JDK8 上学到的 intern 语义、StringTable 参数逻辑在 JDK9 依然成立不需要重学一遍。调参这件事我个人的习惯是先用 jcmd 看当前 StringTable 的桶数和条目数算一下平均链长再决定要不要调-XX:StringTableSize。如果链长已经飙到 10 以上并且代码里清理不掉无谓驻留那就先调大桶数量紧急止血如果链长很低但内存还是涨那问题根本不在哈希表而在字符串对象本身太多该去查业务对象。能分清这两个方向线上字符串内存问题就解决了一半。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑