
每年带人做技术面试的时候String、StringBuilder 和 StringBuffer 这“三兄弟”都是必考的基础题也是翻车率最高的送分题。很多候选人能背出“String 不可变、StringBuilder 线程不安全、StringBuffer 线程安全”可一旦追问“new String(abc) 到底创建了几个对象”“JDK 9 之后 String 底层还是 char[] 吗”“循环里用 拼接为什么慢”场面就立刻冷下来。这篇巩固题不是让你死记答案而是把这三者的底层实现、常量池机制、扩容逻辑和实测结果串成一条线。刷完这一组题你不仅知道选谁还能说清楚为什么这么选——这才是面试官真正想听的。1. 先弄清三兄弟的本质一个不可变两个可变很多人在第一步就理解错了。String、StringBuilder 和 StringBuffer 最大区别不是“拼接快慢”而是可变性。这个特性决定了后续一大半面试题的方向。1.1 按不可变性划分String 为什么是 final 的String 类本身是 final 的它内部的存储字段也是 final 的JDK 8 及以前private final char value[];JDK 9 以后private final byte[] value;也就是说一旦一个 String 对象创建完成它持有的字符数组引用不可变数组内容也不允许修改。你调用的replace()、concat()、substring()、toUpperCase()这些方法全部是返回一个新 String 对象原来那个对象纹丝不动。很多人会问String 设计成不可变到底图什么至少有三个实际好处常量池复用。只有不可变对象才能安全地被多个变量共享引用编译期字面量才能直接放进常量池。如果是可变的A 变量改一下内容B 变量也跟着变那常量池就彻底乱套了。线程安全。不可变对象天然可以在多线程下共享不需要加锁。作为 HashMap 的 key 足够安全。String 的hashCode()会被缓存而且对象的哈希值不会因为内容变化而失效。如果 String 可变MapString, V的 key 分分钟就查不到了。我在评审代码时发现很多新人会混淆“变量赋值”和“修改对象”。比如String s hello; s s world; System.out.println(s); // hello world这里 s 的引用确实变了指向了新对象“hello world”但原来的“hello”对象还是存在常量池里只是没人引用而已。变量可以重新指向新对象不代表对象本身可变。1.2 StringBuilder 和 StringBuffer几乎同源差在一把锁StringBuilder 和 StringBuffer 都继承自AbstractStringBuilder底层都是可变的字符数组JDK 8 以前char[] value;JDK 9 以后byte[] value;它们的方法逻辑基本相同新增字符、扩容、截断都是同一套代码。唯一的显著差别是StringBuffer 在所有公共方法上加了synchronizedStringBuilder 没有。// StringBuffer.append 的源码省略部分逻辑 public synchronized StringBuffer append(String str) { super.append(str); return this; }也就是说StringBuffer 是一个线程安全的可变字符序列代价是每次调用方法都有同步开销。StringBuilder 是线程不安全的但单线程环境下性能更好。在实际项目里99% 的字符串拼接场景都是单线程局部变量所以优先用 StringBuilder 完全没问题。只有非常明确地要把同一个可变字符串对象共享给多个线程操作时才需要考虑 StringBuffer 或手动加锁。1.3 JDK 9 之后底层存储的变化别再答 char[] 了这是近几年面试里最容易踩的暗坑。JDK 9 引入了一个叫COMPACT_STRINGS的特性String 的底层从char[]换成了byte[]同时增加了一个coder字段来标记编码方式coder 0表示 LATIN1 编码每个字符占 1 个字节只能表示 Latin-1 范围内的字符coder 1表示 UTF-16 编码每个字符占 2 个字节可以表示全部 Unicode 字符。为什么要做这个改动因为绝大多数实际业务字符串都是英文字母、数字、常见符号这些字符用 UTF-16 存储时每个 char 固定占 2 字节另一半字节全是 0白白浪费内存。改成 byte[] LATIN1 编码后英文字符串的内存占用直接减半。StringBuilder 和 StringBuffer 也跟随这个变化底层同样从char[]变成了带 coder 标记的byte[]。所以如果你现在回答“String 底层是 final char[]”严格来说只适用于 JDK 8 及之前面试官会立刻追问一句你用的是哪个版本这个改动还引出了一个隐藏考点StringBuffer 的toString()里有一个缓存机制JDK 8 就有后面会单独说。2. 字符串常量池所有 比较题目的根源面试题里只要出现和equals基本都是在考察字符串常量池的机制。这块内容可以从三个层面拆开看。2.1 编译期确定的值去哪了Java 源码里用双引号写出来的字符串字面量会在编译期被放进 class 文件的常量池。运行时JVM 会把它们加载到内存中的字符串常量池String Table。这里有个历史知识点JDK 6 及以前字符串常量池放在方法区永久代里JDK 7 开始它被移到了堆中。这个位置变化直接影响intern()的语义也影响到 JVM 默认字符串池能开多大的问题。字符串常量池是一个哈希表默认大小可以调整。JVM 参数有两个相关开关-XX:StringTableSize100003 -XX:PrintStringTableStatistics实际生产中如果字符串字面量非常多、哈希冲突严重可以适当调大StringTableSize来降低intern()和常量池查找的损耗。但这个参数看情况调别乱开后面会提。编译期还有一个关键优化常量字符串拼接会直接折叠。String a hello world; String b helloworld; System.out.println(a b); // truejavac 在编译时发现两个都是字面量直接把hello world折叠成helloworld所以 a 和 b 指向常量池里同一个对象。这里如果加上 final 变量也是一样的效果final String prefix hello; String c prefix world; // 编译期可确定折叠但如果不是 final 变量编译期无法确定值就不会折叠会变成运行期拼接。运行期拼接的字节码逻辑很有意思放到第 3 节一起讲。2.2 new String(abc) 到底创建了几个对象这个问题几乎是必考题。完整回答需要分情况String s new String(abc);当执行到这一行时如果常量池里还没有abc这个字符串类加载阶段创建常量池中的abc字面量对象创建 1 个运行new时堆中再创建一个独立的 String 对象又 1 个。所以答案是 2 个。如果常量池里已经存在abc比如之前代码里出现过一次那这一行只创建堆中的 1 个新对象。但这里有个更隐蔽的问题new 出来的对象和常量池里的对象是什么关系String pool abc; String heap new String(abc); System.out.println(pool heap); // false引用不同对象 System.out.println(pool.equals(heap)); // true内容相同那有没有办法让 heap 指向常量池里的同一个对象有用intern()。String same heap.intern(); System.out.println(pool same); // trueintern()的语义在 JDK 7 前后有变化JDK 6 及以前如果常量池没有相同字符串intern()会在永久代复制一份返回永久代中的引用JDK 7 及以后如果常量池没有相同字符串intern()会把当前堆中对象的引用直接存入常量池返回这个引用。所以 JDK 7 会出现这样一个结果String x new String(xyz).intern(); System.out.println(x new String(xyz).intern()); // 甚至可能为 true这里很多候选人会绕晕我建议用一句话记JDK 7 之后 intern 是“引用的登记”不是“复制”。2.3 运行期拼接的三种情况运行期拼接主要分三类面试官特别喜欢考循环里那个第一种两个非 final 变量拼接String a he; String b llo; String c a b;javac 编译时不知道 a 和 b 运行时会变成什么于是生成类似这样的字节码逻辑new StringBuilder() .append(a) .append(b) .toString();也就是说非编译期可确定的拼接底层实际是 StringBuilder 在干活。这条结论很关键后面解释“为什么还要手动用 StringBuilder”时就靠它。第二种循环内拼接String s ; for (int i 0; i 10000; i) { s s i; }每次循环都会执行一次new StringBuilder()→ append →toString()。也就是说循环一万次就创建了一万个 StringBuilder 临时对象、一万个中间 String 对象。复制和 GC 压力都非常大。这就是为什么所有性能文章都强调不要在循环里用拼接字符串。第三种JIT 运行期的优化现代 HotSpot VM 在 JDK 8 之后有一个优化开关-XX:OptimizeStringConcat默认开启它会对连续 append 的局部 StringBuilder 做逃逸分析和特殊优化可能避免创建中间 String 对象甚至直接在底层数组上操作。这里要留意这个优化主要针对简单、连续的 append 场景循环里跨越多次迭代的累加拼接依然逃不掉新建对象的代价。所以面试时不要天真地回答“JIT 都优化了所以用 也行”面试官会继续追问优化失效的条件。3. 高频巩固题逐题拆解从送分到拉分题目本身不难但每个题目背后都能挖出一大串知识点。把这几道巩固题吃透比盲目刷一百道记忆题有用得多。3.1 初级题字面量、new、equals 三连问先看下面这段代码说出每一行的输出public class StringQuiz1 { public static void main(String[] args) { String s1 abc; String s2 abc; String s3 new String(abc); System.out.println(s1 s2); // (1) System.out.println(s1 s3); // (2) System.out.println(s1.equals(s3)); // (3) String s4 s3.intern(); System.out.println(s1 s4); // (4) } }输出结果语句结果原因(1) s1 s2true字面量都指向常量池中同一个对象(2) s1 s3falses3 是堆中 new 出来的新对象引用不同(3) s1.equals(s3)trueString 重写了 equals比较字符内容(4) s1 s4trueintern() 返回常量池中的引用和 s1 相同这里有个容易被忽略的点String 重写equals时也重写了hashCode算法是s[0]*31^(n-1) s[1]*31^(n-2) ... s[n-1]。用 31 是因为它是奇素数可以减少哈希碰撞并且现代 CPU 可以把乘法优化成移位和减法性能好。3.2 中级题循环拼接的性能差别到底有多大这是我面试时经常让候选人上机手写的一道题// 方式一使用 拼接 long start System.nanoTime(); String s ; for (int i 0; i 50000; i) { s s a; } long costPlus System.nanoTime() - start; // 方式二使用 StringBuilder StringBuilder sb new StringBuilder(); long start2 System.nanoTime(); for (int i 0; i 50000; i) { sb.append(a); } String result sb.toString(); long costSb System.nanoTime() - start2;不用看具体数字量级完全不对等。常见的结果是 StringBuilder 方案比方案快一到两个数量级而且字符数越多、循环次数越多差距越大。为什么差距这么大回到第 2.3 节的结论在循环里等价于每次 new 一个 StringBuilder再创建新的 String而 StringBuilder 复用同一个对象只在底层数组里追加。两者差的不只是拼接本身还有数万次对象创建和垃圾回收。3.3 进阶题StringBuffer 的 toString() 凭什么比 StringBuilder 快这个问题很多人没料到Steam 笔试题里出现过类似的坑。看源码StringBuilder 的toString()Override public String toString() { return new String(value, 0, count); }每次调用都会基于底层数组复制出一份新的 String。StringBuffer 的toString()Override public synchronized String toString() { if (toStringCache null) { toStringCache Arrays.copyOfRange(value, 0, count); } return new String(toStringCache, true); }StringBuffer 内部维护了一个toStringCache第一次调用toString()时会复制一份缓存下来如果之后没有 append、insert、delete 等修改操作第二次再调用 toString() 时可以直接复用缓存数组减少复制。不过这个缓存只要有任何修改操作就会失效重置所以只有“构建完字符串后多次调用 toString()”的场景下StringBuffer 的缓存优势才比较明显。平时用 StringBuilder 更合适。3.4 陷阱题StringBuilder 线程不安全到底是个什么症状“线程不安全”听多了会麻木很多人其实没见过实证。下面这段代码两个线程同时往一个 StringBuilder 里追加大量字符串结果会出现内容错乱或字符丢失StringBuilder sb new StringBuilder(); Runnable task () - { for (int i 0; i 20000; i) { sb.append(A); } }; Thread t1 new Thread(task); Thread t2 new Thread(task); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(length sb.length()); // 预期 length 40000实际往往小于 40000原因在于 append 内部需要先检查空间、再复制数据、最后更新 count多个线程同时操作时count 的更新和数组写入不是原子的互相覆盖最终长度对不上。把 StringBuilder 换成 StringBuffer 就可以得到固定结果因为方法级synchronized保证了同一时刻只有一个线程能操作。4. StringBuilder 扩容机制一次看懂 append 的代价StringBuilder 的底层是可变的字节数组数组总有填满的时候。所以面试中问“append 的底层流程”时要在讲完数组写入之后把扩容机制讲清楚。4.1 初始容量与扩容规则先看两条核心源码逻辑// AbstractStringBuilder private int newCapacity(int minCapacity) { int oldCapacity value.length 1; // 注意JDK9是 byte[]按 coder 换算 int newCapacity (oldCapacity 1) 2; if (newCapacity - minCapacity 0) { newCapacity minCapacity; } return newCapacity; }大致规则就是默认初始容量 16JDK 9 的 byte[] 场景逻辑会更细但思想一致扩容时新容量约等于旧容量的 2 倍再加 2如果扩容后的容量还不够放就直接扩容到所需最小值扩容本身会申请新数组然后把旧数组内容Arrays.copyOf过去。用默认无参构造器反复 append 时有多次扩容就对应多次数组复制和旧对象垃圾回收。虽然数组复制是高效的本地方法但数据量大以后仍然有可感知的开销。4.2 扩容带来的开销实测我自己做过一个简单的实验往 StringBuilder 里追加 100 万次a分别用无参构造器和预分配容量构造器构建方式大致耗时示例环境扩容次数无参构造器默认 16明显偏慢扩容约 15 次左右每次复制已有数据预分配 1 20 容量明显更快0 次容量刚好够虽然这里的“慢”在大字符串场景下也只有几十毫秒级别的差异但背后的原理值得理解每扩容一次前面所有数据都要被复制一遍扩容次数越多累计复制的数据量越接近 O(n²)。4.3 容量估算与常见性能习惯写代码时的最佳实践是如果能估算出最终字符串长度就优先用指定容量的构造器。// 不建议 StringBuilder sb1 new StringBuilder(); // 建议明确预估长度比如 1000 条记录每条约 50 个字符 StringBuilder sb2 new StringBuilder(1000 * 50);注意容量也不是越大越好如果只拼一个 20 字符的 SQL 片段却给了 1GB 的初始容量那纯粹是浪费内存。合理估算即可。常见操作的复杂度也要心里有数append(char)/append(String)均摊 O(1)insert(int offset, …)需要移动后面的数据O(n)deleteCharAt(int index)同样需要移动数组O(n)reverse()O(n)实现巧妙地在原地交换左右对称位置。基于这些复杂度我在 review 时经常见到把insert(0, /)当“放在开头”用的写法。每调一次 insert(0) 就是一次全数组移动循环多了性能立刻崩正确的做法是先 append 完再整体 reverse或者直接逆序拼接。5. 日常开发选型建议哪些场景真的需要用 StringBuilder刷题归刷题落到真实项目里选型不能只背“性能好就用 StringBuilder”。代码可读性、日志代价、并发模型都要考虑。5.1 日志场景先判断这行日志要不要打我在 review 时经常看到系统里到处是这种代码logger.debug(user userId request params);如果日志级别恰好是 DEBUG这句话会被执行如果生产环境日志级别是 INFO这个字符串依然会被拼接只是最终没有输出。每次接口调用都白白做一次拼接在高 QPS 服务里成本不小。更合理的方式是判断日志级别if (logger.isDebugEnabled()) { logger.debug(user {} request {}, userId, params); }现在主流日志框架比如一些成熟的日志门面内部用了占位符延迟构建消息配合isDebugEnabled()判断能省掉很多无效拼接。少量参数直接字符串拼接也没毛病可读性最重要。5.2 批量组装用 StringBuilder 还是 StringJoiner如果要把一个集合拼成a,b,c,d这种带分隔符的字符串容易写出这么一段StringBuilder sb new StringBuilder(); for (String s : list) { sb.append(s).append(,); } sb.deleteCharAt(sb.length() - 1); // 去掉最后一个逗号能跑但不够优雅。JDK 8 之后有更合适的工具String.join()和Collectors.joining()。// 直接拼接 String result String.join(,, list); // 流式处理时 String result list.stream().collect(Collectors.joining(,));如果还要带前缀后缀比如生成 JSON 风格片段可以用StringJoinernew StringJoiner(,, [, ]) .add(a) .add(b) .toString(); // [a,b]所以“用 StringBuilder”不是万能答案。当元素来自集合、又有明确分隔符时StringJoiner和String.join()可读性更好底层其实也走进度条式的 append 流程性能不差。5.3 review StringBuilder 代码时的高频误用我整理一下平时 code review 里见到最多的几个问题不需要可变拼接却非要用 StringBuilder。比如把写死的三四个片段拼起来直接select table where id id就行编译期加 JIT 优化后基本没开销。硬写一个 StringBuilder 反而冗长。循环里重造轮子。比如在系循环内部new StringBuilder().append(...).toString()这和拼接没有任何区别少数几次没问题循环多了纯属浪费。用 StringBuffer 但没有多线程共享需求。很多老项目为了“稳妥”全项目用 StringBuffer单线程下白白扛着同步锁。如果代码逻辑里这个 StringBuilder 根本不会被其他线程看到用它就是在牺牲性能换一个永远用不上的安全。在循环里频繁用 substring() 拆分大字符串。JDK 7 之后 substring 会创建新的字符数组不再像 JDK 6 及以前那样共享原来大数组的引用。前者多次复制后者则可能让一个大字符串一直被小片段拽着无法回收产生内存浪费。这个属于 String 的底层演进也是常被忽略的老坑。我把现在审视业务代码的顺序也分享给你先看这个拼接是编译期就能确定的还是运行期才能确定的再看是在单线程局部环境还是多线程共享环境最后估算规模决定直接用 、用 StringBuilder还是用 StringJoiner。把顺序捋顺选型基本不会错。最后顺手推荐一个排查手段如果你怀疑系统里字符串池过大可以在启动参数里短暂开启-XX:PrintStringTableStatistics观察统计信息。一般来说如果字符串总量不高但 intern 调用非常频繁通常意味着代码里存在不必要的intern()使用这时候优先改代码而不是盲目调大StringTableSize。刷题的意义也在于此把底层机制弄明白了才能在实际项目里做出稳妥的取舍。