String拼接在循环里为什么慢?——String底层原理
本文目录
先说结论
String在Java中是不可变的(immutable)。每次修改(拼接、替换等)都会创建一个新的String对象,旧对象成为垃圾等待GC。在循环中用'+'拼接String会产生大量临时对象——用StringBuilder替代可以避免这个问题。String的不可变性保证了线程安全和字符串常量池的复用,但也带来了频繁创建对象的开销。
先把这段讲顺;面试官追问时,再展开下面的原理和细节。
详细讲解
String看起来是最简单的Java类型,但面试官总能在这里挖出深度。大数据场景中String使用极其频繁——SQL文本、JSON解析、日志拼接、Key构造……在某知名出海硬件品牌做Spark ETL调优时,发现一个UDF里在循环中用'+'拼接String构造SQL,10万次循环产生了10万个临时String对象,GC压力巨大。改成StringBuilder后内存使用降了60%,UDF执行快了3倍。
String为什么设计成不可变的?
| 原因 | 说明 | 大数据场景 |
|---|---|---|
| 线程安全 | 多线程共享String不需要加锁 | Spark/Flink多线程环境中共享字符串安全 |
| HashCode缓存 | hash值只需要计算一次就可以缓存 | HashMap/HashSet中String作为Key极其高效 |
| 字符串常量池 | 相同内容的String可以共享同一个对象 | 大量重复字符串(event_name等)节省内存 |
| 安全性 | String常用于类名/文件路径/SQL,不可变防止篡改 | SQL注入防护、类加载安全 |
String拼接的性能陷阱
// 错误写法:循环中用'+'拼接(每次创建新String对象)
String sql = '';
for (int i = 0; i < 100000; i++) {
sql = sql + 'UNION ALL SELECT ' + i;
// 每次'+'都创建一个新String对象
// 旧String成为垃圾,等待GC
// 10万次循环 = 10万个临时对象!
}
// 时间复杂度:O(N^2)——每次拼接都要拷贝前面的全部内容
// 正确写法:用StringBuilder(可变字符串)
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100000; i++) {
sb.append('UNION ALL SELECT ').append(i);
// StringBuilder内部是可变的char数组
// append只是在数组后面追加,不创建新对象
}
String sql = sb.toString();
// 时间复杂度:O(N)——只在最后toString时创建一个String
// 性能差距:10万次拼接
// String '+': 约15秒(大量GC)
// StringBuilder: 约5毫秒(几乎没有GC)
// 差距3000倍!
String的底层存储——JDK 8 vs JDK 9+
// JDK 8: String内部用char[]存储(每个字符2字节UTF-16)
// 'hello'占10字节的char数组
// JDK 9+: String内部用byte[]存储(Compact Strings)
// 如果全是Latin-1字符(ASCII),用1字节/字符
// 如果包含非Latin-1字符(中文等),用2字节/字符
// 'hello'只占5字节的byte数组(省50%内存!)
// 大数据的影响:
// 大量英文字段名(user_id/event_name/country等)
// JDK 9+的Compact Strings可以节省大量内存
// 如果你的Flink/Spark还在JDK 8上,升级到JDK 11/17
// 光String内存就能省20-30%
字符串常量池(String Pool)
// 字符串常量池:相同内容的String共享同一个对象
String s1 = 'hello'; // 放入常量池
String s2 = 'hello'; // 从常量池获取,和s1是同一个对象
System.out.println(s1 == s2); // true(同一个对象)
String s3 = new String('hello'); // 在堆上创建新对象
System.out.println(s1 == s3); // false(不同对象)
System.out.println(s1.equals(s3)); // true(内容相同)
// intern()方法:手动放入常量池
String s4 = s3.intern();
System.out.println(s1 == s4); // true(从常量池获取)
// 大数据场景的应用:
// 事件名('page_view','click','purchase'等)大量重复
// 用intern()可以让所有相同的事件名共享一个String对象
// 减少内存占用
// 但注意:intern()有性能开销,不要在高频路径上调用
实战经验:String拼接导致UDF变慢
在某知名出海硬件品牌,有一个Spark UDF根据用户的标签列表生成SQL的IN条件。原来的写法:
// 原始UDF代码(String '+'拼接)
public String buildInClause(List<String> tags) {
String result = 'IN (';
for (int i = 0; i < tags.size(); i++) {
result = result + "'" + tags.get(i) + "'";
if (i < tags.size() - 1) result = result + ',';
}
return result + ')';
}
// 每个用户平均100个标签
// 500万用户 = 5亿次String拼接 = 5亿个临时对象
// Executor频繁Minor GC,UDF执行非常慢
// 优化后(StringBuilder)
public String buildInClause(List<String> tags) {
StringBuilder sb = new StringBuilder('IN (');
for (int i = 0; i < tags.size(); i++) {
if (i > 0) sb.append(',');
sb.append("'").append(tags.get(i)).append("'");
}
return sb.append(')').toString();
}
// 每个用户只创建1个StringBuilder对象
// GC压力减少99%,UDF执行快3倍
面试高频追问
追问一:String s = 'a' + 'b' + 'c' 会创建几个对象?
编译器会优化为String s = 'abc',只有1个对象(直接在常量池中)。但如果是String s = a + b + c(变量拼接),编译器会用StringBuilder优化:new StringBuilder().append(a).append(b).append(c).toString(),创建1个StringBuilder + 1个最终String = 2个对象。在循环中每次迭代都会new一个StringBuilder,所以循环拼接还是要手动用一个StringBuilder。
追问二:StringBuilder和StringBuffer有什么区别?
StringBuffer是线程安全的(方法加了synchronized),StringBuilder不是。单线程场景用StringBuilder更快(不需要锁的开销)。大数据场景中UDF通常是单线程执行的,用StringBuilder就够了。
追问三:为什么String的hashCode()方法用31作为乘数?
31是一个奇素数:① 素数减少hash冲突;② 31*i可以被JVM优化为(i << 5) - i(位运算比乘法快);③ 31不大不小——太小hash分布不均,太大容易溢出。这个选择是性能和分布均匀性的平衡。HashMap能高效运作离不开String的好hash。
总结
String面试讲清楚三层:① 不可变性的原因和好处(线程安全/hashCode缓存/常量池复用/安全);② 拼接性能陷阱(循环中用'+'是O(N^2),用StringBuilder是O(N),差距可达3000倍);③ 底层存储(JDK 9+ Compact Strings省50%内存,字符串常量池共享相同内容)。能讲出UDF中String拼接导致GC压力大的实际案例和StringBuilder优化效果,面试官会觉得你注意过代码级别的性能细节。
下一篇聊Java集合框架:ArrayList和LinkedList到底选哪个?大数据场景的集合选型。
你们代码里有没有String拼接的性能坑?评论区聊聊~
关注「Wood聊大数据」| 9年大数据老兵,聊技术、聊职场、聊成长