如果你去面过 Java 开发岗,几乎绕不开一个问题:String 为什么不可变?我在面试别人的时候,经常把这个问题当“试金石”——背过八股的人能说出一句“因为 final 修饰”,但真正理解的人会从类设计、内存复用、hash 缓存、线程安全、安全敏感场景几个层面给你讲清楚。两种回答的差距,基本就是初级和高级的分水岭。
这篇文章不打算罗列概念,我会把这个知识点拆成三件事:不可变到底是怎么实现的,为什么非这么做不可,以及它在实际开发和面试里最容易踩哪些坑。看完之后,不管是自己写代码还是去面 Java 岗,你都能做到心里有底。
1. String 不可变到底指什么:先把概念掰开揉碎
很多人对“String 不可变”的理解就停在“String 是 final 的”,这其实只说对了一小半。要真正搞懂,得从源码设计、对象创建、内存驻留几个角度一层层看。
1.1 从 String 类的源码设计看不可变
以 JDK8 的源码为例,String 类的核心结构是这样的:
public final class String implements java.io.Serializable, Comparable<String>, CharSequence { private final char value[]; private int hash; }这个简化的结构里藏着三件事:
第一,类本身是final的,所以没有任何人能继承 String、重写它的方法,这是防止通过多态破坏行为规则的第一道锁。
第二,存储字符的char[] value是private final的。final保证了这个数组的引用一旦赋值就不能指向别的数组,private保证了外部代码拿不到这个数组的引用、不能通过arr[0] = 'X'这种操作去修改里面某个字符。这句话是理解不可变的关键,很多人只盯着final关键字,忽略了private的作用——两个条件缺一不可。
第三,String 类没有对外暴露任何能修改value[]内容的方法。你看到的replace()、substring()、trim()、toUpperCase()这些方法,表面上“修改”了字符串,底层其实都是生成一个新的 String 对象并返回。老的字符串对象在原地纹丝不动。
所以,String 不可变的准确含义是:一个 String 对象被创建之后,它内部那个字符数组的内容就永远固定了。不是“变量不能重新赋值”,而是“对象内部状态不能改变”。变量本身只是一个引用,它当然可以从这个 String 对象指向另一个 String 对象,这点后面实战部分会细说。
1.2 字符串常量池和 intern:不可变在内存中的体现
String 不可变最直观的成果,就是 JVM 里那个著名的字符串常量池。
试想一下,如果 String 是可变的,两个不同的字符串变量指向同一个底层字符数组,其中一个改了内容,另一个也会跟着变,那整个程序的内存共享就会乱成一锅粥。正因 String 不可变,JVM 才敢放心地做“驻留”优化——内容相同的字符串字面量,在常量池里只保存一份对象,所有引用都指向它。
String a = "hello"; String b = "hello"; System.out.println(a == b); // true这段代码里a == b是 true,并不是因为 String 重写了==(它没有),而是两个变量指向了常量池里的同一个 String 对象。这种复用只有在不可变的前提下才安全。
与之配套的是intern()方法:主动把一个字符串对象的内容放进常量池,并返回池中对象的引用。从 JDK7 开始常量池从永久代移到了堆中,intern()的语义也变得更符合直觉——如果池里已经有相同内容的字符串,就返回那个引用;如果没有,就把对象本身放入池中。实际开发中intern()用得并不算多,但面试里它几乎是必考延伸题。
1.3 JDK9 之后存储结构变了,不可变还是那个不可变
近几年 Java 版本迭代很快,面试官也喜欢追问“JDK9 之后 String 有什么变化”。JDK9 开始,String 的内部存储从char[]改成了byte[]加一个编码标记coder:
public final class String implements java.io.Serializable, Comparable<String>, CharSequence { private final byte[] value; private final byte coder; }为什么要改?因为大多数程序里字符串都是 Latin-1 字符,一个 char 用两个字节其实是浪费。改成byte[]之后,如果字符串内容能用单字节编码表示,就按一个字符一个字节存;如果包含中文等多字节字符,再看情况用 UTF-16 两个字节存。这个优化叫紧凑字符串,能省不少堆内存。
但要注意:value依然是final的,数组依然是私有的,类依然是final的。存储结构变了,不可变这个设计原则一点没变。所以回答面试题时,你可以把“JDK9 之后 String 由byte[]存储”作为加分细节说出来,但不要误以为它变得可变了。
2. 为什么要把 String 设计成不可变:这笔账是怎么算的
理解了“是什么”,接下来到“为什么”。Java 团队把 String 设计成不可变,不是拍脑袋的决定,而是综合内存、并发、安全、易用性之后的权衡结果。这里面每一笔账都值得展开说。
2.1 复用的前提:一个对象可以被所有人放心共享
我习惯用一个生活类比解释这件事:不可变字符串就像印刷好的书,书印出来之后内容固定,你可以把同一本书借给任何人看,不用担心有人在上面乱写、把书的正文改掉。可变字符串就像一份共享文档,所有人拿着同一个链接就能编辑,你敢把它随便发给别人吗?
Java 程序的运行离不开字符串,同一个字符串字面量可能在几十个地方被使用。如果 String 可变,常量池的复用机制就直接报废——每一次共享都可能被某个调用方悄悄改掉,为了安全你就得每次创建独立副本,内存开销瞬间爆炸。正是因为 String 不可变,JVM 才能放心大胆地复用同一个对象,这就是字符串常量池存在的逻辑前提。
这种共享带来的内存收益是非常可观的。尤其在高并发、大流量的后端服务里,光是一个状态枚举的字符串值,可能就有成千上万个地方在引用它,但常量池里只需要一份真实存储。
2.2 哈希缓存:为什么 String 是 HashMap 最完美的 Key
你看 JDK8 的源码里那个private int hash;字段,它不是摆设。
因为 String 不可变,所以它在第一次调用hashCode()时算出来的哈希值可以安全地缓存下来。同一个对象第二次、第三次再计算,直接返回缓存的hash就行,不需要重复遍历字符数组。源码里就是经典的懒加载写法:
public int hashCode() { int h = hash; if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; } return h; }如果 String 是可变的,这个缓存就必须在每次修改时同步清空,还得小心翼翼处理好并发访问,复杂度会爆炸。更关键的是,HashMap 等散列结构依赖一个前提:对象的 hashCode 在它作为 key 的整个生命周期里保持不变。一旦 key 的哈希值变了,整个散列表就废了,值会永远“丢失”在错误的桶里。String 的不变性保证了 key 的哈希值和内容永远稳定,所以它成了 Java 里最安全、最常用的 Map 键类型。
2.3 线程安全与安全敏感场景
不可变对象天然是线程安全的,这句话很多人在背八股的时候都会说,但知其所以然的人不多。
一个线程能修改对象状态,别的线程才能读到被篡改的数据;如果对象本身没有提供任何修改入口,那无论多少个线程同时访问它,看到的都是同一个版本的内容。String 正是这样——它连修改内部数组的方法都没有,自然不存在数据竞争、脏读、 ABA 问题。这就是为什么 String 可以在多线程环境下被无限制共享,不需要加锁,也不需要做防御性拷贝。
安全方面同样重要。想想看,类加载器的 class 名字、数据库连接 URL、网络 socket 地址、文件路径、反射调用的类名和方法名,全都是字符串。这些字符串经常要穿越信任边界:你的代码把路径传给底层库,底层库拿去打开文件,如果中途某个环节能偷偷改掉这个字符串,攻击者就能诱导程序打开一个完全不同的文件。
Java 安全模型里默认参数是可信的,这种信任建立在 String 不可变的基础上。一旦字符串内容固定,就不可能有人在你传递路径的过程中动什么手脚。这也是为什么很多安全敏感的方法签名都用 String 而不是 CharSequence。
2.4 不可变并非没有代价:所以有了 StringBuilder
不可变这么好,那什么场景会难受?字符串频繁拼接的时候。
String s = ""; for (int i = 0; i < 10000; i++) { s = s + i; }这段代码在循环里每次执行s + i,都得创建一个新的 String 对象,旧的 String 对象失去引用等待垃圾回收。一万次循环就是一万次字符串对象创建和一万次字符数组复制,性能和内存消耗都非常难看。
这个痛点催生了两个可变字符串类:StringBuffer(JDK1.0 就有,线程安全)和StringBuilder(JDK1.5 加入,非线程安全但性能更好)。它们内部维护的是可变字符序列,append()方法直接在数组后面追加内容,不产生新对象。
所以现在你应该能串起来了:String 用不可变换来了复用、哈希缓存、线程安全和安全性;StringBuilder 和 StringBuffer 用可变换来了拼接场景的高性能。二者是互补关系,而不是替代关系。实际开发中,单线程环境下拼接字符串请优先考虑StringBuilder,涉及多线程共享同一个缓冲区再考虑StringBuffer。
3. 和不可变纠缠不清的实战坑
理论说得再透,不踩一遍坑都算没学会。下面这几个问题是我在实际开发、代码评审和带新人过程中反复遇到的,每一个都值得记下来。
3.1 String 引用可以变,对象内容不能变
最常见的误解就是“String 不可变 = 变量不能重新赋值”。看看这段代码:
String s = "hello"; s = "world"; System.out.println(s); // world有新手会说:“你看,s 明明变了,你凭什么说 String 不可变?”这其实是把“对象的不可变”和“引用的可变”混为一谈了。
真实发生的事是:"hello"这个 String 对象在常量池里待得好好的,内容没变;“world” 是另一个新创建的 String 对象;变量 s 只是从指向第一个对象,改成了指向第二个对象。如果"hello"对象还被别的变量引用,它的内容依然是 hello,一点都不会受影响。
搞清楚这点,你回头看字符串比较的那道经典面试题就会非常通透:
String s1 = new String("abc"); String s2 = "abc"; System.out.println(s1 == s2); // false System.out.println(s1.equals(s2)); // trues1指向堆里 new 出来的对象,s2指向常量池里的对象,引用不同,所以==是 false;内容相同,所以equals是 true。字符串内容比较永远用equals(),==只适合比较引用是否是同一个对象。
3.2 所有“修改”操作都在生成新对象
String 提供的replace()、substring()、trim()、toLowerCase()、toUpperCase()等方法,名字看起像在改字符串,实际行为都是返回一个新对象。
有一个流传很广的反模式是这样的:
String url = "https://example.com"; url.replace("http", "https"); // 返回值没有接收,替换结果丢失有人以为这样就把 url 里的 http 改成了 https,其实原对象根本不会变,而且返回值没接住,新对象直接被丢掉了。正确的是要写:
String url = "https://example.com"; String newUrl = url.replace("http", "https");这个坑特别容易出现在老代码改造里,尤其是那些从可变语言转过来的开发者,心里默认方法会“原地修改”对象。记住一条铁律:String 的任何一个方法都不可能改变原字符串,如果你想保留“修改”后的结果,就必须用一个变量接收它的返回值。这是一个需要刻意训练的习惯。
3.3 循环里用 + 拼接字符串:性能杀手实录
说到字符串拼接,很多人知道循环里不能用 +,但不知道为什么,以及编译器到底做了什么。
先看编译期常量的情况:
String s = "hello" + " world";这里+两侧都是编译期常量,javac 在编译阶段会直接把它优化成"hello world",不会在运行时创建一堆中间对象,这种写法没有任何性能问题。
再看非常量拼接:
String prefix = getPrefix(); String s = prefix + "-" + suffix;这种写法等价于:
String s = new StringBuilder(prefix) .append("-") .append(suffix) .toString();编译器会自动创建 StringBuilder 来拼接。但是!如果这个+出现在循环体内,每循环一次就会执行一次“新建 StringBuilder、追加、toString”,累计下来就是几千次对象创建和数组拷贝。
我实际排查过的一个线上性能问题,日志系统里有这么一段:
String log = ""; for (Order o : orderList) { log = log + o.getOrderNo() + ","; }一万个订单,循环里创建了一万个 StringBuilder、一万个临时 String 对象,GC 压力直线上升。改成StringBuilder之后,整段代码只需要一个 StringBuilder 对象,耗时和内存都降了一个量级。所以看到循环里拼字符串,第一反应就是揪出来改掉。
3.4 反射能改 String 吗?能,但请住手
既然 String 不可变,那我用反射强行修改内部的char[]呢?在 JDK8 及之前,确实能:
String s = "hello"; Field field = String.class.getDeclaredField("value"); field.setAccessible(true); char[] value = (char[]) field.get(s); value[0] = 'H'; System.out.println(s); // Hello,被改了!注意这里我拿到的是value数组的引用,然后直接改数组里的元素,确实绕过了 final 和 private 的限制。但你要明白,这不是在否定 Java 的正常 API,而是在用反射强行突破封装边界,本质上是“作弊”。
更可怕的是,修改 String 的内容不仅会坑到使用者,还会坑到所有共享同一个字符串常量池对象的其他代码。可能你只是想改一个局部变量,结果全 JVM 里所有引用"hello"的地方全都变了,这种“幽灵式”的副作用会让人调试到怀疑人生。从 JDK9 到 JDK17,模块系统对强封装的管理越来越严格,默认情况下这种反射修改已经很难成功了。我只把这个知识点当成理解不可变边界的反面教材,绝不建议在真实代码里这么干。
3.5 StringBuffer 转 String:toString 背后的缓存细节
热搜词里有“stringbuffer 转换为 string”,这里单独说透。
最常用的方式当然是:
StringBuffer sb = new StringBuffer("hello"); String result = sb.toString();但很少有人知道,JDK8 里的StringBuffer.toString()是有缓存优化的:
public synchronized String toString() { if (toStringCache == null) { toStringCache = Arrays.copyOfRange(value, 0, count); } return new String(toStringCache, true); }这里有两个细节很有意思:
第一,toString()加了synchronized,因为 StringBuffer 是线程安全的,所有公开方法都要保证并发可见性和互斥性。StringBuilder 的toString()就没有这个关键字,这也是两者性能差异的来源之一。
第二,内部有个toStringCache缓存。只要 StringBuffer 内容没变,多次调用toString()不会重复拷贝字符数组,直接复用上一次的缓存。而 String 构造时用的new String(char[], boolean)是一个包私有构造器,特点是直接共享传入的数组、不做防御性拷贝——这是 JDK 自己内部才敢用的优化,公开的new String(char[])构造器为了保证不可变性,一定会复制一份数组。
如果你在代码里需要频繁把同一个 StringBuffer 转成 String,这个方法几乎是零成本的,放心用。但注意:转出来的 String 对象同样是不可变的,后续对 StringBuffer 继续 append,完全不影响已经转出来的 String 内容。
4. 高频面试题与自检查漏
最后这部分,我把 String 不可变相关的面试题集中整理成一份速查,再拆一道最高频的题,最后给三条实战经验。无论你是准备面试,还是面试别人,这一块都值得反复看。
4.1 高频面试题速答
| 面试题 | 核心要点 | 常见错误 |
|---|---|---|
| String 为什么不可变 | final 类、final 的私有数组、不提供修改方法,三层保证 | 只背出“final 修饰”就说不出别的 |
| 不可变有什么好处 | 常量池复用、hashCode 缓存、线程安全、安全敏感场景稳定 | 只能答出一两个点,缺乏体系 |
String s = new String("abc")创建了几个对象 | 常量池没有时创建 2 个,已有时创建 1 个 | 不加条件地断言“2 个” |
| StringBuilder 和 StringBuffer 区别 | StringBuffer 方法有 synchronized,线程安全;StringBuilder 无锁,性能更好 | 说不清为什么需要两个 |
| 为什么 String 适合做 Map 的 key | 内容稳定 + hashCode 缓存,不会导致散列表失效 | 只答“它是对象”或“它比较稳定” |
==与equals比较字符串 | ==比较引用,equals比较内容;字面量常量池复用导致==偶尔为 true | 想当然地把==当成内容比较 |
面试答题有个技巧:不要只给结论,要按“设计层面 + 内存层面 + 并发层面 + 安全层面”的结构展开。比如问 String 为什么不可变,你先说类设计和数组设计,再说常量池和哈希缓存的前提,再说线程安全和安全性,最后补一句“正因为不可变有代价,才有了 StringBuilder/StringBuffer 做拼接优化”。这样回答有层次、有体系,面试官很难挑出毛病。
4.2 一道易错题现场拆解:new String("abc") 创建了几个对象
这是 String 面试题里争议最大的一道题,我给出一个严谨的答案。
String s = new String("abc");这行代码执行时,分两种情况。
第一种情况:之前没有使用过字面量"abc",常量池里不存在这个字符串。那么在类的加载或字面量解析阶段,JVM 会在常量池里创建一个内容为"abc"的 String 对象;接着执行new关键字,又会在堆中创建一个新的 String 对象。所以一共创建 2 个对象。
第二种情况:之前已经有字面量"abc"出现过,常量池里已经存在这个字符串。那new执行时只需要在堆里创建 1 个新对象,构造器会把常量池对象的字符数组内容复制给自己。所以只创建 1 个对象。
一个更严谨的说法是:"abc"这个字面量对象由常量池管理,new String("abc")的对象在堆里,两者内容相同但引用不同。这也是为什么:
String s1 = new String("abc"); String s2 = "abc"; System.out.println(s1 == s2); // false如果你再调用s1.intern(),它会返回常量池里那个对象的引用。这时候你再用==比较,结果就不一样了。
4.3 给初学者和面试者的三条经验
说点实在的经验。
第一,写代码时养成一个习惯:凡是修改字符串的方法调用,先问自己一句“返回值我接住了吗?”你写s.replace("a", "b")但没接返回值,优雅的代码瞬间变成隐藏 bug。我 review 代码时看到这种写法,一定会打回重改。
第二,在常量字符串上做改动要格外小心。被static final修饰的字符串常量在编译期会被直接内联到使用处,如果你改了常量类的值但没有重新编译所有依赖它的类,线上跑的还是旧值。这个坑在多人协作、热部署环境里特别常见,处理方式也很简单:改了公共常量类之后,强制 clean build,别只编译单个文件。
第三,面试答 String 不可变,别急着背结论。你先画一个内存示意图,把栈上的引用、堆上的对象、常量池三者的关系画清楚,再往上叠加“为什么”。我面试别人的时候,能画出这张图并讲清楚 intern 的人,基本上后面问什么都能接得住。
我个人在实际项目里还有一个心得:如果你的代码里某个字符串会被当作 HashMap 的 key,并且生命周期很长,可以主动调用intern()让内容相同的 key 复用同一个对象,能省下不少内存。但千万不要对大量动态拼接的长字符串调intern(),那会让常量池膨胀,得不偿失。这个度,只能在真实业务里自己把握。