9.1 字符串基础
前置知识:对象、引用、方法调用
预计时间:25 分钟
运行环境:Java 17
完成标志:能解释不可变性、值比较、池化边界与 UTF-16 索引
先分清引用与字符串值
toUpperCase() 不会原地修改字符串,而内容相同的两个 String 用 == 比较又可能得到 false。这两个现象都要求先回答:变量保存的引用和对象保存的值,分别是什么?
String 用起来像内置类型,却是一个类。第一个分界要先划清:变量保存引用,字符串对象保存值;变量可以改指向,字符串值不会原地变化。
1. String 是引用类型
String name = "一名开发者";
String alias = name;name 与 alias 可以指向同一个对象。Java 规范没有要求你用“引用一定在栈上、对象一定在堆上”来理解程序,JVM 也可以做逃逸分析等优化。初学阶段抓住可观察语义即可:
String变量可以是null;- 方法通过引用访问对象;
==检查两个引用是否指向同一个对象;equals检查两个字符串是否包含相同字符序列。
2. 不可变的是对象,不是变量
String message = "hello";
String upper = message.toUpperCase(java.util.Locale.ROOT);
System.out.println(message);
System.out.println(upper);输出:
hello
HELLOtoUpperCase() 返回转换后的字符串,原来的 "hello" 没变。变量本身仍可改指向:
message = upper;这行改的是 message 保存的引用,没有修改原对象。还有一个容易说过头的地方:字符串方法在结果与原值相同的情况下,可以复用已有对象。不可变性保证“旧值不会被改写”,不保证“每次调用都创建新对象”。
String 是 final 类,公开 API 没有原地修改字符内容的方法。JDK 内部怎样存储字符属于实现细节;应用代码不应依赖某个版本使用 char[] 还是 byte[]。
不可变值带来几项直接好处:同一对象可安全共享;字符串适合作为 Map 键;JVM 可以池化字面量。它并不自动让包含 String 字段的整个对象线程安全,也不意味着敏感文本会被及时从内存清除。
3. 字面量、new String 与字符串池
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));输出:
true
false
true字符串字面量以及字符串常量表达式的结果会被驻留到字符串池,相同内容的这些常量保证引用同一个池化对象。显式 new String("hello") 则创建一个不同对象,所以身份比较为 false,内容比较仍为 true。
这段演示的用途是解释身份,不是教你依赖池化。来自文件、网络、数据库或运行时拼接的文本是否与某个字面量同一对象,不应成为业务逻辑的一部分。
String fromInput = new String("admin");
if ("admin".equals(fromInput)) {
System.out.println("内容匹配");
}intern() 会返回池中表示相同内容的规范引用,但它很少是普通业务代码的正确工具。先用分析数据证明大量重复字符串确实造成内存问题,再评估池化策略和生命周期。
4. 值比较、身份比较与 null
| 需求 | 写法 | 说明 |
|---|---|---|
| 比较文本内容 | left.equals(right) | left 不能为 null |
两边都可能为 null | Objects.equals(left, right) | 两个 null 返回 true |
| 与已知常量比较 | "admin".equals(input) | input 为 null 时返回 false |
| 比较是否同一对象 | left == right | 只在身份本身有意义时使用 |
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,某些字符需要一对代理项:
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));输出:
4
3
U+1F600length() 返回 4,因为它数 UTF-16 代码单元;这个特定示例包含 3 个码点,也显示为 3 个字符。charAt(1) 只能取到代理项的一半,不适合逐个处理补充平面字符。
按码点遍历可写:
badge.codePoints().forEach(codePoint ->
System.out.printf("U+%X ", codePoint));码点也不等于用户眼中的“一个字形”。组合附加符、肤色修饰符和家庭 emoji 可能由多个码点组成。Java 标准库能处理码点;需要按用户感知的字符截断界面文本时,应按 Unicode 字素簇分段,并核对所用库支持的 Unicode 版本。例如 "e\u0301" 有两个码点,通常显示成一个带重音的字符;它与单码点的 "é" 用 equals 比较仍为 false,除非先按明确的规范化规则处理。
6. 编译期拼接与运行时拼接
字符串常量表达式的值在编译期确定,并遵守字符串驻留规则:
String folded = "hel" + "lo";
System.out.println(folded == "hello"); // true运行时拼接则由 Java 编译器和 JVM 按版本优化。现代编译器可能使用 invokedynamic,你不能从源码中的一个 + 推导出固定的 StringBuilder 数量或精确分配次数。
选择写法时先看意图:少量固定片段用 + 最清楚;循环或逐步构造长文本时使用 StringBuilder;集合元素连接可用 String.join 或 Collectors.joining。性能结论要用目标 JDK 和真实负载测量。
7. 完整可运行示例
先准备临时目录:
chapter9_semantics_root=$(mktemp -d)
readonly chapter9_semantics_root
mkdir -p "$chapter9_semantics_root/out"
cd "$chapter9_semantics_root"保存为 StringSemanticsDemo.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);
}
}
}运行:
javac --release 17 -encoding UTF-8 -Xlint:all -d out StringSemanticsDemo.java
java -cp out StringSemanticsDemo预期输出:
字符串语义检查通过
UTF-16 单元=4,码点=3保存需要的实验记录后清理:
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;
StringAPI:equals、codePoints、offsetByCodePoints、intern;- Unicode Standard Annex #29:按字素簇处理用户可见字符时的分段规则。
离开这一页前
分别画出字符串变量改指向和字符串对象保持不变的过程,再用字面量、new String 与运行时输入验证身份比较和内容比较。对 A😀B 同时给出 UTF-16 单元数与码点数;若需求说“用户看到的一个字符”,还要追问是否按字素簇处理。源码里的一个 + 也不能直接推断固定分配次数。