写字符串相关的技术博客,说实话是最容易写“烂大街”的题目。但也是最能见基本功的题目.我见过太多开发者在面试前把String、StringBuilder、StringBuffer的区别背得滚瓜烂熟,结果一落到项目里,照样在循环里用String拼JSON,或者在高并发环境里抱着StringBuffer不放。这篇文章我不打算照搬教科书,而是从底层存储、常用API的隐藏陷阱、性能对比到实战选型,一层层把这三个类彻底说透。不管你是刚入门Java的小白,还是写了两三年代码想查漏补缺的同学,这篇文章都值得你花十几分钟慢慢看。
1. 先搞明白:为什么Java会有三种字符串类
很多人对这三个类的第一印象是“一个线程安全、一个线程不安全、一个不可变”,但真要问一句“为什么需要这样设计”,很多人就卡住了。我习惯把这三个类看成是三个不同心态的工具人:
- String:佛系员工,从不改文件,谁要谁拿去读。
- StringBuilder:急性子,干活快,但一个人干的时候很稳,要是多个人同时催他就乱套。
- StringBuffer:老干部,每做一个动作都要先锁门再动手,安全但慢。
这个类比虽然粗糙,但对理解设计初衷很有帮助。往下我要从底层结构入手,把这三个类拆开看看它们的存储和实现思路,这样你才知道什么时候该用谁,而不是死记口诀。
1.1 String的“不可变”到底意味着什么
先看String的定义。在JDK 8及以前,它的内部是一个final char[] value;从JDK 9开始,改成了byte[] value加上一个byte coder。无论怎么变,核心思想一致:String对象一旦创建,内部数组的引用和内容都无法再修改。
// JDK 8及以前 private final char value[]; // JDK 9及以上 private final byte[] value; private final byte coder;这个不可变性带来了四个直接好处:
- 字符串常量池可以安全复用。反正内容不会变,多个引用指向同一个对象不会出问题,所以JVM才会搞出
String Pool机制,避免大量重复对象浪费内存。 - hashCode可以被缓存。String的
hashCode用int hash字段,第一次计算后存下来,此后每次调用都直接返回,所以String很适合做HashMap的键。 - 线程安全。一个对象的内容只读不写,天然可以在多线程环境共享,不需要任何同步措施。
- 安全性。文件路径、网络地址、数据库连接URL等场景下,不可变意味着外部不能通过修改字符串内容来篡改敏感参数。
有人可能会问:String不可变,那String s = "abc"; s = s + "d";不是把s给改了吗?这里要分清楚,改的是引用,不是对象内容。原来的"abc"对象还在常量池里躺着,s先指向一个新拼接出来的"abcd"对象。这个区别如果不理解,后面聊拼接性能的时候会一头雾水。
1.2 StringBuilder和StringBuffer的存储结构
StringBuilder和StringBuffer其实是一对兄弟,不但包名一样,而且都继承了AbstractStringBuilder。核心存储是一个没有final修饰的byte[] value,所以它俩支持append、insert、delete这些修改操作。
abstract class AbstractStringBuilder { byte[] value; // JDK9+ 为byte数组,JDK8为char[] int count; // 当前有效字符数量 }StringBuffer和StringBuilder的关键差异,也就是一个synchronized关键字。StringBuffer的每个公共方法都加了synchronized修饰,而StringBuilder没有。这意味着StringBuffer在多线程下是安全的,但代价是每次方法调用都要经过“加锁-执行-释放锁”的过程,单线程下的吞吐量会明显低于StringBuilder。
有人可能会想:那StringBuffer内部有没有什么优化?其实它在JDK早期版本里还有一个大家不太注意的设计,toString时会缓存一个共享的char[],后续的toString会尝试复用它,但后来因为容易产生共享数组被修改的问题,现代JDK已经做了调整。这块细节暂时不展开,后面讲toString的坑时会补充。
1.3 三个类在继承体系中的真实差异
画个继承链大家就清楚了:
String:直接继承Object,并实现了Serializable、Comparable<String>、CharSequence接口。StringBuilder:继承AbstractStringBuilder,实现Serializable、Comparable<CharSequence>(JDK 11+)、CharSequence。StringBuffer:继承AbstractStringBuilder,实现Serializable、Comparable<CharSequence>(JDK 11+)、CharSequence。
注意一个细节:String和另外两兄弟没有直接继承关系。所以你不能把StringBuilder直接赋值给String变量,只能通过toString()转换。反之,String也不能随意转成StringBuilder,得先new StringBuilder(str)。
还有一个容易忽略的点:StringBuilder和StringBuffer都实现了CharSequence接口,而String也实现了。这意味着你在写方法参数时,如果只需要读取字符串,直接声明成CharSequence类型,调用方传三种类型都能收。这个技巧在写通用工具类时非常实用。
2. String类常用API:用得多不代表用得对
String的API数量非常多,大部分开发者每天都在用,但很多方法都存在“用了但没完全用对”的情况。这节我挑项目里出现频率高、且容易踩坑的API来逐个拆解。
2.1 判空和比较:这两个API最容易翻车
日常代码里最经典的两个判断:
// 方式一 if ("".equals(str)) // 方式二 if (str == null || str.isEmpty())我建议直接用"".equals(str),因为equals方法在参数为null时不会抛异常,只是返回false。而str.isEmpty()在str本身为null时会直接NPE,必须先判空。这个顺序问题看似简单,但我在code review时见过不少次。
JDK 11以后多了个isBlank()方法,比isEmpty()更进一步。isEmpty()只判断length() == 0,而isBlank()会跳过空白字符,比如空格、\t、\n都算“空白”。所以如果你担心用户输入全是空格,用isBlank()更合适。
String s1 = " "; System.out.println(s1.isEmpty()); // false System.out.println(s1.isBlank()); // true还有一个比较常用但容易被忽略的是equals和contentEquals的区别。equals只接受String类型的比较,而contentEquals可以接受StringBuilder、StringBuffer等CharSequence参数,用来比较内容是否一致,但contentEquals不会忽略大小写,也没有提供不区分大小写的重载版本。所以比较两个可变字符串的内容时,记得用contentEquals。
2.2 截取与替换:substring和replace背后的性能真相
先看substring。JDK 6及以前,substring内部会直接复用原字符串的char[],只改偏移量,结果就是:你截取一个巨大的字符串的一小部分,却把整个大数组一直引用着,导致内存无法回收。JDK 7开始,substring会新创建一个字符数组,彻底解决这个内存问题,但代价是每次截取都有一份拷贝开销。
String big = loadFromFile(); // 假设有几十MB String small = big.substring(0, 10); // 老JDK下,small会一直持有big的底层数组所以如果你还在维护老项目,或者面试被问到“JVM优化细节”,这个点值得记一下。
再看replace家族。很多人分不清replace、replaceAll和replaceFirst,关键差异是:replace(CharSequence target, CharSequence replacement)里的target是普通字符串字面量,不做正则解析;而replaceAll和replaceFirst的第一个参数是正则表达式。如果你只是想把字符串里的点号替换掉:
String ip = "192.168.1.1"; ip.replace(".", "-"); // 正确:得到 192-168-1-1 ip.replaceAll(".", "-"); // 错误:正则中.匹配任意字符,结果变成 -----....这个坑我在新人身上见了不止一次。一个简单的原则:如果替换目标不含正则语义,优先用replace,性能更好,也避免转义问题。
2.3 拆分与拼接:split的limit参数你用了没
split(String regex)可能是最常用的拆分方法,但很多人不知道它还有第二个参数limit。limit的调整会直接影响结果数组的长度:
limit< 0:保留尾部所有空字符串。limit= 0:默认行为,丢弃尾部空字符串。limit> 0:最多拆成limit-1次,剩余部分作为最后一个元素。
String s = "a,b,c,,,"; s.split(",", -1).length; // 6,保留所有空串 s.split(",", 0).length; // 3,丢弃尾部空串如果你的业务需要精确统计被拆分的字段数量,比如解析CSV行,那么建议用负数limit,否则尾部空字段会被吃掉,导致数据错位。
再说拼接。JDK 8里如果做String.join,或者JDK 11里的" ".repeat(n),都是相对高效的方式。还有一个容易被忽略的API是String.format,它虽然方便,但性能比直接拼接差不少,大概差一个数量级都不止。如果你有非常高频的日志格式化场景,建议用占位符加预编译的模板,或者干脆用MessageFormat评估后再选,别在热点路径上直接用String.format。
2.4 intern()不只是面试考点
intern()方法在面试题里出现频率极高,核心规则是:如果字符串常量池里已经有相同内容的字符串,就返回池中的引用;否则把当前字符串加入常量池并返回引用。
这里需要区分JDK版本。JDK 6及以前,常量池在方法区里的PermGen,intern()大量使用容易造成OOM。JDK 7开始,字符串常量池被移到了堆中。JDK 8以后,PermGen变成Metaspace,字符串常量池依然在堆中。这个演化直接决定了intern在实际生产中的可用性。
String a = new String("abc"); String b = a.intern(); System.out.println(a == b); // false这种基础问题我就不多说了,真正要留意的是:不要在高频业务中大量调用intern()。虽然能节省内存,但intern本身需要对常量池做查找和插入,存在性能开销,而且大量唯一字符串会让常量池容量膨胀,甚至触发GC压力。我见过有人为了“优化内存”把所有动态生成的字符串都intern一遍,结果接口P99从50ms涨到800ms,这就是典型的得不偿失。
3. StringBuilder和StringBuffer的API与扩容机制
说完了String,再来看可变字符串这边。StringBuilder和StringBuffer的API几乎完全一致,区别只在线程安全性上,所以下面的API讲解两者通用。
3.1 append、insert、replace、reverse,一套组合拳
最常用的自然是append,它支持String、int、long、boolean、char、Object等几乎所有类型。注意一个细节:append(null)的行为在不同重载下不同。
StringBuilder sb = new StringBuilder(); sb.append((String) null); // 追加 "null" 四个字符 sb.append((char[]) null); // 抛出 NullPointerException为什么会这样?因为append(char[] str)里会检查数组是否为空,而append(String s)的源码里走了appendNull()逻辑,直接拼了“null”字符。如果你在拼接时遇到诡异的NPE,可以回想一下这个重载选择的坑。
insert(int offset, String str)也很常用,但很多人会忽略偏移量验证。offset必须在[0, count]范围内,否则抛StringIndexOutOfBoundsException。另外replace(int start, int end, String str)里的end是开区间,不包含end位置字符。这个区间规则和Java的很多API一致,但写起来容易多一位少一位。
还有个非常实用的reverse()方法,能把内容原地翻转。判断回文字符串时,一句sb.reverse().toString()就搞定了。这个API在算法竞赛里很出圈,但在业务代码里也时有使用。
3.2 容量管理:别忽视那两个容易被忽略的方法
每个可变字符串都有一个“容量”概念,容量当前不一定是刚好等于字符串长度,而是底层数组最多能装的字符数。看下面这组代码:
StringBuilder sb = new StringBuilder(); System.out.println(sb.capacity()); // 默认容量16 System.out.println(sb.length()); // 0 sb.append("1234567890123456"); System.out.println(sb.capacity()); // 34如果你想知道为什么是34,要看AbstractStringBuilder的扩容算法。当append之后所需容量超过当前容量时,新容量大约是“旧容量 * 2 + 2”,如果还不够,就直接扩容到所需容量。
int newCapacity = (oldCapacity << 1) + 2;所以当你明确知道要拼接大量内容时,new StringBuilder(expectedSize)直接指定初始容量能省去多次扩容带来的数组拷贝开销。假设你要拼一个大概2KB的XML报文,就别用默认16容量,直接用new StringBuilder(2048),不然中途会扩好几次容。
ensureCapacity(int minimumCapacity)可以手动扩容,而trimToSize()则会把容量压缩到与length相等,用来释放多余内存。注意trimToSize()之后底层数组的大小就刚好等于当前内容长度,之后再append又会触发扩容,所以它不是万能的,适合在构建完大字符串、准备长期持有的场景下调用。
3.3 StringBuilder的toString与StringBuffer的toString差异
toString()的差异,很多人可能没注意。StringBuilder的toString()每次都重新创建String对象。而老版本StringBuffer曾经做过优化:如果缓存数组还在,就会直接基于共享数组构造String,减少一次拷贝。但共享数组存在被后续修改污染的风险,所以现代JDK里StringBuffer的toString也改成了每次都创建新String。
虽然现在是每次都拷贝,但这个细节依然值得了解,因为面试官问“StringBuffer有没有比StringBuilder更优的toString实现”本质是在考你对历史版本的熟悉程度。
3.4 线程安全到底带来了多少性能损失
都说StringBuffer慢,到底慢多少?我曾在单线程环境做过一个很简单的基准测试:同样执行100万次append操作,StringBuffer比StringBuilder慢大约30%~50%,具体数值受JVM和操作系统影响。这个差距在低并发场景下非常明显。
而到了高并发场景,如果不加外部同步,直接让多个线程共用同一个StringBuilder,轻则内容错乱,重则抛出ArrayIndexOutOfBoundsException。原因在于:底层数组扩容和count更新不是原子操作。所以多线程场景要么用StringBuffer,要么自己加锁,要么直接用ThreadLocal隔离,后者实际上是更推荐的方案。
4. 性能对比与选型:字符串拼接到底该怎么选
这部分我想聚焦一个非常实际的问题:平时写代码,字符串拼接到底用哪种方式?我见过太多的性能“优化”其实是劣化,也见过不少项目在无关紧要的路径上过度设计。
4.1 编译器和JVM对拼接做了哪些优化
先看一个最简单的场景:
String s1 = "a" + "b" + "c";这是编译期常量折叠,编译器直接算出"abc",运行时没有任何拼接操作,快得不能再快。这种就不要画蛇添足去改用StringBuilder了。
再看变量拼接:
String a = "a"; String b = "b"; String c = a + b;JDK 8及以前,编译器会把它转成类似下面的字节码逻辑:
String c = new StringBuilder().append(a).append(b).toString();也就是说,编译器已经在帮你用StringBuilder了。但在循环体内拼接时,问题就来了:
String result = ""; for (int i = 0; i < 10000; i++) { result += i; // 每次循环都会new一个StringBuilder }这段代码每次迭代都产生一个StringBuilder对象,并且把之前的结果拷贝进去。1万次循环就有1万个临时对象,GC压力直接拉满。正确写法是:
StringBuilder result = new StringBuilder(10000 * 4); for (int i = 0; i < 10000; i++) { result.append(i); }JDK 9以后,字符串拼接改为使用invokedynamic调用StringConcatFactory,底层的拼接策略会由JVM实时选择,通常也能避免重复创建StringBuilder,但本质依然是“生成一个新的字符串对象”。如果循环体里你手动创建了StringBuilder,JVM不会自动帮你消除这个创建,所以“循环体内手动建StringBuilder”和“用+拼接变量”对比,前者虽然可控,但也不是完全没有对象开销,只是比在循环外用+拼接要靠谱得多。
4.2 不同业务场景的选型建议
我可以把这几年总结的选型经验浓缩成一张表:
| 使用场景 | 推荐方案 | 理由 |
|---|---|---|
| 固定字符串拼接,如配置项拼接 | String + 或 String.format | 代码可读性高,性能影响可忽略 |
| 循环内动态拼接 | StringBuilder(预估容量) | 避免大量临时对象和重复扩容 |
| 方法内少量拼接,逻辑简单 | StringBuilder或+均可 | 编译器优化后差距很小 |
| 多线程共享同一个可变对象 | StringBuffer或加锁自定义同步 | 避免数据错乱和并发异常 |
| 线程内独立使用,不共享 | StringBuilder | 无锁,性能更优 |
| 高并发场景下每个线程拼接自己的日志 | ThreadLocal<StringBuilder> | 兼顾并发安全与低开销 |
| 需要频繁toString并继续拼接 | 优先考虑链式append | 减少中间String对象 |
注意,别把一个常量固定不变的SQL、URL硬生生改成StringBuilder拼接,那属于负优化。代码最重要的是先让人看懂,再谈微优化。
4.3 一个容易忽略的“缓冲区复用”技巧
在高性能场景中,比如网关的日志组装、消息体构建,一个线程经常需要处理成千上万条消息,每条消息都new一个StringBuilder是有成本的。这时候可以用ThreadLocal复用StringBuilder:
private static final ThreadLocal<StringBuilder> SB_HOLDER = ThreadLocal.withInitial(() -> new StringBuilder(1024));每次使用前setLength(0)清空,然后append,最后toString。但要特别注意:toString之后如果还要继续append,得到的String对象和StringBuilder互不影响,所以是安全的。这个模式我实际测过,在每秒处理上万条日志的场景下,对象创建和GC次数都能明显下降。
不过,这种复用不能放在多线程共享的静态字段上,否则两个线程同时append就乱了。ThreadLocal是唯一推荐的做法。
4.4 关于StringBuffer的一个争议点
有观点认为Java完全可以去掉StringBuffer,只保留StringBuilder和String,让开发者自己在需要并发时加锁。但从历史角度看,StringBuffer是Java 1.0就有的类,StringBuilder直到Java 5才加入,目的是为单线程场景提供更快的替代方案。如果没有StringBuffer,老代码迁移成本会很高,而且StringBuffer这种“内置锁”对比外部手动锁,写起来更不容易出错。虽然现代并发编程提倡java.util.concurrent里的显式锁,但StringBuffer在简单并发场景下仍然可以胜任,只是不要把它用在超高吞吐路径上就行。
5. 实战中的经典问题与避坑经验
最后一节我要分享一些我实际踩过的坑,以及一些看似合理但实际有问题的写法。这些问题在面试中也很容易被追问。
5.1 循环里到底能不能放心用String拼接
直接给你一个结论:如果是几十次迭代,用+=拼接不会出大问题,GC都能扛住。但如果是十万、百万次迭代,必须改用StringBuilder。我处理过的一个线上小事故:某个定时任务要把上万条记录拼成一个超长报文,写代码的同学图省事用了+=,结果任务跑了十分钟还没结束,GC占用了大量CPU。改成new StringBuilder(预估容量)之后,整个任务耗时直接降到几秒。
这里有个预估值怎么算的问题。不要拍脑袋给一个很大的数字,可以先根据业务记录数和平均长度估算,比如一个字段平均50字符,1万条就是50万,那可以初始化为500_000。如果实在无法预估,可以用两倍平均长度去试探,扩两次容也完全可以接受。
5.2 StringBuffer高并发下就一定安全吗
StringBuffer的方法级同步确实让每个方法自身是原子的,但如果你组合调用多个方法,就不一定安全了。一个很典型的例子:
StringBuffer sb = new StringBuffer(); // 线程A和线程B同时执行 if (sb.length() == 0) { sb.append("data"); }即使length()和append()都是同步方法,但两个方法之间隔着判断,仍可能出现两个线程同时看到length为0,然后都执行append,最终结果是“data data”之类的重复内容。所以StringBuffer的线程安全是“单一方法原子性”,不是“复合操作原子性”。真正要求严格的业务,还是要加更大的锁或改用其他并发容器。
5.3 字符串相等的判断:面试必问的几种写法
我把这个问题整理成了一张速查表:
| 写法 | 行为 | 适用 |
|---|---|---|
str1 == str2 | 比较引用是否相同 | 只在确定两个引用都指向常量池同一对象时使用 |
str1.equals(str2) | 比较内容 | 日常通用 |
str1.contentEquals(sb) | 比较String与CharSequence内容 | 和StringBuilder/StringBuffer比较 |
Objects.equals(str1, str2) | 内容比较且处理null | 推荐在工具类里使用 |
有一个经典坑:两个字符串内容相同,但一个是new String("ab"),一个是直接字面量"ab",用==比较会返回false。这就是因为一个在堆上、一个在常量池里,引用不同。
5.4 不要在append时漏掉null判断
我记得有一次排查一个很诡异的NPE,代码大致是:
StringBuilder sb = new StringBuilder(); sb.append(order.getRemark()); // 这里居然抛了NPE?实际原因是getRemark()返回的char[]和String类型选错了重载。如果对象是null但是被编译器识别成String类型,append并不会抛异常,会变成“null”。但如果方法返回类型是char[]并且真的为null,就会触发NullPointerException。所以遇到append报NPE,先怀疑一下是不是char[]类型。
5.5 大量动态字符串去重的取舍
前面提过intern,这里再深入说一句。如果业务里有大量重复度较高的字符串,比如从数据库读出的固定枚举值、redis key的前缀等,用intern确实可以提高内存复用率。但如果字符串重复度低且数量大,比如用户昵称、邮件内容,intern就变成了负优化。终极建议:先用工具或者JVM参数观察字符串占用与重复率,再决定要不要intern,别凭感觉优化。
5.6 使用StringBuilder时容易踩的边界值
deleteCharAt(int index)要求index在有效范围内,否则抛StringIndexOutOfBoundsException。insert的offset可以等于count,相当于追加,但等于负数或大于count都会报错。setCharAt不允许index等于count,这是和insert最大的差别。substring在StringBuilder里返回String,不影响原StringBuilder的内容。
这些边界条件听上去很琐碎,但在单元测试里经常被忽略,导致线上偶发异常。我建议你给操作字符串的工具类做一轮边界测试,把0、负数、刚好等于length、大于length这四类输入都跑一遍,能提前规避大量问题。
写在最后的体会
这三个类在Java里已经存在很多年了,但每次看源码和实际排查问题,我都能发现一些新的细节。可能在很多人眼里,String、StringBuilder、StringBuffer无非是“不可变、非线程安全可变、线程安全可变”的老三样,但真正把这些差异落到代码里,每个选择背后其实都藏着GC压力、并发正确性和代码可读性之间的平衡。还是那句话,没有绝对的好与坏,关键是搞清楚你所在场景的核心矛盾是什么。如果这篇文章能帮你少踩一个字符串的坑,我就觉得值了。