ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

String为什么不可变?从源码设计到面试考点全解析

String为什么不可变?从源码设计到面试考点全解析 如果你去面过 Java 开发岗几乎绕不开一个问题String 为什么不可变我在面试别人的时候经常把这个问题当“试金石”——背过八股的人能说出一句“因为 final 修饰”但真正理解的人会从类设计、内存复用、hash 缓存、线程安全、安全敏感场景几个层面给你讲清楚。两种回答的差距基本就是初级和高级的分水岭。这篇文章不打算罗列概念我会把这个知识点拆成三件事不可变到底是怎么实现的为什么非这么做不可以及它在实际开发和面试里最容易踩哪些坑。看完之后不管是自己写代码还是去面 Java 岗你都能做到心里有底。1. String 不可变到底指什么先把概念掰开揉碎很多人对“String 不可变”的理解就停在“String 是 final 的”这其实只说对了一小半。要真正搞懂得从源码设计、对象创建、内存驻留几个角度一层层看。1.1 从 String 类的源码设计看不可变以 JDK8 的源码为例String 类的核心结构是这样的public final class String implements java.io.Serializable, ComparableString, 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[]加一个编码标记coderpublic final class String implements java.io.Serializable, ComparableString, 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 对象失去引用等待垃圾回收。一万次循环就是一万次字符串对象创建和一万次字符数组复制性能和内存消耗都非常难看。这个痛点催生了两个可变字符串类StringBufferJDK1.0 就有线程安全和StringBuilderJDK1.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 转 StringtoString 背后的缓存细节热搜词里有“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()那会让常量池膨胀得不偿失。这个度只能在真实业务里自己把握。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进