Skip to content

第9章:字符串与常用工具类

本章分层

  • 必读:String 不可变性、StringBuilder 正确拼接、常用方法(length()/charAt()/substring()/split()/replace())、日期时间 API
  • 选读trim() vs strip()、枚举的字段与方法
  • 深水区:正则表达式的 Pattern/Matcher 基础、Math 工具类的陷阱

本章不会要求你掌握

  • String.intern() 的底层实现与字符串常量池细节
  • 正则引擎的 NFA vs DFA 原理

元数据卡

项目内容
难度等级(新手村二星)
前置章节第8章·面向对象
核心知识点5(String不可变性、StringBuilder、常用方法、正则表达式、工具类)
预估学习时间3–4 小时
涉及语言Java(主)+ Python / C++(对比)

你在哪

老陈师傅把你叫到工坊的书架前,抽出一本厚厚的册子:"你面向对象学得不错了,但有一样东西你一直在用,却从没认真想过——字符串。"

你已经学完了面向对象设计,知道怎么定义类、创建对象、使用继承。但到现在为止,你还在用最原始的方式处理文本:String s = "hello" + "world"

等一下——如果你每次拼接字符串都创建一个新对象,一百次拼接之后呢?内存里多出一百个临时字符串?

你隐约感觉到:字符串没有表面上那么简单。

字符串是编程世界里最大的内存黑洞之一。许多线上故障最终被追踪到一行不起眼的字符串操作。本章就是要填上这个坑。


你的任务

你接手了老陈工坊后院的订单簿。刚翻开账本,就发现了这些"精彩"的记录:

java
// 场景A:每晚对账时 OOM
String report = "";
for (Order o : orders) {
    report += o.toLine() + "\n";
}

// 场景B:村口布告栏的传单去重总是不对
String notice = "  村口集会-下月初三  ";
Set<String> dedup = new HashSet<>();
dedup.add(notice);
// 再查另一张告示的"村口集会-下月初三"——猜猜结果?

// 场景C:日期格式五花八门
String date1 = "甲辰年-腊月-十五";
String date2 = "腊月/十五/甲辰年";

你的任务:搞清楚这些符文为什么出问题,然后给出正确的改写方案。


遭遇战→获得技能

遭遇战 #1:拼接一百次,内存炸了

java
// ✗ 错误写法 —— 双循环拼接
String result = "";
for (int i = 0; i < 100_000; i++) {
    result += "line " + i + "\n";
}

运行上面的代码,JVM 会告诉你:OutOfMemoryError: Java heap space

为什么?

因为 String 是不可变的(immutable)。每次 result += "..." 都等价于:

java
result = new StringBuffer().append(result).append("line ").append(i).append("\n").toString();

100,000 次循环 → 100,000 个临时 StringBuffer 对象 + 100,000 个 String 对象。GC 哭都来不及。

正确做法

java
// ✓ 正确写法
StringBuilder sb = new StringBuilder(1_000_000); // 预分配容量
for (int i = 0; i < 100_000; i++) {
    sb.append("line ").append(i).append("\n");
}
String result = sb.toString();

预期效果:一个 StringBuilder 对象跑完全程,无额外 GC 压力。

String vs StringBuilder vs StringBuffer 三兄弟

特性StringStringBuilderStringBuffer
可变性不可变可变可变
线程安全(不可变天然安全)不安全同步
性能拼接时最差最优比 Builder 慢 2–3 倍
适用场景固定文本、字面量单线程大量拼接多线程共享字符串缓冲区

跨界窗口:在其他语言中

python
# Python:看似无害,实则也有坑
result = ""
for i in range(100_000):
    result += f"line {i}\n"  # 每次创建新字符串!O(n²)

Python 也一样。Python 的 str 也是不可变的。上面的代码复杂度是 O(n²)。

python
# ✅ Python 正道
lines = [f"line {i}\n" for i in range(100_000)]
result = "".join(lines)  # O(n) 单次拼接
cpp
// C++:std::string 可变!但+=也有陷阱
std::string result;
for (int i = 0; i < 100000; ++i) {
    result += "line " + std::to_string(i) + "\n";
    // C++ string 可变,+= 通常摊销 O(1)
    // 但如果 result 容量不够会触发重分配+拷贝
}
// ✅ 预分配更稳
result.reserve(1_000_000);
语言String 可变?最优拼接方式
Java不可变StringBuilder
Python不可变"".join(list)
C++可变+=std::ostringstream

遭遇战 #2:substring 的内存泄漏陷阱

🕰 历史实现细节:在 Java 7u6 之前,substring() 底层共享了原始字符串的 char[], 导致截取大字符串中一小段时会阻止整个大数组被 GC。 Java 7u6 之后已修复——substring() 会复制新的字符数组。 现代 Java 开发者无需担心此问题,了解即可。

java
// 从大日志中取前 1000 条
String hugeLog = read100MBLogFile();
String firstThousand = hugeLog.substring(0, 100_000);
// Java 7+ 不会泄漏,放心使用

遭遇战 #3:split 正则坑

java
// 需求:按"."分割 IP 地址 "192.168.1.1"
String ip = "192.168.1.1";
String[] parts = ip.split(".");     // ❓ 结果是什么?
System.out.println(Arrays.toString(parts)); // 输出啥?

输出:[](空数组)

因为 split() 的参数是正则表达式. 在正则中匹配"任意字符",不是字面点号。

java
// ✅ 正确
String[] parts = ip.split("\\.");   // 转义
// ✅ 或者用
String[] parts = ip.split(Pattern.quote(".")); // Pattern.quote 自动转义

这是面试高频题,也是生产线上每天都会踩的坑。

跨界窗口

python
# Python 的 split() —— 默认不是正则!
ip = "192.168.1.1"
parts = ip.split(".")  # ✅ 字面语义,直接按 '.' 分割
print(parts)           # ['192', '168', '1', '1']

# re.split() 才支持正则
import re
parts = re.split(r"\.", ip)

语言对比:Java 的 split 默认正则(性能略差、易踩坑),Python 默认字面(直观)。


常见陷阱:逐层解剖核心知识点

解剖 #1:String 不可变性的真面目

java
String s = "Hello";
s = s + " World";
// 这到底发生了什么?

在内存中发生的是:

  1. 在字符串常量池中创建 "Hello"(如果不存在)
  2. 创建 StringBuilder 对象
  3. 追加 "Hello"" World"
  4. toString() 创建新字符串 "Hello World"
  5. 在堆上分配新 String 对象
  6. s 引用指向这个新对象

原来的 "Hello" 对象还在常量池里,没有被修改,也没有消失。 这就是"不可变"的含义——不是你改不了它,而是每次"改"都是新生。

为什么 Java 要把 String 设计成不可变的?

  • 线程安全:不需要同步就能安全共享
  • 字符串常量池:同一字面量复用同一个对象,节省内存
  • Hash 缓存String 缓存了 hashCode,计算一次就能用,HashMap 性能关键
  • 安全性:类加载器、网络连接等依赖字符串的地方,不会因为引用被修改而出问题
java
// 不可变带来的"坑"
String a = "hello";
String b = a.toUpperCase();  // a 还是 "hello",b 是 "HELLO"
// 初学者常问:"我改了 a,为什么没变?" —— 因为 a 从来没被改

解剖 #2:常用方法速查

方法作用时间复杂度注意
length()字符串长度O(1)不是 length 属性
charAt(i)取第 i 个字符O(1)越界抛异常
substring(b, e)截取Java 7+ O(n)endIndex 不包含
indexOf(ch)查找字符O(n)返回 -1 找不到
split(regex)分割O(n)参数是正则
replace(a, b)替换字符O(n)字符版不是正则
replaceAll(re, s)替换全部O(n)参数是正则
trim()去除首尾空格O(n)只去 ASCII 空格
strip()Java 11+ 去除空格O(n)支持 Unicode 空格
java
// 对比演示
String t = "  \u2003Hello\u2003  ";  // 包含 EM SPACE(Unicode空格)

System.out.println("'" + t.trim() + "'");   // 输出: '    Hello   ' (没去掉!)
System.out.println("'" + t.strip() + "'");  // 输出: 'Hello' (✅ 去干净了)

解剖 #3:正则表达式——最小匹配入门

💡 正则表达式博大精深,本章只学最常用的匹配模式——能解决 80% 的日常需求即可。 完整的正则引擎知识在后续卷中深入。

正则表达式是字符串处理的神器,也是"写到一半自己也看不懂"的重灾区。

Java 中正则的使用三件套:

java
import java.util.regex.Pattern;
import java.util.regex.Matcher;

// 1. Pattern —— 编译正则(很贵,要复用)
Pattern pattern = Pattern.compile("\\b\\w{6,}\\b");  // 匹配6个以上字母的单词

// 2. Matcher —— 执行匹配
Matcher matcher = pattern.matcher("hello world, programming is fun");

// 3. 结果处理
while (matcher.find()) {
    System.out.println(matcher.group());  // → programming
}

输出:programming

常见正则速记表

模式含义示例匹配
\d数字"123"
\w字母/数字/下划线"abc_123"
\s空白字符' ', '\t', '\n'
^行首^Hello
$行尾world$
[A-Z]大写字母范围'K', 'M'
{n,m}重复 n 到 m 次\d{3,4}
(X|Y)X 或 Y(com|org|cn)
java
// 实用例子:验证手机号(简单版,仅做示范)
Pattern MOBILE = Pattern.compile("^1[3-9]\\d{9}$");
System.out.println(MOBILE.matcher("13812345678").matches());  // true
System.out.println(MOBILE.matcher("12345678901").matches());  // false

性能警告:正则回溯可能引发灾难性性能问题(ReDoS)。(a+)+b 这种嵌套量词的模式在匹配长串失败时,可能从 O(n) 变成 O(2ⁿ)。生产环境建议对用户输入的正则设置超时,或使用 RE2 等安全引擎。

解剖 #4:Math 工具类

java.lang.Math 全部静态方法,无需创建实例。

java
Math.abs(-10);        // 10
Math.max(a, b);       // 取大值
Math.min(a, b);       // 取小值
Math.pow(2, 10);      // 1024.0(返回double)
Math.sqrt(16);        // 4.0
Math.random();        // [0.0, 1.0) 随机浮点数
Math.round(3.6);      // 4(四舍五入)
Math.floor(3.6);      // 3.0(向下取整)
Math.ceil(3.2);       // 4.0(向上取整)

** 老生常谈的 Math.abs 陷阱:**

java
System.out.println(Math.abs(Integer.MIN_VALUE));
// 输出: -2147483648 ???
// 因为 int 范围是 -2^31 ~ 2^31-1,2^31 超出了上限,溢出了
// ✅ 改为 long
System.out.println(Math.abs((long) Integer.MIN_VALUE)); // 2147483648

解剖 #5:日期时间——从 Date 到 LocalDateTime 的迁移

Java 的日期 API 经历了三代演变,如果你还在用 java.util.Date,你正在写遗留代码。

三代日期 API

版本API状态问题
Java 1.0java.util.Date遗留可变、月份从0开始、线程不安全
Java 1.1java.util.Calendar半遗留仍然烦琐、月份偏移、可变
Java 8java.time.*现代不可变、线程安全、设计优美
java
// ❌ 老式写法(2024年了别这样写)
Date now = new Date();
System.out.println(now.getMonth());  // 5?但现在是6月!因为月份从0开始
System.out.println(now.getYear());   // 124?这是1900+124=2024

// ✅ 现代写法
import java.time.*;

LocalDate today = LocalDate.now();                    // 2026-06-23
LocalTime now = LocalTime.now();                      // 16:17:00.123
LocalDateTime dt = LocalDateTime.now();               // 2026-06-23T16:17:00

// 构建和解析
LocalDate date = LocalDate.of(2024, 6, 15);
LocalDate parsed = LocalDate.parse("2024-06-15");     // ✅ 默认ISO格式

// 计算
LocalDate tomorrow = today.plusDays(1);
LocalDate lastMonth = today.minusMonths(1);

// 比较
boolean isBefore = date.isBefore(today);              // true

// 格式化
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy/MM/dd");
System.out.println(today.format(fmt));                // 2026/06/23

跨界窗口

python
# Python
from datetime import datetime, timedelta

now = datetime.now()
yesterday = now - timedelta(days=1)
formatted = now.strftime("%Y/%m/%d")  # 2026/06/23
cpp
// C++20 之前用 <ctime> 或第三方库
// C++20 之后有 std::chrono
#include <chrono>
auto now = std::chrono::system_clock::now();

解剖 #6:枚举——比常量更聪明的选择

java
// ❌ 常量方式的痛苦
public static final int STATUS_PENDING = 0;
public static final int STATUS_ACTIVE = 1;
public static final int STATUS_BANNED = 2;

// 问题:可以传任意 int
void setStatus(int status) { ... }
setStatus(999);  // 编译器不报错,逻辑崩了

// ✅ 枚举
public enum Status {
    PENDING,    // 0
    ACTIVE,     // 1
    BANNED       // 2
}

void setStatus(Status status) { ... }
setStatus(Status.PENDING);    // ✅ 类型安全
// setStatus(999);             // ❌ 编译错误
java
// 枚举的高级用法:带字段和方法
public enum OrderStatus {
    PENDING(0, "待支付"),
    PAID(1, "已支付"),
    SHIPPED(2, "已发货"),
    DELIVERED(3, "已送达"),
    CANCELLED(4, "已取消");

    private final int code;
    private final String desc;

    OrderStatus(int code, String desc) {
        this.code = code;
        this.desc = desc;
    }

    public static OrderStatus fromCode(int code) {
        for (OrderStatus s : values()) {
            if (s.code == code) return s;
        }
        throw new IllegalArgumentException("Invalid code: " + code);
    }
}

// 使用
OrderStatus s = OrderStatus.fromCode(1);
System.out.println(s.getDesc());  // 已支付

枚举的隐藏能力:

  • 单例安全:每个枚举常量是且仅有一个实例
  • 天然线程安全:JVM 保证枚举实例的唯一性
  • 可用在 switch 语句、HashMap 的 key(比普通对象快,因有 EnumMap

通关挑战

任务:写一个日志分析器的核心函数。

需求

  1. 从 10 万行日志中统计每个 URL 的访问次数
  2. 每行格式:2024-06-15 14:30:22 [INFO] GET /api/products - 200
  3. URL 部分格式不统一,可能有 /api/products/123(带参数)或 /api/products
  4. 要求:URL 要去掉末尾的数字参数部分,仅统计路由模式
  5. 输出格式:Top 10 路由及其访问次数

提示:需要用到的知识点——Pattern.compileStringBuilder、正则分组、Map(下章会学,先用 HashMap

java
// 骨架代码
public class LogAnalyzer {
    // 日志行正则:时间 级别 方法 路径 - 状态码
    private static final Pattern LOG_PATTERN = Pattern.compile(
        "\\[INFO\\] \\w+ (/[\\w/]+?)(/\\d+)? - \\d{3}"
    );

    public static void main(String[] args) {
        List<String> logs = loadLogs("access.log"); // 假设已实现
        // TODO: 统计并输出 top 10
    }
}

自测:

  • 能处理 /api/products/123/api/products 吗?
  • 能处理 /api/products(无参数)吗?
  • 能处理 /static/js/app.a1b2c3.js 这种非数字后缀吗?
  • 输出的 top 10 按访问次数降序排列了吗?

验收标准

完成本章后,你应该能回答:

  • [ ] String 为什么不可变?不可变带来了哪些优缺点?
  • [ ] 什么场景用 StringBuilder?什么场景用 StringBuffer?
  • [ ] "abc".split(".") 结果是什么?为什么?怎么修正?
  • [ ] trim()strip() 的区别是什么?
  • [ ] 正则中的 \d\w^${n,m} 分别是什么意思?
  • [ ] Math.abs(Integer.MIN_VALUE) 结果是什么?为什么?
  • [ ] Java 8 日期 API 相比于 Date/Calendar 有什么优势?
  • [ ] 为什么用枚举代替 public static final int 常量?
  • [ ] 如何正确拼接 10 万次字符串?

常见卡点

卡点 1:"我用了 equals 为什么还是 false?" → 检查是不是忘了 equals 比较的是值,== 比较的是引用。String 字面量用 == 有时返回 true(因为常量池),但那只是巧合,别依赖。

卡点 2:"正则写对了但 split 结果不对" → 调试技巧:用 Pattern.compile(regex).matcher(testStr).matches() 反向验证。

卡点 3:"日期加了 1 个月,怎么少了几天?" → 2024-01-31 +1 个月 = 2024-02-29(不是 02-31!)。LocalDate 会自动"钳制"到合法值。

卡点 4:"说好的 Pattern.compile 要复用,为什么我的程序还是慢?" → Matcher 对象轻量,Pattern 对象重量。复用 Pattern 即可,不要复用 Matcher 实例用于不同输入。

卡点 5:"枚举里放了好多字段,序列化会出问题吗?" → Java 枚举的序列化由 JVM 特殊处理,name 决定了身份,反序列化时不会调用构造器。在枚举里加字段没问题,但确保字段本身可序列化。


现在不需要理解

这些知识点在初中级阶段暂时不需要深究,知道存在即可:

  • String.intern() 的底层实现:方法区的字符串常量池细节,和 GC 算法相关
  • 正则引擎的 NFA vs DFA 区别:涉及自动机理论,和实际写正则关系不大
  • DateTimeFormatter 线程安全细节:知道它是不可变且线程安全的即可,内部实现不用看
  • EnumSet / EnumMap 的位向量实现:了解有这俩性能更好的工具就行,内部位运算优化不需要你手动实现
  • Unicode 规范化 (Normalization)"\u00C9" vs "E\u0301" 在日常开发中极少遇到

旅人笔记

  • 字符串是典型的"看起来简单、深挖吓人"的知识点。很多人写了三年 Java,第一次线上 OOM 就是因为字符串拼接。记住:单次拼接用 + 没事(编译器会优化为 StringBuilder),循环里一定要手动用 StringBuilder
  • 正则表达式是你迟早得学会的技能。 学的时候痛苦,学会了永远回不去。用 regex101.com 在线调试,写一个测一个。
  • Java 8 的日期 API 不是"新增特性",是"迟到的救赎"。 新项目里还在用 java.util.Date 的人,要么是不知道,要么是懒得改。
  • 枚举比看上去强大得多。 它不仅是类型安全的常量,还能带行为、实现接口、用于单例模式。
  • 一个常用工具如果能帮你省 5 行代码,这个工具就值得学。 你花 30 分钟学会 String.format()DateTimeFormatter 这些工具,一年能节省几十个小时。

→ 下一站预告

你刚刚学会了字符串和常用工具类的底层原理与正确用法。但每次写博客时,提到对散列表的一点改动就天翻地覆了……

下一章:【第10章:核心集合】——我们会深入 HashMap 的扩容机制、ArrayList 到底多大时才该扩容、以及让无数人倒下的 equalshashCode 契约。

Built with VitePress | Software Systems Atlas