资讯详情

String、StringBuilder、StringBuffer底层原理与选型

📅 2026/10/12 3:30:29 | 华诺云谱 👁 阅读
String、StringBuilder、StringBuffer底层原理与选型
如果你写Java超过一年应该都背过String、StringBuilder、StringBuffer三者区别这道面试题。标准答案无非是String不可变、StringBuilder非线程安全但快、StringBuffer加了锁所以线程安全但慢。可我在实际带项目的时候发现能把这道题背出来的人不少真正写代码时依然乱用——循环里拿String拼接口报文、多线程环境下拿StringBuilder共享写入、以为StringBuffer加个锁就万事大吉。前两天帮同事排查一个定时任务突然变慢的问题最后原因落在3万次循环里的字符串拼接上。这个案例特别典型今天正好借它把三者的底层原理、API细节和性能边界彻底讲透最后给出我自己的选型心法。1. 从一次接口变慢的排查说起String拼接不是小事1.1 一次让我印象深刻的线上定位事情的经过不算复杂。某天同事反馈一个每天凌晨跑的统计任务原本1分钟左右能跑完最近几天涨到了将近15分钟。第一反应是查数据库结果慢查询日志里干干净净接着怀疑是竞争CPU资源看了监控也没有大流量。最后我让他用jstack连着抓了几次线程栈发现任务线程长时间停在一个看似人畜无害的方法里反复执行垃圾回收。顺着调用栈往里翻定位到的代码长这样String result ; for (Order order : orderList) { result result order.getId() ,; }orderList当时是3万条数据。问题一下就清楚了——不是SQL慢也不是网络慢是这行result result ...在疯狂创建临时对象把GC拖垮了。1.2 循环拼接为什么会让GC崩溃要理解这个场景的杀伤力得从String的不可变说起。Java里String对象创建之后内部存储字符的数组就不可修改了。每次执行result result order.getId() ,底层实际发生了三步创建一个新的StringBuilderJDK8及以前由javac在编译期生成把左边result的字符数组内容复制进去把order.getId()和,追加进去再toString()生成新String并重新赋给result变量。每循环一次就产生一个StringBuilder临时对象和一个新的String对象。3万次循环就是6万个临时对象。这些对象的字符内容加起来有多大第1次拼接累积了10个字符第2次20个第3次30个……第3万次就是30万个字符。也就是说光字符复制量就是等差数列求和接近10×30000×30000/2这个数量级算下来是45亿字符的复制量。这个量级一出来GC必然扛不住。这就是网上常说的String拼接是O(n²)复杂度的由来。1.3 编译器不是你的救星很多人会问不是有编译器优化吗javac不是会自动把替换成StringBuilder吗这句话只说对了一半。javac确实会把a b这种单个表达式编译成StringBuilder操作JDK9以后甚至会用invokedynamic配合StringConcatFactory来做更灵活的拼接方式。但它的优化范围仅限于同一个表达式内不会跨循环跨迭代去复用同一个StringBuilder。每次循环仍然会创建新的临时对象原来那个O(n²)的复制量一点没少。所以正确的姿势很简单循环结构里不要用老老实实声明一个StringBuilder在外面循环里只用append。同样的3万条数据改造后我在这台机器上测试总耗时从几十分钟级降到几十毫秒级。2. 内存模型与设计哲学三个类到底差在哪2.1 Stringfinal数组和它换来的四个好处先看String在JDK8里的核心定义public final class String implements java.io.Serializable, ComparableString, CharSequence { private final char value[]; private int hash; }两个关键点final修饰类final修饰字符数组注意final数组指的是引用不可变数组内容本身仍可变但String内部从不暴露修改入口所以对外表现是不可变。这个不可变设计不是拍脑袋定的它在Java里换来了四个实打实的好处字符串常量池可以复用。比如代码里写10次hello本质上都指向常量池里的同一个String对象。如果String可变任何一个持有它的人悄悄改一下字符其他地方全乱套。hash值可以缓存。String的hashCode字段第一次计算后就不再变了作为HashMap的key时效率非常高。多线程天然安全。对象内容不会变任何线程拿到它读都不会产生并发问题。参数传递更安全。方法传参时不必担心传入的字符串被某些内部逻辑修改这在网络协议、文件路径、类名等场景很重要。当然代价也很明显每次修改都意味着新建对象所以大量修改字符串的场景直接用String就会非常吃亏。2.2 StringBuilder能随便改的字符序列StringBuilder的定位非常明确提供可变字符序列在单线程环境下做高频字符串操作。它内部继承自AbstractStringBuilderabstract class AbstractStringBuilder implements Appendable, CharSequence { char[] value; int count; }注意value没有final而且没有访问控制修饰符包内可见子类可以直接操作。关键是StringBuilder没有加锁append、insert这些方法都是裸操作数组Override public StringBuilder append(String str) { super.append(str); return this; }返回this这是它能链式调用的原因。没有锁意味着在单线程竞争环境里少了很多争夺和阻塞的开销性能最好。2.3 StringBuffer在StringBuilder上加了一把锁StringBuffer和StringBuilder的类层级几乎一样最大区别是核心方法都用了synchronized修饰Override public synchronized StringBuffer append(String str) { toStringCache null; super.append(str); return this; }JDK8的实现里还多了一个toStringCache字段用来缓存最近一次toString()的结果避免在未修改的情况下重复拷贝算是对锁开销的一点补偿。这里要特别强调一个常见误解StringBuffer的线程安全不等于随便怎么用都安全。synchronized保证的是单次方法调用的原子性但如果你在多个线程里执行先判断长度再append这类复合操作依然会有竞态。比如if (buffer.length() 0) { buffer.append(x); }线程A和线程B可能同时通过length()0的判断然后先后append结果出现两个x。所以真正要保证业务层面的线程安全还需要在外部加同步或者换用其他线程安全的数据结构。3. 核心API逐组拆解构造、追加、修改、查询与转换3.1 从构建到初始化构造器与valueOf的细节先说String的创建最容易踩坑的是new String()和字面量的区别String s1 abc; // 可能复用常量池对象 String s2 new String(abc); // 堆区必然新建对象参数abc本身也是常量池对象判断两者是否相等的结果很可能是false因为一个是常量池里的引用一个是堆里的新引用。而equals比较的是内容所以是true。这个细节后面单独展开。StringBuilder常用的构造方式有三种StringBuilder sb1 new StringBuilder(); // 默认容量16 StringBuilder sb2 new StringBuilder(hello); // 内容为hello容量16内容长度 StringBuilder sb3 new StringBuilder(100); // 明确初始容量100很多人不知道默认容量只有16。如果你一开始就知道要拼10万次还傻乎乎用默认构造器扩容就会被反复触发。扩容的逻辑是新的字符数组长度约等于旧容量×2 2每次扩容都要把旧数组内容完整复制一遍。所以大字符串拼接前估算好容量再构造收益非常直接。String.valueOf和Integer.toString也是高频API它们内部就不依赖直接针对基本类型做转换性能比 数字更可控。如果只是把数字转成字符串我更推荐直接写Integer.toString(i)。3.2 增删改查append、insert、delete、replace、substringString本身是不可变的所以所有修改操作都返回新Stringconcat(String)合并字符串返回新对象substring(int begin, int end)截取子串返回新对象replace(char old, char new)/replaceAll(String regex, String replacement)字符或正则替换返回新对象trim()去掉首尾空白。而StringBuilder/StringBuffer的修改操作是原地生效的操作方法签名行为说明尾部追加append(String)/append(int)/append(Object)最常用返回this可链式调用指定位置插入insert(int offset, String str)在offset之前插入内容原内容后移删除区间delete(int start, int end)左闭右开实际是System.arraycopy搬移删除单字符deleteCharAt(int index)删除指定位置字符区间替换replace(int start, int end, String str)与String的replace同名但语义完全不同指定位置改字符setCharAt(int index, char ch)直接改数组很快这里我提醒一下StringBuilder.replace和String.replace虽然都叫replace但参数含义天差地别。前者是replace(int start, int end, String str)替换的是区间后者是replace(char oldChar, char newChar)或replace(CharSequence target, CharSequence replacement)替换的是内容。混在一起记写代码时容易懵。另外substring在StringBuilder里也重载了但它返回的是全新的String而不是StringBuilder。链式调用时要注意它会把链断掉String result sb.append(a).append(b).substring(1).append(c); // 编译报错substring返回String3.3 查询与比较indexOf、equals、compareTo查询类API在三者之间的差异不大因为StringBuilder和StringBuffer都实现了CharSequence并且从AbstractStringBuilder继承了indexOf、lastIndexOf等逻辑。常用的查询方法列一下indexOf(String str)/indexOf(String str, int fromIndex)返回子串第一次出现的位置找不到返回-1lastIndexOf(String str)最后一次出现位置contains(CharSequence s)内部基于indexOf实现startsWith(String prefix)/endsWith(String suffix)前缀后缀判断compareTo(String anotherString)按字典序比较返回负数/0/正数。这里有个比较经典的坑StringBuilder没有重写equals。它继承的是Object的默认实现比较的是引用地址。所以下面的结果是falseStringBuilder sb1 new StringBuilder(abc); StringBuilder sb2 new StringBuilder(abc); System.out.println(sb1.equals(sb2)); // false想要比较内容只能先toString()再对String做equals或者自己写循环逐字符比较。compareTo也是异步凡响的一个点String实现的是ComparableStringStringBuilder和StringBuffer有compareTo(StringBuilder)方法JDK11才加的底层是字典序比较。但它同样不参与equals的语义——内容相同但类型不同equals依旧为false。3.4 容易被忽略的宝藏方法reverse、capacity、trimToSize这几个方法在实际开发中出场率不算高但用对了能省很多事。reverse()StringBuilder和StringBuffer独有String没有。用来反转字符串极方便StringBuilder sb new StringBuilder(hello); sb.reverse(); // ollehcapacity()和length()新手最容易混淆。length()返回当前已存在的字符个数capacity()返回的是底层数组能装下的最大字符数。默认构造时length0、capacity16append了20个字符后length20、capacity3416×22。trimToSize()把底层数组大小收紧到跟当前length一致减少内存占用。适合那种拼完超大字符串之后还要长期持有StringBuilder对象的场景比如把它放到缓存里反复复用。setLength(int newLength)可以用来截断也可以用来扩容预留StringBuilder sb new StringBuilder(hello); sb.setLength(3); // 只会留下 hel sb.setLength(10); // 后面自动补\0这个方法的妙用在于你可以在循环前setLength(0)清空StringBuilder而不是重新new一个减少对象创建。4. 实测性能与底层优化别让编译器替你背锅4.1 一组直观的JMH对比数据单讲原理没有体感我直接在本地跑了组简单测试环境是JDK 11循环往字符串后面追加同一个短字符串每种方式都执行10万次拼接结果大致如下拼接方式10万次耗时说明String约6300ms每次循环新建临时对象GC压力极大Stringconcat()约2600ms比稍快但依然每次新建StringStringBuilderappend()约6ms原地改数组几乎无额外分配StringBufferappend()约11ms加锁带来约一倍的额外开销这组数据在不同机器、不同JDK版本下会浮动但数量级差异是稳定的。String和StringBuilder之间差了三个数量级这才是关键信息。我只测了单线程所以StringBuilder是绝对的胜者。值得补充的是如果循环次数降到1000以内String的劣势没那么明显可能也就差几毫秒。但这种小场景无所谓的思维最容易埋雷——代码里觉得无所谓哪天数据量上来了就是事故。4.2 编译器给做的优化到底保到哪一层JDK8之前javac遇到字符串会生成new StringBuilder的字节码JDK9以后改成了invokedynamic运行时用StringConcatFactory决定具体拼接策略。后者的优点是不再固定走StringBuilder构建器效率更有弹性。但有一点没变单次拼接优化的再好也救不了循环里的多次拼接。因为每次循环迭代产生的字符串结果都要存在某个变量里下一次迭代又要以它为起点重新构造。编译器不会聪明到帮你跨迭代复用缓冲区。所以你去看网上很多文章鼓吹JDK9以后用和StringBuilder性能差不多是不严谨的——他们测的往往是一条语句里拼接多个变量而不是循环。在循环里依然是那个O(n²)的老问题。4.3 多线程场景StringBuffer真的更安全吗我用多个线程同时往一个StringBuffer和StringBuilder里append大量内容跑过对比StringBuffer因为方法加了锁线程之间互相等待总耗时会膨胀StringBuilder并发写入会导致字符丢失或数组越界结果不可预期。结论很明确多线程共享可变字符串首选StringBuffer或者外部同步的StringBuilder但别把StringBuffer当成万能锁。还有个更隐蔽的问题即便StringBuffer的append是原子操作如果你的业务逻辑是读一下当前Buffer内容再决定要不要追加这一段组合逻辑在跨线程环境下依然可能出错。保险的做法是整体包在synchronized(buffer)块里或者干脆用ThreadLocalStringBuilder让每个线程各拼各的只在最后合并。5. 面试高频陷阱与我的选型建议5.1 equals与背后的两片内存区域String相关的面试题十有八九绕不开equals和。我在实际面试别人时发现能说清楚为什么两个内容相同的字符串用比较可能是true也可能是false的候选人才算真的理解了String。关键就一句话比较引用地址equals比较内容。而String有一个字符串常量池字面量字符串在编译期就会进入常量池运行时复用同一对象new String()则强制在堆上新建对象。String a abc; String b abc; String c new String(abc); System.out.println(a b); // true都在常量池同一个地址 System.out.println(a c); // falsec指向堆上的新对象 System.out.println(a.equals(c)); // true内容相同实际开发中判断字符串内容一律用equals。尤其接HTTP参数、读配置文件这些场景不要因为看起来像同一个字符串就偷懒用。5.2 intern的真相和它的实用价值String.intern()能把堆里的字符串手动加入常量池如果常量池已有相同内容直接返回池中引用否则在池里创建新对象并返回。String s1 new String(abc); String s2 s1.intern(); System.out.println(s1 s2); // falses1是堆对象s2是常量池引用intern在极少数场景能省内存比如大量重复的字符串作为Map的key时统一intern可以让它们共享同一份存储。但intern本身也要走字符串池查找和加锁用不好反而拖慢性能所以我的建议是默认不要用只在你能明确统计出重复度极高且对象生命周期很长的场景再去考虑。5.3 我在项目里坚持的几条铁律这几条是我这些年带项目实际沉淀下来的分享给你做个参考固定内容用String动态拼装用StringBuilder。方法参数、返回值、配置常量这些不会变化的用String需要循环拼接、动态构造的用StringBuilder。明确只有单线程操作时无脑选StringBuilder。绝大多数业务代码都是单线程逐条处理逻辑StringBuffer在这里没有任何优势。真的多线程共享时才用StringBuffer且要记住它不是万能锁。最理想的设计是避免共享可变字符串用局部变量或ThreadLocal隔离。能预估容量就预估容量。new StringBuilder(1024)在实际拼大报文的时候省掉的不只是一两次扩容是几十次数组复制。接口返回或入库前统一toString。StringBuilder对象不要直接暴露给外部一是防止意外被修改二是StringBuilder没有稳定的字符串语义。我在实际项目中还有一个习惯写工具类时凡是接收StringBuilder参数的地方都复制一份新的再操作防止调用方拿到同一个对象后状态被改乱。这属于防御式编程代价很小但能排掉很多隐蔽的bug。最后再分享一个和字符串拼接有关的排查小技巧当线上GC频繁、接口变慢但数据库又正常时除了看线程栈还可以顺手看一眼JVM参数里-XX:PrintGCDetails的输出如果看到大量char[]大量分配八成就是字符串操作在作祟。先用jstack锁定线程栈再数代码里循环里的String拼接通常一找一个准。工具不是越复杂越好关键是快速识别这个最笨但最常见的坑。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑