第9章:字符串与常用工具类
本章分层
- 必读:String 不可变性、
StringBuilder正确拼接、常用方法(length()/charAt()/substring()/split()/replace())、日期时间 API- 选读:
trim()vsstrip()、枚举的字段与方法- 深水区:正则表达式的
Pattern/Matcher基础、Math工具类的陷阱本章不会要求你掌握
String.intern()的底层实现与字符串常量池细节- 正则引擎的 NFA vs DFA 原理
元数据卡
| 项目 | 内容 |
|---|---|
| 难度等级 | (新手村二星) |
| 前置章节 | 第8章·面向对象 |
| 核心知识点 | 5(String不可变性、StringBuilder、常用方法、正则表达式、工具类) |
| 预估学习时间 | 3–4 小时 |
| 涉及语言 | Java(主)+ Python / C++(对比) |
你在哪
老陈师傅把你叫到工坊的书架前,抽出一本厚厚的册子:"你面向对象学得不错了,但有一样东西你一直在用,却从没认真想过——字符串。"
你已经学完了面向对象设计,知道怎么定义类、创建对象、使用继承。但到现在为止,你还在用最原始的方式处理文本:String s = "hello" + "world"。
等一下——如果你每次拼接字符串都创建一个新对象,一百次拼接之后呢?内存里多出一百个临时字符串?
你隐约感觉到:字符串没有表面上那么简单。
字符串是编程世界里最大的内存黑洞之一。许多线上故障最终被追踪到一行不起眼的字符串操作。本章就是要填上这个坑。
你的任务
你接手了老陈工坊后院的订单簿。刚翻开账本,就发现了这些"精彩"的记录:
// 场景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:拼接一百次,内存炸了
// ✗ 错误写法 —— 双循环拼接
String result = "";
for (int i = 0; i < 100_000; i++) {
result += "line " + i + "\n";
}运行上面的代码,JVM 会告诉你:OutOfMemoryError: Java heap space。
为什么?
因为 String 是不可变的(immutable)。每次 result += "..." 都等价于:
result = new StringBuffer().append(result).append("line ").append(i).append("\n").toString();100,000 次循环 → 100,000 个临时 StringBuffer 对象 + 100,000 个 String 对象。GC 哭都来不及。
正确做法
// ✓ 正确写法
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 三兄弟
| 特性 | String | StringBuilder | StringBuffer |
|---|---|---|---|
| 可变性 | 不可变 | 可变 | 可变 |
| 线程安全 | (不可变天然安全) | 不安全 | 同步 |
| 性能 | 拼接时最差 | 最优 | 比 Builder 慢 2–3 倍 |
| 适用场景 | 固定文本、字面量 | 单线程大量拼接 | 多线程共享字符串缓冲区 |
跨界窗口:在其他语言中
# Python:看似无害,实则也有坑
result = ""
for i in range(100_000):
result += f"line {i}\n" # 每次创建新字符串!O(n²)Python 也一样。Python 的 str 也是不可变的。上面的代码复杂度是 O(n²)。
# ✅ Python 正道
lines = [f"line {i}\n" for i in range(100_000)]
result = "".join(lines) # O(n) 单次拼接// 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 开发者无需担心此问题,了解即可。
// 从大日志中取前 1000 条
String hugeLog = read100MBLogFile();
String firstThousand = hugeLog.substring(0, 100_000);
// Java 7+ 不会泄漏,放心使用遭遇战 #3:split 正则坑
// 需求:按"."分割 IP 地址 "192.168.1.1"
String ip = "192.168.1.1";
String[] parts = ip.split("."); // ❓ 结果是什么?
System.out.println(Arrays.toString(parts)); // 输出啥?输出:[](空数组)
因为 split() 的参数是正则表达式,. 在正则中匹配"任意字符",不是字面点号。
// ✅ 正确
String[] parts = ip.split("\\."); // 转义
// ✅ 或者用
String[] parts = ip.split(Pattern.quote(".")); // Pattern.quote 自动转义这是面试高频题,也是生产线上每天都会踩的坑。
跨界窗口
# 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 不可变性的真面目
String s = "Hello";
s = s + " World";
// 这到底发生了什么?在内存中发生的是:
- 在字符串常量池中创建
"Hello"(如果不存在) - 创建
StringBuilder对象 - 追加
"Hello"和" World" toString()创建新字符串"Hello World"- 在堆上分配新 String 对象
- s 引用指向这个新对象
原来的 "Hello" 对象还在常量池里,没有被修改,也没有消失。 这就是"不可变"的含义——不是你改不了它,而是每次"改"都是新生。
为什么 Java 要把 String 设计成不可变的?
- 线程安全:不需要同步就能安全共享
- 字符串常量池:同一字面量复用同一个对象,节省内存
- Hash 缓存:
String缓存了hashCode,计算一次就能用,HashMap 性能关键 - 安全性:类加载器、网络连接等依赖字符串的地方,不会因为引用被修改而出问题
// 不可变带来的"坑"
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 空格 |
// 对比演示
String t = " \u2003Hello\u2003 "; // 包含 EM SPACE(Unicode空格)
System.out.println("'" + t.trim() + "'"); // 输出: ' Hello ' (没去掉!)
System.out.println("'" + t.strip() + "'"); // 输出: 'Hello' (✅ 去干净了)解剖 #3:正则表达式——最小匹配入门
💡 正则表达式博大精深,本章只学最常用的匹配模式——能解决 80% 的日常需求即可。 完整的正则引擎知识在后续卷中深入。
正则表达式是字符串处理的神器,也是"写到一半自己也看不懂"的重灾区。
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) |
// 实用例子:验证手机号(简单版,仅做示范)
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 全部静态方法,无需创建实例。
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 陷阱:**
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.0 | java.util.Date | 遗留 | 可变、月份从0开始、线程不安全 |
| Java 1.1 | java.util.Calendar | 半遗留 | 仍然烦琐、月份偏移、可变 |
| Java 8 | java.time.* | 现代 | 不可变、线程安全、设计优美 |
// ❌ 老式写法(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
from datetime import datetime, timedelta
now = datetime.now()
yesterday = now - timedelta(days=1)
formatted = now.strftime("%Y/%m/%d") # 2026/06/23// C++20 之前用 <ctime> 或第三方库
// C++20 之后有 std::chrono
#include <chrono>
auto now = std::chrono::system_clock::now();解剖 #6:枚举——比常量更聪明的选择
// ❌ 常量方式的痛苦
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); // ❌ 编译错误// 枚举的高级用法:带字段和方法
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)
通关挑战
任务:写一个日志分析器的核心函数。
需求:
- 从 10 万行日志中统计每个 URL 的访问次数
- 每行格式:
2024-06-15 14:30:22 [INFO] GET /api/products - 200 - URL 部分格式不统一,可能有
/api/products/123(带参数)或/api/products - 要求:URL 要去掉末尾的数字参数部分,仅统计路由模式
- 输出格式:Top 10 路由及其访问次数
提示:需要用到的知识点——Pattern.compile、StringBuilder、正则分组、Map(下章会学,先用 HashMap)
// 骨架代码
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 到底多大时才该扩容、以及让无数人倒下的 equals 和 hashCode 契约。