排查String内存泄漏的核心是识别依赖“可变”假定的写法:循环中滥用+或concat、误用new String()、旧版JDK下substring共享底层数组、滥用intern()导致元空间膨胀。

排查因误解 String 不可变性导致的内存泄漏,核心不是“找泄漏点”,而是识别哪些写法在逻辑上依赖“可变”假定,却实际制造了大量冗余对象或意外强引用。这类问题往往不报错、不抛异常,只表现为 GC 频繁、堆内存缓慢上涨、老年代占用持续升高。
检查字符串拼接是否在循环中滥用 + 或 concat
这是最常见也最容易被忽略的源头。开发者常以为 str += "x" 是“追加”,实则是每次创建新 String 对象,并保留对前一个对象的不可达引用(等待 GC)。若循环次数大(如处理日志行、解析 CSV),中间产生的临时 String 和底层 char[]/byte[] 会迅速堆积。
- 查代码:搜索
for.*\+=、while.*\+=、.concat(在循环体内出现的位置 - 验证方式:用 VisualVM 或 JFR 抓取一段运行期间的堆快照,按 class 过滤
java.lang.String和[C(char[])或[B(byte[]),观察实例数与预期是否严重偏离 - 修复动作:统一替换为
StringBuilder,且初始化时指定合理容量(如已知最终长度约 2KB,就写new StringBuilder(2048))
确认是否误用 new String() 引入重复堆对象
new String("abc") 看似无害,但它绕过常量池,在堆上新建一个完全冗余的对象。如果该操作出现在高频路径(如配置加载、HTTP Header 解析、JSON 字段提取),就会悄悄增加堆压力,尤其当字符串内容重复率高时,浪费更明显。
- 查代码:全局搜索
new String\(,重点看参数是否为字面量或确定不变的字符串变量 - 验证方式:开启 JVM 参数
-XX:+PrintStringDeduplicationStatistics(JDK 8u20+),观察去重成功率;或用 JMC 查看字符串对象分布,比对常量池引用数与堆中 String 实例数 - 修复动作:99% 场景直接删掉
new String,改用字面量赋值;仅在极少数需强制区分堆对象身份(如测试反射修改行为)时保留
审查 substring 是否仍被当作“轻量切片”使用(尤其 JDK 6 及更早兼容场景)
虽然 JDK 7u40+ 已修复 substring 共享底层数组的问题,但如果项目仍在维护旧版 JDK,或存在跨版本迁移遗留代码,就可能有大字符串调用 substring(0,10) 后,小结果却长期持有整个大数组的引用——这属于典型的“语义误解”:以为切片不占空间,实则拖着几 MB 数据无法回收。
- 查代码:搜索
.substring\(,特别关注参数来自长文本(如读取的文件内容、数据库 CLOB 字段、HTTP 响应体)的调用点 - 验证方式:在可疑方法前后打内存快照,用 MAT 分析该 substring 返回对象的
value字段实际长度与count的差值;若差值巨大,说明仍存在共享残留风险 - 修复动作:JDK 6 环境下,显式用
new String(str.substring(...))强制复制;JDK 7+ 则无需此操作,但建议统一用str.substring(...).intern()(仅当内容高度重复)或直接信任 substring 行为
留意 intern() 调用是否造成常量池膨胀
有人误以为 string.intern() 是“释放内存”的手段,其实它把字符串强行塞进常量池(方法区/元空间),而该区域 GC 较弱。若对大量动态生成、内容各异的字符串反复调用 intern(如解析 UUID、时间戳拼接串),会导致常量池持续增长,最终触发元空间 OOM。
- 查代码:搜索
.intern\(\),检查调用对象是否来自非字面量、不可控输入源(如用户请求参数、随机生成 ID) - 验证方式:添加 JVM 参数
-XX:+PrintGCDetails -XX:+PrintStringDeduplicationStatistics,观察 intern 后的字符串是否真被复用;也可用 JConsole 查看java.lang:type=MemoryPool,name=Metaspace使用趋势 - 修复动作:除非明确需要全局唯一引用(如状态码枚举、固定协议字段),否则避免对动态字符串调用 intern;可用 WeakHashMap 缓存替代
Java免费学习笔记:立即使用
解锁 Java 大师之旅:从入门到精通的终极指南











