Skip to content

String 与不可变性 ​

S1-011必学入门约 90 分钟JDK 21编程语言基础 · 常用类

1. 本节定位 ​

String 是你写得最多的类型。本节讲两件事:它有哪些常用方法(这部分靠查,不用背),以及为什么它是不可变的(这部分必须理解)。

第二件事的意义超出 String 本身:String 是"不可变类"最标准的范例。理解了它,你就能理解为什么 《封装》 建议把值对象设计成不可变,也能明白 LocalDate、BigDecimal、record 为什么都是"改一下就返回新对象"。

2. 学习目标 ​

完成本节后,你应该能够:

  • 解释什么是不可变对象,以及它的三个好处;
  • 说清字符串常量池如何影响 == 的结果;
  • 正确使用 strip / isBlank / repeat / split / join / format 等常用方法;
  • 说出循环拼接为什么必须用 StringBuilder;
  • 认出 == 比较字符串、split 正则、null 调用这三类高频错误。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

第一,String 无处不在。 接口参数、JSON、数据库字段、日志、配置,全是字符串。字符串处理的熟练度直接决定编码速度。

第二,字符串的坑最多。 == 比较偶尔"看起来能用"(常量池)、split 的参数其实是正则、null 上调用方法直接崩——踩过的人都印象深刻。

第三,不可变性的思想会反复出现。 后面学 LocalDate、BigDecimal、Optional、record 时,你会发现它们都遵循同一套设计。

5. 核心概念 ​

5.1 什么是不可变 ​

java
String original = "java";
String upper = original.toUpperCase();
System.out.println(original);   // java —— 原对象没变
System.out.println(upper);      // JAVA —— 返回的是新对象

String 类被声明为 final,内部存放字符的数组也是私有的、不对外暴露——创建之后,它的内容永远不会变。所有"修改"类方法(trim、replace、substring…)都返回新字符串。

不可变的三个好处:

好处说明
可安全共享多个变量可以指向同一个字符串,谁也不会改坏它
哈希值可缓存String 的 hashCode 算一次就存下来,所以适合做 Map 的 key(《HashMap》)
天生线程安全多线程共享不需要加锁(《线程与同步基础》)

5.2 字符串常量池与 == ​

java
String a = "abc";                 // 字面量:存进常量池,复用
String b = "abc";                 // 复用同一个对象
String c = new String("abc");     // new:在堆上新建一个对象

a == b;         // true  —— 同一个常量池对象
a == c;         // false —— 不同对象
a.equals(c);    // true  —— 内容相同

结论:比较内容永远用 equals。 == 偶尔为 true 只是因为常量池复用了对象,不该依赖它。

一个额外现象:编译期常量折叠

java
String d = "ab" + "c";
d == "abc";         // true

因为编译器在编译期就把两个字面量拼成了 "abc",直接进常量池。但只要拼接里出现变量(如 a + "c"),结果就在运行时生成,== 就变 false 了。

5.3 常用方法速查 ​

分类方法说明
判断isEmpty() / isBlank()isBlank 把空白字符也算"空"(Java 11+)
查找contains / indexOf / startsWith / endsWith
截取substring(begin, end)左闭右开,end 不包含
替换replace / replaceAllreplaceAll 用正则,注意转义
切分split(regex)参数是正则,"." 要写成 "\\."
去空白strip() / stripLeading / stripTrailingJava 11+,比 trim 更懂 Unicode
大小写toUpperCase / toLowerCase建议指定 Locale
拼接String.join("-", a, b) / String.format
其他repeat / lines / charsJava 11+ 新增

5.4 循环拼接必须用 StringBuilder ​

java
// 慢:每次 += 都会创建新的 String 对象
String result = "";
for (int i = 0; i < 10000; i++) {
    result += i;
}

// 快:StringBuilder 内部维护一个可变数组
StringBuilder builder = new StringBuilder();
for (int i = 0; i < 10000; i++) {
    builder.append(i);
}

为什么?String 不可变,每次拼接都要复制整段内容,10000 次拼接就是 O(n²) 的复制量。

那 a + b + c 呢?

单次拼接用 + 完全没问题,编译器会自动优化成 StringBuilder(Java 9+ 用 invokedynamic,见 《Java 语言与运行机制》 的字节码)。

只有循环内的拼接才必须手动用 StringBuilder。

5.5 文本块(Java 15+) ​

java
String json = """
        {
          "name": "知识库",
          "lessons": 24
        }
        """;

三个引号包裹,内部的换行与缩进自动处理,写 SQL、JSON、HTML 时比拼接清晰得多。

6. 递进示例 ​

6.1 最小示例:不可变性 ​

java
String original = "java";
String upper = original.toUpperCase();
System.out.println(original + " / " + upper);   // java / JAVA

6.2 业务示例:字符串清洗与切分 ​

java
String text = "  Java 21 学习知识库  ";
String stripped = text.strip();
System.out.println(stripped.length());          // 13(去掉前后空白)
System.out.println(stripped.contains("21"));    // true
System.out.println(stripped.replace("21", "25"));  // Java 25 学习知识库

6.3 反例:三个高频错误 ​

java
// 错误一:用 == 比较内容
if (status == "PAID") { }                  // 可能为 false,应该用 equals

// 错误二:split 的参数是正则
"2026.09.21".split(".");                   // 返回长度为 0 的数组!
"2026.09.21".split("\\.");                 // 正确

// 错误三:对可能为 null 的字符串调方法
String value = map.get("missing");
if (value.startsWith("A")) { }             // 可能 NPE
if (value != null && value.startsWith("A")) { }   // 正确

另一个常用技巧:把常量写在前面避免 NPE——"A".equals(value) 永远不会 NPE。

7. 完整可运行代码 ​

文件:labs/common-classes/src/main/java/cn/kb/learn/common/StringLesson.java

java
package cn.kb.learn.common;

import java.util.Arrays;

public class StringLesson {
    public static void main(String[] args) {
        // 1. 不可变性:所有"修改"方法都返回新字符串
        String original = "java";
        String upper = original.toUpperCase();
        System.out.println("原字符串: " + original + ",转换后: " + upper);

        // 2. 字符串常量池与 ==
        String a = "abc";
        String b = "abc";
        String c = new String("abc");
        System.out.println("字面量 == 字面量: " + (a == b));
        System.out.println("new String == 字面量: " + (a == c));
        System.out.println("内容相等(equals): " + a.equals(c));

        // 3. 编译期常量折叠
        String d = "ab" + "c";
        System.out.println("编译期拼接 == 字面量: " + (a == d));

        // 4. 常用 API
        String text = "  Java 21 学习知识库  ";
        String stripped = text.strip();
        System.out.println("strip 后: [" + stripped + "]");
        System.out.println("长度: " + stripped.length());
        System.out.println("是否空白串: " + "   ".isBlank());
        System.out.println("包含 21: " + stripped.contains("21"));
        System.out.println("替换后: " + stripped.replace("21", "25"));
        System.out.println("截取前 4 个字符: " + stripped.substring(0, 4));
        System.out.println("按逗号切分: " + Arrays.toString("a,b,c".split(",")));
        System.out.println("拼接: " + String.join("-", "a", "b", "c"));
        System.out.println("格式化: " + String.format("%s 已收录 %d 节课", "知识库", 24));
        System.out.println("重复: " + "ab".repeat(3));

        // 5. 循环拼接要用 StringBuilder
        String byPlus = "";
        for (int i = 0; i < 5; i++) {
            byPlus += i;
        }
        StringBuilder builder = new StringBuilder();
        for (int i = 0; i < 5; i++) {
            builder.append(i);
        }
        System.out.println("加号拼接: " + byPlus);
        System.out.println("StringBuilder: " + builder);
    }
}

运行命令:

powershell
cd labs
./mvnw -pl common-classes compile
java -cp common-classes/target/classes cn.kb.learn.common.StringLesson

预期输出:

text
原字符串: java,转换后: JAVA
字面量 == 字面量: true
new String == 字面量: false
内容相等(equals): true
编译期拼接 == 字面量: true
strip 后: [Java 21 学习知识库]
长度: 13
是否空白串: true
包含 21: true
替换后: Java 25 学习知识库
截取前 4 个字符: Java
按逗号切分: [a, b, c]
拼接: a-b-c
格式化: 知识库 已收录 24 节课
重复: ababab
加号拼接: 01234
StringBuilder: 01234

前三行的对比很关键:a == b 为 true、a == c 为 false、equals 为 true——这就是常量池造成的"时对时错"。

8. Debug 观察点 ​

  1. 在 String c = new String("abc"); 处断点,把 a、b、c 同时加入 Watch,对比 id:a 与 b 相同,c 不同——常量池的作用一眼可见;
  2. 在 original.toUpperCase() 后观察 original 的内容没变——按 F7 进入 toUpperCase 内部,看它最后返回的是 new String(...);
  3. 把 stripped.substring(0, 4) 改成 substring(0, 100),用异常断点停在 StringIndexOutOfBoundsException——记住 substring 的第二个参数是结束下标(不包含);
  4. 在 builder.append(i) 处断点,观察 StringBuilder 内部的 byte[] value 与 count 如何变化——它靠"预分配 + 追加"避免了每次复制。

9. 常见错误 ​

错误 1:== 比较字符串 ​

java
if (name == "张三") { }          // 时对时错
if ("张三".equals(name)) { }     // 正确,且不怕 null

错误 2:忘记 split 的参数是正则 ​

java
"2026.09.21".split(".");         // 返回空数组
"2026.09.21".split("\\.");       // 正确
"a|b".split("\\|");              // 正确

修复:特殊字符(. | * + ? ( ) [ ] { } ^ $ \)都要转义;不确定时用 Pattern.quote(".")。

错误 3:在可能为 null 的字符串上调用方法 ​

java
String value = null;
value.length();                  // NullPointerException

修复:先用常量做判断("x".equals(value)),或显式判空(《读懂 Java 错误信息》 讲过排查思路)。

错误 4:循环里用 + 拼接 ​

见 5.4 节。数据量大时性能差距可达数百倍。

10. 动手实验 ​

目标:写一个字符串工具方法集,并用 StringBuilder 优化。

步骤:

  1. 运行第 7 节代码,核对输出(重点看 == 的三行);
  2. 写方法 String reverse(String text):反转字符串(不能直接用 StringBuilder.reverse(),先手写一遍,再用 StringBuilder 验证);
  3. 写方法 boolean isPalindrome(String text):忽略大小写与空白,判断回文;
  4. 写方法 String maskPhone(String phone):把中间四位替换成 ****(如 13812345678 → 138****5678);
  5. 用 10000 次循环分别测"用 + 拼接"与"用 StringBuilder 拼接"的耗时(System.currentTimeMillis()),记录差距。

验收方式:能说出第 5 步差距的数量级与原因。

扩展任务:用文本块(""")改写一个包含多行的字符串拼接场景(如生成一段 JSON),对比可读性。

11. 练习题 ​

基础题

  1. 判断:String 是可变类型。(对 / 错)
  2. 填空:比较字符串内容要用 ______;循环拼接字符串要用 ______。
  3. 简答:为什么说"不可变对象天生线程安全"?

代码阅读题

java
String a = "hello";
String b = "hel" + "lo";
String c = "hel" + new String("lo");
System.out.println(a == b);
System.out.println(a == c);
System.out.println(a == c.intern());
  1. 三行分别输出什么?intern() 做了什么?

编程题

  1. 写方法 Map<String, Integer> wordCount(String text):统计一段英文文本中每个单词出现的次数(按空白切分,忽略大小写,去掉逗号与句号)。

综合任务

  1. 写一个 LogFormatter 类:
    • 方法 String format(String level, String message, LocalDateTime time):生成 [2026-09-21 14:30:00] INFO - 消息 格式的日志行;
    • 方法 String shorten(String text, int maxLength):超长文本截断为"前 N 个字符 + ...";
    • 方法 String repeat(String text, int times):返回重复拼接的结果(用 StringBuilder 实现,不用 repeat);
    • 说明你在哪里用了 StringBuilder、为什么。
分级提示

提示 1(方向):第 4 题的关键是"编译期折叠"与"运行时创建"的区别。

提示 2(关键点):第 5 题先 toLowerCase() 再切分,切分前用 replace 去掉标点。

提示 3(接近答案):第 6 题的 format 用 DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),shorten 要先判断长度是否超限。

12. 参考答案 ​

基础题
  1. 错。String 是不可变类,所有修改方法都返回新对象。
  2. equals、StringBuilder。
  3. 因为不可变对象的状态永远不会变,多个线程同时读取时不存在"读到一半被别人改掉"的情况,所以不需要加锁。这是不可变性最有价值的性质之一。
代码阅读题(第 4 题)
text
true      ← "hel" + "lo" 是编译期常量折叠,进常量池,与 a 同一个对象
false     ← 有 new String("lo"),拼接在运行时进行,得到新对象
true      ← intern() 会把这个字符串放入常量池并返回池中对象,与 a 相同

intern():查常量池,存在就返回池中对象,不存在则放入再返回。它是"手动入池"的机制,实证应用里很少用(有性能与内存风险),但面试常问。

编程题(第 5 题)
java
package cn.kb.learn.common;

import java.util.HashMap;
import java.util.Map;
import java.util.TreeMap;

public class WordCount {
    static Map<String, Integer> wordCount(String text) {
        Map<String, Integer> counter = new HashMap<>();
        if (text == null || text.isBlank()) {
            return counter;
        }
        String cleaned = text.toLowerCase()
                .replace(",", " ")
                .replace(".", " ")
                .replace("!", " ")
                .replace("?", " ");
        for (String word : cleaned.split("\\s+")) {
            if (!word.isBlank()) {
                counter.merge(word, 1, Integer::sum);
            }
        }
        return counter;
    }

    public static void main(String[] args) {
        String text = "Java is simple. Java is powerful!";
        System.out.println(new TreeMap<>(wordCount(text)));
    }
}

要点:split("\\s+") 用 + 合并连续空白;打印时用 TreeMap 保证顺序稳定。

预期输出:

text
{is=2, java=2, powerful=1, simple=1}
综合任务(第 6 题)
java
package cn.kb.learn.common;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class LogFormatter {
    private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    public static String format(String level, String message, LocalDateTime time) {
        return String.format("[%s] %s - %s", time.format(FORMATTER), level, message);
    }

    public static String shorten(String text, int maxLength) {
        if (text == null || text.length() <= maxLength) {
            return text;
        }
        return text.substring(0, maxLength) + "...";
    }

    public static String repeat(String text, int times) {
        StringBuilder builder = new StringBuilder(text.length() * times);
        for (int i = 0; i < times; i++) {
            builder.append(text);
        }
        return builder.toString();
    }

    public static void main(String[] args) {
        System.out.println(format("INFO", "知识库已加载 24 节课", LocalDateTime.of(2026, 9, 21, 14, 30, 0)));
        System.out.println(shorten("这是一段很长的日志内容,需要被截断显示", 10));
        System.out.println(repeat("ab", 3));
    }
}

哪里用了 StringBuilder:repeat 方法。原因有三:循环内拼接、次数由参数决定(可能很大)、以及提前指定容量(new StringBuilder(text.length() * times))避免内部数组反复扩容。

为什么 format 用 String.format 而不是拼 +:可读性更好,且格式串与参数分离,方便调整。

预期输出:

text
[2026-09-21 14:30:00] INFO - 知识库已加载 24 节课
这是一段很长的日志内...
ababab

13. 掌握标准 ​

  • [ ] 能解释不可变性及其三个好处;
  • [ ] 能说清常量池对 == 的影响,并坚持用 equals 比较内容;
  • [ ] 熟练使用 strip / isBlank / split / join / format;
  • [ ] 知道循环拼接要用 StringBuilder,并说清原因;
  • [ ] 遇到字符串相关异常时能想到 ==、正则转义、null 这三个方向。

14. 扩展资源 ​

15. 下一步学习 ​

《equals、hashCode 与对象比较》:讲清"两个对象什么时候算相等",这是集合、去重、缓存的基础。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引