ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

String、StringBuffer与StringBuilder:底层原理与实战选型指南

String、StringBuffer与StringBuilder:底层原理与实战选型指南 先声明一下今天这篇不是什么入门科普也不是那种背完就忘的面试八股。我想从一个真实经历说起去年我接手一个老项目线上频繁告警JVM内存曲线涨上去就下不来GC日志里Old区持续膨胀。查了半天最后定位到一段代码——一个循环里用String String拼接了几千次日志。那瞬间我意识到很多Java开发者包括当时的我对字符串类的理解停留在“能背区别”的层面并没有真正理解它们背后设计者的意图。String、StringBuffer和StringBuilder这三者几乎出现在每一次Java面试里但真正在项目里把它们用对的人并不多。本文我想从底层原理、源码演进和真实项目案例三个角度把三者的区别、联系和使用场景讲透。适合正在备战面试的开发者也适合想在代码质量上再进一步的实战派。1. 三个类之间的血缘关系与不为人知的设计差异1.1 StringBuffer和StringBuilder的“双胞胎”本质先看类定义public final class StringBuffer extends AbstractStringBuilder implements Serializable, ComparableStringBuffer, CharSequence public final class StringBuilder extends AbstractStringBuilder implements Serializable, ComparableStringBuilder, CharSequence看到没有两个类都有AbstractStringBuilder这个共同父类也就是说核心的存储结构、扩容逻辑、append、insert这些方法在父类里就是同一份实现。唯一的差异在于StringBuffer在所有公开方法上加了synchronized关键字而StringBuilder没有。这个差异有多大我打个比方StringBuffer就像银行柜台每个窗口都配了保安来一个人处理一个安全但慢StringBuilder就像自助取款机你自己操作快但出了问题自己负责。理论上两者可能产生行为差异的场景只有一个同一个实例被多个线程同时读写。如果你永远在单线程环境下操作StringBuffer的性能开销完全是无意义的。1.2 为什么不直接让StringBuilder继承String这个问题的答案藏在String的final声明里。String类被设计为不可变immutable这意味着一旦创建它的值就不能被修改。StringBuffer和StringBuilder恰恰需要的是可变性——它们内部维护了一个char[]数组JDK 9之后是byte[]可以不断往里面塞内容所以它们在设计上根本不可能是String的子类。这里有个很精妙的地方StringBuilder提供了一个toString()方法它做了什么不是简单地返回内部的数组那样外部就能通过修改char数组来破坏String的不可变性而是new了一个新的String对象。这就是为什么标题里说的“StringBuffer转换为String”其实是把可变容器的内容“快照”成一个不可变对象这个过程是有代价的——一次数组复制。1.3 JDK版本的演进带来的底层变化如果你还在背“String底层是char数组”那你的知识版本已经过时了。JDK 9开始String底层从char[]换成了byte[]同时引入COMPACT_STRINGS字段用一个编码标志位来区分LATIN1单字节还是UTF16双字节。这个改动直接影响StringBuffer和StringBuilder它们内部同样改成了byte[]并且增加了一个coder字段来标记当前存储的是单字节还是双字节字符。这个改动的意义在于如果字符串内容全部是ASCII字符每个字符只占用一个字节内存占用直接减半。在大量字符串操作的场景下这个问题非常可观。我在一个配置中心的项目里测过JDK 8升级到JDK 11之后同样的接口字符串相关的内存占用大约下降了25%。所以如果你还在用老版本JDK这是一个值得升级的理由。2. 底层原理不可变性、常量池与扩容机制2.1 不可变性带来的连锁反应String的不可变性不是Java设计者拍脑袋决定的它有四个直接好处线程安全不可变对象天然线程安全不需要任何同步手段。哈希值缓存String的hashCode()只需要计算一次之后直接返回缓存值。这也是为什么String非常适合做HashMap的key。字符串常量池因为不可变所以JVM可以放心地复用相同内容的字符串对象节省内存。安全性类加载器、网络协议等场景大量使用字符串如果可变恶意代码可能篡改。比如String path /api/user我可以通过修改path[0]为/api/admin来绕过权限校验。但不可变性也有代价每次修改String都会创建新对象。下图的场景大家应该都遇到过String s Hello; s s World; // 这里发生了什么第一步创建Hello对象第二步进行字符串拼接时编译器优化为new StringBuilder(s).append( World).toString()第三步toString()又new了一个新String对象Hello World。原来那个Hello对象如果没人引用就变成了垃圾等待回收。所以你在循环里做拼接时等于每迭代一次就创建一个StringBuilder、一个char数组、一个String对象这就是最典型的性能灾难。2.2 字符串常量池与intern()的理解学完字符串面试官十有八九会问String s1 abc;和String s2 new String(abc);有什么区别前者字符串字面量会被放入字符串常量池JDK 7之后是堆中的一个区域如果池中已存在abc则直接复用。后者不管池里有没有abc它都会在堆中创建一个新对象同时把字面量abc放入常量池。所以new String(abc)可能产生两个对象一个在常量池一个在堆。而判断的是引用是否相等不是内容。这里有一点很多人忽略intern()方法。它做的事是如果常量池中有相同内容的字符串就返回池中的引用否则把当前字符串加入池中并返回引用。在一些大量使用重复字符串的场景比如从数据库读出来的状态值调用intern()可以显著减少内存占用。但它也有限制常量池过大时反而会增加GC压力和字符串表哈希碰撞的代价。我通常只在确认字符串种类有限且重复率极高的场景下才用比如订单状态、日志级别这类枚举型字符串。2.3 StringBuilder的扩容机制从10到15万的翻倍策略StringBuilder创建的默认容量是16个字符JDK 9之后是16个字节但你往里面塞的内容超过16时它不会报错而是自动扩容。扩容的逻辑在AbstractStringBuilder里private int newCapacity(int minCapacity) { int oldCapacity value.length coder; int newCapacity (oldCapacity 1) 2; if (newCapacity - minCapacity 0) { newCapacity minCapacity; } return (newCapacity 0 || 10 Integer.MAX_VALUE - 8) ? hugeCapacity(minCapacity) : newCapacity; }核心逻辑新容量 旧容量 * 2 2。扩容意味着什么创建一个新的byte数组然后把旧数组内容copy过去。如果你频繁追加且不预知容量就会反复触发扩容每次都是在堆上申请新内存再复制。所以当你对最终字符串长度有一定预估时应该使用带初始容量的构造器StringBuilder sb new StringBuilder(1024); // 预估最终长度约1KB实测项目中合理设置初始容量可以把拼接耗时缩短30%~50%。一个很实用的估算公式如果业务上知道预计拼接的字符串总字符数就直接用这个数如果不知道可以按当前段的平均长度乘以预估条数来估算。宁可稍大也不要反复扩容。2.4 为什么StringBuilder的toString()不共享内部数组深入源码你会看到public String toString() { return new String(value, 0, count); }为什么不是直接return new String(value)因为这些可变类在创建String时需要精确控制范围从0到count且必须复制数组。如果直接共享数组后续StringBuilder再append新内容就会破坏已经生成的String对象的内容这违背了不可变原则。这是理解“String是不可变的”这句话的最后一环。3. 高频操作的实战场拼接、判等、切割与转换3.1 StringBuffer转String——先搞清楚你在转什么很多人搜过“stringbuffer转换为string”这个问题其实答案简单到让人怀疑调用toString()即可。StringBuffer sb new StringBuffer(Hello); String s sb.toString();但真正值得思考的是为什么要转以及转的时候发生了什么举个例子我要往一个接口的body里写数据这个body是MapString, String而value恰好是一个可变容器里拼出来的。如果不转就直接放进去编译器会报类型错误转了之后你获得的是一个不可变的对象后续业务代码再怎么修改StringBuffer都不会影响这个String。所以你转的本质上不是“内容”而是“可变性契约”。在很多公司代码规范里接口间传输对象的值类型原则上不能是可变类就是为了避免一种隐蔽的Bug调用方拿到对象后以为是一份独立的String实际上底层数组还在被另一个线程改。3.2 几个判等场景的陷阱与正确姿势字符串判等是另一个高频热搜词通常和“”、“equals”绑定在一起。标准答案是比较内容用equals()不要用。但实战里总有些变体场景一忽略大小写比较String status SUCCESS; if (success.equalsIgnoreCase(status)) { // OK }场景二比较开头或结尾if (filename.endsWith(.jpg) || filename.endsWith(.png)) { // 图片文件 }场景三空安全的判空String s null; if (expected.equals(s)) { // 安全不会NPE }为什么推荐把常量放在equals前面因为常量的equals永远不会抛空指针而s.equals(expected)在s为null时会直接NPE。这个细节写代码的时候不觉得Debug的时候真的能救命。还有一点容易被忽略String的equals是先比较引用this anObject再看类型再看长度最后才逐字符比较。如果你频繁进行大量字符串比较可以考虑先比较hashCode()是否相等来快速过滤这也是HashSet和HashMap内部提高效率的关键思路之一。3.3 字符串切割的坑从split到正则表达式的性能陷阱split()应该是日常用得最多也坑得最多的API。很多人不知道的是split()的参数是正则表达式不是普通字符串。比如你想按小数点切割版本号String version 1.0.1; String[] parts version.split(\\.); // 必须转义如果不转义,也会匹配任意字符结果会和你预期差很远。这类转义问题在“IP地址切割”、“文件路径切割”等场景里非常常见。另外一个性能相关的坑如果你在一个大日志文件解析场景里每一行都要split(|)而这个正则每次都会被编译代价是很大的。此时更适合用StringTokenizer虽然它也被诟病但性能确实好或者是手动循环indexOf切割private static ListString splitByIndex(String str, char delimiter) { ListString result new ArrayList(); int start 0; for (int i 0; i str.length(); i) { if (str.charAt(i) delimiter) { result.add(str.substring(start, i)); start i 1; } } result.add(str.substring(start)); return result; }实测在百万行日志解析场景下这个手写方法比split快30%以上而且不会遇到正则回溯的极端性能问题。当然了代码可读性会下降要不要这样优化需要根据业务场景权衡。我的原则是只有在我能实测证明它是性能瓶颈时才会用这种手段。3.4 逆序、格式化、替换……那些不起眼的API各有脾气热搜词里还有“字符串逆序输出c”、“字符串排序”这类问题Java里最直接的逆序API是StringBuilder.reverse()StringBuilder sb new StringBuilder(abc); String reversed sb.reverse().toString(); // cba但它有个特点会直接修改当前对象。如果你还需要原字符串务必先复制一份或先toString()。这算是一个很容易被忽视的“副作用”。格式化是一个常被忽略性能的地带。String.format()非常方便但内部会用到Formatter、正则解析和额外的对象分配。在高频日志场景下我测过String.format比手动拼接慢一个数量级。如果你对性能敏感可以考虑用MessageFormat或直接拼接。但话说回来代码可读性也很重要我会在非HotPath上优先选String.format。再来说替换。replaceAll的第一个参数也是正则和split一样有编译开销和转义问题。如果你想替换所有的.得写replaceAll(\\\\., /)非常容易写错。而replace是普通字符串替换没有正则参与如果你的需求只是替换字面值优先用replace而不是replaceAll。4. 从八股文到线上事故选型原则与真实案例4.1 为什么说“大多数场景下StringBuilder是唯一解”翻开代码规范很多团队会规定“字符串拼接统一用StringBuilder”少数团队会在模板代码里看到StringBuffer。我的看法是大多数业务场景下你根本不需要StringBuffer的线程安全单线程内StringBuilder就是最优解。什么时候真的需要线程安全比如公共的字符串缓冲区被多个线程同时append。但这类需求极少而且就算有用StringBuffer也不是最好的方案——更好的做法是从设计上规避共享可变状态。比如把拼接逻辑放到每个线程内部各自用各自的StringBuilder最后再合并。有个反直觉的结论即使两个线程同时写同一个StringBuilder即线程不安全如果它们写的内容互不冲突的位点结果可能“碰巧对”但这个“碰巧”极其脆弱任何一次JIT优化、指令重排、缓存刷新都可能让结果变成乱码。我见过一个故障两个线程各自往一个共享StringBuilder里追加日志测试环境运行了一周都没问题上线后第一次高并发就出现日志内容交叉错位。这就是不可控的随机性。4.2 线程安全不等于性能安全StringBuffer的锁代价StringBuffer每个公开方法都加了synchronized这意味着每次append都会进行一次重量级的锁获取与释放。在现代JVM里无竞争的锁其实很便宜偏向锁但一旦锁竞争发生代价就上来了。这里我放一张我自己在JMH里测过的一组数据环境JDK 11单线程场景循环拼接10万次操作方式耗时毫秒相对StringBuilderString直接拼接约420ms约10倍String concat()约180ms约4倍StringBuilder约45ms1倍StringBuffer约52ms约1.15倍注意单线程下StringBuffer比StringBuilder慢得不算多但如果你用JMH在多线程下压测同一个共享StringBuffer会有比较明显的锁竞争开销。而StringBuilder在单线程下是最快线程共享时直接报错风险最大。所以真正的建议是单线程用StringBuilder多线程不要用共享字符串缓冲。4.3 一个典型的OOM排查为什么字符串也会撑爆内存之前提到的那个线上OOM根因链路是这样的业务循环里拼接一个超长XML报文每次追加都用String chunk。每拼接一次JVM在堆里新建一个StringBuilder、一个char数组、一个String对象旧对象变成垃圾。大量短命对象加上GC回收不及时Old区里堆积了大量无用字符串。最终OOMJVM宕机。修复方案很直观把循环里的拼接改成单例StringBuilder并且预估总长度设置初始容量。改完之后同样的压力下内存稳定GC次数大幅下降。这个案例让我深刻理解了一个道理字符串三剑客的区别不是在“哪个更快”而是在于“你让它怎么创建对象”。4.4 另一个容易被忽略的点字符串常量池的内存影响很多公司出过类似问题代码里用了大量String.intern()导致字符串常量池里的Bucket数量过多发生严重的哈希碰撞性能断崖式下降。JDK 7之后字符串常量池被移到了堆中之前是PermGen所以它不再有“OOM: PermGen space”的风险但如果你在池子里塞上万种不同内容的字符串它依然会占用大量堆内存。我在一个工单系统里就见过一个状态字段有几十种取值代码为了省内存对所有从数据库读出来的状态值调用了intern()结果因为状态值本身种类少内存确实降了但另一个新同事接手后把用户的昵称也intern()了那可就捅了马蜂窝几百万个不重复昵称全被塞进了常量池内存直接翻倍。所以intern()不是不能用但是一定要清楚它的适用边界。5. 面试官真正想问的事常被误解的三个点和一套回答思路5.1 “String底层是什么结构”有两个版本面试官问“String底层结构”时你要先反问“您问我的是JDK 8还是JDK 9的”JDK 8及之前private final char value[];JDK 9及以后private final byte[] value;加private final byte coder;这个反问不是抬杠而是展示你对JDK版本演进有感知。然后你可以顺带说明为什么改为了压缩ASCII字符串的内存占用。这就是加分项。5.2new String(abc)到底创建了几个对象这是所有Java面试题里最经典的一道。正确答案取决于“常量池里有没有abc”。如果常量池里已有“abc”只创建一个堆对象。如果常量池里没有“abc”会创建两个对象一个在常量池通过字面量一个在堆通过new。但还有一个隐藏细节JVM在解析字面量abc时如果常量池里没有它会在类加载阶段就把字面量加入常量池而new String的执行期才创建堆对象。所以有时候你在一段代码里先new String(abc)再另一个类里用字面量abc那么常量池在类加载阶段就已经有一个了。这类问题最怕死记硬背你要理解的是“字面量解析时机”和“new的执行时机”这两条时间线。5.3 StringBuilder线程不安全到底不安全在哪一行很多背过八股的人会回答“StringBuilder的方法没有加synchronized”。但面试官如果继续问“具体哪一行会出问题”就露馅了。真正不安全的操作是append里的这行count len;count len不是原子操作。在CPU层面它至少分解为“读取count”、“计算新值”、“写回count”三步。两个线程同时进入时可能同时读到相同的count都写回相同的新值导致最后写入的数据被覆盖而count只增加一次。也就是说日志数据丢失或者顺序错乱。这就是线程不安全的本质。理解了这一行你就能理解为什么有时候“StringBuilder在多线程下没报错但数据就是不对”的原因。5.4 从三剑客延伸出去的设计思想最后面试官如果问你“String、StringBuffer、StringBuilder三者的区别”不要只背结论要有点自己的深度思考。我的回答框架是可变性String不可变后两者可变。不可变带来线程安全、哈希缓存、常量池复用但也带来了修改时创建新对象的代价。线程安全String天然安全StringBuffer通过synchronized保证单个方法级别的安全但是组合操作比如append再append并不是原子的StringBuilder非线程安全。性能单线程下StringBuilder最优StringBuffer次之多了锁开销String最差每次修改都新对象。使用场景字符串常量用String少量拼接用String大量拼接且单线程用StringBuilder有明确的线程共享可变缓冲需求时再考虑StringBuffer但这类场景建议从架构上避免。底层演进JDK 9之后byte[]存储内存优化的同时保留了API兼容性。这个框架的好处是每个点都有来龙去脉不是背出来的。6. 一些个人实测出的经验和一个小工具建议文章写到这里按惯例应该来一段“经验补充”我就分享几个在项目里实测出来的细节经验一能用单行拼接就用单行拼接。Java编译器会对单行内的a b c做编译期常量折叠直接生成abc不会产生中间对象。所以String log user: userId , action: action; // 编译器优化为append链这种写法在字节码层面等同于StringBuilder.append但代码可读性更好性能也不差。真正需要显式StringBuilder的是循环内拼接和动态逻辑拼接。经验二预估容量真的能救命。我在一个日志上报组件里把new StringBuilder()全部改成new StringBuilder(1024)在并发场景下Young GC次数直接下降了大约20%。原因很简单不用反复扩容和复制数组。经验三别在toString()之后再加工。很多同事写惯了String以后会把StringBuilder拼接完的字符串再去做trim()、replace()、concat()其实把这些操作放到拼接过程中往往更高效且更清晰。比如你要拼一个CSV最后一行不要逗号可以在循环里判断索引而不是拼完后再把最后一个字符去掉。经验四用javap看字节码比背八股直观。我建议所有学Java字符串的开发者把这个步骤亲手做一遍# 写一段包含字符串拼接的代码执行 javac StringTest.java javap -c StringTest你会看到编译器到底把优化成了什么。这一步做完你对“字符串拼接”这件事的理解会比背一百道面试题都深刻。最后再分享一个小技巧如果你在排查字符串相关的性能问题不要靠猜。用JFRJDK Flight Recorder录制一段看java.lang.String相关的分配速率或者用JProfiler看一下内存中的字符串对象大小和数量。我曾经用这个方法定位到一个“字符串缓冲区反复扩容”的问题那问题藏在第三方SDK里光看代码根本看不出来。工具和数据永远比感觉诚实。
返回列表