跳到内容

第 1 卷 / 第 9 章 / 第 1 课

9.1 字符串基础 ​

前置知识:对象、引用、方法调用

预计时间:25 分钟

运行环境:Java 17

完成标志:能解释不可变性、值比较、池化边界与 UTF-16 索引

先分清引用与字符串值 ​

toUpperCase() 不会原地修改字符串,而内容相同的两个 String 用 == 比较又可能得到 false。这两个现象都要求先回答:变量保存的引用和对象保存的值,分别是什么?

String 用起来像内置类型,却是一个类。第一个分界要先划清:变量保存引用,字符串对象保存值;变量可以改指向,字符串值不会原地变化。

1. String 是引用类型 ​

java
String name = "一名开发者";
String alias = name;

name 与 alias 可以指向同一个对象。Java 规范没有要求你用“引用一定在栈上、对象一定在堆上”来理解程序,JVM 也可以做逃逸分析等优化。初学阶段抓住可观察语义即可:

  • String 变量可以是 null;
  • 方法通过引用访问对象;
  • == 检查两个引用是否指向同一个对象;
  • equals 检查两个字符串是否包含相同字符序列。

2. 不可变的是对象,不是变量 ​

java
String message = "hello";
String upper = message.toUpperCase(java.util.Locale.ROOT);

System.out.println(message);
System.out.println(upper);

输出:

text
hello
HELLO

toUpperCase() 返回转换后的字符串,原来的 "hello" 没变。变量本身仍可改指向:

java
message = upper;

这行改的是 message 保存的引用,没有修改原对象。还有一个容易说过头的地方:字符串方法在结果与原值相同的情况下,可以复用已有对象。不可变性保证“旧值不会被改写”,不保证“每次调用都创建新对象”。

String 是 final 类,公开 API 没有原地修改字符内容的方法。JDK 内部怎样存储字符属于实现细节;应用代码不应依赖某个版本使用 char[] 还是 byte[]。

不可变值带来几项直接好处:同一对象可安全共享;字符串适合作为 Map 键;JVM 可以池化字面量。它并不自动让包含 String 字段的整个对象线程安全,也不意味着敏感文本会被及时从内存清除。

3. 字面量、new String 与字符串池 ​

java
String first = "hello";
String second = "hello";
String copied = new String("hello");

System.out.println(first == second);
System.out.println(first == copied);
System.out.println(first.equals(copied));

输出:

text
true
false
true

字符串字面量以及字符串常量表达式的结果会被驻留到字符串池,相同内容的这些常量保证引用同一个池化对象。显式 new String("hello") 则创建一个不同对象,所以身份比较为 false,内容比较仍为 true。

这段演示的用途是解释身份,不是教你依赖池化。来自文件、网络、数据库或运行时拼接的文本是否与某个字面量同一对象,不应成为业务逻辑的一部分。

java
String fromInput = new String("admin");

if ("admin".equals(fromInput)) {
    System.out.println("内容匹配");
}

intern() 会返回池中表示相同内容的规范引用,但它很少是普通业务代码的正确工具。先用分析数据证明大量重复字符串确实造成内存问题,再评估池化策略和生命周期。

4. 值比较、身份比较与 null ​

需求写法说明
比较文本内容left.equals(right)left 不能为 null
两边都可能为 nullObjects.equals(left, right)两个 null 返回 true
与已知常量比较"admin".equals(input)input 为 null 时返回 false
比较是否同一对象left == right只在身份本身有意义时使用
java
import java.util.Objects;

String input = null;
System.out.println("admin".equals(input));
System.out.println(Objects.equals(input, null));

equalsIgnoreCase 按 Java 定义的 Unicode 大小写规则比较,但不提供语言环境排序或 Unicode 规范化。协议若限定 ASCII 或另有大小写规则,先校验允许的字符范围,不能直接把它当作所有标识符的等价判断。用户可见文本需要更明确的规则,例如指定 Locale、使用 Collator,或在领域边界进行规范化。

5. length() 数的是 UTF-16 代码单元 ​

Java 的 String 使用 UTF-16 语义编址。常见汉字通常占一个 char,某些字符需要一对代理项:

java
String badge = "A😀B";

System.out.println(badge.length());
System.out.println(badge.codePointCount(0, badge.length()));
System.out.printf("U+%X%n", badge.codePointAt(1));

输出:

text
4
3
U+1F600

length() 返回 4,因为它数 UTF-16 代码单元;这个特定示例包含 3 个码点,也显示为 3 个字符。charAt(1) 只能取到代理项的一半,不适合逐个处理补充平面字符。

按码点遍历可写:

java
badge.codePoints().forEach(codePoint ->
        System.out.printf("U+%X ", codePoint));

码点也不等于用户眼中的“一个字形”。组合附加符、肤色修饰符和家庭 emoji 可能由多个码点组成。Java 标准库能处理码点;需要按用户感知的字符截断界面文本时,应按 Unicode 字素簇分段,并核对所用库支持的 Unicode 版本。例如 "e\u0301" 有两个码点,通常显示成一个带重音的字符;它与单码点的 "é" 用 equals 比较仍为 false,除非先按明确的规范化规则处理。

6. 编译期拼接与运行时拼接 ​

字符串常量表达式的值在编译期确定,并遵守字符串驻留规则:

java
String folded = "hel" + "lo";
System.out.println(folded == "hello"); // true

运行时拼接则由 Java 编译器和 JVM 按版本优化。现代编译器可能使用 invokedynamic,你不能从源码中的一个 + 推导出固定的 StringBuilder 数量或精确分配次数。

选择写法时先看意图:少量固定片段用 + 最清楚;循环或逐步构造长文本时使用 StringBuilder;集合元素连接可用 String.join 或 Collectors.joining。性能结论要用目标 JDK 和真实负载测量。

7. 完整可运行示例 ​

先准备临时目录:

bash
chapter9_semantics_root=$(mktemp -d)
readonly chapter9_semantics_root
mkdir -p "$chapter9_semantics_root/out"
cd "$chapter9_semantics_root"

保存为 StringSemanticsDemo.java:

java
import java.util.Objects;

public class StringSemanticsDemo {
    public static void main(String[] args) {
        String original = "hello";
        String upper = original.toUpperCase(java.util.Locale.ROOT);
        checkEquals("hello", original);
        checkEquals("HELLO", upper);

        String literal = "hello";
        String sameLiteral = "hel" + "lo";
        String copied = new String("hello");
        check(literal == sameLiteral, "编译期常量应复用字面量");
        check(literal != copied, "new String 应产生不同身份");
        check(literal.equals(copied), "内容应相等");

        String nullable = null;
        check(!"admin".equals(nullable), "常量在左侧可避免 NPE");
        check(Objects.equals(nullable, null), "两个 null 的内容比较应相等");

        String badge = "A😀B";
        checkEquals(4, badge.length());
        checkEquals(3, badge.codePointCount(0, badge.length()));
        checkEquals(0x1F600, badge.codePointAt(1));
        String combined = "e\u0301";
        checkEquals(2, combined.codePointCount(0, combined.length()));
        check(!combined.equals("é"), "视觉相近不代表代码单元相同");

        System.out.println("字符串语义检查通过");
        System.out.println("UTF-16 单元=" + badge.length()
                + ",码点=" + badge.codePointCount(0, badge.length()));
    }

    static void check(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
    }

    static void checkEquals(Object expected, Object actual) {
        if (!Objects.equals(expected, actual)) {
            throw new AssertionError("expected=" + expected + ", actual=" + actual);
        }
    }
}

运行:

bash
javac --release 17 -encoding UTF-8 -Xlint:all -d out StringSemanticsDemo.java
java -cp out StringSemanticsDemo

预期输出:

text
字符串语义检查通过
UTF-16 单元=4,码点=3

保存需要的实验记录后清理:

bash
cd
ls -ld -- "$chapter9_semantics_root"
rm -r -- "$chapter9_semantics_root"

专业查阅入口 ​

  • Java Language Specification 3.10.5:String Literals;
  • Java Language Specification 15.18.1:String Concatenation Operator;
  • String API:equals、codePoints、offsetByCodePoints、intern;
  • Unicode Standard Annex #29:按字素簇处理用户可见字符时的分段规则。

离开这一页前 ​

分别画出字符串变量改指向和字符串对象保持不变的过程,再用字面量、new String 与运行时输入验证身份比较和内容比较。对 A😀B 同时给出 UTF-16 单元数与码点数;若需求说“用户看到的一个字符”,还要追问是否按字素簇处理。源码里的一个 + 也不能直接推断固定分配次数。

→ 下一步:字符串操作与构造

查阅入口:JLS §3.10.5:字符串字面量、Java 17 String API、Unicode UAX #29。

Built with VitePress | Software Systems Atlas