跳到内容

第 1 卷 / 第 9 章 / 第 2 课

9.2 字符串操作与构造 ​

前置知识:字符串基础、数组、循环

预计时间:30 分钟

运行环境:Java 17

完成标志:能按 API 契约截取、分割、清理、替换和构造文本

订单行看着只是一段文字 ​

一行订单包含日期、客户、物品和数量。能否正确拆分并重新构造它,取决于 API 契约:端点是否包含、分隔符是否按正则表达式解释、尾部空字段是否保留。片段里的 Arrays、Pattern、Matcher 等类型需要导入,完整示例列出了所有 import;各片段使用同名变量时应分开运行。

1. substring 使用左闭右开区间 ​

java
String order = "2026-07-15|一名开发者|铁锤|2";
String date = order.substring(0, 10);
String details = order.substring(11);

System.out.println(date);
System.out.println(details);

输出:

text
2026-07-15
一名开发者|铁锤|2

substring(begin, end) 包含 begin,不包含 end,有效范围满足:

text
0 <= begin <= end <= text.length()

越界或 begin > end 按 API 契约会抛 IndexOutOfBoundsException(常见 JDK 的具体异常是其子类 StringIndexOutOfBoundsException)。索引以 UTF-16 代码单元计数;若切点落在代理项中间,就会得到不完整 Unicode 字符。按码点定位时先用 offsetByCodePoints 计算索引。

substring 承诺返回指定区间的文本,不承诺底层数组如何分配或共享;截取整个字符串时还可能返回原对象。若排查内存占用,应检查目标 JDK 的实现与实际保留对象,不能只从子串长度推断分配量。

2. split 的第一个参数是正则 ​

点号在默认正则模式中匹配行终止符以外的任意字符,所以不能直接写:

java
String ip = "192.168.1.1";

System.out.println(Arrays.toString(ip.split(".")));
System.out.println(Arrays.toString(ip.split("\\.")));

输出:

text
[]
[192, 168, 1, 1]

分隔符来自配置或用户输入时,不要自己猜需要转义哪些字符:

java
String delimiter = ".";
String[] parts = ip.split(Pattern.quote(delimiter));

第二个参数决定切分次数与尾部空项 ​

java
String row = "铁锤,2,,";

System.out.println(Arrays.toString(row.split(",")));
System.out.println(Arrays.toString(row.split(",", -1)));
System.out.println(Arrays.toString(row.split(",", 3)));

输出:

text
[铁锤, 2]
[铁锤, 2, , ]
[铁锤, 2, ,]
  • 不传 limit 或传 0:丢弃末尾空字符串;
  • 负数:保留全部字段,包括末尾空字段;
  • 正数 n:最多切成 n 段,最后一段保留尚未切分的内容。

CSV 有引号、转义和嵌入换行规则,不能靠一个 split(",") 正确解析。生产代码应使用符合目标 CSV 方言的库。

3. trim 与 strip 的空白定义不同 ​

java
String padded = "  \u2003Hello\u2003  ";

System.out.println("'" + padded.trim() + "'");
System.out.println("'" + padded.strip() + "'");

输出:

text
' Hello '
'Hello'

trim() 移除首尾代码值不大于 U+0020 的字符。strip() 从 Java 11 起按 Character.isWhitespace 判断,更适合多语言文本,但“Unicode 中看起来像空白的字符”不一定都被该方法认作 whitespace。例如 "\u00A0".strip().isEmpty() 为 false:不换行空格 U+00A0 不属于 Character.isWhitespace 的集合。选择方法时按输入协议,而不是笼统地说 strip 清除所有 Unicode 空白。

相关方法还有:

java
text.stripLeading();
text.stripTrailing();
text.isBlank();

isBlank() 接受空字符串或全部由 Character.isWhitespace 字符组成的字符串;isEmpty() 只检查长度是否为 0。

4. replace 处理字面文本,replaceAll 处理正则 ​

java
String notice = "Cats and cats";

System.out.println(notice.replace("cats", "dogs"));
System.out.println(notice.replaceAll("(?i)cats", "dogs"));

输出:

text
Cats and dogs
dogs and dogs

replace(CharSequence, CharSequence) 把目标当作字面文本。replaceAll(regex, replacement) 的第一个参数是正则,第二个参数也有自己的替换语法:$1 引用捕获组,反斜杠用于转义。

替换文本来自外部输入时,用 Matcher.quoteReplacement:

java
String price = "price=10";
String safe = price.replaceAll("\\d+", Matcher.quoteReplacement("$5"));
System.out.println(safe); // price=$5

若只需替换字面内容,优先用 replace,既直白也避开两层正则规则。

5. 查找与大小写转换 ​

方法结果边界
indexOf(text)首次出现位置,找不到为 -1可指定起始索引
lastIndexOf(text)最后出现位置找不到为 -1
contains(text)是否包含字面子串参数不能为 null
startsWith / endsWith是否匹配前缀或后缀区分大小写
toUpperCase(locale)按指定地区转换面向用户时不要依赖默认地区

协议允许这种转换时,可用 Locale.ROOT 获得不依赖机器语言环境的大小写结果;允许哪些字符、是否区分大小写,仍由协议本身决定:

java
String normalized = command.toUpperCase(Locale.ROOT);

自然语言搜索、排序和大小写折叠更复杂,不能假设一次 toLowerCase() 就解决所有 Unicode 等价问题。

6. 选择合适的构造方式 ​

少量固定片段:使用 + ​

java
String label = "订单 #" + orderId + ":" + itemName;

源码清楚,编译器会负责运行时拼接策略。不要为了两个片段手写 Builder。

循环或分支中逐步追加:使用 StringBuilder ​

java
StringBuilder report = new StringBuilder();
for (OrderLine line : lines) {
    report.append(line.item())
            .append(" x ")
            .append(line.quantity())
            .append(System.lineSeparator());
}
String text = report.toString();

容量会自动增长。只有在有可靠估算且分析显示扩容值得避免时,才传入初始容量;随手写一个巨大的容量可能浪费内存。

已有元素集合:使用 join ​

java
String summary = String.join(" | ", "铁锤", "钉子", "木板");
String commaSeparated = names.stream().collect(Collectors.joining(","));

这里仅把名称用逗号连接,并没有转义字段内的逗号、引号或换行,因此不构成完整 CSV 编码。需要前缀和后缀时,Collectors.joining(", ", "[", "]") 比手动处理最后一个分隔符更稳妥。

模板文本:格式化或文本块 ​

java
String receipt = "订单 %s,共 %d 件".formatted(orderId, quantity);

String template = """
        客户:%s
        物品:%s
        """.formatted(customer, item);

格式字符串也有类型契约,外部输入不应直接充当可信模板。

7. StringBuilder 与 StringBuffer ​

类型并发契约常见用途
StringBuilder不提供线程安全保证方法内部或单线程拥有的可变缓冲区
StringBuffer在必要操作上同步,支持单次操作的线程安全必须兼容要求该类型的旧 API

StringBuffer 在必要操作上同步,但“先检查再追加”这样的多个调用仍不是一个原子事务。向它追加另一个可变 CharSequence 时,对目标缓冲区加锁也不会自动保护源对象。让多个线程共享一个可变文本缓冲区通常会把顺序、锁和错误处理缠在一起。更常见的设计是每个任务在本地构造结果,最后由明确的汇总阶段合并。

不要写固定的“慢 2 到 3 倍”或“99% 场景”结论。差异取决于 JDK、竞争程度和工作负载;有性能要求时用 JMH 等工具测量目标代码。

8. 完整可运行示例 ​

先准备临时目录:

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

保存为 StringOperationsDemo.java:

java
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

public class StringOperationsDemo {
    public static void main(String[] args) {
        String order = "2026-07-15|一名开发者|铁锤|2";
        checkEquals("2026-07-15", order.substring(0, 10));
        checkEquals("一名开发者|铁锤|2", order.substring(11));

        String ip = "192.168.1.1";
        checkEquals(List.of("192", "168", "1", "1"),
                Arrays.asList(ip.split(Pattern.quote("."))));

        String row = "铁锤,2,,";
        checkEquals(2, row.split(",").length);
        checkEquals(4, row.split(",", -1).length);
        checkEquals("", row.split(",", -1)[3]);

        String padded = "  \u2003Hello\u2003  ";
        checkEquals("\u2003Hello\u2003", padded.trim());
        checkEquals("Hello", padded.strip());

        String safePrice = "price=10".replaceAll(
                "\\d+", Matcher.quoteReplacement("$5"));
        checkEquals("price=$5", safePrice);

        List<String> items = List.of("铁锤", "钉子", "木板");
        String joined = items.stream().collect(Collectors.joining(" | "));
        checkEquals("铁锤 | 钉子 | 木板", joined);

        StringBuilder report = new StringBuilder();
        for (int index = 0; index < items.size(); index++) {
            report.append(index + 1).append(". ").append(items.get(index));
            if (index + 1 < items.size()) report.append(System.lineSeparator());
        }

        System.out.println(order.substring(0, 10));
        System.out.println(joined);
        System.out.println("REPORT".toLowerCase(Locale.ROOT) + ":");
        System.out.println(report);
        System.out.println("字符串操作检查通过");
    }

    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 StringOperationsDemo.java
java -cp out StringOperationsDemo

预期输出:

text
2026-07-15
铁锤 | 钉子 | 木板
report:
1. 铁锤
2. 钉子
3. 木板
字符串操作检查通过

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

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

专业查阅入口 ​

  • String API:substring、split、strip、formatted、lines;
  • Pattern.quote 与 Matcher.quoteReplacement 的不同用途;
  • StringBuilder 的 capacity、length 与 setLength 契约;
  • JMH:需要比较拼接方案时使用基准测试框架,避免用一次 nanoTime 得出结论。

离开这一章前 ​

为 substring 写出左闭右开边界,为 split 分别测试正则点号、末尾空字段和正数 limit;外部替换文本则用 Matcher.quoteReplacement。再按场景选择少量 +、增量 StringBuilder 或集合 join,并明确 StringBuffer 的单方法同步不等于整段流程原子。

保存这些固定输入和预期字段。下次订单格式变化时,先看解析契约需要怎样调整。下一章用集合管理数量会变化的数据。

→ 下一步:核心集合

API 入口:Java 17 Pattern、Java 17 Matcher、Java 17 StringBuilder。

Built with VitePress | Software Systems Atlas