String拼接的5种方式:你用的+号操作符其实做了什么
String拼接的5种方式:你用的+号操作符其实做了什么
适读人群:Java初中级开发者、对JVM字节码有兴趣的后端工程师 | 阅读时长:约14分钟 | 文章类型:原理剖析+性能对比
开篇故事
大概三年前,我在做一个日志格式化工具。需求很简单:把若干字段拼成一行日志输出。我写的第一版是这样的:
String log = "[" + timestamp + "] " + level + " " + module + " - " + message;当时测试环境跑得飞快,没什么问题。但上了高并发的生产,日志量一起来,GC日志里开始出现大量短命的String对象,Full GC频率明显上升。
后来换了StringBuilder,GC压力直接降了一大半。
这事之后我就开始认真研究Java字符串拼接。结果越研究越有意思——Java里字符串拼接的方式不止一种,每种在不同场景下性能差异可以达到数十倍,而且JDK版本不同,编译器优化的行为也不一样。
很多人对+号拼接有误解,觉得"反正编译器会优化成StringBuilder,没区别"。这句话对了一半,错了一半。今天把这事说清楚。
一、5种拼接方式总览
先把5种方式列出来:
| 方式 | 典型场景 | 线程安全 |
|---|---|---|
+ 操作符 | 简单拼接、字面量拼接 | 无状态,N/A |
StringBuilder | 循环内拼接、单线程 | 否 |
StringBuffer | 多线程共享拼接 | 是 |
String.format / formatted | 模板格式化 | 是(无状态) |
String.join / StringJoiner | 集合元素拼接 | 否 |
接下来逐一分析。
二、核心原理深挖
+号操作符:编译器的手
String log = a + b + c;
这行代码,编译器(javac)在编译成字节码时,会把它改写成:
String log = new StringBuilder().append(a).append(b).append(c).toString();用javap -c反编译一下,可以清楚地看到这个过程:
// 源码:String result = a + b + c;
// 字节码(简化):
new #2 <java/lang/StringBuilder>
dup
invokespecial #3 <java/lang/StringBuilder.<init>>
aload_1 // a
invokevirtual #4 <java/lang/StringBuilder.append>
aload_2 // b
invokevirtual #4 <java/lang/StringBuilder.append>
aload_3 // c
invokevirtual #4 <java/lang/StringBuilder.append>
invokevirtual #5 <java/lang/StringBuilder.toString>看起来编译器已经帮你优化了。那为什么循环里用+还是有问题?
关键在循环:
// 错误示范
String result = "";
for (int i = 0; i < list.size(); i++) {
result = result + list.get(i); // 每次循环都创建新的StringBuilder!
}编译后等价于:
String result = "";
for (int i = 0; i < list.size(); i++) {
result = new StringBuilder().append(result).append(list.get(i)).toString();
}每次循环都new StringBuilder(),每次都.toString()创建新String,每次都把上一轮的结果复制进来。如果列表有1000个元素,就创建了1000个StringBuilder和1000个String对象,总的字符复制量是O(n²)。
字面量拼接是特殊情况:
String s = "Hello" + " " + "World";这种纯字面量的拼接,编译器在编译期就会直接合并成"Hello World",根本不会生成StringBuilder。
JDK 9+的invokedynamic优化
JDK 9开始,+号拼接的实现方式换了——不再是编译器直接生成StringBuilder字节码,而是生成一条invokedynamic指令,把拼接逻辑推迟到运行时,由StringConcatFactory来决定具体实现。
// JDK 9+ 字节码
invokedynamic #2:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;StringConcatFactory默认使用MH_INLINE_SIZED_EXACT策略,直接分配恰好大小的byte数组来构建字符串,比StringBuilder少了扩容和toString的开销。
但循环里每次调用,仍然是每次创建新对象的问题,这一点没有改变。
Mermaid图:+号拼接的执行路径
StringBuilder:手动掌控
StringBuilder sb = new StringBuilder(expectedCapacity);
for (String item : list) {
sb.append(item);
}
String result = sb.toString();关键点:
- 提前指定容量:
new StringBuilder(expectedCapacity),避免内部char数组扩容(默认初始容量16,超了就×2+2) - 只创建一次StringBuilder,只调用一次
toString() append方法返回this,支持链式调用
StringBuilder的内部是一个可扩容的byte[](JDK 9+,之前是char[]),append就是往数组里写,到结尾toString就是把数组包装成String。
StringBuffer:别用了
StringBuffer和StringBuilder几乎一模一样,唯一区别是所有方法都加了synchronized。
// StringBuffer.append 源码
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}在单线程场景下,这个synchronized是纯开销。在多线程场景下,你通常也不会用一个共享的StringBuffer来拼字符串——那设计本身就有问题。
我的建议:StringBuffer基本上不需要用了。 如果真的有多线程安全需求,用ThreadLocal<StringBuilder>或者每个线程自己建StringBuilder更合适。
String.format:好看但慢
String log = String.format("[%s] %s %s - %s", timestamp, level, module, message);String.format底层用了java.util.Formatter,会解析格式字符串、创建Formatter对象、通过反射处理参数类型。开销相当大。
JDK 15+的formatted()方法是String.format的实例方法版本,功能一样,性能差不多:
String log = "[%s] %s %s - %s".formatted(timestamp, level, module, message);格式化字符串适合用在错误消息、日志模板等低频调用的地方,不适合在热点路径里用。
String.join / StringJoiner:集合专用
// String.join
String result = String.join(", ", "a", "b", "c"); // "a, b, c"
String result2 = String.join("-", list); // 连接List<String>
// StringJoiner:可以指定前缀和后缀
StringJoiner sj = new StringJoiner(", ", "[", "]");
for (String item : list) {
sj.add(item);
}
String result3 = sj.toString(); // "[a, b, c]"String.join底层就是StringJoiner,StringJoiner底层是StringBuilder。适合用在需要分隔符的集合拼接场景,比手写循环+判断最后一个元素更干净。
三、完整代码实现
代码一:5种方式性能对比测试
package com.laozhang.string.concat;
import java.util.ArrayList;
import java.util.List;
import java.util.StringJoiner;
/**
* String拼接5种方式性能对比
* 每种方式拼接10000个元素,重复5次取平均
*
* 典型结果(JDK 17,MacBook M1):
* + 号循环: ~85ms
* StringBuilder: ~2ms
* StringBuffer: ~4ms
* String.format: ~120ms(格式化开销)
* String.join: ~3ms
*/
public class ConcatPerformanceTest {
private static final int ELEMENT_COUNT = 10_000;
private static final int ROUNDS = 5;
public static void main(String[] args) {
List<String> data = prepareData(ELEMENT_COUNT);
System.out.println("=== String拼接5种方式性能对比 ===");
System.out.printf("元素数量: %d,重复轮次: %d%n%n", ELEMENT_COUNT, ROUNDS);
benchmarkPlusOperator(data);
benchmarkStringBuilder(data);
benchmarkStringBuffer(data);
benchmarkStringFormat(data);
benchmarkStringJoin(data);
}
private static void benchmarkPlusOperator(List<String> data) {
long total = 0;
for (int r = 0; r < ROUNDS; r++) {
long start = System.currentTimeMillis();
String result = "";
for (String s : data) {
result = result + s; // 每次循环新建StringBuilder
}
total += System.currentTimeMillis() - start;
}
System.out.printf("+号循环拼接: 平均 %dms%n", total / ROUNDS);
}
private static void benchmarkStringBuilder(List<String> data) {
long total = 0;
for (int r = 0; r < ROUNDS; r++) {
long start = System.currentTimeMillis();
// 预估容量:每个元素约8字节
StringBuilder sb = new StringBuilder(data.size() * 8);
for (String s : data) {
sb.append(s);
}
String result = sb.toString();
total += System.currentTimeMillis() - start;
}
System.out.printf("StringBuilder: 平均 %dms%n", total / ROUNDS);
}
private static void benchmarkStringBuffer(List<String> data) {
long total = 0;
for (int r = 0; r < ROUNDS; r++) {
long start = System.currentTimeMillis();
StringBuffer sb = new StringBuffer(data.size() * 8);
for (String s : data) {
sb.append(s);
}
String result = sb.toString();
total += System.currentTimeMillis() - start;
}
System.out.printf("StringBuffer: 平均 %dms%n", total / ROUNDS);
}
private static void benchmarkStringFormat(List<String> data) {
long total = 0;
for (int r = 0; r < ROUNDS; r++) {
long start = System.currentTimeMillis();
StringBuilder sb = new StringBuilder();
for (String s : data) {
// 模拟实际格式化场景
sb.append(String.format("[%s]", s));
}
String result = sb.toString();
total += System.currentTimeMillis() - start;
}
System.out.printf("String.format循环: 平均 %dms%n", total / ROUNDS);
}
private static void benchmarkStringJoin(List<String> data) {
long total = 0;
for (int r = 0; r < ROUNDS; r++) {
long start = System.currentTimeMillis();
String result = String.join(",", data);
total += System.currentTimeMillis() - start;
}
System.out.printf("String.join: 平均 %dms%n", total / ROUNDS);
}
private static List<String> prepareData(int count) {
List<String> list = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
list.add("item_" + i);
}
return list;
}
}代码二:实际项目中的正确用法
package com.laozhang.string.concat;
import java.util.List;
import java.util.StringJoiner;
/**
* 各种场景下字符串拼接的正确实践
*/
public class ConcatBestPractice {
/**
* 场景1:简单的几个变量拼接(非循环)
* 直接用+号,可读性好,编译器会优化
*/
public String buildSimpleLog(String level, String message) {
return "[" + level + "] " + message;
}
/**
* 场景2:循环内拼接
* 必须用StringBuilder,并预估容量
*/
public String buildFromList(List<String> items) {
if (items == null || items.isEmpty()) {
return "";
}
// 预估:每个item平均10字符 + 逗号
StringBuilder sb = new StringBuilder(items.size() * 11);
for (String item : items) {
if (sb.length() > 0) {
sb.append(',');
}
sb.append(item);
}
return sb.toString();
}
/**
* 场景3:带分隔符的集合拼接
* 用String.join更简洁
*/
public String joinWithSeparator(List<String> items, String separator) {
return String.join(separator, items);
}
/**
* 场景4:带前缀后缀的集合拼接
* 用StringJoiner
*/
public String joinWithBrackets(List<String> items) {
StringJoiner sj = new StringJoiner(", ", "[", "]");
for (String item : items) {
sj.add(item);
}
return sj.toString();
// Java 8+: items.stream().collect(Collectors.joining(", ", "[", "]"))
}
/**
* 场景5:模板化的错误消息(低频,格式化需求强)
* 用String.format或formatted
*/
public String buildErrorMessage(String field, Object value, String constraint) {
return "字段[%s]的值[%s]不满足约束[%s]".formatted(field, value, constraint);
}
/**
* 场景6:高性能日志构建(热点路径)
* 用StringBuilder,避免String.format
*/
public String buildHighPerfLog(long traceId, String level,
String module, String message) {
return new StringBuilder(128)
.append('[').append(traceId).append(']')
.append(' ').append(level)
.append(' ').append(module)
.append(" - ").append(message)
.toString();
}
}四、踩坑实录
坑1:循环内+号拼接导致GC飙升
报错现象:
生产上没有Exception,但GC监控里看到Young GC频率异常高,每秒几十次。heap dump里有大量[C(char数组)和String对象,生命周期极短。
根本原因:
// 某段导出CSV的代码
String csv = "";
for (DataRow row : queryResult) { // 10万行数据
csv += row.toCSVLine() + "\n"; // 每行创建2个对象:StringBuilder + String
}10万行,每行平均100字节,拼到第n行时要把前n-1行全部复制一遍,总复制量约50亿字节。
具体解法:
StringBuilder csv = new StringBuilder(queryResult.size() * 120); // 预估容量
for (DataRow row : queryResult) {
csv.append(row.toCSVLine()).append('\n'); // 注意用 '\n' 而不是 "\n"
}
return csv.toString();注意'\n'(char)比"\n"(String)稍微快一点,因为少了一次字符串对象的处理。
坑2:StringBuilder的初始容量没设,频繁扩容
报错现象:
切换到StringBuilder之后,性能还是没预期中好。分析后发现大量时间花在数组扩容上。
根本原因:
StringBuilder sb = new StringBuilder(); // 默认容量16
// 然后往里append了几千字节的内容
// 16 -> 34 -> 70 -> 142 -> ... 每次扩容都要复制具体解法:
// 如果知道大致长度,提前指定
StringBuilder sb = new StringBuilder(estimatedLength);
// 如果不确定,可以保守估算
int estimated = list.size() * avgItemLength;
StringBuilder sb = new StringBuilder(estimated);StringBuilder内部扩容规则:newCapacity = (oldCapacity << 1) + 2。每次扩容都要Arrays.copyOf,如果能一次性分配足够空间,整体性能会好很多。
坑3:用String.format拼接SQL条件,格式化符号冲突
报错现象:
java.util.MissingFormatArgumentException: Format specifier '%t'
at java.base/java.util.Formatter.format(Formatter.java:2630)触发代码:
String sql = String.format("SELECT * FROM order WHERE create_time > '%s' AND status = %d",
startTime, status);用户传入的startTime字符串里含有%t这样的字符,被Formatter当成格式化占位符处理了。
根本原因:
String.format会解析所有%开头的序列。如果用户输入里有%,就会引发格式解析错误,甚至造成格式注入。
具体解法:
// 方案1:用StringBuilder拼接,不用format
StringBuilder sb = new StringBuilder("SELECT * FROM order WHERE create_time > '")
.append(startTime).append("' AND status = ").append(status);
// 方案2:如果一定要用format,先转义%
String safeName = startTime.replace("%", "%%");
// 方案3(最佳):用PreparedStatement,不拼SQL
String sql = "SELECT * FROM order WHERE create_time > ? AND status = ?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setString(1, startTime);
ps.setInt(2, status);顺便说一句,拼接SQL字符串本来就是高危操作,SQL注入风险也在这里。如果你用ORM框架,这些都不是问题。
五、总结与延伸
字符串拼接的选择,说白了就一个原则:在什么场景下,用什么开销合适的工具。
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 少量变量拼接(1-5个) | +号 | 可读性好,编译器会优化 |
| 循环内拼接 | StringBuilder(预设容量) | 避免O(n²)复制 |
| 带分隔符的集合 | String.join / StringJoiner | 语义清晰 |
| 模板格式化(低频) | String.format / formatted | 可读性好,接受性能代价 |
| 高性能路径 | StringBuilder手动控制 | 最快,完全可控 |
| 多线程共享(几乎不存在) | StringBuffer 或 设计改造 | 同步开销明显 |
关于JDK版本,JDK 9+的invokedynamic优化让+号在非循环场景下性能更好了,但循环内的问题依然存在,不要依赖这个优化。
最后说一句:日志框架(SLF4J/Log4j2)里的参数占位符设计——log.info("user {} logged in", userId)——就是为了避免在不打印的情况下进行无意义的字符串拼接。如果你还在写log.debug("value: " + value),可以改一下了,特别是debug级别在生产上通常是关闭的,这段拼接完全白做了。
