Java面试复盘(二):String不可变、字符串常量池与字符串拼接

前言

上一篇复盘了 ==equals()hashCode()、HashMap 桶和 Java 值传递。这一讲继续沿着对象相等与哈希容器展开,重点学习 Java 中使用频率最高的类之一:String

开始前,我原以为自己只需要记住几条结论:String 被 final修饰、字符串常量池可以复用对象、StringBuilder 比字符串拼接快。但真正回答题目时,我连续遇到了两个容易混淆的问题:

  1. private final数组是否意味着数组内容不能修改?

  2. final String参与拼接时,结果是否一定进入字符串常量池?

通过代码输出题和两次纠正,我最终把 String 不可变、编译期常量、运行期拼接和字符串常量池串成了一条完整逻辑。


一、我的第一次回答与暴露的问题

我第一次对 String 不可变的理解是:

String 被 final 修饰,不能被继承,内部数组是 private final,也不能修改,String 也没有暴露给外部修改内部数组的接口,看似修改其实都是返回新对象。

后半部分基本正确,但"内部数组是 final,所以不能修改"不够严谨。

例如:

复制代码
final char[] chars = {'a', 'b'};
chars[0] = 'c';                 // 可以修改数组元素
chars = new char[]{'c', 'd'};  // 编译错误

final限制的是变量保存的引用:初始化以后,chars不能再指向另一个数组。但是,它不能单独阻止数组内部元素变化。

因此,String 的不可变性不是只靠一个 final实现,而是多个设计共同保证:

  • String 类本身被 final修饰,不能被继承;

  • 内部数组使用 private final修饰;

  • 外部不能直接获得并修改内部数组;

  • String 不提供修改内部内容的方法;

  • 看起来像修改字符串的方法,通常返回一个新String对象。

这也是本讲第一个需要纠正的模糊表达:

final保证引用不再指向其他对象,不等于被引用对象内部的状态一定不可修改。


二、String为什么不可变

所谓不可变,是指:

String对象创建以后,其字符内容不能再发生变化。

看下面的代码:

复制代码
String str = "hello";
str = str + " world";

变量 str最后保存了 "hello world",但原来的 "hello"对象并没有被修改。实际发生的是:程序得到一个新的字符串对象,再让变量 str指向新对象。

再看一个例子:

复制代码
String str = "hello";
str.toUpperCase();

System.out.println(str); // hello

toUpperCase()没有改变原字符串,而是返回一个新String。需要接收返回值:

复制代码
str = str.toUpperCase();
System.out.println(str); // HELLO

不同JDK版本的内部存储

  • JDK 8及以前,String主要使用 char[]保存内容;

  • JDK 9以后,String主要使用 byte[]和编码标识实现Compact Strings。

可以简化理解为:

复制代码
public final class String {
    private final byte[] value;
}

面试标准回答

String是不可变类。String类被final修饰,不能被继承;内部保存内容的数组使用private final修饰,外部不能直接访问;String也没有提供修改内部数组的方法,相关操作通常返回新的String对象。因此String对象创建以后,其内容不会改变。


三、String为什么要设计成不可变

1. 安全复用字符串常量池

复制代码
String s1 = "vpn";
String s2 = "vpn";

s1s2通常复用常量池中的同一个String对象。如果String可以修改,一个变量修改内容就可能影响所有共享这个对象的变量。

String不可变后,多个变量共享同一对象是安全的。

2. 保证hashCode稳定

String经常作为HashMap的Key:

复制代码
Map<String, Object> cache = new HashMap<>();

由于String内容不会变化,它的 equals()hashCode()结果稳定。String还可以缓存计算过的哈希值,避免重复计算。

如果String可变,放入HashMap以后内容和哈希值发生变化,可能导致再也无法通过该Key找到原来的键值对。

3. 天然线程安全

不可变对象没有可供多个线程竞争修改的状态,因此多个线程可以安全共享同一个String对象。

String的线程安全来自不可变性,不是因为String内部使用了锁。

4. 提升关键参数的安全性

Java中很多关键参数都使用String表示,例如:

  • 文件路径;

  • URL和网络地址;

  • 类名;

  • 数据库连接信息;

  • JWT Token;

  • 用户ID和任务ID。

不可变性可以避免参数校验完成以后,又被其他代码意外修改。

面试标准回答

String设计成不可变类,可以安全复用字符串常量池中的对象;能够保证hashCode稳定,适合作为HashMap的Key并支持哈希值缓存;不可变对象天然线程安全;文件路径、URL和类名等关键参数大量使用String,不可变性也能降低内容被意外篡改的风险。


四、String中的==equals()

复制代码
String s1 = new String("abc");
String s2 = new String("abc");

System.out.println(s1 == s2);      // false
System.out.println(s1.equals(s2)); // true
  • ==判断两个引用是否指向同一个对象;

  • String重写了 equals(),按照字符串内容比较。

业务代码比较字符串内容时应该使用:

复制代码
"SUCCESS".equals(status)

而不是:

复制代码
status == "SUCCESS"

将确定非空的常量写在前面,还能避免 statusnull时出现空指针异常。


五、什么是字符串常量池

字符串常量池可以理解为:

JVM为字符串字面量提供的一种共享复用机制。内容相同的字面量通常复用同一个String对象,以减少重复对象和内存占用。

复制代码
String s1 = "abc";
String s2 = "abc";

System.out.println(s1 == s2); // true

执行字符串字面量相关代码时,JVM会查找池中是否已经存在对应内容:

  • 已经存在:直接复用;

  • 不存在:创建对应对象并纳入常量池复用体系。

这里的 ==true,不是因为String重写了运算符。Java不能重写 ==。真正原因是两个变量恰好指向常量池中的同一个对象。

在现代JDK中,字符串常量池中的String对象位于堆中。面试时不要机械回答"字符串常量池一定在方法区"。


六、new String("abc")到底创建几个对象

经典代码:

复制代码
String s = new String("abc");

不能机械回答"一定创建两个对象"。准确数量取决于常量池中是否已经存在 "abc"

常量池中原本没有"abc"

最多涉及两个对象:

  1. 常量池中的字面量对象;

  2. new在堆中创建的新String对象。

常量池中已经存在"abc"

池中对象直接复用,只需要创建 new出来的新String对象。

无论哪种情况,变量 s最终都指向 new创建的对象,而不是直接指向池中的对象。

面试标准回答

new String("abc")最多涉及两个对象,但准确数量取决于常量池中是否已经存在该字面量。如果不存在,需要准备池中的字面量对象,并由new创建一个新对象;如果已经存在,则只创建new出来的对象。变量最终指向new创建的对象。


七、我第一次做错的字符串拼接题

题目如下:

复制代码
String s1 = "ab";
String s2 = "a" + "b";

String a = "a";
String s3 = a + "b";

System.out.println(s1 == s2);
System.out.println(s1 == s3);
System.out.println(s1.equals(s3));

我第一次给出的判断是:

复制代码
true
true
false

正确答案是:

复制代码
true
false
true

1. s1 == s2为什么是true

复制代码
String s2 = "a" + "b";

"a""b"都是编译期可以确定的字面量,编译器会进行常量折叠,可以直接理解为:

复制代码
String s2 = "ab";

因此s1s2都指向池中的"ab"

2. s1 == s3为什么是false

复制代码
String a = "a";
String s3 = a + "b";

a是普通变量,拼接通常需要在运行期完成,可以简化理解为:

复制代码
String s3 = new StringBuilder()
        .append(a)
        .append("b")
        .toString();

s3的内容虽然也是 "ab",但它通常指向运行期生成的新String对象,所以和池中的s1不是同一个对象。

3. s1.equals(s3)为什么是true

String重写了 equals(),比较字符内容。二者内容都是 "ab",所以结果为 true


八、第二次错误:final变量不一定都是编译期常量

为了继续检验理解,我又判断了下面的代码:

复制代码
String s1 = "vpn-rag";
String prefix = "vpn";
String s2 = prefix + "-rag";

final String finalPrefix = "vpn";
String s3 = finalPrefix + "-rag";

System.out.println(s1 == s2);
System.out.println(s1 == s3);
System.out.println(s1.equals(s2));

我回答为:

复制代码
false
false
true

正确结果是:

复制代码
false
true
true

prefix是普通变量,所以s2在运行期拼接,s1 == s2false

而:

复制代码
final String finalPrefix = "vpn";

finalPrefix的值在编译期就能确定,因此:

复制代码
finalPrefix + "-rag"

可以被编译器折叠为:

复制代码
"vpn-rag"

所以s1s3复用池中的同一对象,s1 == s3true

不是所有final String都能常量折叠

复制代码
final String p1 = "vpn";
final String p2 = new String("vpn");

String s1 = "vpn-rag";
String s2 = p1 + "-rag";
String s3 = p2 + "-rag";

System.out.println(s1 == s2); // true
System.out.println(s1 == s3); // false

虽然p2也被 final修饰,但它的值需要在运行期通过 new String()获得,不属于编译期常量,不能完成相同的常量折叠。

本节核心结论

final String不一定是编译期常量。只有初始化表达式本身是编译期常量表达式,并且值能在编译期确定时,字符串拼接才可能被常量折叠。


九、intern()方法

intern()用于获得字符串常量池中相同内容对应的规范引用。

复制代码
String s1 = new String("abc");
String s2 = s1.intern();
String s3 = "abc";

System.out.println(s1 == s2); // false
System.out.println(s2 == s3); // true
  • s1指向 new创建的对象;

  • s2获得池中内容为 "abc"的规范引用;

  • s3也引用池中的 "abc"

面试标准回答

intern()会返回字符串常量池中与当前字符串内容相同的规范引用。如果池中已有对应字符串,就返回池中的对象;如果没有,则将对应字符串纳入常量池复用体系并返回规范引用。它可以用于字符串复用,但不应该对大量动态字符串无脑调用,否则会增加内存和常量池管理压力。


十、String、StringBuilder与StringBuffer

类型 内容是否可变 线程安全 主要使用场景
String 不可变 天然线程安全 常量、参数、Key、少量字符串
StringBuilder 可变 非线程安全 单线程大量字符串拼接
StringBuffer 可变 相对线程安全 多线程共享可变字符串对象

StringBuilder

复制代码
StringBuilder builder = new StringBuilder();
builder.append("VPN");
builder.append('-');
builder.append("RAG");

String result = builder.toString();

StringBuilder内部维护可变数组,能够在同一容器上不断追加内容。它通常不使用同步保护,因此单线程性能较好。

StringBuffer

StringBuffer的很多公开方法使用 synchronized提供同步保护,因此相对线程安全,但也会带来同步开销。

需要注意:单个方法同步,不代表多个方法组合起来的业务操作自动具有整体原子性。

复制代码
if (buffer.length() > 0) {
    buffer.deleteCharAt(0);
}

length()deleteCharAt()分别同步,不代表"先判断再删除"整体不可被其他线程打断。

面试标准回答

String不可变,因此天然线程安全;StringBuilder和StringBuffer都是可变字符串容器。StringBuilder没有同步保护,非线程安全,但单线程性能通常更好;StringBuffer的许多方法使用synchronized,相对线程安全,但存在同步开销。单线程大量拼接一般使用StringBuilder,多线程场景则应先判断是否真的需要共享同一个可变字符串对象。


十一、为什么循环中不推荐使用+

复制代码
String result = "";

for (int i = 0; i < 10000; i++) {
    result = result + i;
}

String不可变。循环中每次拼接都可能产生新的String和临时StringBuilder,并复制之前已经拼接的内容。随着 result越来越长,会带来:

  • 大量临时对象;

  • 频繁内存分配;

  • 更多垃圾回收压力;

  • 对已有字符的重复复制;

  • 随字符串增长不断增加的时间开销。

正确写法:

复制代码
StringBuilder builder = new StringBuilder();

for (int i = 0; i < 10000; i++) {
    builder.append(i);
}

String result = builder.toString();

循环外只创建一个StringBuilder,循环内持续 append(),最后调用一次 toString()

但也不要机械优化。下面这种简短的一次性拼接直接使用 +完全可以:

复制代码
String key = "vip:activity:" + activityId + ":user:" + userId;

真正需要关注的是循环拼接、大文本构造和多分支动态拼接。


十二、StringBuilder的扩容

StringBuilder内部维护一个可变数组。无参构造时,默认初始容量通常为16:

复制代码
StringBuilder builder = new StringBuilder();

空间不足时,经典扩容策略可以简化理解为:

复制代码
新容量 = 旧容量 × 2 + 2

如果新容量仍然不足以容纳本次追加内容,则以实际所需容量为准。

如果能够提前估算最终字符串长度,可以设置初始容量:

复制代码
StringBuilder builder = new StringBuilder(1024);

这样可以减少扩容和数组复制,但不需要在普通短字符串场景中过度优化。


十三、联系项目理解

1. 构造RAG Prompt

不推荐:

复制代码
String prompt = "";

for (Document document : documents) {
    prompt = prompt + document.getContent();
}

更合适:

复制代码
StringBuilder promptBuilder = new StringBuilder();

for (Document document : documents) {
    promptBuilder
            .append("证据来源:")
            .append(document.getSource())
            .append('\n')
            .append(document.getContent())
            .append("\n\n");
}

String prompt = promptBuilder.toString();

3. 构造Redis业务Key

复制代码
String key = "vip:activity:" + activityId + ":user:" + userId;

这种短小、一次性的拼接直接使用 +即可。面试中需要体现根据场景判断,而不是看到字符串拼接就机械改写。


十四、高频连环追问

1. String不可变,为什么变量可以重新赋值?

复制代码
String str = "a";
str = "b";

改变的是变量 str保存的引用,原来的 "a"对象没有变化。

2. final String和String不可变是一回事吗?

不是。

复制代码
final String str = "abc";

这里的 final表示变量 str不能再指向其他对象。String不可变则表示String对象内部的字符内容不能变化。

一个限制变量引用,另一个描述对象状态。

3. String为什么适合作为HashMap的Key?

因为String不可变,equals()hashCode()结果稳定,同时String已经正确重写这两个方法,并能够缓存哈希值。

4. String线程安全是因为加锁吗?

不是。String线程安全主要因为不可变,没有需要多个线程竞争修改的共享状态。

5. StringBuilder一定比String快吗?

不一定。字面量拼接可能在编译期直接完成,少量的一次性拼接也没有必要手动改写。循环或大量动态拼接时,复用StringBuilder才有明显优势。

6. StringBuffer能保证整个业务操作线程安全吗?

不一定。许多单个方法有同步保护,但多个方法组成的复合操作可能仍需额外同步。

7. StringBuilder的equals()比较内容吗?

StringBuilder没有像String一样重写 equals()比较字符内容:

复制代码
StringBuilder b1 = new StringBuilder("abc");
StringBuilder b2 = new StringBuilder("abc");

System.out.println(b1.equals(b2)); // false

需要比较内容时,可以转换为String后比较:

复制代码
b1.toString().equals(b2.toString());

十五、本讲最终背诵卡

String为什么不可变

String类被final修饰,不能被继承;内部保存内容的数组使用private final修饰,外部不能直接访问;String也不提供修改内部数组的方法,相关操作通常返回新的String对象。因此String对象创建以后内容不会变化。

String为什么设计成不可变

不可变性使字符串常量池可以安全复用对象,保证hashCode稳定并适合作为HashMap的Key,使String天然线程安全,同时避免文件路径、URL和类名等关键参数被意外修改。

字符串常量池

字符串常量池用于复用内容相同的字符串字面量。使用字面量时,JVM会查找池中是否存在相同内容,存在就复用,不存在再创建对应对象。在现代JDK中,池中的String对象位于堆中。

new String("abc")创建几个对象

最多涉及两个对象,但准确数量取决于常量池中是否已经存在该字面量。如果池中不存在,需要准备字面量对象并由new创建新对象;如果已经存在,则只创建new出来的对象。

StringBuilder与StringBuffer

二者都是可变字符串容器。StringBuilder没有同步保护,非线程安全,但单线程性能通常更好;StringBuffer的许多方法使用synchronized,相对线程安全,但具有同步开销。

循环字符串拼接

String不可变,循环中使用加号反复拼接会产生大量中间对象并重复复制已有内容,增加内存和GC压力。应该在循环外创建StringBuilder,循环内持续append,最后调用一次toString。

编译期常量

字符串字面量之间的拼接可以在编译期常量折叠;普通变量参与的拼接通常在运行期完成。final String也不一定是编译期常量,只有其初始化表达式本身能在编译期确定时才可以参与常量折叠。


十六、本次学习复盘

这一讲中,我的主体知识已经基本掌握,但两次代码判断题暴露出了概念边界不够准确的问题。

第一次,我把 private final数组理解为"数组不能修改"。纠正后我明确了:final只限制引用重新赋值,String还需要依靠封装和不暴露修改能力共同实现不可变。

第二次,我把普通变量参与的运行期拼接误认为编译期拼接,导致把 ==equals()的结果答反。

第三次,我知道了 final String可能参与常量折叠,但又漏掉了"值必须能在编译期确定"这一前提。最终形成了更准确的判断方法:

  1. 先判断表达式是否能在编译期确定;

  2. 能确定时,编译器可能直接完成常量折叠;

  3. 普通变量或运行期才能取得的值参与拼接时,通常产生新的String结果;

  4. ==判断是否为同一对象,equals()判断字符串内容。

这套判断过程比死记某一道输出题更重要。它能够迁移到更多字符串常量池、编译器优化和对象创建问题中。

下一篇将继续学习Java集合体系,并深入分析ArrayList与LinkedList的底层结构、扩容机制和使用场景。

相关推荐
讲温控就好了1 小时前
光刻温控的产业链价值:从精度指标到半导体制造竞争力
python·制造
小秋求学记.2 小时前
PHP安全漏洞实战:从环境配置到RCE攻击
开发语言·php
南极星10052 小时前
2026电赛E题有感
python·opencv·电赛
看浪的路人2 小时前
第3讲:代码补全引擎
开发语言·windows·python
小小龙学IT2 小时前
Day 26-27 项目实战:从零构建一个高并发聊天室(epoll + 线程池)
linux·服务器·c语言·开发语言·网络
想会飞的蒲公英2 小时前
PyTorch 学习率实战:从零理解衰减策略与调度器
人工智能·pytorch·python·深度学习·机器学习
布值倒区什么name3 小时前
python文件IO学习
python
我就是妖怪3 小时前
【无标题】
开发语言
丁引3 小时前
《数据清洗的艺术:如何用20行核心逻辑优雅地删除无标签图片》
前端·数据库·python