"能用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 = "A😀B"; 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。同样是"abc",JDK 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之前常量池放永久代(PermGen),JDK 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要设计成不可变?回答时别只背"安全、线程安全、常量池复用",最好再用一个例子说明。比如:
HashMap<String, 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,循环内append |
| emoji被截断或反转乱码 | 按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怎么升级,遇到字符串相关的问题都能迎刃而解。