Java 字符串性能深度实战——String intern、StringBuilder 误用、字符串拼接的真相
Java 字符串性能深度实战——String intern、StringBuilder 误用、字符串拼接的真相
适读人群:日常写 Java 业务代码的开发者 | 阅读时长:约 15 分钟 | 核心价值:澄清字符串相关的常见性能误区,给出有数据支撑的实践建议
我在 2021 年做过一次代码 review,发现一个同事在一个每秒调用 3000 次的接口里,用了这样的代码:
String result = "";
for (int i = 0; i < items.size(); i++) {
result = result + items.get(i).getName() + ",";
}循环体里用 + 拼接 String。如果 items 有 200 个元素,这里会创建 400 多个临时 String 对象。
我指出来之后,同事很快改成了 StringBuilder。但这件事让我想深入研究一下 Java 字符串这个话题——不是说教"要用 StringBuilder",而是想搞清楚:字符串操作里,哪些是真问题,哪些是被过度担忧的伪问题。
研究了一圈之后,我发现自己之前也有很多错误的认知。
String + 拼接:真的都要改成 StringBuilder 吗?
答案是不。这是最常见的误区。
从 Java 5 开始,编译器会把循环外的字符串拼接自动优化成 StringBuilder。比如:
String a = "Hello" + ", " + "World" + "!";编译器直接把这合并成一个常量,不会创建任何临时对象。
String name = getName();
String greeting = "Hello, " + name + "!";这也会被编译器编译成相当于:
String greeting = new StringBuilder("Hello, ").append(name).append("!").toString();问题只出现在循环体内部:
// 这是真问题:每次循环都创建新的 StringBuilder 然后 toString
String result = "";
for (String item : items) {
result += item; // 等价于 result = new StringBuilder(result).append(item).toString()
}Java 9+ 又做了进一步优化(invokedynamic + StringConcatFactory),实际情况更复杂。但有一条原则是可靠的:循环体内用 + 拼接累加字符串,一定要改成 StringBuilder。其他场景大概率不需要改。
踩坑实录一:滥用 String.intern(),把字符串常量池撑爆了
String.intern() 的作用是把字符串放入常量池,让相同内容的字符串共享同一个对象,节省内存。
我曾经在一个用户标签系统里,对所有标签字符串都调用了 intern(),想节省内存:
// 错误示范
public class TagService {
public List<String> getUserTags(long userId) {
List<String> tags = fetchFromDb(userId);
// 以为这样能节省内存……
return tags.stream()
.map(String::intern)
.collect(Collectors.toList());
}
}系统运行了两周后,出现了奇怪的 Full GC,而且频率越来越高,GC 日志显示 Metaspace(不是 Heap)占用在持续增长。
原因:String.intern() 在 Java 7+ 将字符串放入堆中的字符串常量池,但这个常量池本身在 JVM 中不是无限的。 我们系统有海量用户,标签组合几乎不重复,intern() 不仅没有节省内存,反而把所有这些字符串都钉在了内存里,GC 无法回收,最终把常量池撑到内存溢出。
什么时候 intern() 真的有用:
- 字符串集合重复度极高(比如国家代码、状态枚举这类字段)
- 字符串数量有上限(不是用户生成的无限字符串)
什么时候别用 intern():
- 用户生成的内容
- 动态拼接的字符串
- 数量不可预期的场景
正确处理高重复度字符串的方式:
package com.example.tag;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 用 Map 手动管理常量池,可控内存大小,不依赖 JVM 内置字符串常量池
* 适合枚举值有限的字符串
*/
public class StringInterner {
private static final Map<String, String> CACHE = new ConcurrentHashMap<>(256);
/**
* 只对固定的枚举值做 intern,不对用户输入做 intern
*/
public static String internFixed(String s) {
if (s == null) return null;
String cached = CACHE.get(s);
if (cached != null) return cached;
// 注意:putIfAbsent 返回旧值或 null,要处理并发情况
String old = CACHE.putIfAbsent(s, s);
return old != null ? old : s;
}
}StringBuilder 的常见误用
误用一:多线程环境用 StringBuilder
StringBuilder 不是线程安全的,如果在多线程场景共享一个 StringBuilder 实例,结果会是乱的。
// 这是有线程安全 BUG 的代码
public class WrongLogBuilder {
// 多个线程共享这一个 StringBuilder
private static final StringBuilder LOG_BUFFER = new StringBuilder();
// 多线程调用这个方法,LOG_BUFFER 会被并发修改,结果不可预期
public static void appendLog(String msg) {
LOG_BUFFER.append(msg).append("\n");
}
}多线程场景要用 StringBuffer(线程安全但性能差)或者每个线程用自己的 StringBuilder:
package com.example.log;
/**
* 正确做法:每个线程有自己的 StringBuilder,用 ThreadLocal 隔离
*/
public class ThreadSafeStringBuilder {
// ThreadLocal 保证每个线程有独立副本
private static final ThreadLocal<StringBuilder> THREAD_LOCAL_SB =
ThreadLocal.withInitial(() -> new StringBuilder(256));
public static String buildMessage(String... parts) {
StringBuilder sb = THREAD_LOCAL_SB.get();
// 必须 reset!复用时记得清空,不然上次的内容还在
sb.setLength(0);
for (String part : parts) {
sb.append(part);
}
return sb.toString();
}
}误用二:StringBuilder 初始容量设太小
StringBuilder 默认初始容量是 16 个字符。如果你要拼接一个几百字节的字符串,中途会发生多次扩容(每次扩容 = 申请新数组 + 复制旧数据 + GC 旧数组)。
如果知道大概长度,初始化时就指定:
// 预估最终字符串约 200 个字符
StringBuilder sb = new StringBuilder(256); // 稍微大一些,避免边界扩容踩坑实录二:字符串比较用了 ==
这是 Java 新手最常见的 bug,但有时候老手也会在某些情况下犯。
// 危险代码——从接口参数或数据库读来的字符串不能用 ==
String status = request.getParameter("status");
if (status == "active") { // 这里永远是 false!
// ...
}== 比较的是引用(内存地址),equals() 比较的是内容。从外部来的字符串几乎肯定不在常量池里,所以 == 永远返回 false。
但有一个我犯过的微妙 bug 版本:
// 这段代码在大部分情况下能跑通,但有时候会失效
public class ConfigManager {
private static String cachedEnv = "production";
public boolean isProduction() {
String currentEnv = System.getProperty("app.env", "production");
return cachedEnv == currentEnv; // 小心!System.getProperty 不一定返回常量池里的字符串
}
}我在一个配置管理类里写了类似的代码。大多数时候没问题,因为两个字符串恰好都来自常量池。但当环境变量是从外部配置中心动态加载的时候,== 就失效了,导致生产环境配置判断出错,触发了一个只在"非生产环境"才会执行的清理逻辑。查这个 bug 花了两个多小时。
规则:字符串内容比较永远用 equals(),没有例外。
// 正确
if ("active".equals(status)) { ... }
// 也对,但要小心 status 为 null 的情况
if (status.equals("active")) { ... }
// 推荐:常量在前,防止 NPE
if (Objects.equals(status, "active")) { ... }踩坑实录三:CharSequence 和 String 类型转换的隐形开销
这个踩坑比较少见,但我在做一个高吞吐量文本处理服务时遇到了。
代码大概是这样的:
// 处理每秒 10 万条消息的文本解析器
public class TextParser {
public Map<String, String> parse(CharSequence input) {
Map<String, String> result = new HashMap<>();
// 这里把 CharSequence 转成 String,然后再各种 split/substring
String str = input.toString(); // 如果 input 本来就是 String,这里会创建副本吗?
String[] parts = str.split("\\|");
// ...
return result;
}
}input.toString() 如果 input 已经是 String,这行其实什么都不做(String.toString() 返回 this)。但如果 input 是 StringBuilder 或 CharBuffer,就会创建一个新的 String 对象,带来内存分配。
在高吞吐量场景下,应该尽量在整个调用链中使用 CharSequence,延迟到最后不得不转时才转:
package com.example.text;
/**
* 高性能文本解析:尽量延迟 String 实例化
*/
public class HighPerfTextParser {
/**
* 直接操作 CharSequence,减少不必要的 String 创建
* 用 indexOf/subSequence 代替 split,split 每次都返回 String[]
*/
public static String extractField(CharSequence input, int fieldIndex, char delimiter) {
int start = 0;
int fieldCount = 0;
for (int i = 0; i <= input.length(); i++) {
boolean isDelimiter = (i == input.length()) || (input.charAt(i) == delimiter);
if (isDelimiter) {
if (fieldCount == fieldIndex) {
// 只在找到目标字段时才创建 String
return input.subSequence(start, i).toString();
}
start = i + 1;
fieldCount++;
}
}
return null;
}
}字符串的几个基本真相
整理一下,帮你建立正确的认知模型:
真相一:小字符串拼接(循环外)不要优化 编译器已经帮你做了,不要用 StringBuilder 重写一段本来清晰的代码,让代码更难读,还没有收益。
真相二:循环体内累加字符串,必须用 StringBuilder 这是唯一需要手动用 StringBuilder 的常见场景。
真相三:String.intern() 是双刃剑,大多数场景不需要用 现代 JVM 的 GC 对短命字符串处理得很好,不要为了"省内存"反而制造麻烦。
真相四:Java 11+ 的字符串 API 已经很完善strip()(比 trim() 更正确地处理 Unicode 空白)、isBlank()、repeat()、lines()、stripLeading()/stripTrailing(),多用新 API。
真相五:字符串性能问题通常不是单个操作的问题,是设计问题 如果一个接口里有大量字符串操作,先想想是不是可以从源头减少操作量,而不是从 StringBuilder 的初始容量上抠。
