Java 基础体系 · 第 48/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS
正则表达式同时包含两层含义:
- 模式语言:用字符、字符类、量词、分支和断言描述一组字符串。
- 执行机制:Java 将模式编译为
Pattern,再由Matcher在输入文本上执行匹配。
只理解第一层,容易写出语义错误的表达式;只理解第二层,又容易忽略回溯、分组和输入边界造成的性能问题。尤其是在处理不可信输入时,正则表达式可能成为一种拒绝服务攻击面,即 ReDoS(Regular Expression Denial of Service,正则表达式拒绝服务)。
本文以 Java 25 LTS 的 java.util.regex API 为范围,分别说明模式对象、匹配器、匹配操作、分组、回溯、性能和 ReDoS 的因果关系。
1. 从字符串到匹配结果:Pattern 和 Matcher
一个典型的调用过程是:
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class BasicExample {
public static void main(String[] args) {
Pattern pattern = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})");
Matcher matcher = pattern.matcher("发布日期:2025-09-15。");
if (matcher.find()) {
System.out.println(matcher.group()); // 2025-09-15
System.out.println(matcher.group(1)); // 2025
System.out.println(matcher.group(2)); // 09
System.out.println(matcher.group(3)); // 15
System.out.println(matcher.start()); // 5
System.out.println(matcher.end()); // 15
}
}
}
输出为:
2025-09-15
2025
09
15
5
15
这里有三个不同的对象或概念:
String:待搜索的输入字符序列。Pattern:编译后的正则表达式。它描述“如何匹配”。Matcher:绑定了一个Pattern和一个输入字符序列的匹配器。它保存当前搜索位置、最近一次匹配结果、分组边界等状态。
可以把生命周期表示为:
flowchart LR
A[正则表达式字符串] --> B[Pattern.compile]
B --> C[Pattern]
C --> D[matcher 输入]
D --> E[Matcher]
E --> F{matches / lookingAt / find}
F --> G[匹配结果与分组]
E --> H[region / reset / usePattern]
Pattern 是不可变的,编译成功后可以被多个线程共享。Matcher 包含可变状态,通常不应在多个线程之间并发复用;每个并发任务应创建自己的 Matcher。
1.1 Pattern.compile 的职责
Pattern p = Pattern.compile("[A-Z][a-z]+");
Pattern.compile 会解析模式并检查语法。如果模式非法,会抛出 PatternSyntaxException:
Pattern.compile("[a-z");
// java.util.regex.PatternSyntaxException
编译阶段处理的是模式本身,不是输入文本。输入文本很长时,主要成本通常发生在 Matcher 执行阶段;模式复杂时,编译本身也会有成本。
如果同一个正则表达式被反复使用,应显式复用 Pattern:
private static final Pattern USERNAME =
Pattern.compile("[A-Za-z][A-Za-z0-9_]{2,31}");
不要在高频路径中反复调用:
boolean valid(String value) {
return Pattern.matches("[A-Za-z][A-Za-z0-9_]{2,31}", value);
}
Pattern.matches 是一次性便利方法,调用时需要完成编译和匹配。它适合偶尔使用,不适合作为高频校验路径的缓存替代品。
1.2 Matcher 是有状态的
同一个 Matcher 连续调用 find() 时,后一次搜索会从前一次匹配结束的位置继续:
Pattern p = Pattern.compile("\\d+");
Matcher m = p.matcher("x12y345");
while (m.find()) {
System.out.println(m.group() + " [" + m.start() + ", " + m.end() + ")");
}
输出:
12 [1, 3)
345 [4, 7)
调用 reset() 可以将匹配器重新定位到输入开头:
m.reset();
if (m.find()) {
System.out.println(m.group()); // 12
}
也可以绑定新的输入:
m.reset("abc99");
reset 会清除当前匹配状态,但不会改变 Pattern。
2. matches、lookingAt 和 find 的语义不同
这三个方法经常被误用。
2.1 matches:整个区域必须匹配
Pattern p = Pattern.compile("\\d+");
System.out.println(p.matcher("123").matches()); // true
System.out.println(p.matcher("x123").matches()); // false
System.out.println(p.matcher("123x").matches()); // false
matches() 要求模式匹配整个 Matcher 当前区域。它不等价于“模式中自动添加了 ^ 和 $”,但在常见的完整字符串场景中,效果类似于整段匹配。
2.2 lookingAt:必须从区域开头开始
Pattern p = Pattern.compile("\\d+");
System.out.println(p.matcher("123abc").lookingAt()); // true
System.out.println(p.matcher("abc123").lookingAt()); // false
它要求匹配从当前区域的起点开始,但不要求消耗到区域结尾。
2.3 find:搜索下一个子串
Pattern p = Pattern.compile("\\d+");
Matcher m = p.matcher("abc123def45");
System.out.println(m.find()); // true,匹配 123
System.out.println(m.group());
System.out.println(m.find()); // true,匹配 45
System.out.println(m.group());
System.out.println(m.find()); // false
find() 会尝试多个起始位置。对于长文本中的提取任务,它既可能产生大量匹配,也可能在每个起始位置执行复杂模式,因此成本不只是“模式本身有多复杂”。
3. Java 字符串和正则表达式有两层转义
Java 源代码中的正则表达式首先要经过 Java 字符串转义,然后才交给正则表达式解析器。
例如,正则表达式 \d+ 表示一个或多个数字,但 Java 字符串必须写成:
String regex = "\\d+";
第一层是 Java 字符串:
"\\d+"
Java 编译后传递给正则引擎的内容是:
\d+
常见写法如下:
| 目标正则 | Java 字符串 |
|---|---|
\d+ |
"\\d+" |
\. |
"\\." |
\bword\b |
"\\bword\\b" |
\Q...\E |
"\\Q...\\E" |
这也是为什么路径、反斜杠和边界表达式经常写错。
如果要把外部输入当作普通文本,而不是正则语法,应使用:
String userText = "a+b*c";
Pattern p = Pattern.compile(Pattern.quote(userText));
等价地,也可以使用 Pattern.LITERAL:
Pattern p = Pattern.compile(userText, Pattern.LITERAL);
两者都能避免用户输入中的 +、*、[ 等字符被解释为正则元字符。若还需要和其他正则片段组合,Pattern.quote 通常更灵活。
4. 正则表达式的基本匹配构件
4.1 字面字符和元字符
字母和数字通常表示自身:
cat
只匹配连续的 cat。
以下字符具有特殊含义:
. ^ $ * + ? { } [ ] \ | ( )
例如:
.:默认匹配除行终止符外的任意字符;*:前一个构件重复零次或多次;+:重复一次或多次;?:重复零次或一次,或改变量词的贪婪性;|:分支;():分组;[]:字符类;\:转义或引入预定义类别。
要匹配字面句点,不能直接写 .,应写:
\.
在 Java 字符串中则是:
"\\."
4.2 字符类
[abc]
匹配 a、b 或 c 中的一个字符。
[a-z]
匹配一个小写 ASCII 字母。
[^0-9]
匹配一个不是 ASCII 数字的字符。字符类中的 ^ 只有放在开头时才表示否定;放在其他位置通常表示字面字符。
常见预定义字符类包括:
\d:数字;\s:空白字符;\w:单词字符;\D、\S、\W:对应否定类;.:通常表示除行终止符外的任意字符。
在 Java Pattern 中,预定义类和 POSIX 类的 Unicode 行为受标志影响。默认语义不能简单等同于“所有 Unicode 数字”或“所有 Unicode 字母”。需要 Unicode 兼容类别时,可以使用:
Pattern.compile("\\d+", Pattern.UNICODE_CHARACTER_CLASS);
或者直接使用 Unicode 属性,例如:
\p{IsHan}+
如果输入协议要求 ASCII,例如 HTTP token、十六进制标识符或机器协议字段,显式写出 ASCII 范围通常更清楚:
[0-9A-Fa-f]+
4.3 量词
对 X 使用量词:
X* 零次或多次
X+ 一次或多次
X? 零次或一次
X{m} 恰好 m 次
X{m,n} 至少 m 次,至多 n 次
X{m,} 至少 m 次
例如:
a{2,4}
可以匹配 aa、aaa 或 aaaa,但不能匹配 a 或 aaaaa。
量词默认是贪婪的:在不导致整体失败的前提下,优先消耗更多字符。
Matcher m = Pattern.compile("a+").matcher("aaaa");
m.find();
System.out.println(m.group()); // aaaa
惰性量词在普通量词后加 ?:
a+?
它优先消耗更少字符,但如果后续模式无法匹配,仍然可能继续扩展。
Matcher m = Pattern.compile("a+?a").matcher("aaaa");
m.find();
System.out.println(m.group()); // aa
执行过程大致是:
a+?先取一个a;- 后面的
a成功; - 整体已经成功,因此不再继续扩展。
惰性不等于高效。它仍可能通过回溯尝试大量长度。
5. 锚点、边界和输入区域
5.1 ^、$、\A、\z
常见锚点包括:
^:输入或行的开头,是否按行解释受MULTILINE影响;$:输入或行的结尾,是否按行解释受MULTILINE影响;\A:整个输入的开头;\z:整个输入的严格结尾;\Z:整个输入的结尾,但允许最后一个终止符存在。
如果校验整个字符串,并且不希望末尾换行被特殊处理,可以优先考虑:
\A[0-9]+\z
Java 字符串写作:
"\\A[0-9]+\\z"
例如:
Pattern p = Pattern.compile("\\A[0-9]+\\z");
System.out.println(p.matcher("123").matches()); // true
System.out.println(p.matcher("123\n").matches()); // false
实际工程中,matches() 已经要求整个区域匹配,因此通常不必再添加首尾锚点;但锚点在 find()、多行文本和嵌套表达式中仍然有明确作用。
5.2 MULTILINE 和 DOTALL
Pattern.compile("^ERROR.*$", Pattern.MULTILINE);
MULTILINE 改变 ^ 和 $ 的含义,使它们可以匹配每一行的开头和结尾。
Pattern.compile(".*", Pattern.DOTALL);
DOTALL 使 . 也能匹配行终止符。
这两个标志作用不同:
MULTILINE影响锚点;DOTALL影响点号。
把它们混为一谈会产生漏匹配或过度匹配。
5.3 Matcher 的 region
Matcher 可以只在输入的一部分区域内工作:
Pattern p = Pattern.compile("\\d+");
Matcher m = p.matcher("abc123xyz456");
m.region(3, 6);
System.out.println(m.matches()); // true,区域是 "123"
这里区域采用半开区间 [start, end):
start包含;end不包含。
因此 region(3, 6) 覆盖索引 3、4、5。
Matcher 还提供:
m.useAnchoringBounds(false);
m.useTransparentBounds(true);
它们影响区域边界与锚点、前后查找的关系:
- anchoring bounds:区域边界是否被视为
^、$等锚点; - transparent bounds:区域外的字符是否可以被前瞻、后顾等边界检查看到。
这些设置只改变匹配器观察输入的边界,不会截断底层字符串。
6. 分组:编号、命名和捕获状态
6.1 捕获组和组 0
括号默认创建捕获组:
(\d{4})-(\d{2})-(\d{2})
组编号按左括号出现顺序分配:
- 组
0:整个匹配; - 组
1:第一个捕获组; - 组
2:第二个捕获组; - 组
3:第三个捕获组。
Pattern p = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})");
Matcher m = p.matcher("2025-09-15");
if (m.matches()) {
for (int i = 0; i <= m.groupCount(); i++) {
System.out.println(i + ": " + m.group(i));
}
}
输出:
0: 2025-09-15
1: 2025
2: 09
3: 15
start(i) 和 end(i) 返回第 i 组的边界。如果某个可选组没有参与匹配,group(i) 返回 null,对应边界通常为 -1。
Matcher m = Pattern.compile("(a)?b").matcher("b");
if (m.matches()) {
System.out.println(m.group(1)); // null
System.out.println(m.start(1)); // -1
System.out.println(m.end(1)); // -1
}
在成功匹配前调用 group()、start() 等方法会抛出 IllegalStateException。组编号越界会抛出索引相关异常,因此生产代码不要假设组编号与模式永远同步;复杂模式更适合使用命名组。
6.2 非捕获组
如果只需要组织结构,不需要读取内容,应使用非捕获组:
(?:https?|ftp)://
(?:...) 参与匹配,但不分配捕获组编号。
例如:
Pattern p = Pattern.compile("(?:GET|POST) /([A-Za-z0-9_/.-]+)");
Matcher m = p.matcher("GET /api/users");
if (m.matches()) {
System.out.println(m.groupCount()); // 1
System.out.println(m.group(1)); // api/users
}
如果写成 (GET|POST) /(...),前面的协议分支也会占用一个编号,后续修改模式更容易造成编号错位。非捕获组还有一个性能和内存上的小收益,但主要价值是表达意图和稳定组编号。
6.3 命名捕获组
命名组的形式是:
(?<name>...)
示例:
Pattern p = Pattern.compile(
"(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})"
);
Matcher m = p.matcher("2025-09-15");
if (m.matches()) {
System.out.println(m.group("year")); // 2025
System.out.println(m.group("month")); // 09
System.out.println(m.group("day")); // 15
}
命名组名必须符合 Pattern 对命名组的语法约束,通常以英文字母开头,后面使用字母或数字。命名组不能重复定义。
命名组仍然有编号,因此命名访问不是“另一种完全不同的分组”,而是更可读的访问方式。
6.4 重复分组只保留最后一次捕获
下面的模式匹配多个单词:
Pattern p = Pattern.compile("(\\w+\\s*)+");
Matcher m = p.matcher("one two three");
if (m.matches()) {
System.out.println(m.group(0)); // one two three
System.out.println(m.group(1)); // three
}
组 1 位于重复结构中。它不是一个列表;每次重复都会覆盖之前的捕获,匹配结束后通常只能看到最后一次成功捕获的内容。
因此,若要提取所有元素,应使用 find():
Matcher m = Pattern.compile("\\w+").matcher("one two three");
while (m.find()) {
System.out.println(m.group());
}
输出:
one
two
three
这是“用一个重复捕获组收集所有结果”的常见误解。捕获组保存边界,不提供集合语义。
6.5 反向引用
反向引用要求后续文本与前面捕获到的内容相同:
\b([A-Za-z]+)\s+\1\b
它可以匹配:
hello hello
但不能匹配:
hello world
Java 示例:
Pattern p = Pattern.compile("\\b([A-Za-z]+)\\s+\\1\\b");
System.out.println(p.matcher("hello hello").find()); // true
System.out.println(p.matcher("hello world").find()); // false
反向引用依赖运行时捕获内容,不再是简单的“只根据当前位置和固定状态判断”的正则匹配。它会增加回溯复杂度,也使某些本来容易用有限自动机处理的问题变得更难优化。
7. 断言:检查上下文但不消耗字符
断言验证条件,但不把验证的字符纳入当前匹配消耗范围。
7.1 前瞻
\d+(?=元)
含义是:匹配数字,但要求数字后面紧接着出现 元;元 本身不属于匹配结果。
Pattern p = Pattern.compile("\\d+(?=元)");
Matcher m = p.matcher("价格 100元");
if (m.find()) {
System.out.println(m.group()); // 100
}
7.2 否定前瞻
foo(?!bar)
匹配 foo,但要求其后不能是 bar。
7.3 后顾
(?<=金额:)\d+
匹配数字,但要求其前面紧接着是 金额:。后顾表达式不会消耗前面的字符。
断言本身也可能包含量词、分支和嵌套结构。如果断言在每个输入位置重复执行,复杂断言会放大总体成本。因此,“没有消耗字符”不等于“没有执行成本”。
8. 替换 API 和分组引用
匹配和替换使用同一个 Matcher,但替换字符串有自己的语法:
$1、${name}:引用捕获组;\和$在替换文本中具有特殊含义。
Pattern p = Pattern.compile("(?<key>[A-Za-z]+)=(?<value>[^&]+)");
Matcher m = p.matcher("name=alice&role=admin");
String result = m.replaceAll("${key}=[REDACTED]");
System.out.println(result);
输出:
[REDACTED]&[REDACTED]
如果替换内容来自外部输入,不能直接把它当作替换模板。例如用户输入中的 $1 可能被解释为分组引用。应使用:
String safeReplacement = Matcher.quoteReplacement(userText);
完整示例:
String userText = "$1 is not a group reference";
String result = Pattern.compile("secret")
.matcher("secret")
.replaceAll(Matcher.quoteReplacement(userText));
System.out.println(result);
Pattern.quote 保护的是正则模式;Matcher.quoteReplacement 保护的是替换字符串。两者作用位置不同,不能互换。
9. 回溯:Matcher 如何处理选择和失败
9.1 规范语义和实现机制要区分
Java Pattern 文档定义的是正则语法和匹配结果语义。具体的内部执行算法属于实现细节,不应把某个 JDK 版本的内部类结构当作 API 契约。
但 Java 的 Pattern 常见实现采用带回溯的正则执行策略。理解这种策略对解释以下现象非常有用:
- 贪婪量词为什么会缩短;
- 分支为什么会换另一条路径;
- 某些模式为什么在失败输入上特别慢;
- 原子组和占有量词为什么能限制搜索。
9.2 一个完整的回溯过程
考虑模式:
a+ab
输入:
aaab
执行过程可以抽象为:
a+是贪婪量词,先尽可能多地匹配aaa;- 下一个字面量
a没有可匹配字符,因为当前位置已经到达b前; a+回溯,释放一个a;- 此时
a+匹配aa,后面的字面量a匹配第三个a; - 最后的
b匹配成功; - 整体匹配成功。
回溯不是“重新从头随机尝试”,而是回到之前存在选择的节点,改变该节点的决定。
9.3 分支的回溯
考虑:
(ab|a)c
输入:
ac
执行过程:
- 先尝试分支
ab; a成功,但b试图匹配c,失败;- 回到分支节点;
- 尝试分支
a; - 后面的
c成功; - 整体成功。
如果没有后续失败,Java 的分支通常按从左到右的顺序选择第一个可行分支:
Pattern p = Pattern.compile("(a|aa)");
Matcher m = p.matcher("aa");
if (m.find()) {
System.out.println(m.group()); // a
}
因为第一分支 a 已经成功,find() 不会为了追求更长结果自动选择 aa。如果后续结构迫使第一分支失败,回溯才可能尝试第二个分支。
9.4 回溯树和复杂度直觉
如果某个位置有两个选择,后续又有两个选择,失败时可能需要探索多个组合。假设有 k 个独立的二选一决策,最坏情况下的路径数上界可能接近:
这里:
k是需要回退并重新选择的决策数;2^k表示每个决策有两种可能时的组合数量。
这不是所有正则的精确运行时间公式,也不是 Java API 的复杂度保证;它用于说明为什么“每次只多一个字符”可能导致尝试路径大幅增加。
10. 贪婪、惰性、占有量词和原子组
10.1 三种量词行为
对基础量词 *、+、?、{m,n},Java 支持三种常见形式:
| 类型 | 示例 | 行为 |
|---|---|---|
| 贪婪 | a+ |
尽量多取,失败时允许回退 |
| 惰性 | a+? |
尽量少取,必要时扩展 |
| 占有 | a++ |
尽量多取,之后不允许回退 |
占有量词通过放弃回退点减少搜索空间。
10.2 占有量词改变的不只是性能
System.out.println(Pattern.matches("a+a", "aa")); // true
System.out.println(Pattern.matches("a++a", "aa")); // false
对于 a+a:
a+先匹配两个a;- 最后的
a失败; a+释放一个a;- 最后的
a成功。
对于 a++a:
a++匹配两个a;- 最后的
a失败; a++不允许释放字符;- 整体失败。
所以占有量词只有在“已经确定前面的字符不应交给后续模式”的地方才是语义正确的优化。
10.3 原子组
原子组使用:
(?>...)
组内一旦成功离开,就不允许回到组内重新选择。
例如:
System.out.println(Pattern.matches("(a|ab)c", "abc")); // true
System.out.println(Pattern.matches("(?>a|ab)c", "abc")); // false
第一种模式:
- 分支
a先成功; - 后面的
c试图匹配b,失败; - 回到分支内部;
- 尝试
ab; c成功。
第二种模式:
- 原子组先选择
a; - 离开原子组后,不能回到内部尝试
ab; - 后面的
c失败; - 整体失败。
原子组和占有量词都是限制回溯的工具,但不能机械地“到处加”。它们可能消除本来需要的合法回退,从而把正确匹配变成错误失败。
11. 性能:编译成本、搜索成本和结果成本
正则处理的总成本可以分解为:
其中:
- :解析和编译
Pattern的成本; - :在输入上执行匹配的成本;
- :读取分组、生成替换结果、创建返回字符串等成本。
重复编译会增加第一项;复杂回溯会增加第二项;大量捕获和替换会增加第三项。
11.1 find() 可能多次启动匹配
设输入长度为 ,模式没有明确的前缀约束,find() 可能尝试许多起始位置。即使一次匹配平均很快,反复启动也可能使总成本接近:
其中 是一次起始位置尝试的成本。
如果模式本身在一次尝试中还会产生指数级回溯,那么两者会叠加,而不是互相抵消。
11.2 捕获组不是免费功能
捕获组需要保存开始位置和结束位置。一个模式包含大量不需要读取的括号时,会增加状态管理复杂度。应将结构性括号写成非捕获组:
(?:https?|ftp)://
但不要把“减少捕获组”误认为解决回溯问题的方法。性能灾难的主要来源是可重新组合的匹配路径,而不是单纯的组数量。
11.3 正则不是所有解析任务的合适工具
以下任务通常更适合专用解析器或手写状态机:
- 嵌套且需要递归层级的语言;
- JSON、XML、SQL 等具有结构语法的输入;
- 需要精确错误位置和恢复策略的编译器式解析;
- 复杂协议解析和长度字段校验。
正则可以用于提取局部结构,但“用一个超长表达式完成整个语言解析”往往同时损害可读性、可测试性和性能可控性。
12. ReDoS:正则表达式拒绝服务
12.1 ReDoS 的本质
ReDoS 不是“正则表达式很长”这么简单。其核心条件是:
- 输入由攻击者或不可信来源控制;
- 模式包含多个可重复或可分支的选择;
- 这些选择对同一段输入存在多种解释;
- 输入最终失败,迫使引擎探索大量路径。
一个典型模式是:
^(a+)+$
Java 字符串写法:
"^(a+)+$"
攻击输入:
aaaaaaaaaaaaaaaaaaaaaaaaX
模式要求整段只能由 a 组成,但末尾的 X 使整体失败。
12.2 为什么 (a+)+ 会产生大量路径
设输入中有 个连续的 a,并且最后有一个不匹配字符 X。
外层 + 可以把这段 a 分成若干个非空块:
aaaaa
可能的划分包括:
aaaaa
a|aaaa
aa|aaa
aaa|aa
aaaa|a
a|a|aaa
a|aa|aa
...
每个相邻位置都可以选择“在这里切分”或“不在这里切分”。因此,正数长度为 的连续字符存在:
种切分方式。
Java 的回溯执行会在外层重复、内层 a+ 和末尾 $ 失败之间反复调整匹配长度。不同 JDK 和不同模式细节可能触发优化,不能把上式当作 Java 的精确运行时间;但这种模式确实具有产生指数级回溯的结构风险。
12.3 另一个典型:重叠分支
^(a|aa)+$
a 和 aa 都能匹配以 a 开头的输入。相同字符序列可以被分解成许多不同方式:
aaaa
a|a|a|a
aa|a|a
a|aa|a
a|a|aa
aa|aa
...
如果末尾再增加一个不匹配字符,失败会触发对这些组合的探索。
重叠分支的关键不是“分支数量多”,而是不同分支能够消费相同前缀。以下分支的重叠较小:
^(?:cat|dog|bird)$
每个分支首字符不同,失败时通常更容易排除候选;但这仍不是 Java API 对复杂度的保证。
12.4 含通配符的嵌套量词
例如:
^(.+)+$
或者:
^(.*a){10}$
这些模式让多个结构都可以消费相同字符。.* 本身就有大量长度选择,放进重复结构后,失败路径会显著增多。
需要注意:.* 不必然导致 ReDoS。若其后有明确锚点、输入长度很小,或者后续结构能快速排除大多数路径,风险可能有限。风险需要结合:
- 输入是否可控;
- 最大输入长度;
- 匹配调用频率;
- 模式是否存在嵌套重复;
- 分支是否有重叠;
- 失败是常见情况还是成功是常见情况;
- 是否在请求线程中同步执行。
13. 如何改写高风险模式
13.1 直接消除嵌套量词
高风险模式:
^(a+)+$
如果实际需求只是“整段只能包含一个或多个 a”,应改成:
^a+$
这不是性能微调,而是删除了不必要的语义自由度。
13.2 使用互斥字符类
高风险写法:
^(.+)+$
如果需求是“非空且不含换行”,可能应直接写:
^[^\r\n]+$
如果需求是“由小写字母组成”:
^[a-z]+$
越具体的字符类,越能减少引擎的选择空间。
13.3 使用占有量词
当后续模式不需要从前面的量词中取回字符时,可以使用占有量词:
^[a-z]++$
Java 示例:
Pattern safe = Pattern.compile("\\A[a-z]++\\z");
System.out.println(safe.matcher("abcxyz").matches()); // true
System.out.println(safe.matcher("abcXYZ").matches()); // false
这里 [a-z]++ 消耗的字符不可能被别的结构重新解释,因此禁止回溯是安全的。
13.4 使用原子组控制边界
例如,协议字段由逗号分隔时,可以让字段消费逻辑保持封闭:
\G(?>[^,]*)(?:,|$)
但原子组是否正确取决于具体语法。它不是通用“安全开关”。如果字段内部本来允许多种解释,原子化可能导致合法输入被拒绝。
13.5 让分支具有互斥前缀
高重叠:
^(foo|foobar)+$
如果语法允许,通常应重新设计为共享公共前缀:
^foo(?:bar)?$
重写后的结构表达的是:
- 先匹配固定的
foo; - 再选择是否匹配
bar。
这比让引擎在两个完整分支之间反复切换更直接。
14. ReDoS 防护不能只依赖改模式
14.1 限制输入长度
如果字段业务上最多 256 个字符,应在进入正则前拒绝更长输入:
boolean validUsername(String value) {
if (value == null || value.length() > 32) {
return false;
}
return USERNAME.matcher(value).matches();
}
长度限制必须符合业务约束。它不是替代模式审查的理由,因为短输入也可能触发高成本路径;但它能限制最坏情况的规模。
String.length() 返回 UTF-16 char 数量,不是 Unicode 码点数量,也不是用户感知字符数量。如果业务按码点限制,可以使用:
int codePointCount =
value.codePointCount(0, value.length());
如果业务按字素簇限制,则需要更高层的 Unicode 文本处理规则,不能把 length() 当作用户可见字符数。
14.2 Java 标准 Matcher 没有通用超时参数
Java SE 的标准 Pattern/Matcher API 没有类似“匹配最多执行 100 毫秒”的通用超时参数。因此不能写出一个标准 API 调用来可靠地终止任意正在执行的匹配。
常见但不充分的做法是:
Future<Boolean> future = executor.submit(
() -> pattern.matcher(input).matches()
);
try {
return future.get(100, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
future.cancel(true);
return false;
}
这段代码可以让调用线程停止等待,但 future.cancel(true) 只是发出中断请求。它不构成 Java Matcher 一定立即停止的保证。若把大量可能超时的任务继续留在线程池中,线程仍可能被占满,最终依然造成服务不可用。
对高风险、不可信的正则匹配,更可靠的隔离边界通常是:
- 预先审查和限制正则模式;
- 限制输入长度;
- 在独立进程或受控沙箱中执行;
- 为执行进程设置 CPU、内存和生命周期限制;
- 超时后销毁执行单元并记录诊断信息。
14.3 不能把线程中断当作安全边界
线程中断适合协作式取消。正则执行是否在所有路径上及时检查中断,不应被当作标准 API 的强制终止契约。尤其是服务端处理攻击输入时,必须考虑“取消等待”与“实际计算停止”之间的差异。
15. flags:改变语义,也可能改变成本
常用编译标志包括:
Pattern.CASE_INSENSITIVE
Pattern.MULTILINE
Pattern.DOTALL
Pattern.COMMENTS
Pattern.UNICODE_CASE
Pattern.UNICODE_CHARACTER_CLASS
Pattern.LITERAL
也可以使用内联标志:
(?i)abc
(?s:.*)
其中 (?s:...) 只对局部组启用 DOTALL。
15.1 CASE_INSENSITIVE 与 Unicode
Pattern.compile("straße", Pattern.CASE_INSENSITIVE);
大小写折叠的具体范围还受到 Unicode 相关标志影响。若业务需要跨语言、跨脚本的大小写不敏感匹配,应明确选择 CASE_INSENSITIVE、UNICODE_CASE 或 UNICODE_CHARACTER_CLASS,并使用代表性 Unicode 测试样本验证,而不要只用 ASCII 测试。
15.2 COMMENTS 不是任意空白忽略
COMMENTS 允许模式中的空白和注释采用更适合维护的写法,但字符类中的语义和转义规则仍需遵守正则语法。它不是“忽略输入文本空白”的标志;输入文本中的空白是否匹配,仍由模式决定。
15.3 LITERAL 会关闭正则语法
Pattern p = Pattern.compile("a+b", Pattern.LITERAL);
System.out.println(p.matcher("a+b").matches()); // true
System.out.println(p.matcher("aaab").matches()); // false
此时 + 不再是量词,而是普通字符。
16. 一个可运行的端到端示例:提取日志字段
下面的程序提取日志中的时间、级别和消息:
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class LogParser {
private static final Pattern LOG = Pattern.compile(
"\\A\\[(?<time>[^\\]]+)]\\s+" +
"(?<level>INFO|WARN|ERROR)\\s+" +
"(?<message>.*)\\z"
);
public static void main(String[] args) {
String line = "[2025-09-15T10:20:30Z] ERROR database unavailable";
Matcher matcher = LOG.matcher(line);
if (!matcher.matches()) {
System.out.println("invalid log line");
return;
}
System.out.println("time = " + matcher.group("time"));
System.out.println("level = " + matcher.group("level"));
System.out.println("message = " + matcher.group("message"));
}
}
输出:
time = 2025-09-15T10:20:30Z
level = ERROR
message = database unavailable
每个部分的作用是:
\A和\z约束整行输入;(?<time>...)创建命名组;[^\\]]+在 Java 字符串中表示正则^[^\]]+的局部形式,即读取直到]前的内容;INFO|WARN|ERROR限定日志级别;(?<message>.*)读取剩余消息;matches()确保整行而不是某个子串符合格式。
这个模式仍有一个业务边界:.* 默认不匹配行终止符,但示例输入是一行。如果日志消息允许包含换行,应明确决定是使用 DOTALL,还是逐行解析,而不能依赖默认行为。
17. 常见误解和失败表现
17.1 “用了 matches(),所以 ^ 和 $ 都必须写”
错误。matches() 已经要求整个区域成功匹配:
Pattern.matches("\\d+", "123"); // true
重复写成:
Pattern.matches("^\\d+$", "123"); // 也正确,但通常冗余
锚点在模式被用于 find()、多行文本或嵌套上下文时仍然有价值。
17.2 “find() 只调用一次就是匹配全部”
错误。find() 查找一个子串。需要全部匹配时使用 matches(),或者使用明确的首尾边界并理解区域语义。
Pattern p = Pattern.compile("\\d+");
System.out.println(p.matcher("abc123").find()); // true
System.out.println(p.matcher("abc123").matches()); // false
17.3 “捕获组可以保存重复匹配的所有值”
错误。重复捕获组通常只保留最后一次成功捕获。需要所有结果时循环调用 find(),或在应用层构造集合。
17.4 “惰性量词一定比贪婪量词快”
错误。惰性量词只是改变尝试顺序。若后续条件直到很晚才失败,它仍可能尝试大量长度。
17.5 “占有量词总是更好”
错误。占有量词会改变可回溯性。只有在确认后续结构不需要取回已消费字符时才能使用。
17.6 “编译一次就解决了性能问题”
错误。复用 Pattern 只能消除重复编译成本,不能修复匹配阶段的指数级回溯。需要同时检查模式结构和输入边界。
18. 诊断正则性能问题的方法
遇到疑似慢匹配时,应区分以下几类问题:
- 编译慢:是否在循环中重复
Pattern.compile; - 搜索次数多:是否使用
find()在大文本中反复尝试; - 单次匹配慢:是否存在嵌套量词、重叠分支或复杂断言;
- 结果处理慢:是否创建了大量替换结果或读取大量捕获组;
- 输入异常:是否包含超长无界字段或恶意失败后缀。
一个简单的测试程序可以比较成功输入和失败输入:
import java.util.regex.Pattern;
public class RegexProbe {
public static void main(String[] args) {
Pattern pattern = Pattern.compile("^(a+)+$");
for (int length : new int[] {10, 15, 20, 25, 30}) {
String input = "a".repeat(length) + "X";
long start = System.nanoTime();
boolean result = pattern.matcher(input).matches();
long elapsed = System.nanoTime() - start;
System.out.printf(
"length=%d, result=%s, time=%d ns%n",
input.length(), result, elapsed
);
}
}
}
这个程序的用途是观察趋势,不是生成跨机器稳定的性能结论。结果会受到以下因素影响:
- JDK 实现;
- CPU;
- JVM 预热和 JIT 编译;
- 垃圾回收;
- 系统负载;
- 输入长度。
生产评估应使用固定数据集、多次预热、独立测量和超时保护。不能只测一个成功样本,因为许多回溯灾难恰恰发生在“几乎匹配但最终失败”的输入上。
19. 生产代码中的边界设计
19.1 把校验拆成结构和业务规则
例如,邮箱、日期、用户名等字段不应仅靠一个巨大正则完成所有语义校验。可以先做:
- 长度限制;
- 字符集限制;
- 基本结构匹配;
- 类型解析和范围校验。
日期示例:
private static final Pattern DATE =
Pattern.compile("\\A(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})\\z");
该模式能识别 2025-02-31 的结构,但不能证明它是有效日期。真正的日期合法性应交给 java.time:
import java.time.LocalDate;
import java.time.DateTimeException;
static boolean isValidDate(String text) {
Matcher matcher = DATE.matcher(text);
if (!matcher.matches()) {
return false;
}
try {
LocalDate.of(
Integer.parseInt(matcher.group("year")),
Integer.parseInt(matcher.group("month")),
Integer.parseInt(matcher.group("day"))
);
return true;
} catch (DateTimeException | NumberFormatException e) {
return false;
}
}
正则负责文本结构,日期 API 负责日期语义。这样的边界比把闰年、月份天数全部塞进一个表达式更可靠。
19.2 明确 null 策略
Pattern.matcher(null) 会失败并抛出 NullPointerException,不会返回 false。如果输入来自请求、配置或数据库,是否允许 null 应由调用方明确处理:
static boolean isUsername(String value) {
return value != null && USERNAME.matcher(value).matches();
}
这属于 API 调用边界,不是正则语义本身。
19.3 不要让用户任意提供模式
如果产品确实需要用户输入搜索条件,应优先提供有限语法,例如:
- 固定字段;
- 白名单操作符;
- 最大长度;
- 最大分支数;
- 禁止反向引用、嵌套量词和复杂后顾;
- 将用户条件转换成安全的字面匹配或受限 AST。
直接把用户输入交给 Pattern.compile,不仅有 ReDoS 风险,还可能产生与用户预期不同的语义,例如 .、.*、反向引用和替换字符串中的 $1。
20. 核心关系总结
Java 正则表达式的关键因果链可以概括为:
- 正则字符串先经过 Java 字符串转义;
Pattern.compile解析并编译模式;Pattern.matcher创建有状态的Matcher;matches、lookingAt和find选择不同的匹配范围;- 分组保存匹配边界,重复分组通常只保留最后一次捕获;
- 贪婪量词、惰性量词和分支产生不同的候选路径;
- 后续失败会触发回溯,重新选择之前的路径;
- 嵌套重复和重叠分支会使候选路径呈指数级增长;
- 攻击者控制长输入时,这种增长可能形成 ReDoS;
- 解决方案必须同时包含模式重写、输入边界和执行隔离,而不是只缓存
Pattern或简单依赖线程中断。
Pattern 解决的是模式的编译和复用,Matcher 解决的是一次具体输入上的状态化执行;分组解决的是结果定位和引用,回溯解释了表达式如何在失败时寻找替代路径,而 ReDoS 则是这些执行特征在不受控输入下产生的安全后果。只有把这几层放在同一个模型中,才能既写出语义正确的正则,也能判断它是否适合运行在生产请求路径上。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java Optional:创建、组合、空值边界、性能与错误用法
- 下一篇:Java 国际化:Locale、ResourceBundle、日期数字和消息格式
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论