4.2 字符串拼接与常量池 本节摘要:循环里用 拼字符串,是新生代 GC 压力的经典来源。本节从一次 GC 报警报起,讲清 String 不可变设计、字符串常量池的复用规则、编译期与运行期拼接的分界、StringBuilder 的容量扩容,以及 intern 的适用边界。 事故现场:新生代 GC 每 2 秒一次 报表服务的 GC 监控半夜告警:新生代 GC 频率从每 30 秒一次涨到每 2 秒一次,接口 P99 翻倍。GC 日志里大量短命对象,堆 dump 显示 Top 对象是几十万个 和 。定位到报表导出的拼接代码: 不可变, 的每次执行都要:新建一个长度为"当前长度+新增长度"的 StringBuilder 或 String、拷贝全部旧内容、追加新内容、再转成新 String。
本节摘要:循环里用
+拼字符串,是新生代 GC 压力的经典来源。本节从一次 GC 报警报起,讲清 String 不可变设计、字符串常量池的复用规则、编译期与运行期拼接的分界、StringBuilder 的容量扩容,以及 intern 的适用边界。
报表服务的 GC 监控半夜告警:新生代 GC 频率从每 30 秒一次涨到每 2 秒一次,接口 P99 翻倍。GC 日志里大量短命对象,堆 dump 显示 Top 对象是几十万个 char[] 和 String。定位到报表导出的拼接代码:
String report = ""; for (Row row : rows) { // rows 5 万行 report += row.toCsvLine() + "\n"; // 每轮循环 新建一个越来越长的 String }
String 不可变,+= 的每次执行都要:新建一个长度为"当前长度+新增长度"的 StringBuilder 或 String、拷贝全部旧内容、追加新内容、再转成新 String。第 n 轮拷贝量正比于已累计长度,总代价是 O(n²)——5 万行、平均行长 100 字符,大约 12.5 亿字符的拷贝量,同时制造海量马上就死的中间对象,新生代被反复填满。
String 被设计成不可变(final 类、私有 final 字节数组),换来四个好处:字符串常量池可以安全复用同一实例;哈希值可缓存(HashMap 的键才快得起来,呼应第 2 章);多线程共享无需同步;安全性(路径、类名等参数传进来不会被偷偷改)。代价就是每次"修改"都是新对象——拼接性能问题的根在这。
常量池的复用规则用一段实验看最清楚:
String a = "hello"; // 字面量 进常量池 String b = "hello"; // 复用同一个池对象 String c = new String("hello"); // 堆里新建 即使内容相同 System.out.println(a == b); // true System.out.println(a == c); // false 比较的是引用 System.out.println(a == c.intern()); // true intern 返回池中实例 String d = "hel" + "lo"; // 编译期常量折叠 结果就是池对象 String e = "hel"; String f = e + "lo"; // 运行期拼接 内部走 StringBuilder 产生新对象 System.out.println(a == d); // true System.out.println(a == f); // false
编译期能确定的拼接被折叠,运行期的拼接产生新对象——这是很多"看起来一样结果不一样"的根源。也再次印证第 1 章的纪律:内容比较一律 equals,== 对字符串只用于研究对象同一性的教学场景。

StringBuilder sb = new StringBuilder(rows.size() * 110); // 预估容量 免扩容 for (Row row : rows) { sb.append(row.toCsvLine()).append('\n'); } String report = sb.toString();
两个细节:给初始容量(StringBuilder 内部数组不够时翻倍扩容并整体拷贝,预估到位可完全避免);StringBuilder 非线程安全,多线程拼共享日志请用 StringBuffer 或(更好的)各自拼完再合并,或直接上日志框架的参数化占位(log.info("order {} paid", id),不拼不必要的东西才是最高级的优化)。
顺带纠正一个过时的"性能技巧":JDK 9 之后,单行 a + b + c 会被 javac 编译成 invokedynamic 指令,运行期由 StringConcatFactory 生成优化的拼接方法,普通场景手写 StringBuilder 已无优势。需要动手的只剩两处:循环内拼接,和需要精确控制容量/复用的热点。
intern() 把字符串放进(或查回)常量池,让重复内容共享一份实例。听起来是内存优化,但现代 JVM 常量池在堆里且字符串去重已有 G1 的 -XX:+UseStringDeduplication 兜底,业务代码大规模 intern 反而增加池查找开销。合理场景只剩:与海量重复字符串比较(把比较目标 intern 化,== 比指针),以及框架级的规范化。默认答案:不写 intern。
split 的参数是正则,split(".") 返回空数组——按字面量切要转义或用 Pattern.quotesubstring 在 JDK 7u6 之后是拷贝不再是共享偏移,老资料里"substring 促发内存泄漏"的说法已过时"constant".equals(var) 天然 null 安全Path.of / File 处理跨平台差异⚠️ 常见坑:在异常信息或日志里对超大对象隐式调用
toString拼接。"防御性日志"拼了几十 KB 又被日志级别过滤掉,白花 CPU 与 GC。
💡 关键直觉:String 的性能问题不是"拼接慢",是"为不可变性支付的拷贝费在循环里复利"。把拼接挪出循环,或预估容量一次到位,账就平了。
== 的true只是常量池的巧合+= 是 O(n²) 拷贝 + 海量短命对象,新生代 GC 频率飙升下一节是 Java 圈最著名的线程安全反面教材:SimpleDateFormat。