每次面试,只要候选人简历上敢写“熟悉 Java”,我几乎必问的一个问题就是:String 到底存在哪里?字符串常量池还是堆?不少人能答上“字面量在常量池,new 出来的在堆”。我一追问“那String str = new String("abc")到底创建了几个对象”,就开始翻车了。今天这篇就把 String 这个“最熟悉的陌生人”拆开揉碎,从内存模型、常用方法、拼接性能到高频易错点,一次讲清楚。适合刚学 Java 的基础党,也适合正在准备面试,或者写代码被字符串坑过无数次的同学。
1. String 为什么“不可变”?这背后是内存模型和常量池的设计
1.1 先搞清楚字符串在 JVM 里怎么放
String 在 JDK 8 之前底层是char[],JDK 9 开始优化成byte[]并引入coder字段,用来区分 Latin-1 和 UTF-16 编码。这是为了节省内存:纯英文场景下,一个 char 占 2 字节,一个 byte 只占 1 字节。但真正让 String 特别的是它被设计成了final class,内部核心字段private final char value[]或private final byte[] value,也就是说字符串对象创建之后,里面的字符序列就不能再被修改了。
JVM 为了性能和内存考虑,专门划了一块“字符串常量池”,存放字符串常量。当你写出"abc"这种字面量时,JVM 会先去常量池里查,有没有相同内容的字符串。有就直接复用,没有就创一个放进去。JDK 7 之后,字符串常量池从永久代移到了堆中。这个细节经常被拿来考,记忆点就是:常量池不是 JVM 里一个单独的神秘区域,它本质上是堆管理的一部分,只是逻辑上独立。
这里有个很常见的理解偏差:常量池里的字符串和普通的new String()对象,虽然内容可能完全一样,但在内存里是完全不同的对象。"abc"是常量的规范化存储,而new String("abc")是在堆上额外给你造了一个新对象。理解不了这点,后面的==和equals问题一定绕晕。
1.2 字面量和 new String() 的区别
直接看代码,这是面试里出现频率最高的代码片段之一:
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和s2都指向常量池里的同一个对象,所以==为true。s3不是常量池里那个对象,而是在堆上 new 出来的另一个 String 对象,所以s1 == s3是false。但内容一样,所以equals是true。
这个例子背后藏着一个非常重要的原则:在 Java 里比较字符串内容,永远用equals,不要用==。==只比较引用地址,不比较字符串是否相等。除非你能百分之百确定两个引用指向同一个常量池对象,否则==就是给自己埋雷。
还有一个经典衍生题:String s3 = new String("abc")到底创建了几个对象?标准答案分两种情况:
- 如果常量池里已经有
"abc",那么只在堆上创建了 1 个对象。 - 如果常量池里还没有
"abc",那么会先在常量池创建 1 个 String 对象,再在堆上创建 1 个 String 对象,一共 2 个对象。
注意,日常面试讨论一般不算内部char[]/byte[]数组对象,否则可以追问到 3 个甚至更多。先把这个常规答案记牢,再深入一点就有优势。
1.3 为什么设计成不可变
很多初学者不理解:为什么要让 String 不可变?改成可变的多方便?实际上不可变背后有四个实打实的好处。
一是字符串常量池安全。既然多个引用可能指向同一个字符串对象,如果这个对象可以被修改,那一个地方改了,其他引用全部会被影响,程序瞬间乱套。不可变让常量池共享变得安全。
二是线程安全。不可变对象天然线程安全,不需要加锁,可以在多线程环境里放心共享。String 作为最常见的参数类型、日志信息、网络数据载体,这一条价值极大。
三是哈希缓存。String 重写了hashCode(),而且因为内容不可变,哈希值可以缓存在对象内部。这也是为什么 String 特别适合做 HashMap 的 key。如果字符串可变,hashCode 一变,HashMap 里的数据就找不到了。
四是安全性。比如我们读取一个文件名、路径、类名,如果拿到的是可变字符串,调用过程中可能被其他代码偷偷改掉,造成不可预料的后果。final class 还避免了被子类重写行为。
看似简单的不可变设计,其实是为了整个 JVM 和 Java 生态的稳定。这个知识点不光是面试用来背的,写代码时也应该有这种意识。
2. String 常用方法实战,附易错点
2.1 equals 与 ==:字符串比较的生死线
前面已经提到==和equals,这里再往深挖一层。Object.equals()默认就是==比较引用地址,而String重写了它,改成逐个字符比较内容。所以两个 String 内容相同,equals一定返回true。
看几个容易出错的变体:
String s = "abc"; StringBuilder sb = new StringBuilder("abc"); System.out.println(s.equals(sb)); // false System.out.println(sb.equals(s)); // false为什么两个都是 false?因为String.equals()里做了一个instanceof String判断,StringBuilder 不是 String 的实例,直接返回 false;而 StringBuilder 没有重写equals(),用的是Object.equals()的引用比较,s 和 sb 根本就是两个对象,所以也是 false。如果想把 String 和 StringBuilder 内容比较,得先sb.toString()再 equals。
还有 null 的问题。很多新手喜欢写:
String str = getFromDb(); // 可能返回 null if (str.equals("success")) { ... }一旦 str 是 null,这里直接抛 NullPointerException。所以更稳妥的写法是:
if ("success".equals(str)) { ... }把常量放前面,变量为 null 也不会报错,就是 false。这个习惯养成之后,能帮你少踩无数坑。
2.2 intern():理解字符串拘留的妙用
String.intern()是一个容易被忽略,但面试很喜欢问的方法。它的作用一句话说:如果常量池里有内容相同的字符串,直接返回池中的引用;如果没有,就把当前字符串加入常量池并返回引用。
看这个例子:
String a = "abc"; String b = new String("abc"); System.out.println(a == b); // false System.out.println(a == b.intern()); // trueb.intern()返回的是常量池里那个"abc",所以和a是同一个引用。实际开发中,如果你的系统里有大量内容重复的字符串,比如从数据库读出来的状态值、编码值,可以考虑用 intern 去重,减少内存占用。
但 intern 不是万能的。频繁调用 intern 本身有额外的字符串查找和哈希操作,如果池里根本没有这个字符串,还可能引起常量池膨胀。尤其是 JDK 7 之前,字符串常量池在永久代,intern 用多了很容易 OOM。所以它更适合用在内容有限且重复度高的场景,不适合无脑乱用。
2.3 常用方法:split、replace、substring 的隐藏坑
这几个方法每个单独拿出来都看起来很简单,组合起来就变成面试易错点密集区。
先说split。split的参数是正则表达式,不是普通字符串。这意味着你想按.分割字符串,不能写str.split("."),因为正则里.代表任意字符,这样切割出来的结果往往不是你要的。必须转义:
String str = "a.b.c"; String[] arr = str.split("\\."); // 正确同理,竖线|要写成str.split("\\|"),反斜杠本身要写成str.split("\\\\")。还有一个隐藏坑:split会丢弃尾部空字符串。看代码:
String str = "a,b,,"; String[] arr = str.split(","); System.out.println(arr.length); // 2,而不是 4结果只有["a", "b"],因为默认 limit 为 0,尾部空串被丢弃。如果你需要保留尾部空字符串,要传负数:
String[] arr = str.split(",", -1); // ["a", "b", "", ""]再说replace和replaceAll。replace是普通字符匹配,replaceAll是正则匹配。新人最容易犯的错误是用replaceAll替换字符串里的$或\。比如想把"price=100$"中的$替换成空,直接写str.replaceAll("$", "")会发现在末尾多了一个空替换位置,因为$在正则里是字符串结尾的锚点。正确做法是转义"\\$",或者干脆用replace("$", ""),因为 replace 不涉及正则,匹配的是字面字符。
最后说substring。在 JDK 6 时代,substring返回的新 String 并没有复制字符数组,而是继续引用原来的 String 底层数组。假如你截取了一个超大字符串的一小段,并长期持有,那整个大数组就一直被引用着没法回收,内存就泄漏了。JDK 7 之后改成复制字符数组,这个问题基本解决。如果你还在维护老 JDK 6/7 的项目,就需要谨慎,截取完后主动new String(str.substring(...))这种操作在一些老系统里确实有人这么干。
2.4 判空、空串与 null 的处理
很多 Java 老手也会在这里翻车。空串""是长度为 0 的字符串对象,而null表示引用不指向任何对象,两者有天壤之别。
String a = ""; String b = null; System.out.println(a.isEmpty()); // true System.out.println(b.isEmpty()); // NPE最稳妥的判断写法是:
public static boolean isEmpty(String str) { return str == null || str.isEmpty(); }如果你用的是 JDK 11 及以上,还可以考虑isBlank(),它不仅能判断空串,还能判断只包含空白字符的字符串,比如空格、Tab。" "用isEmpty()判断是 false,但用isBlank()判断是 true。这对处理用户输入的校验场景非常有用。
String.valueOf(null)这个也要注意。虽然String.valueOf(Object obj)在传 null 时返回字符串"null",但如果你直接调用String.valueOf(char[] data)并传 null,行为又不一样。实际开发里最常见的坑是字符串拼接:任何字符串和 null 用+拼接,null 会被输出成"null"。比如"结果:" + null的结果是"结果:null"。代码审查时经常看到有人因为这个把脏数据写进了日志,排查的时候一脸懵。
3. 字符串拼接:性能、原理与最佳实践
3.1 + 号拼接的背后:StringBuilder
很多人以为"a" + "b"就是简单字符串拼一起,没什么技术含量。但放到 JVM 层面,编译器会做很多手脚。
如果是两个字面量相加:
String s = "a" + "b";编译期间就会直接优化成"ab",不会有任何运行时拼接。如果是变量相加:
String a = "a"; String b = "b"; String s = a + b;编译器会生成类似这样的代码:
String s = new StringBuilder() .append(a) .append(b) .toString();也就是说,+的本质是创建 StringBuilder 然后 append。用起来省事,但要小心循环里的情况。如果在一个循环里反复写s += item,每次循环都会 new 一个 StringBuilder,再调 toString,循环一万次就创建一万个临时对象,GC 压力巨大。
我自己写代码时确实见过这种“性能杀手”,在一个批量拼接邮件内容的场景里,循环里直接content += line,数据量上来后接口直接超时。改成外面放一个 StringBuilder,循环里只 append,性能立刻回暖。这不是玄学,是明摆着的对象创建和复制开销。
3.2 StringBuilder 与 StringBuffer 怎么选
两者的 API 几乎一模一样,区别就在线程安全和性能。StringBuffer 的公开方法基本都加了synchronized,所以是线程安全的;StringBuilder 没有加锁,线程不安全,但单线程下更快。
日常开发中,绝大多数拼接场景都在方法内部,变量不会逃逸到其他线程,所以优先用 StringBuilder。只有明确需要多线程共享同一个可变字符串缓冲区的时候,才考虑 StringBuffer。另外,StringBuffer 因为同步带来的额外开销,在并发竞争激烈时会放大,尽量不要为了“保险”而无脑使用。
还有一个容量问题。StringBuilder 和 StringBuffer 初始容量默认是 16。当内容超过容量时,底层数组会扩容,扩容规则大约是(旧容量 << 1) + 2,也就是变成原来的两倍再加 2。扩容意味着要申请新数组、复制旧数据,对象一大就很浪费。所以如果你能预估字符串最终长度,最好在创建时指定容量:
StringBuilder sb = new StringBuilder(1024);这个习惯在小字符串上看不出差别,但当你拼接几 KB 甚至几十 KB 的内容时,提前设置容量能省下好几轮数组复制,实测对性能有不小帮助。
3.3 循环拼接的优化方案
很多场景其实没必要自己 new StringBuilder,JDK 已经提供了更高级的工具。例如把集合元素用逗号连接:
List<String> list = Arrays.asList("A", "B", "C"); String result = String.join(",", list);或者用 Stream:
String result = list.stream() .collect(Collectors.joining(","));这些方式内部都用了 StringBuilder,但帮你处理了分隔符边界,代码更简洁,也不容易出错。
如果一定要用循环拼接,记住这个模板:
StringBuilder sb = new StringBuilder(list.size() * 10); for (String item : list) { sb.append(item).append(','); } sb.deleteCharAt(sb.length() - 1); String result = sb.toString();这套写法的关键是提前估算容量,避免扩容;同时把 append 链式调用,减少临时对象。虽然看起来比一行+啰嗦,但在高并发、大数据量场景下,差别非常明显。我的建议是:字符串长度小于几十个字符的简单拼接,直接+没问题,人类可读性最高;数据量大、循环里拼接,老老实实用 StringBuilder。
4. 面试高频题与八股串联
4.1 String、StringBuilder、StringBuffer 的区别
这是 Java 面试必考题,可以说覆盖了字符串核心知识点。我建议用一个表格来记忆:
| 对比项 | String | StringBuilder | StringBuffer |
|---|---|---|---|
| 可变性 | 不可变 | 可变 | 可变 |
| 线程安全 | 安全(不可变) | 不安全 | 安全(方法同步) |
| 性能 | 拼接时产生大量对象 | 高 | 略低 |
| 使用场景 | 常量、少量拼接 | 单线程大量拼接 | 多线程共享缓冲区 |
面试官问这个问题时,不要只背结论,要能补充一句“String 的不可变带来了常量池共享、哈希缓存、线程安全等好处,所以它适合做 key 和通用参数;StringBuilder 和 StringBuffer 是为了解决字符串频繁修改时的性能和可变性问题出现的,单线程用 Builder,多线程用 Buffer”。这样回答既有深度,又显得你理解底层,而不是背了八股。
另外要提醒一点:StringBuffer 的线程安全和 String 的线程安全不是一回事。String 是因为不可变,所以多个线程读同一个引用不会出问题;StringBuffer 是通过锁保证每次方法调用原子性。但多个线程同时调用 append 后再做更复杂的操作,StringBuffer 也不保证复合操作的原子性。很多人拿这个概念糊弄面试官,一旦被追问就露馅。
4.2 new String("abc") 创建了几个对象?
前面已经提到大部分答案,这里补充一些容易忽略的上下文。你要先分清楚:
- 如果常量池里已经有
"abc",此时new String("abc")只是在堆上多创建一个 String 对象,重点在于这个对象的底层 value 数组也是新建的。面试的标准答案:创建了 1 个 String 对象。 - 如果常量池里没有
"abc",那么加载类的时候会先把"abc"作为常量放进常量池,然后new String("abc")又在堆上创建 1 个新对象。面试的标准答案:创建了 2 个 String 对象。
但这个题经常有变体:构造函数里的"abc"算不算?底层char[]算不算?如果面试官深挖底层,你可以说“从语言层面看是两个字符串对象;如果再算内部数组,会有更多对象”。这种回答显得你懂边界,而不是死记答案。
还有一道类似的题:String s = new String("a") + new String("b")创建了几个对象?这就更复杂了,涉及"a"、"b"、StringBuilder、"ab"等。面试里遇到这种问题,不要慌,先分析每一步创建了哪些对象,再给出结论。实际上,懂原理的人不会刻意去背数字,而是能现场推出来。
4.3 String 为什么用 final 修饰?
String 类被声明为final,也就是不能被继承。这是保证不可变设计不被破坏的关键。如果 String 可以被继承,那子类就能覆盖方法,甚至修改内部的 value 字段,整个字符串安全性就崩塌了。
除了整个类final,内部的value数组也用了final修饰,但这只能保证引用不被重新赋值,不能保证数组内容不被修改。懂反射的人可能会说可以用反射去改 value 数组里的字符,确实能改,但这是暴力破坏了 Java 的封装语义,实际代码里没人会这么干。面试时主动提这个点,能让面试官觉得你不是机械背概念。
final 还有一层含义是编译期优化。字符串常量在编译时就确定下来了,"a" + "b"可以变成"ab",大量依赖 final 常量的场景也能在编译期直接计算。String 作为 Java 里使用量最大的类,这种优化价值很高。
4.4 switch 用 String 的底层原理
Java 7 开始,switch 支持 String。很多人不知道底层的实现方式,面试容易被问倒。
本质上,编译器对 String 的 switch 不是直接比较字符串,而是先用字符串的hashCode()做整型 switch,然后在每个 case 分支里再用equals()做二次确认。为什么要这样做?因为 hashCode 可能碰撞,只有二次 equals 才能保证结果准确。
这里藏着一个易错点:switch的 case 不能为 null,switch 的变量也不能为 null,否则会在内部调用hashCode()时抛出 NullPointerException。所以如果你想对可能为 null 的字符串做 switch,一定先判空,或者把它转成默认值:
String result = getResult(); switch (result == null ? "" : result) { case "SUCCESS": ... break; default: ... }这个处理在实际代码里非常实用,很多人接口返回 null 时直接在 switch 里炸了,就是因为没这个意识。
5. 实践中的坑与排查技巧
5.1 split 和正则容易踩的雷
除了前文提到的.、|、$,还有一个每过一阵子就有人踩的坑:用split("\\")切反斜杠。如果你想按单个反斜杠切分 Windows 路径C:\a\b\c,正则表达式需要一个正斜杠表示转义,所以代码得写"\\\\":
String path = "C:\\a\\b\\c"; String[] parts = path.split("\\\\");每多一层转义,代码就越难看懂,但这就是正则的规则。如果不确定某个字符在正则里有没有特殊含义,最稳妥的办法是用Pattern.quote()包裹待分隔符,这样它会把输入当作字面量处理:
String[] parts = str.split(Pattern.quote("."));这个方法虽然有点绕,但不会再为特殊字符转义头疼。另一个习惯是加入团队后,把这类正则字符转义写进团队的代码规范,避免反复踩坑。
5.2 substring 曾经的内存泄漏
这里再展开说一下。JDK 6 里的String内部是char[] value,substring(beginIndex, endIndex)返回的新 String 会通过调整偏移量和长度,直接复用同一个char[]。看起来高效,但隐藏一个致命问题:如果你截取一个 1 GB 大字符串的其中 10 个字符,并把截取结果长期持有,那 1 GB 的 char[] 因为被新对象引用着,永远不会被回收。这就是典型的内存泄漏。
JDK 7 改成了复制底层数组,新字符串独立持有自己的 char[],旧的大数组只要没有被原字符串引用就能回收。现在主流 JDK 已经不存在这个问题,但如果你在维护一些老项目,遇到“内存只增不减”的现象,可以怀疑是不是有老版本的 JVM 和 substring 在搞事。另外,就算现在不会有这个问题,如果你把一个超大字符串本身长期保存在变量里,只用了其中很小一段,这仍然是在浪费内存。尽早让大字符串变量失去引用,或者显式处理成小字符串,永远是好的习惯。
5.3 其他容易被忽略的坑
这里把我实际审查代码时经常看到的问题集中列一下,每个都有人中招过。
第一个是String.replaceAll替换内容里有$。比如你要把模板里的{var}替换成一个包含$的值,replaceAll的第二个参数里$有特殊含义,它会引用正则捕获组,结果跟预期完全不一样。遇到这种场景,优先用replace,或者用Matcher.quoteReplacement()包裹替换内容。
第二个是字符串常量拼接和变量拼接结果不一致。常量拼接在编译期就完成了,变量拼接走 StringBuilder。面试题常问:
String a = "hello"; String b = "hello"; String c = "he"; String d = c + "llo"; System.out.println(b == d); // false因为 d 是运行时拼接出来的新对象,不在常量池里。但如果你用final String c = "he",那 c 也是编译期常量,d 的结果就会是 true。这个点极其容易混淆。
第三个是String.valueOf(null)的重载问题:
String.valueOf(null);这行代码在 Java 里会调用String.valueOf(char[] data)这个重载方法,因为 null 可以匹配多个重载版本,编译器会选择最具体的类型。结果是什么?很遗憾,它并不是返回"null",而是直接抛 NullPointerException。因为这版 valueOf 里访问了参数长度。这种重载陷阱非常冷门,但一旦在面试里遇到就很容易让候选人措手不及。实际开发中尽量避免这种模糊调用。
第四个是字符串在 equals 比较时大小写不一致。比如状态码比对,有人写成"SUCCESS".equals(result),但 result 可能是"success",永远比较不过。需要忽略大小写时用equalsIgnoreCase()。
6. 个人实操心得与建议
折腾了这么多年 Java,我的体会是 String 的坑不在于 API 少,而在于 API 太常用,反而没人认真看文档。很多问题只要记住一个核心原则就能避免一大半:内容比较用 equals,不要用 ==;正则相关先查转义;拼接量大用 StringBuilder;判空前先考虑 null。
还有一个工程上的建议:实体类里的字符串字段,默认初始化成空串"",而不是 null。比如 Java Bean 的属性,如果从数据库读出来可能是 null,你返回给前端时就不确定字段是"null"还是不返回,很容易制造前后端联调的问题。与其到处写str == null ? "" : str,不如在某些明确允许为空的场景里直接用空串,让代码少一层判断。
另外,新 JDK 的 API 值得花时间熟悉。JDK 11 的isBlank()、strip()、repeat(),JDK 12 的indent(),JDK 15 的formatted(),这些都能让字符串处理代码更简洁。尤其是repeat(),以前要实现字符串重复 N 遍,得写循环拼接,现在一行搞定,性能还更稳。很多人还在用老 JDK 写旧代码,能接触新特性的时候没必要排斥。
最后分享一个小技巧,也是在代码 review 时我经常教新人的:凡是涉及字符串拼接后又做 equals 判断的场景,尽量先把拼接结果规范化,不要两边都拿+现拼现比。比如if (a + b).equals(c + d),中间产生的临时字符串既浪费内存,又容易让逻辑混乱。先定义好拼接结果,可读性和性能都会好很多。字符串没有特别多高深技巧,但把细节做到位,写出来的代码会稳很多。