String不可变指内容不可修改,本质是“内容锁死”:final类、私有不可变底层数组、所有方法返回新对象;常量池共享、HashMap键稳定、多线程安全均依赖此特性。

String不可变,本质是“内容锁死”,不是“变量锁死”
很多人看到 str = str + "a" 就以为字符串被修改了,其实只是变量 str 指向了一个新对象;原来的字符串对象(比如 "Hello")在堆里纹丝不动,哪怕没人再引用它,也得等GC来收——这才是“不可变”的真实含义。
关键实现靠三重防护:final class String 阻止继承、private final char[] value(JDK8及之前)或 private final byte[] value(JDK9+)封住底层数组引用、所有 public 方法(如 substring()、replace())全部返回新对象,绝不碰原数组一个字节。
- 别信“
final数组就安全”这种说法:final int[] arr = {1}仍可改arr[0] = 2;String 安全靠的是封装 + 不暴露value+ 所有方法绕开原数组操作 - 反射强行修改
value是可能的(比如用Unsafe或Field.setAccessible(true)),但这属于破坏 JVM 合约,生产环境严禁,且 JDK9+ 的byte[]+ 编码压缩让反射更难生效
字符串常量池(String Pool)为什么必须依赖不可变性
当你写 String a = "abc"; String b = "abc";,JVM 让 a 和 b 共享同一个对象——这省内存、提速度。但如果 "abc" 可被修改,a.toUpperCase() 一调,b 看到的就变成 "ABC",逻辑直接崩坏。
常量池不是“功能开关”,而是不可变性的必然产物:只有内容永不改变,多个引用共享才敢放心。
立即学习“Java免费学习笔记(深入)”;
- 使用
new String("abc")会绕过常量池,在堆里新建对象,==比较必为false -
intern()是手动“拉回”常量池的补救手段,但代价是字符串首次入池时要查表,高频调用反而拖慢性能 - JDK7 把常量池从永久代移到堆内存后,
intern()行为更可控,但依然不建议在循环里滥用
HashMap 的 key 为什么非得用不可变对象
如果 String 可变,你把 "user" 当 key 存进 HashMap,之后又把它改成 "USER",它的 hashCode() 就变了——但 HashMap 还按旧 hash 值找桶,结果 map.get("user") 返回 null,数据“消失”了。
String 的 hashCode() 方法内部缓存了结果:private int hash,首次计算后就不再重算。这个优化只在不可变前提下成立。
- 自定义类作 key 时,务必保证
hashCode()和equals()所依赖的字段不可变,否则一样掉坑里 - 别为了“节省对象创建”而复用
StringBuilder拼出的字符串去当 key:虽然内容一样,但它是新对象,没问题;但若你把它转成String后又用反射改了内容……那就自己挖坑自己跳
多线程场景下,不可变性省掉的不是代码,是排查时间
一个 public static final String CONFIG_PATH = "/etc/app.conf" 被十个线程同时读,完全不用加锁、不用 volatile、不用 synchronized。因为没人能改它——连“改”的入口都被语言层堵死了。
换成可变对象,哪怕只读不写,你也得确认:有没有其他线程正在调用它的 setter?有没有子类偷偷覆写了行为?有没有某个工具类在背后做了 in-place 修改?
- 类加载器用字符串定位类名(如
"java.lang.Object"),如果这段字符串中途被篡改,可能加载恶意类,这是 JVM 安全模型的基石之一 - 日志框架中传递的
String格式模板(如"User {} logged in from {}")也是靠不可变性确保打印时不被并发修改导致格式错乱
真正容易被忽略的点是:不可变性不是“语法糖”,它是贯穿 JVM 内存模型、安全机制和集合设计的底层契约。一旦你开始用反射、Unsafe 或 JNI 去绕过它,后续所有基于该假设的优化(常量池、hash 缓存、线程安全共享)都会失效——而这种失效往往不会报错,只会悄悄出 bug。









