
能用String别用char[]能用StringBuilder别反复拼String——这种顺口溜我在带团队、做技术评审时几乎每周都能听到一次但真问到为什么的时候能讲清楚的人其实没几个。今天我把Java里char、String、StringBuilder这三兄弟放到一起从内存、编码、性能、并发角度逐个拆开讲透顺便回答那些收藏了八百遍还没懂的面试题。这篇文章既适合刚学Java的新手建立底层认知也适合准备跳槽的兄弟把知识体系补全看完可以直接拿去用在代码评审和面试里。1. char在Java里到底存的是什么1.1 char是16位整数别把它当成字符很多初学者有一个根深蒂固的误解char就是用来存字符的一个char等于一个字符。这个理解在ASCII时代是对的放到今天已经严重过时。先说Java里char的真实面目。char是Java中唯一一个无符号基本类型占16位取值范围0到65535。它本质上就是个非负整数只不过我们在大多数场景下把它当成字符来使用。因为Java诞生的时候Unicode标准还停留在所有字符最多用16位就能表示的阶段JVM设计者选了UTF-16作为内部字符串编码。那时的判断是两个字节够用而且可以和Unicode序号一一对应平台无关性也好做。谁也没料到后来emoji大规模爆发Unicode直接冲出了BMP基本多语言平面16位不再够用。有个很常见的蓝桥杯或入门题会用到String.format(%04b, (int) c)很多人第一次看到这行代码会懵。拆开看就懂了(int) c把char当成整数取出来%04b是按二进制输出、最短宽度为4位、不足补0。比如char c 5;格式化输出就是0101。这类题目考察的正是char本质是整数这个点常见于位运算、编码转换、数字拼装等场景。实际操作里判断一个字符是不是字母或数字我推荐直接使用Character工具类而不是自己去手写范围判断。比如判断既不是字母也不是数字boolean valid true; for (char c : str.toCharArray()) { if (!Character.isLetterOrDigit(c)) { valid false; break; } }Character.isLetterOrDigit内部还会处理Unicode中的字母数字不只是ASCII。如果你用(c a c z)这种写法遇到中文、阿拉伯数字、全角字符时就会漏判这也是我在代码评审里经常给新人指出的问题。1.2 代理对、码点与char的真实局限既然char只有16位那超出BMP的字符怎么存答案是代理对surrogate pair机制把Unicode码点拆成两个char一个高位代理范围0xD800-0xDBFF一个低位代理范围0xDC00-0xDFFF两个char合起来表示一个完整字符。最常见的例子就是emoji。随便写一句String emoji ; System.out.println(emoji.length()); // 2 System.out.println(emoji.codePointCount(0, emoji.length())); // 1同一个字符串length()返回2因为底层确实是2个char但看成用户感知的字符只有1个。这在截取、反转、统计长度的时候全是坑。如果你按charAt去遍历看到的是两个孤立的代理项打印出来还是乱码。正确做法是面向码点code point处理String text AB; int cpCount text.codePointCount(0, text.length()); text.codePoints().forEach(cp - { System.out.println(Integer.toHexString(cp)); });codePoints()是Java 8引入的流式接口能把字符串按真正的字符拆开。如果你需要在旧版本JDK上遍历可以配合Character.codePointAt手动处理边界但新项目我建议统一用codePoints()。这个限制直接影响代码设计。凡是涉及用户输入的文本处理比如评论内容、用户名就不能假设一个char就是一个显示字符。我在实际项目里遇到过因为表情符号被截断导致数据库报错也遇到过字符串反转后emoji变成两个问号。这些都是char这个基础类型带来的连锁反应理解了UTF-16的编码模型排查这类问题就有方向了。2. String不可变性最大的设计也是最大的坑2.1 不可变到底换来了什么String在Java里被设计成final类内部的字符数组也不允许外部修改任何修改操作都会返回一个新字符串。很多人背过String是不可变的但没想过为什么。第一个原因是字符串常量池的共享。因为不可变JVM可以放心地把同一个字面量字符串缓存起来复用。String s1 abc; String s2 abc;两个变量指向常量池里同一个对象这大大减少了内存占用。如果字符串可变池里的对象可能被任意一个引用改掉所有人拿到的值都会变。第二个原因是线程安全。不可变对象天然是线程安全的不需要加锁。所以在多线程环境下String可以作为HashMap的key放心使用不用担心hash值因为内容变化而失效。谈到Java怎么保证数据一致性时不可变对象正是最简单可靠的手段之一。第三个原因是安全。String经常用来表示路径、类名、配置项、网络参数如果字符串可变攻击者可以通过修改字符串内容绕过安全检查。这也是String被设计成final的直接动机之一。这里顺带补充一个版本细节JDK 9之前String内部确实是char[]从JDK 9开始内部改成了byte[]加一个coder标识。当字符串内容能用ISO-8859-1表示时用单字节存只有需要时才升级成UTF-16。同样是abcJDK 8下一个对象占用48字节左右JDK 9之后明显更省。这个改动对内存敏感的大流量服务很关键也解释了为什么String底层有时候被叫byte[]。2.2 拼接真相号背后发生了什么先抛结论字符串拼接绝不是无脑用也不是绝对不能碰。你得知道编译器到底做了什么。如果拼接的全是字面量比如String s a b c编译阶段就会直接折叠成abc没有任何运行时开销。这得益于常量折叠机制。如果是变量拼接比如String prefix order_; String id 1001; String key prefix id;编译之后的字节码等价于new了一个StringBuilder然后连续两次append最后toString。也就是说本质上是语法糖底层还是StringBuilder。这解释了为什么短串拼接完全不需要手动优化——编译器已经帮你做了。但循环拼接是另一种情况String result ; for (int i 0; i 10000; i) { result i; }这段代码每次循环都创建一个新的StringBuilder做一次append再toString结果赋值给result下一轮继续。一轮操作至少产生一个新StringBuilder加一个新String对象1万次循环就是2万个临时对象。如果这个循环跑在业务高峰期GC压力一下就上来了。所以我的建议分两种情况。在非循环、拼接次数很少的代码里用可读性最好不用刻意改成StringBuilder属于过度优化。在循环、批处理、日志拼大对象的场景里老老实实用StringBuilder。另外Java 9之后引入了indy字符串拼接编译器不一定总是生成StringBuilder但逻辑上仍然是缓冲拼接的思路性能优化的方向没变。2.3 、equals与字符串常量池这是面试和线上bug的重灾区。我见过太多事故因为比较字符串导致逻辑判断失败。记住一句话比较的是引用equals比较的是内容。看一段经典代码String s1 abc; String s2 abc; String s3 new String(abc); System.out.println(s1 s2); // true常量池复用 System.out.println(s1 s3); // false堆上新对象 System.out.println(s1.equals(s3)); // true内容相等为什么s1 s3是false因为new String(abc)直接向堆里申请了一个新对象虽然内容和常量池里的abc相同但引用地址不同。如果代码里习惯用判断字符串一旦某个字符串恰好来自接口返回、JSON解析、数据库读取就极有可能不是常量池里的对象判断结果自然不符合预期。intern()方法偶尔会被问到。它的作用是把一个字符串尝试放入常量池并返回池中的引用。比如String s4 new String(abc); System.out.println(s4.intern() s1); // true这里intern()会去常量池找abc找到后返回常量池中对象的引用所以和s1相等。intern()可以用来省内存但我不推荐在日常代码里频繁使用因为常量池本身也是内存区域滥用会导致永久代或堆里字符串堆积反而造成性能问题。JDK 7之前常量池放永久代PermGenJDK 7开始移到堆里也降低了OOM风险。3. StringBuilder性能问题的标准答案3.1 和String、StringBuffer放一起看StringBuilder解决的问题很直观可变、不产生中间对象、按需扩容。和String的本质区别在于StringBuilder内部维护了一个可修改的字符序列append操作直接写进这块缓冲区不搞复制。StringBuffer则是StringBuilder的线程安全版本几乎所有公开方法都加上了synchronized。问题在于大多数业务场景里一个StringBuilder变量只存在于方法内部根本不会被多个线程共享这时候加锁就是纯开销。我实测过单线程高频拼接下StringBuffer比StringBuilder慢20%到50%并且拼接次数越多差距越明显。所以现代代码规范里推荐优先用StringBuilder只有明确的共享可变字符串场景才用StringBuffer。StringBuilder还有一个很容易被忽略的设计append方法返回this。这就支持了链式调用StringBuilder sb new StringBuilder() .append(name).append(name) .append(,age).append(age);这样写的好处不光是代码漂亮还能让编译器和读者一眼看出这是一次完整构造过程减少中间变量。实际开发中拼SQL、组报文、拼日志我都建议用链式。StringBuffer的线程安全也不是万能的。多个线程同时append同一个StringBuffer实例虽然每个方法原子但如果你在append之后立即toString另一个线程可能又append了新内容整体顺序仍然不可控。如果需要整个字符串在多线程间保持一致快照正确做法是加锁范围覆盖到整个拼接过程或者干脆每个线程用局部变量。3.2 扩容机制与初始化容量StringBuilder无参构造默认容量是16。当我们不断append时底层数组不够用就要扩容。很多人不知道扩容规则面试也常考新容量大约是旧容量左移一位加2也就是翻倍加2。看JDK源码里的逻辑int newCapacity (oldCapacity 1) 2;为什么是翻倍加2这里要说清楚。翻倍是保证扩容后有一段稳定的缓冲空间后续几次append都不需要再次扩容均摊下来扩容的复制成本很低。加2则是历史设计留下的余量避免容量扩到0后追加字符触发越界。这个细节在源码注释里没有写透但翻倍策略的本质就是用少量空间换时间。扩容是要付出成本的申请新数组、把旧数组内容System.arraycopy过去、释放旧数组。如果你的字符串最终长度是1万字符而初始容量是16中间可能扩容10次左右每次都要复制越往后复制量越大。所以我建议在预估到字符串长度的场景里指定初始容量StringBuilder sb new StringBuilder(userList.size() * 32 64);比如拼一批用户数据每条大概32个字符集合有1000个元素那就给1000 * 32 64的容量。预留一点余量能显著减少扩容带来的数组复制。这条优化在循环批量处理时非常有效我做过压测指定合适容量后整体耗时能再降20%到30%。3.3 五个容易被忽略的高效用法第一个是setLength(0)清空复用。如果你需要在一个循环里反复拼接同一个StringBuilder不要每次new只需要setLength(0)把长度归零底层数组还在可以继续用。这样可以避免反复分配新对象StringBuilder sb new StringBuilder(128); for (Row row : rows) { sb.setLength(0); sb.append(row.getId()).append(,).append(row.getName()); write(sb.toString()); }第二个是reverse()反转。字符串反转在面试题里出现频率极高最简单的生产级写法就是new StringBuilder(input).reverse().toString()。自己用char数组交换当然也行但代码容易出错尤其是遇到前文说的代理对字符反转之后直接乱码。StringBuilder的reverse会把代理对当作一个整体处理正好绕开这个坑。第三个是deleteCharAt()配合delete()做末位清理。比如拼a,b,c这种CSV格式可以在循环里每拼一个元素加一个逗号循环结束后deleteCharAt(sb.length() - 1)把最后一个逗号删掉。比每次判断要不要加逗号清爽得多。第四个是getChars()复制到char数组。某些底层网络编程需要把字符串内容放到固定缓冲区用getChars能直接复制内容到目标char数组避免经过toCharArray产生临时对象。第五个是toString()之后释放引用。JDK 8及更早版本里toString()可能直接基于内部char数组构造String外部如果继续持有StringBuilder理论上可以通过其他方法修改数组内容引发数据异常。新版本已经改为拷贝但为安全起见字符串构造完成后不要再依赖原来的StringBuilder实例尤其不要跨线程传播这种共享可变对象。4. 面试题与实战避坑清单4.1 高频面试题背后真正想考什么先说最经典的一道String s new String(abc)创建了几个对象如果常量池里还没有abc答案是两个一个常量池中的abc一个堆中的String对象。如果常量池已有就是一个。这道题考察的不是记忆力而是你对常量池、堆、字面量加载机制的理解。第二个必问为什么String要设计成不可变回答时别只背安全、线程安全、常量池复用最好再用一个例子说明。比如HashMapString, Integer map new HashMap(); map.put(key, 1);如果String可变存入HashMap后被改成其他内容哈希桶就会错乱再也取不出原来的value。不可变保证了hash值稳定HashMap才能安全地用字符串做key。这个例子一说面试官就知道你是真理解。第三个必问String、StringBuilder、StringBuffer的区别。我的标准答法是三层可变性上String不可变、后两者可变目标上StringBuffer通过synchronized保证线程安全但性能差应用选择上单线程拼大量字符串用StringBuilder多线程共享字符串用StringBuffer或加锁。如果还能补一句Java 9之后String内部用byte[]压缩了纯ASCII字符串的内存占用会显得知识更新很及时。第四个常见题字符串反转有哪些写法我建议至少能写出两种一种是new StringBuilder(str).reverse()一种是用char数组手动交换char[] chars str.toCharArray(); int left 0, right chars.length - 1; while (left right) { char tmp chars[left]; chars[left] chars[right]; chars[right] tmp; left; right--; }但要注意char数组手动反转会把emoji拆坏遇到代理对会乱码。面试时主动指出这个边界问题会显得你踩过坑、有实战意识。4.2 实战中我踩过的坑我自己踩过最深的坑是线上日志拼接导致的GC问题。当时有个接口打印很详细的请求报文日志框架用的log.info(请求参数: param1 , param2 ...)。参数有20多个字符串拼接长度接近2KB接口QPS过千每一条日志都产生大量临时对象。监控一看Young GC暴涨。把日志改成占位符{}并由日志框架内部处理或者手动用StringBuilder组装GC立刻降下来。这个案例让我记住了日志里的大动态字符串拼接一定要走StringBuilder或者日志框架自己的缓冲机制不能让随意生成中间对象。第二个坑是统一编码的绕路问题。接口返回的数据偶发乱码查了很久发现是有人在代码里写了new String(bytes, GBK)但从数据库到网关全程都是UTF-8。这种一步错、步步错的编码问题源头往往是开发机器和服务器默认字符集不同。getBytes()不传编码时依赖JVM默认字符集项目启动参数一换行为就变。我的建议是所有字符串和字节数组互转显式指定StandardCharsets.UTF_8。第三个坑是使用StringBuilder做全局静态变量。有个老项目把StringBuilder放进Spring的单例Bean里当缓冲用结果多用户请求互相污染数据。StringBuilder不是线程安全的多个请求写入同一块缓冲区最终拼接出的字符串乱七八糟。修复方案也很简单要么改方法内局部变量要么用ThreadLocal要么加锁。但最佳实践永远是局部变量优先。4.3 典型故障排查实录与速查表有一次排查应用内存持续上涨、老年代不断增长heap dump之后发现大量相同内容的String对象。哪里来的代码里有一个方法每次从外部配置中心读取一段模板再在循环里用拼接数据1万个用户就生成1万个相同前缀的字符串。模板部分每次重新创建堆里堆了大量不可变String。修复方案把模板常量定义为static final拼接改为复用StringBuilder配合substring只截取需要变化的部分。还有一次是substring引发的老年代溢出。这个坑和JDK版本强相关。JDK 6及之前String.substring不会复制底层数组而是新String对象直接引用原String的数组。如果从一个超大字符串里截取一个小段再把这个小段长期保存底层整个大数组就都无法回收。JDK 7之后改成了复制这个隐患小了很多但在老项目升级时还是值得注意。类似场景如果必须长期持有截取结果可以new String(origin.substring(...))强制脱钩。下面整理一个速查表覆盖我见过的高频问题现象可能原因推荐解法字符串比较结果总不对用了比较内容改成equals字面量常量用常量引用循环拼字符串后GC升高在循环里产生大量中间对象循环外建立StringBuilder循环内appendemoji被截断或反转乱码按char截断/遍历破坏代理对用codePointCount、codePoints处理多线程共享StringBuilder后数据错乱StringBuilder非线程安全改局部变量或加锁、改StringBuffer编码转换出现乱码getBytes()依赖默认字符集显式指定StandardCharsets.UTF_8大批量相同内容的String堆内存模板字符串每次重建常量池化复用static final截取小段字符串后内存不释放JDK 6及以前substring持有原数组用new String(sub)拷贝脱钩文件末尾再分享一个我个人用得很顺手的小技巧。当你要拼接一个多字段的HTTP头或者SQL片段时与其写一长串append不如用StringJoiner或者String.join()StringJoiner joiner new StringJoiner(,, [, ]); joiner.add(a).add(b); System.out.println(joiner); // [a,b]好处是前缀后缀和分隔符一次定义代码里不用自己处理首尾逗号可读性比手工deleteCharAt强很多。实际开发中JVM对字符串处理的优化一直在变但底层思路没变理解char的编码局限、理解String的不可变设计、理解StringBuilder的缓冲思想这三块地基打牢无论JDK怎么升级遇到字符串相关的问题都能迎刃而解。