前言
上一篇复盘了 ==、equals()、hashCode()、HashMap 桶和 Java 值传递。这一讲继续沿着对象相等与哈希容器展开,重点学习 Java 中使用频率最高的类之一:String。
开始前,我原以为自己只需要记住几条结论:String 被 final修饰、字符串常量池可以复用对象、StringBuilder 比字符串拼接快。但真正回答题目时,我连续遇到了两个容易混淆的问题:
-
private final数组是否意味着数组内容不能修改? -
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";
s1和s2通常复用常量池中的同一个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"
将确定非空的常量写在前面,还能避免 status为 null时出现空指针异常。
五、什么是字符串常量池
字符串常量池可以理解为:
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"
最多涉及两个对象:
-
常量池中的字面量对象;
-
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";
因此s1和s2都指向池中的"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 == s2为 false。
而:
final String finalPrefix = "vpn";
finalPrefix的值在编译期就能确定,因此:
finalPrefix + "-rag"
可以被编译器折叠为:
"vpn-rag"
所以s1和s3复用池中的同一对象,s1 == s3为 true。
不是所有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可能参与常量折叠,但又漏掉了"值必须能在编译期确定"这一前提。最终形成了更准确的判断方法:
-
先判断表达式是否能在编译期确定;
-
能确定时,编译器可能直接完成常量折叠;
-
普通变量或运行期才能取得的值参与拼接时,通常产生新的String结果;
-
==判断是否为同一对象,equals()判断字符串内容。
这套判断过程比死记某一道输出题更重要。它能够迁移到更多字符串常量池、编译器优化和对象创建问题中。
下一篇将继续学习Java集合体系,并深入分析ArrayList与LinkedList的底层结构、扩容机制和使用场景。