Android 面试里,String 不可变性是高频考点,但答好的人不多。它常被包装成一道开放题:"为什么 String 是不可变的?"这道题的分水岭,从来不在能不能背出"final 修饰"那句结论,而在能不能把"不可变"这件事从源码、内存、线程安全一直推到实际编码的坑。把原理、用法、坑一起讲清楚,深浅一试就分出来了。
先把结论立住:不可变到底指什么
一句话:所有看似修改 String 的操作——substring、replace、concat、toUpperCase——实际都返回一个新对象,原对象的内容永不变。一个 String 对象一旦创建,它的字符序列就冻结了,任何"修改"方法都不会改动它,只是产出另一个 String。
这里要先立住一个关键前提:String 类被 final 修饰,且内部存储字符的数组也是私有的、不对外暴露。这意味着子类不能重写它的行为,调用方也拿不到内部数组去改。再加上字符数据在构造后就不再被任何方法修改,这三件事合起来才构成了"不可变"。只说"final 修饰"是片面的,final 只保证不能继承,不保证内容不变。
机制拆解:常量池、intern 与内存布局
Java 把字符串字面量放进运行时常量池,相同字面量会复用同一个对象,这是 == 比较有时"看起来成立"的原因,但也正是误用的根源——比较内容应当用 equals,而不是 ==。用 new String("hello") 会在堆上再建一个对象,即使常量池里已有,所以它是"两个对象",== 比较为 false。
intern() 方法把字符串入池:如果池里已有相等内容的字符串就返回那个引用,否则把当前对象加入池并返回。早期 JDK 里常量池在永久代,大量 intern 有 OOM 风险;现代 JDK 移到堆上后风险小了很多,但 intern 仍有成本,不该滥用。理解这一点,才能讲清"为什么建议用字面量、谨慎用 new"。
从线程安全角度看,不可变对象天生线程安全:多个线程读同一个 String 不需要加锁,因为它不会被改。这也是 String 能放心地当 HashMap 的 key、能在并发场景里到处传的根本原因。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
// String 不可变性:每次"修改"都返回新对象
String s = "hello";
String u = s.toUpperCase(); // s 本身不变,u 是新对象
String in = new String("hello"); // 堆上新对象,与常量池的 s 不是同一个
System.out.println(s == u); // false:引用比较,不是同一对象
System.out.println(s.equals("hello")); // true:值比较才正确
System.out.println(s == in); // false:new 强制在堆上新建
// 字面量进常量池,相同字面量复用
String a = "hi";
String b = "hi";
System.out.println(a == b); // true:同一常量池对象
// intern:入池复用
String c = new String("hi").intern();
System.out.println(a == c); // true:intern 返回池中已有引用
// 拼接:编译期可优化的用常量,运行期拼接产生新对象
String x = "he" + "llo"; // 编译期折叠成 "hello"
String y = s.substring(0, 3); // 返回新 String,s 不变
这段代码值得盯两处:第一处,s.toUpperCase() 之后 s 本身仍是 "hello",变化只体现在返回值 u 上——这就是不可变的落点;第二处,s == u 为 false,而 s.equals("hello") 才为 true,引用比较和值比较的区别在这里一目了然。面试讲到这一层,基本就稳了。
最常见的几个坑
最常见的坑是在循环里用 += 拼接字符串,每次都创建新对象并拷贝旧内容,编译器对常量化拼接的优化救不了热路径上的运行期拼接。正确做法是改用 StringBuilder,只分配一次缓冲,append 多次,最后 toString。
其次是以为 s.replace(...) 后原串变了,用旧引用取值结果没变,排查半天发现是返回值没接住。String 的每个"修改"方法都有返回值且不改原对象,漏接返回值在编译期不会有任何提示,是静默 bug 的高发地。
还有一个更隐蔽的坑:把 String 当可变容器在并发里传来传去时,误以为"改了就生效"。因为不可变,你手里的引用指向的对象谁都改不了,但如果你把引用本身重新赋值给别人,别人看到的还是老对象。真正想共享变更,得用 AtomicReference 或在外部同步,而不是依赖 String 本身。
再一个是用 == 比较字符串内容,偶尔因为常量池复用"碰巧"为 true,就误以为这是正确写法。只要其中一端是 new 出来或来自网络/文件读取,== 就会翻车。比较内容一律 equals,并在可能为 null 时小心 NPE。
面试中的经典追问
"请简单介绍一下 String 不可变性,它在 Android 开发中起什么作用?"——一句话定位:String 一旦创建内容不变,所有修改方法返回新对象;一个场景佐证:它天生线程安全,能放心当 Map 的 key 和并发传参;一个参数收尾:比较内容用 equals 而非 ==。如果只能留一条关于 String 的团队约定,你会留哪一条?临场组织比背答案重要,按结论、依据、边界三步作答最稳。
"String 不可变性的底层原理是什么?能不能详细说一下?"——别用一句话打发:动机是"安全、可共享、可池化",机制是 final 类 + 私有不可变字符存储 + 无修改方法,代价是频繁修改产生大量临时对象、需要 StringBuilder 兜底。讲到这顺手写出 s.toUpperCase() 后 s 不变的代码,面试官会记住你。再深一层:不可变性在编译期和运行期各自做了什么?编译期折叠常量拼接,运行期靠对象冻结保证不变。
"在项目中因为 String 踩过什么坑?"——行为题,拿真实案例说话。比如曾在循环里用 += 拼接日志导致某页卡顿,用 systrace 抓到大量临时 String 分配,改成 StringBuilder 后帧率回升;或者曾用 == 比较接口返回的字符串导致偶发判断失败。讲"复现—定位—修复"的结构,比空谈概念有说服力得多。
"String、StringBuilder、StringBuffer 有什么区别?"——先列三者:String 不可变、StringBuilder 非线程安全但快、StringBuffer 线程安全(方法加 synchronized)。再按场景结论:单线程拼接用 StringBuilder,多线程共享拼接用 StringBuffer,固定内容用 String。把"不可变带来的临时对象开销"摆到台面上再决定。
工程落地:真实项目里怎么用
String 在项目里无处不在,几个高频落点:一是日志、SQL、JSON 拼接这类循环或多段累积场景一律用 StringBuilder,避免临时对象放大 GC;二是把对外接口的字符串入参视为不可变,不在内部持有后去"期待"它被外部改动;三是常量、配置 key、枚举名尽量用字面量并复用,减少重复对象;四是把"比较内容用 equals、可能为 null 先判空"做成静态检查规则。
一个稳妥的工程约定:拼接涉及循环或未知次数用 StringBuilder;字符串比较用 equals 且注意 null;不滥用 intern(除非明确要省内存且量可控);把高频出现的字面量提成 static final 常量。把这几条固化,String 相关的性能和并发坑能少一大半。
给正在准备面试的你
理解 String 不可变性之后,需要在真实项目里练一遍:写一个循环用 += 拼接一百次,再用 StringBuilder 写一遍,对比两者在内存分配上的差别;再亲手跑 s.toUpperCase() 后打印 s 确认它没变。这两步做下来,比再读三遍资料都管用。
如果只记两句话,就记这两句:第一,String 所有修改方法都返回新对象,原对象永不变,漏接返回值就是静默 bug;第二,比较内容用 equals 而非 ==,== 只比较引用且会被常量池复用误导。把这两句讲顺,String 这一关就过了。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android 软件开发面试·从入门到精通」连载系列
上一篇:数组与多维数组:批量数据的地基
下一篇预告:String、StringBuilder-与-StringBuffer:拼接性能三选一
有任何问题欢迎在评论区留言交流。