String 与字符串常量池 | JavaSE

String 与字符串常量池

一、学习目标

学完本章,你应该能够:

  1. 能够解释 String 是什么,以及为什么它属于引用数据类型。
  2. 能够使用字符串字面量和构造器创建字符串对象。
  3. 能够解释 String 不可变(Immutable)的真正含义。
  4. 能够区分字符串变量改变与字符串对象内容改变。
  5. 能够解释字符串字面量为什么可以共享对象。
  6. 能够判断常见字符串代码中的 == 比较结果。
  7. 能够区分 ==equals() 在 String 比较中的基本语义。
  8. 能够理解 String.intern() 与字符串池的基本关系。
  9. 能够避免“所有字符串都在字符串常量池”“只要写 + 就一定创建新对象”等过度简化结论。

二、核心知识

2.1 String 是什么

String 是 Java 中表示字符串的核心类。

例如:

String name = "LingXi";
String language = "Java";
String siteName = "星雨笔录";

这些变量引用的对象都是:

java.lang.String

类型。

String 位于:

java.lang

包。

因此通常不需要手动:

import java.lang.String;

2.2 String 是引用数据类型

Java 数据类型可以从基础阶段分为:

数据类型
│
├── 基本数据类型
│   ├── byte
│   ├── short
│   ├── int
│   ├── long
│   ├── float
│   ├── double
│   ├── char
│   └── boolean
│
└── 引用数据类型
    └── String

所以:

String text = "hello";

不是说变量 text 本身直接“就是字符串对象”。

更准确的对象模型是:

text
 │
 │ reference
 ↓
String 对象 "hello"

这也是后面理解:

==

为什么容易出错的前提。


2.3 字符串字面量本身就是 String 对象

Java 代码:

String s = "abc";

其中:

"abc"

叫做字符串字面量(String Literal)。

它的类型就是:

String

因此甚至可以直接调用方法:

System.out.println("abc".length());

这里并不是:

"abc" 先变成某种特殊基本类型,再转成 String。

而是:

字符串字面量求值后得到 String 实例的引用。


2.4 创建 String 的两种基本思路

方式一:字符串字面量

String s1 = "hello";

这是最常见、最推荐的普通字符串创建方式。


方式二:构造器

例如:

String s2 = new String();

创建空字符串对象。

还可以:

String s3 = new String("hello");

或者:

char[] chars = {'J', 'a', 'v', 'a'};

String s4 = new String(chars);

再例如:

byte[] bytes = {65, 66, 67};

String s5 = new String(bytes);

不过 byte[] 到 String 涉及字符集与解码问题,后面的字符编码章节会进一步学习。


2.5 实际开发中是否应该写 new String("abc")

大多数普通情况没有必要写:

String name = new String("LingXi");

直接:

String name = "LingXi";

通常更加自然。

因为:

new String(...)

明确要求构造一个新的 String 对象,而字符串字面量本身已经能够表示这个字符串。

因此:

new String("abc")

在很多基础业务代码中属于没有必要的额外对象创建。


2.6 String 是不可变对象

JDK 对 String 的一个核心设计是:

String 对象创建以后,其值不能被修改。

例如:

String s = "Java";

假设执行:

s = s + "SE";

最后:

System.out.println(s);

输出:

JavaSE

初学者很容易得出:

“原来的 String 对象从 Java 被修改成 JavaSE 了。”

其实不是。

正确模型:

第一步

s
 ↓
"Java"

执行:

s = s + "SE";

后可以理解成:

原对象
"Java"

新结果
"JavaSE"

s
 ↓
"JavaSE"

发生变化的是:

引用变量 s 最终指向哪个字符串对象。

而不是:

原来的 "Java" 对象被修改。


2.7 “变量可变”和“对象可变”必须分开

下面代码完全合法:

String s = "A";

s = "B";

s = "C";

因此不能说:

String 变量不能改。

变量 s 当然可以重新赋值。

String 不可变真正说的是:

一个已经创建的 String 对象所表示的字符序列不会被原地修改。

例如:

"A"

这个 String 对象不会因为:

s = "B";

而自身变成:

"B"

只是 s 改为引用另一个对象。


2.8 为什么 String 设计为不可变

不可变性带来一个非常重要的能力:

String 对象可以安全共享。

例如:

String a = "hello";
String b = "hello";

如果字符串对象可以被某一方直接修改:

a 修改 hello
        ↓
b 看到的内容也被一起修改

共享就会变得非常危险。

因为 String 不可变:

共享 "hello"
        ↓
任何引用都不能把这个对象原地改掉

所以相同字符串内容可以安全复用。

这也是字符串池设计能够发挥价值的重要基础。


2.9 什么是字符串池

Java 语言会对字符串字面量进行 intern,使相同字符串值能够共享规范化(canonical)实例。

初学时可以使用“字符串池”这个概念理解:

String Pool

"Java"
"hello"
"abc"
...

当代码中多次出现相同的字符串字面量:

String a = "abc";
String b = "abc";

二者可以引用同一个规范化 String 实例。

因此:

System.out.println(a == b);

结果为:

true

2.10 == 比较引用

对于引用类型:

a == b

主要判断:

两个引用是否指向同一个对象。

例如:

String a = "abc";
String b = "abc";

System.out.println(a == b);

由于相同字符串字面量被 intern:

a ──┐
    ├──> "abc"
b ──┘

因此:

a == b
→ true

2.11 new String("abc") 又发生了什么

观察:

String a = "abc";

String b = new String("abc");

可以建立基础模型:

a
 ↓
interned "abc"

b
 ↓
new String object
内容也是 "abc"

因此:

System.out.println(a == b);

通常结果是:

false

因为:

内容一样,并不代表两个引用指向的是同一个对象。


2.12 两个 new String

例如:

String a = new String("abc");
String b = new String("abc");

new 明确表示创建新的类实例。

因此:

System.out.println(a == b);

结果是:

false

可以抽象成:

a → String 对象 #1 → "abc"

b → String 对象 #2 → "abc"

对象不同。


2.13 == 和 equals 的核心区别

对于 String:

==

关注:

是否是同一个对象引用。

而:

equals()

用于比较 String 所表示的字符内容是否相同。

例如:

String a = "abc";
String b = new String("abc");

System.out.println(a == b);
System.out.println(a.equals(b));

结果:

false
true

可以记成:

==

问:
“是不是同一个对象?”


equals()

问:
“字符串内容是否相等?”

因此业务中的:

用户名比较
密码比较
验证码比较
状态文本比较

通常关注字符串内容,而不是引用身份。


三、使用方法

3.1 字符串字面量对象复用

观察:

public class StringDemo {

    public static void main(String[] args) {

        String a = "Java";
        String b = "Java";

        System.out.println(a == b);
    }
}

结果:

true

原因:

相同字符串字面量
        ↓
intern
        ↓
共享规范化实例

3.2 new String 与字面量比较

public class StringDemo {

    public static void main(String[] args) {

        String a = "Java";
        String b = new String("Java");

        System.out.println(a == b);
        System.out.println(a.equals(b));
    }
}

结果:

false
true

这是 String 初学阶段最重要的代码模型之一。


3.3 字符串拼接不能只背结论

观察:

String a = "hello";

String b = "hel" + "lo";

System.out.println(a == b);

结果:

true

为什么?

因为:

"hel" + "lo"

可以作为编译期常量表达式计算。

编译器可以在编译阶段得到:

"hello"

这个结果。

因此不要死记:

“只要 String 使用 +,就一定会产生不同对象。”

这是错误的。


3.4 运行时拼接

再看:

String a = "hello";

String part = "lo";

String b = "hel" + part;

System.out.println(a == b);

这里:

part

是普通变量。

表达式:

"hel" + part

需要运行时完成字符串连接。

因此得到的运行时连接结果并不是简单等同于编译期 interned 字面量引用。

在 JDK 21 环境下:

a == b

结果为:

false

3.5 对比三个经典表达式

String a = "hello";

String b = "hello";

String c = "hel" + "lo";

String part = "lo";
String d = "hel" + part;

String e = new String("hello");

对比:

System.out.println(a == b);
System.out.println(a == c);
System.out.println(a == d);
System.out.println(a == e);

结果:

true
true
false
false

理解模型:

a == b
→ 相同字面量

a == c
→ 编译期常量表达式

a == d
→ 运行时拼接

a == e
→ new 创建新对象

这一组代码比单纯背:

字面量池
new 堆

更接近 Java 语言真正的规则。


四、原理与进阶

4.1 字符串池真正的关键字:intern

字符串池的核心概念可以理解为:

对相同字符串内容维护一个规范化 String 实例。

String 提供:

intern()

方法。

例如:

String a = "hello";

String part = "lo";
String b = "hel" + part;

System.out.println(a == b);
System.out.println(a == b.intern());

在这一案例中:

false
true

b.intern() 得到:

与该字符串内容对应的规范化引用。


4.2 String.intern() 的基本模型

假设:

String s = new String("Java");

然后:

String canonical = s.intern();

intern() 关注的不是:

“复制一个新的字符串。”

而是寻找或建立:

与这个字符串内容相对应的 canonical representation。

因此:

"Java".intern() == "Java"

成立。


4.3 字符串池不等于“所有 String 对象”

下面对象:

new String("abc")

和:

"abc"

不能因为内容一样就直接认为:

它们永远是同一个对象。

实际上:

new

会创建新的 String 实例。

字符串池解决的是:

字符串字面量及被 intern 的字符串规范化共享问题。

它不是:

JVM 中所有 String 对象的唯一存储区域。


4.4 不要把“常量池”混成一个概念

学习 JVM 时你还会遇到:

class 文件常量池
运行时常量池
字符串 intern 池

这些概念相关,但不是完全同一个东西。

本章只需要建立:

字符串字面量
    ↓
String 实例
    ↓
intern / canonical representation
    ↓
相同字符串可以共享引用

至于 class 文件常量池的结构、运行时常量池等 JVM 底层内容,不需要在 JavaSE String 入门阶段无限下钻。


4.5 String 为什么声明为 final

String 类本身是:

public final class String

这意味着普通开发者不能通过:

class MyString extends String

继承 String。

这与 String 整体不可变设计相配合,使其行为更加稳定。

不过需要注意:

final class 本身并不自动意味着“对象一定不可变”。

String 的不可变性来自其整体 API 和内部设计,而不是仅仅因为类写了 final


4.6 不可变不等于“没有任何性能问题”

String 可以安全共享,非常适合:

  • 配置文本;
  • 用户名;
  • URL;
  • JSON 字段名;
  • Map key;
  • 日志文本;
  • 常量字符串。

但是如果不断:

s = s + "a";
s = s + "b";
s = s + "c";

每次都要产生新的字符串结果。

大量循环拼接时效率可能较低。

因此后续 03-03 会专门学习:

StringBuilder

解决大量可变字符串构建问题。


五、实践应用

5.1 用户登录为什么不能使用 ==

假设:

String correctName = "admin";
String inputName = scanner.next();

不能依赖:

correctName == inputName

因为业务真正想比较的是:

字符内容

而不是:

是否恰好引用同一个 String 对象

应该按照字符串内容比较语义处理。

完整的 equals()equalsIgnoreCase() 等 API 会在下一章展开。


5.2 字符串不可变为什么适合作为 Map Key

以后学习 HashMap 时会经常看到:

Map<String, User>

String 作为 key 极其常见。

其中一个重要原因就是:

String 的内容不会在对象创建后突然被原地修改。

这对稳定的:

equals
hashCode

语义非常重要。

相关哈希表机制会在集合框架章节完整学习。


5.3 常量文本可以安全共享

例如:

public static final String SUCCESS = "SUCCESS";

系统中的多个位置都可能引用:

"SUCCESS"

由于字符串不可变,共享相同字符串对象不会出现:

一个模块修改字符串,另一个模块的数据突然跟着改变

这种问题。


六、常见问题

6.1 String 是基本数据类型吗?

不是。

String 是:

java.lang.String

类,是引用类型。


6.2 String 对象不可变,那为什么变量还能重新赋值?

因为:

String s

是引用变量。

例如:

s = "A";
s = "B";

变化的是:

s 的引用

而不是:

"A" 对象被修改成了 "B"

6.3 String s = "abc" 有没有创建对象?

字符串字面量:

"abc"

本身表示 String 实例。

如果相同内容已经有对应 interned 实例,则可以复用已有对象,而不必为每一次相同字面量都创建独立 String 对象。


6.4 new String("abc") 一定只有一个对象吗?

不要简单用:

“这一行创建几个对象?”

这种脱离运行上下文的方式死记。

因为:

"abc"

字面量对应的 interned String 是否此前已经存在,与类加载及运行上下文有关。

但可以确定:

new String(...)

这次类实例创建表达式会创建新的 String 对象。

因此学习重点应该放在:

new 得到的新 String 引用

和

字面量 interned 引用

不是同一个引用

而不是机械背“固定创建 N 个对象”。


6.5 相同内容的字符串 == 一定为 true 吗?

不一定。

例如:

String a = "abc";
String b = new String("abc");

虽然内容一样:

a.equals(b)

为真。

但:

a == b

为假。

所以:

内容相同
≠
引用一定相同

6.6 两个相同字面量 == 为什么为 true?

因为字符串字面量被 intern,同内容字面量引用规范化共享实例。

例如:

String a = "abc";
String b = "abc";

因此:

a ─┐
   ├→ canonical "abc"
b ─┘

6.7 "a" + "b" 一定产生新 String 吗?

不能这么绝对地说。

例如:

String a = "ab";
String b = "a" + "b";

"a" + "b" 是编译期常量表达式,可以在编译阶段求值得到 "ab"

而:

String x = "b";
String c = "a" + x;

属于运行时字符串连接。

所以判断时要先问:

这是编译期常量表达式,还是运行时拼接?


6.8 业务字符串应该用 == 还是 equals?

通常业务关注内容:

username.equals(input)

而不是:

username == input

== 更适合判断:

两个引用是否指向同一个对象。


6.9 StringBuilder 和 String 谁更好?

没有绝对谁更好。

String
→ 表示最终字符串
→ 不可变

StringBuilder
→ 适合频繁构建、修改字符串
→ 可变

本章先掌握 String。

03-03 会系统学习 StringBuilder。


七、练习与验收

7.1 知识问答

  1. String 是基本类型还是引用类型?
  2. 字符串字面量是什么类型?
  3. String 为什么被称为不可变对象?
  4. s = s + "abc" 是否修改了原 String 对象?
  5. 什么是字符串池?
  6. 相同字符串字面量为什么能够共享对象?
  7. == 比较 String 时主要比较什么?
  8. equals() 比较 String 时关注什么?
  9. new String("abc")"abc" 有什么对象身份差异?
  10. 什么是 intern()
  11. "a" + "b""a" + variable 为什么可能具有不同对象行为?
  12. 为什么不能简单认为“所有字符串都在字符串池”?
  13. 为什么 String 不可变适合共享?
  14. 为什么 String 很适合作为 HashMap key?
  15. 为什么大量字符串拼接后续需要学习 StringBuilder?

7.2 代码阅读

禁止运行,判断结果:

String a = "abc";
String b = "abc";

System.out.println(a == b);

说明:

  1. 输出什么?
  2. 为什么?

分析:

String a = "abc";
String b = new String("abc");

System.out.println(a == b);
System.out.println(a.equals(b));

回答:

  1. 两行结果分别是什么?
  2. 两种比较分别比较什么?

分析:

String a = "hello";

String b = "hel" + "lo";

String x = "lo";
String c = "hel" + x;

System.out.println(a == b);
System.out.println(a == c);
System.out.println(a == c.intern());

回答:

  1. 三个结果分别是什么?
  2. b 为什么与普通运行时拼接不同?
  3. intern() 在第三个判断中发挥什么作用?

7.3 手写代码

  1. 分别使用字符串字面量和 new String() 创建 String。
  2. 使用 char[] 创建 String。
  3. 编写程序验证两个相同字符串字面量的引用关系。
  4. 编写程序验证两个 new String("Java")==equals() 结果。
  5. 编写编译期字符串拼接案例。
  6. 编写运行时字符串拼接案例。
  7. 使用 intern() 观察引用比较结果。
  8. 编写案例证明:
    • String 对象不可变;
    • String 引用变量可以重新赋值。

7.4 Debug

下面代码用于登录:

String correctName = "admin";

String inputName = scanner.next();

if (correctName == inputName) {
    System.out.println("登录成功");
}

要求:

  1. 判断业务逻辑存在什么风险。
  2. 解释 == 比较的是什么。
  3. 修改为正确的字符串内容比较方案。

分析:

String a = "Java";

a.concat("SE");

System.out.println(a);

要求:

  1. 不运行程序,预测结果。
  2. 从 String 不可变性角度解释。
  3. 思考如果要保存新字符串结果应该如何处理。

concat() 的完整 API 使用方式将在下一章学习。

7.5 综合训练

编写 StringIdentityDemo

要求同时创建:

String a = "hello";
String b = "hello";
String c = new String("hello");
String d = "hel" + "lo";

String part = "lo";
String e = "hel" + part;

完成:

  1. 分别判断 a == b
  2. 判断 a == c
  3. 判断 a == d
  4. 判断 a == e
  5. 判断 a == e.intern()
  6. 使用 equals() 比较所有字符串内容。
  7. 为每一个结果画出简化引用关系图。
  8. 解释:
    • 字面量;
    • new
    • 编译期常量拼接;
    • 运行时拼接;
    • intern; 五者之间的区别。

7.6 本章验收

闭卷完成:

  • [ ] 解释 String 为什么是引用类型。
  • [ ] 解释字符串字面量。
  • [ ] 手写两种 String 创建方式。
  • [ ] 解释 String 不可变。
  • [ ] 区分“引用改变”和“对象改变”。
  • [ ] 解释字符串池。
  • [ ] 正确判断 "abc" == "abc"
  • [ ] 正确判断 "abc" == new String("abc")
  • [ ] 正确判断 "a" + "bc" 的常量表达式场景。
  • [ ] 区分 ==equals()
  • [ ] 解释 intern()
  • [ ] 不再使用“String 内容相同,所以 == 一定 true”这样的错误判断。
  • [ ] 不再死记“new String 一行固定创建几个对象”,而能够分析具体上下文。

如果你能够对字符串代码做到:

先判断表达式类型
→ 再判断对象来源
→ 再判断是否 intern
→ 最后分析 == / equals

说明 String 对象模型已经基本建立。