WR Blog 加载中...
返回文章
JavaJava 25 LTS正则

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS封面

Java 基础体系 · 第 48/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS

正则表达式同时包含两层含义:

  1. 模式语言:用字符、字符类、量词、分支和断言描述一组字符串。
  2. 执行机制: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]

匹配 abc 中的一个字符。

[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}

可以匹配 aaaaaaaaa,但不能匹配 aaaaaa

量词默认是贪婪的:在不导致整体失败的前提下,优先消耗更多字符。

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

执行过程大致是:

  1. a+? 先取一个 a
  2. 后面的 a 成功;
  3. 整体已经成功,因此不再继续扩展。

惰性不等于高效。它仍可能通过回溯尝试大量长度。


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 MULTILINEDOTALL

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

执行过程可以抽象为:

  1. a+ 是贪婪量词,先尽可能多地匹配 aaa
  2. 下一个字面量 a 没有可匹配字符,因为当前位置已经到达 b 前;
  3. a+ 回溯,释放一个 a
  4. 此时 a+ 匹配 aa,后面的字面量 a 匹配第三个 a
  5. 最后的 b 匹配成功;
  6. 整体匹配成功。

回溯不是“重新从头随机尝试”,而是回到之前存在选择的节点,改变该节点的决定。

9.3 分支的回溯

考虑:

(ab|a)c

输入:

ac

执行过程:

  1. 先尝试分支 ab
  2. a 成功,但 b 试图匹配 c,失败;
  3. 回到分支节点;
  4. 尝试分支 a
  5. 后面的 c 成功;
  6. 整体成功。

如果没有后续失败,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 个独立的二选一决策,最坏情况下的路径数上界可能接近:

2k2^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

  1. a+ 先匹配两个 a
  2. 最后的 a 失败;
  3. a+ 释放一个 a
  4. 最后的 a 成功。

对于 a++a

  1. a++ 匹配两个 a
  2. 最后的 a 失败;
  3. a++ 不允许释放字符;
  4. 整体失败。

所以占有量词只有在“已经确定前面的字符不应交给后续模式”的地方才是语义正确的优化。

10.3 原子组

原子组使用:

(?>...)

组内一旦成功离开,就不允许回到组内重新选择。

例如:

System.out.println(Pattern.matches("(a|ab)c", "abc"));   // true
System.out.println(Pattern.matches("(?>a|ab)c", "abc")); // false

第一种模式:

  1. 分支 a 先成功;
  2. 后面的 c 试图匹配 b,失败;
  3. 回到分支内部;
  4. 尝试 ab
  5. c 成功。

第二种模式:

  1. 原子组先选择 a
  2. 离开原子组后,不能回到内部尝试 ab
  3. 后面的 c 失败;
  4. 整体失败。

原子组和占有量词都是限制回溯的工具,但不能机械地“到处加”。它们可能消除本来需要的合法回退,从而把正确匹配变成错误失败。


11. 性能:编译成本、搜索成本和结果成本

正则处理的总成本可以分解为:

Ttotal=Tcompile+Tmatch+TresultT_{\text{total}} = T_{\text{compile}} + T_{\text{match}} + T_{\text{result}}

其中:

  • TcompileT_{\text{compile}}:解析和编译 Pattern 的成本;
  • TmatchT_{\text{match}}:在输入上执行匹配的成本;
  • TresultT_{\text{result}}:读取分组、生成替换结果、创建返回字符串等成本。

重复编译会增加第一项;复杂回溯会增加第二项;大量捕获和替换会增加第三项。

11.1 find() 可能多次启动匹配

设输入长度为 nn,模式没有明确的前缀约束,find() 可能尝试许多起始位置。即使一次匹配平均很快,反复启动也可能使总成本接近:

O(nCattempt)O(n \cdot C_{\text{attempt}})

其中 CattemptC_{\text{attempt}} 是一次起始位置尝试的成本。

如果模式本身在一次尝试中还会产生指数级回溯,那么两者会叠加,而不是互相抵消。

11.2 捕获组不是免费功能

捕获组需要保存开始位置和结束位置。一个模式包含大量不需要读取的括号时,会增加状态管理复杂度。应将结构性括号写成非捕获组:

(?:https?|ftp)://

但不要把“减少捕获组”误认为解决回溯问题的方法。性能灾难的主要来源是可重新组合的匹配路径,而不是单纯的组数量。

11.3 正则不是所有解析任务的合适工具

以下任务通常更适合专用解析器或手写状态机:

  • 嵌套且需要递归层级的语言;
  • JSON、XML、SQL 等具有结构语法的输入;
  • 需要精确错误位置和恢复策略的编译器式解析;
  • 复杂协议解析和长度字段校验。

正则可以用于提取局部结构,但“用一个超长表达式完成整个语言解析”往往同时损害可读性、可测试性和性能可控性。


12. ReDoS:正则表达式拒绝服务

12.1 ReDoS 的本质

ReDoS 不是“正则表达式很长”这么简单。其核心条件是:

  1. 输入由攻击者或不可信来源控制;
  2. 模式包含多个可重复或可分支的选择;
  3. 这些选择对同一段输入存在多种解释;
  4. 输入最终失败,迫使引擎探索大量路径。

一个典型模式是:

^(a+)+$

Java 字符串写法:

"^(a+)+$"

攻击输入:

aaaaaaaaaaaaaaaaaaaaaaaaX

模式要求整段只能由 a 组成,但末尾的 X 使整体失败。

12.2 为什么 (a+)+ 会产生大量路径

设输入中有 nn 个连续的 a,并且最后有一个不匹配字符 X

外层 + 可以把这段 a 分成若干个非空块:

aaaaa

可能的划分包括:

aaaaa
a|aaaa
aa|aaa
aaa|aa
aaaa|a
a|a|aaa
a|aa|aa
...

每个相邻位置都可以选择“在这里切分”或“不在这里切分”。因此,正数长度为 nn 的连续字符存在:

2n12^{n-1}

种切分方式。

Java 的回溯执行会在外层重复、内层 a+ 和末尾 $ 失败之间反复调整匹配长度。不同 JDK 和不同模式细节可能触发优化,不能把上式当作 Java 的精确运行时间;但这种模式确实具有产生指数级回溯的结构风险。

12.3 另一个典型:重叠分支

^(a|aa)+$

aaa 都能匹配以 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)?$

重写后的结构表达的是:

  1. 先匹配固定的 foo
  2. 再选择是否匹配 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_INSENSITIVEUNICODE_CASEUNICODE_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

每个部分的作用是:

  1. \A\z 约束整行输入;
  2. (?<time>...) 创建命名组;
  3. [^\\]]+ 在 Java 字符串中表示正则 ^[^\]]+ 的局部形式,即读取直到 ] 前的内容;
  4. INFO|WARN|ERROR 限定日志级别;
  5. (?<message>.*) 读取剩余消息;
  6. 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. 诊断正则性能问题的方法

遇到疑似慢匹配时,应区分以下几类问题:

  1. 编译慢:是否在循环中重复 Pattern.compile
  2. 搜索次数多:是否使用 find() 在大文本中反复尝试;
  3. 单次匹配慢:是否存在嵌套量词、重叠分支或复杂断言;
  4. 结果处理慢:是否创建了大量替换结果或读取大量捕获组;
  5. 输入异常:是否包含超长无界字段或恶意失败后缀。

一个简单的测试程序可以比较成功输入和失败输入:

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 把校验拆成结构和业务规则

例如,邮箱、日期、用户名等字段不应仅靠一个巨大正则完成所有语义校验。可以先做:

  1. 长度限制;
  2. 字符集限制;
  3. 基本结构匹配;
  4. 类型解析和范围校验。

日期示例:

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 正则表达式的关键因果链可以概括为:

  1. 正则字符串先经过 Java 字符串转义;
  2. Pattern.compile 解析并编译模式;
  3. Pattern.matcher 创建有状态的 Matcher
  4. matcheslookingAtfind 选择不同的匹配范围;
  5. 分组保存匹配边界,重复分组通常只保留最后一次捕获;
  6. 贪婪量词、惰性量词和分支产生不同的候选路径;
  7. 后续失败会触发回溯,重新选择之前的路径;
  8. 嵌套重复和重叠分支会使候选路径呈指数级增长;
  9. 攻击者控制长输入时,这种增长可能形成 ReDoS;
  10. 解决方案必须同时包含模式重写、输入边界和执行隔离,而不是只缓存 Pattern 或简单依赖线程中断。

Pattern 解决的是模式的编译和复用,Matcher 解决的是一次具体输入上的状态化执行;分组解决的是结果定位和引用,回溯解释了表达式如何在失败时寻找替代路径,而 ReDoS 则是这些执行特征在不受控输入下产生的安全后果。只有把这几层放在同一个模型中,才能既写出语义正确的正则,也能判断它是否适合运行在生产请求路径上。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
WR Blog 加载中...
返回文章
JavaJava 25 LTS正则

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS封面

Java 基础体系 · 第 48/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS

正则表达式同时包含两层含义:

  1. 模式语言:用字符、字符类、量词、分支和断言描述一组字符串。
  2. 执行机制: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]

匹配 abc 中的一个字符。

[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}

可以匹配 aaaaaaaaa,但不能匹配 aaaaaa

量词默认是贪婪的:在不导致整体失败的前提下,优先消耗更多字符。

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

执行过程大致是:

  1. a+? 先取一个 a
  2. 后面的 a 成功;
  3. 整体已经成功,因此不再继续扩展。

惰性不等于高效。它仍可能通过回溯尝试大量长度。


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 MULTILINEDOTALL

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

执行过程可以抽象为:

  1. a+ 是贪婪量词,先尽可能多地匹配 aaa
  2. 下一个字面量 a 没有可匹配字符,因为当前位置已经到达 b 前;
  3. a+ 回溯,释放一个 a
  4. 此时 a+ 匹配 aa,后面的字面量 a 匹配第三个 a
  5. 最后的 b 匹配成功;
  6. 整体匹配成功。

回溯不是“重新从头随机尝试”,而是回到之前存在选择的节点,改变该节点的决定。

9.3 分支的回溯

考虑:

(ab|a)c

输入:

ac

执行过程:

  1. 先尝试分支 ab
  2. a 成功,但 b 试图匹配 c,失败;
  3. 回到分支节点;
  4. 尝试分支 a
  5. 后面的 c 成功;
  6. 整体成功。

如果没有后续失败,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 个独立的二选一决策,最坏情况下的路径数上界可能接近:

2k2^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

  1. a+ 先匹配两个 a
  2. 最后的 a 失败;
  3. a+ 释放一个 a
  4. 最后的 a 成功。

对于 a++a

  1. a++ 匹配两个 a
  2. 最后的 a 失败;
  3. a++ 不允许释放字符;
  4. 整体失败。

所以占有量词只有在“已经确定前面的字符不应交给后续模式”的地方才是语义正确的优化。

10.3 原子组

原子组使用:

(?>...)

组内一旦成功离开,就不允许回到组内重新选择。

例如:

System.out.println(Pattern.matches("(a|ab)c", "abc"));   // true
System.out.println(Pattern.matches("(?>a|ab)c", "abc")); // false

第一种模式:

  1. 分支 a 先成功;
  2. 后面的 c 试图匹配 b,失败;
  3. 回到分支内部;
  4. 尝试 ab
  5. c 成功。

第二种模式:

  1. 原子组先选择 a
  2. 离开原子组后,不能回到内部尝试 ab
  3. 后面的 c 失败;
  4. 整体失败。

原子组和占有量词都是限制回溯的工具,但不能机械地“到处加”。它们可能消除本来需要的合法回退,从而把正确匹配变成错误失败。


11. 性能:编译成本、搜索成本和结果成本

正则处理的总成本可以分解为:

Ttotal=Tcompile+Tmatch+TresultT_{\text{total}} = T_{\text{compile}} + T_{\text{match}} + T_{\text{result}}

其中:

  • TcompileT_{\text{compile}}:解析和编译 Pattern 的成本;
  • TmatchT_{\text{match}}:在输入上执行匹配的成本;
  • TresultT_{\text{result}}:读取分组、生成替换结果、创建返回字符串等成本。

重复编译会增加第一项;复杂回溯会增加第二项;大量捕获和替换会增加第三项。

11.1 find() 可能多次启动匹配

设输入长度为 nn,模式没有明确的前缀约束,find() 可能尝试许多起始位置。即使一次匹配平均很快,反复启动也可能使总成本接近:

O(nCattempt)O(n \cdot C_{\text{attempt}})

其中 CattemptC_{\text{attempt}} 是一次起始位置尝试的成本。

如果模式本身在一次尝试中还会产生指数级回溯,那么两者会叠加,而不是互相抵消。

11.2 捕获组不是免费功能

捕获组需要保存开始位置和结束位置。一个模式包含大量不需要读取的括号时,会增加状态管理复杂度。应将结构性括号写成非捕获组:

(?:https?|ftp)://

但不要把“减少捕获组”误认为解决回溯问题的方法。性能灾难的主要来源是可重新组合的匹配路径,而不是单纯的组数量。

11.3 正则不是所有解析任务的合适工具

以下任务通常更适合专用解析器或手写状态机:

  • 嵌套且需要递归层级的语言;
  • JSON、XML、SQL 等具有结构语法的输入;
  • 需要精确错误位置和恢复策略的编译器式解析;
  • 复杂协议解析和长度字段校验。

正则可以用于提取局部结构,但“用一个超长表达式完成整个语言解析”往往同时损害可读性、可测试性和性能可控性。


12. ReDoS:正则表达式拒绝服务

12.1 ReDoS 的本质

ReDoS 不是“正则表达式很长”这么简单。其核心条件是:

  1. 输入由攻击者或不可信来源控制;
  2. 模式包含多个可重复或可分支的选择;
  3. 这些选择对同一段输入存在多种解释;
  4. 输入最终失败,迫使引擎探索大量路径。

一个典型模式是:

^(a+)+$

Java 字符串写法:

"^(a+)+$"

攻击输入:

aaaaaaaaaaaaaaaaaaaaaaaaX

模式要求整段只能由 a 组成,但末尾的 X 使整体失败。

12.2 为什么 (a+)+ 会产生大量路径

设输入中有 nn 个连续的 a,并且最后有一个不匹配字符 X

外层 + 可以把这段 a 分成若干个非空块:

aaaaa

可能的划分包括:

aaaaa
a|aaaa
aa|aaa
aaa|aa
aaaa|a
a|a|aaa
a|aa|aa
...

每个相邻位置都可以选择“在这里切分”或“不在这里切分”。因此,正数长度为 nn 的连续字符存在:

2n12^{n-1}

种切分方式。

Java 的回溯执行会在外层重复、内层 a+ 和末尾 $ 失败之间反复调整匹配长度。不同 JDK 和不同模式细节可能触发优化,不能把上式当作 Java 的精确运行时间;但这种模式确实具有产生指数级回溯的结构风险。

12.3 另一个典型:重叠分支

^(a|aa)+$

aaa 都能匹配以 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)?$

重写后的结构表达的是:

  1. 先匹配固定的 foo
  2. 再选择是否匹配 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_INSENSITIVEUNICODE_CASEUNICODE_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

每个部分的作用是:

  1. \A\z 约束整行输入;
  2. (?<time>...) 创建命名组;
  3. [^\\]]+ 在 Java 字符串中表示正则 ^[^\]]+ 的局部形式,即读取直到 ] 前的内容;
  4. INFO|WARN|ERROR 限定日志级别;
  5. (?<message>.*) 读取剩余消息;
  6. 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. 诊断正则性能问题的方法

遇到疑似慢匹配时,应区分以下几类问题:

  1. 编译慢:是否在循环中重复 Pattern.compile
  2. 搜索次数多:是否使用 find() 在大文本中反复尝试;
  3. 单次匹配慢:是否存在嵌套量词、重叠分支或复杂断言;
  4. 结果处理慢:是否创建了大量替换结果或读取大量捕获组;
  5. 输入异常:是否包含超长无界字段或恶意失败后缀。

一个简单的测试程序可以比较成功输入和失败输入:

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 把校验拆成结构和业务规则

例如,邮箱、日期、用户名等字段不应仅靠一个巨大正则完成所有语义校验。可以先做:

  1. 长度限制;
  2. 字符集限制;
  3. 基本结构匹配;
  4. 类型解析和范围校验。

日期示例:

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 正则表达式的关键因果链可以概括为:

  1. 正则字符串先经过 Java 字符串转义;
  2. Pattern.compile 解析并编译模式;
  3. Pattern.matcher 创建有状态的 Matcher
  4. matcheslookingAtfind 选择不同的匹配范围;
  5. 分组保存匹配边界,重复分组通常只保留最后一次捕获;
  6. 贪婪量词、惰性量词和分支产生不同的候选路径;
  7. 后续失败会触发回溯,重新选择之前的路径;
  8. 嵌套重复和重叠分支会使候选路径呈指数级增长;
  9. 攻击者控制长输入时,这种增长可能形成 ReDoS;
  10. 解决方案必须同时包含模式重写、输入边界和执行隔离,而不是只缓存 Pattern 或简单依赖线程中断。

Pattern 解决的是模式的编译和复用,Matcher 解决的是一次具体输入上的状态化执行;分组解决的是结果定位和引用,回溯解释了表达式如何在失败时寻找替代路径,而 ReDoS 则是这些执行特征在不受控输入下产生的安全后果。只有把这几层放在同一个模型中,才能既写出语义正确的正则,也能判断它是否适合运行在生产请求路径上。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
”,但在常见的完整字符串场景中,效果类似于整段匹配。\n\n### 2.2 lookingAt:必须从区域开头开始\n\n```java\nPattern p = Pattern.compile(\"\\\\d+\");\n\nSystem.out.println(p.matcher(\"123abc\").lookingAt()); // true\nSystem.out.println(p.matcher(\"abc123\").lookingAt()); // false\n```\n\n它要求匹配从当前区域的起点开始,但不要求消耗到区域结尾。\n\n### 2.3 find:搜索下一个子串\n\n```java\nPattern p = Pattern.compile(\"\\\\d+\");\n\nMatcher m = p.matcher(\"abc123def45\");\n\nSystem.out.println(m.find()); // true,匹配 123\nSystem.out.println(m.group());\n\nSystem.out.println(m.find()); // true,匹配 45\nSystem.out.println(m.group());\n\nSystem.out.println(m.find()); // false\n```\n\n`find()` 会尝试多个起始位置。对于长文本中的提取任务,它既可能产生大量匹配,也可能在每个起始位置执行复杂模式,因此成本不只是“模式本身有多复杂”。\n\n---\n\n## 3. Java 字符串和正则表达式有两层转义\n\nJava 源代码中的正则表达式首先要经过 Java 字符串转义,然后才交给正则表达式解析器。\n\n例如,正则表达式 `\\d+` 表示一个或多个数字,但 Java 字符串必须写成:\n\n```java\nString regex = \"\\\\d+\";\n```\n\n第一层是 Java 字符串:\n\n```text\n\"\\\\d+\"\n```\n\nJava 编译后传递给正则引擎的内容是:\n\n```text\n\\d+\n```\n\n常见写法如下:\n\n| 目标正则 | Java 字符串 |\n|---|---|\n| `\\d+` | `\"\\\\d+\"` |\n| `\\.` | `\"\\\\.\"` |\n| `\\bword\\b` | `\"\\\\bword\\\\b\"` |\n| `\\Q...\\E` | `\"\\\\Q...\\\\E\"` |\n\n这也是为什么路径、反斜杠和边界表达式经常写错。\n\n如果要把外部输入当作普通文本,而不是正则语法,应使用:\n\n```java\nString userText = \"a+b*c\";\nPattern p = Pattern.compile(Pattern.quote(userText));\n```\n\n等价地,也可以使用 `Pattern.LITERAL`:\n\n```java\nPattern p = Pattern.compile(userText, Pattern.LITERAL);\n```\n\n两者都能避免用户输入中的 `+`、`*`、`[` 等字符被解释为正则元字符。若还需要和其他正则片段组合,`Pattern.quote` 通常更灵活。\n\n---\n\n## 4. 正则表达式的基本匹配构件\n\n### 4.1 字面字符和元字符\n\n字母和数字通常表示自身:\n\n```regex\ncat\n```\n\n只匹配连续的 `cat`。\n\n以下字符具有特殊含义:\n\n```text\n. ^ $ * + ? { } [ ] \\ | ( )\n```\n\n例如:\n\n- `.`:默认匹配除行终止符外的任意字符;\n- `*`:前一个构件重复零次或多次;\n- `+`:重复一次或多次;\n- `?`:重复零次或一次,或改变量词的贪婪性;\n- `|`:分支;\n- `()`:分组;\n- `[]`:字符类;\n- `\\`:转义或引入预定义类别。\n\n要匹配字面句点,不能直接写 `.`,应写:\n\n```regex\n\\.\n```\n\n在 Java 字符串中则是:\n\n```java\n\"\\\\.\"\n```\n\n### 4.2 字符类\n\n```regex\n[abc]\n```\n\n匹配 `a`、`b` 或 `c` 中的一个字符。\n\n```regex\n[a-z]\n```\n\n匹配一个小写 ASCII 字母。\n\n```regex\n[^0-9]\n```\n\n匹配一个不是 ASCII 数字的字符。字符类中的 `^` 只有放在开头时才表示否定;放在其他位置通常表示字面字符。\n\n常见预定义字符类包括:\n\n- `\\d`:数字;\n- `\\s`:空白字符;\n- `\\w`:单词字符;\n- `\\D`、`\\S`、`\\W`:对应否定类;\n- `.`:通常表示除行终止符外的任意字符。\n\n在 Java `Pattern` 中,预定义类和 POSIX 类的 Unicode 行为受标志影响。默认语义不能简单等同于“所有 Unicode 数字”或“所有 Unicode 字母”。需要 Unicode 兼容类别时,可以使用:\n\n```java\nPattern.compile(\"\\\\d+\", Pattern.UNICODE_CHARACTER_CLASS);\n```\n\n或者直接使用 Unicode 属性,例如:\n\n```regex\n\\p{IsHan}+\n```\n\n如果输入协议要求 ASCII,例如 HTTP token、十六进制标识符或机器协议字段,显式写出 ASCII 范围通常更清楚:\n\n```regex\n[0-9A-Fa-f]+\n```\n\n### 4.3 量词\n\n对 `X` 使用量词:\n\n```text\nX* 零次或多次\nX+ 一次或多次\nX? 零次或一次\nX{m} 恰好 m 次\nX{m,n} 至少 m 次,至多 n 次\nX{m,} 至少 m 次\n```\n\n例如:\n\n```regex\na{2,4}\n```\n\n可以匹配 `aa`、`aaa` 或 `aaaa`,但不能匹配 `a` 或 `aaaaa`。\n\n量词默认是**贪婪的**:在不导致整体失败的前提下,优先消耗更多字符。\n\n```java\nMatcher m = Pattern.compile(\"a+\").matcher(\"aaaa\");\n\nm.find();\nSystem.out.println(m.group()); // aaaa\n```\n\n惰性量词在普通量词后加 `?`:\n\n```regex\na+?\n```\n\n它优先消耗更少字符,但如果后续模式无法匹配,仍然可能继续扩展。\n\n```java\nMatcher m = Pattern.compile(\"a+?a\").matcher(\"aaaa\");\n\nm.find();\nSystem.out.println(m.group()); // aa\n```\n\n执行过程大致是:\n\n1. `a+?` 先取一个 `a`;\n2. 后面的 `a` 成功;\n3. 整体已经成功,因此不再继续扩展。\n\n惰性不等于高效。它仍可能通过回溯尝试大量长度。\n\n---\n\n## 5. 锚点、边界和输入区域\n\n### 5.1 `^`、` Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS - WR Blog
WR Blog 加载中...
返回文章
JavaJava 25 LTS正则

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS封面

Java 基础体系 · 第 48/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS

正则表达式同时包含两层含义:

  1. 模式语言:用字符、字符类、量词、分支和断言描述一组字符串。
  2. 执行机制: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]

匹配 abc 中的一个字符。

[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}

可以匹配 aaaaaaaaa,但不能匹配 aaaaaa

量词默认是贪婪的:在不导致整体失败的前提下,优先消耗更多字符。

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

执行过程大致是:

  1. a+? 先取一个 a
  2. 后面的 a 成功;
  3. 整体已经成功,因此不再继续扩展。

惰性不等于高效。它仍可能通过回溯尝试大量长度。


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 MULTILINEDOTALL

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

执行过程可以抽象为:

  1. a+ 是贪婪量词,先尽可能多地匹配 aaa
  2. 下一个字面量 a 没有可匹配字符,因为当前位置已经到达 b 前;
  3. a+ 回溯,释放一个 a
  4. 此时 a+ 匹配 aa,后面的字面量 a 匹配第三个 a
  5. 最后的 b 匹配成功;
  6. 整体匹配成功。

回溯不是“重新从头随机尝试”,而是回到之前存在选择的节点,改变该节点的决定。

9.3 分支的回溯

考虑:

(ab|a)c

输入:

ac

执行过程:

  1. 先尝试分支 ab
  2. a 成功,但 b 试图匹配 c,失败;
  3. 回到分支节点;
  4. 尝试分支 a
  5. 后面的 c 成功;
  6. 整体成功。

如果没有后续失败,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 个独立的二选一决策,最坏情况下的路径数上界可能接近:

2k2^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

  1. a+ 先匹配两个 a
  2. 最后的 a 失败;
  3. a+ 释放一个 a
  4. 最后的 a 成功。

对于 a++a

  1. a++ 匹配两个 a
  2. 最后的 a 失败;
  3. a++ 不允许释放字符;
  4. 整体失败。

所以占有量词只有在“已经确定前面的字符不应交给后续模式”的地方才是语义正确的优化。

10.3 原子组

原子组使用:

(?>...)

组内一旦成功离开,就不允许回到组内重新选择。

例如:

System.out.println(Pattern.matches("(a|ab)c", "abc"));   // true
System.out.println(Pattern.matches("(?>a|ab)c", "abc")); // false

第一种模式:

  1. 分支 a 先成功;
  2. 后面的 c 试图匹配 b,失败;
  3. 回到分支内部;
  4. 尝试 ab
  5. c 成功。

第二种模式:

  1. 原子组先选择 a
  2. 离开原子组后,不能回到内部尝试 ab
  3. 后面的 c 失败;
  4. 整体失败。

原子组和占有量词都是限制回溯的工具,但不能机械地“到处加”。它们可能消除本来需要的合法回退,从而把正确匹配变成错误失败。


11. 性能:编译成本、搜索成本和结果成本

正则处理的总成本可以分解为:

Ttotal=Tcompile+Tmatch+TresultT_{\text{total}} = T_{\text{compile}} + T_{\text{match}} + T_{\text{result}}

其中:

  • TcompileT_{\text{compile}}:解析和编译 Pattern 的成本;
  • TmatchT_{\text{match}}:在输入上执行匹配的成本;
  • TresultT_{\text{result}}:读取分组、生成替换结果、创建返回字符串等成本。

重复编译会增加第一项;复杂回溯会增加第二项;大量捕获和替换会增加第三项。

11.1 find() 可能多次启动匹配

设输入长度为 nn,模式没有明确的前缀约束,find() 可能尝试许多起始位置。即使一次匹配平均很快,反复启动也可能使总成本接近:

O(nCattempt)O(n \cdot C_{\text{attempt}})

其中 CattemptC_{\text{attempt}} 是一次起始位置尝试的成本。

如果模式本身在一次尝试中还会产生指数级回溯,那么两者会叠加,而不是互相抵消。

11.2 捕获组不是免费功能

捕获组需要保存开始位置和结束位置。一个模式包含大量不需要读取的括号时,会增加状态管理复杂度。应将结构性括号写成非捕获组:

(?:https?|ftp)://

但不要把“减少捕获组”误认为解决回溯问题的方法。性能灾难的主要来源是可重新组合的匹配路径,而不是单纯的组数量。

11.3 正则不是所有解析任务的合适工具

以下任务通常更适合专用解析器或手写状态机:

  • 嵌套且需要递归层级的语言;
  • JSON、XML、SQL 等具有结构语法的输入;
  • 需要精确错误位置和恢复策略的编译器式解析;
  • 复杂协议解析和长度字段校验。

正则可以用于提取局部结构,但“用一个超长表达式完成整个语言解析”往往同时损害可读性、可测试性和性能可控性。


12. ReDoS:正则表达式拒绝服务

12.1 ReDoS 的本质

ReDoS 不是“正则表达式很长”这么简单。其核心条件是:

  1. 输入由攻击者或不可信来源控制;
  2. 模式包含多个可重复或可分支的选择;
  3. 这些选择对同一段输入存在多种解释;
  4. 输入最终失败,迫使引擎探索大量路径。

一个典型模式是:

^(a+)+$

Java 字符串写法:

"^(a+)+$"

攻击输入:

aaaaaaaaaaaaaaaaaaaaaaaaX

模式要求整段只能由 a 组成,但末尾的 X 使整体失败。

12.2 为什么 (a+)+ 会产生大量路径

设输入中有 nn 个连续的 a,并且最后有一个不匹配字符 X

外层 + 可以把这段 a 分成若干个非空块:

aaaaa

可能的划分包括:

aaaaa
a|aaaa
aa|aaa
aaa|aa
aaaa|a
a|a|aaa
a|aa|aa
...

每个相邻位置都可以选择“在这里切分”或“不在这里切分”。因此,正数长度为 nn 的连续字符存在:

2n12^{n-1}

种切分方式。

Java 的回溯执行会在外层重复、内层 a+ 和末尾 $ 失败之间反复调整匹配长度。不同 JDK 和不同模式细节可能触发优化,不能把上式当作 Java 的精确运行时间;但这种模式确实具有产生指数级回溯的结构风险。

12.3 另一个典型:重叠分支

^(a|aa)+$

aaa 都能匹配以 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)?$

重写后的结构表达的是:

  1. 先匹配固定的 foo
  2. 再选择是否匹配 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_INSENSITIVEUNICODE_CASEUNICODE_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

每个部分的作用是:

  1. \A\z 约束整行输入;
  2. (?<time>...) 创建命名组;
  3. [^\\]]+ 在 Java 字符串中表示正则 ^[^\]]+ 的局部形式,即读取直到 ] 前的内容;
  4. INFO|WARN|ERROR 限定日志级别;
  5. (?<message>.*) 读取剩余消息;
  6. 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. 诊断正则性能问题的方法

遇到疑似慢匹配时,应区分以下几类问题:

  1. 编译慢:是否在循环中重复 Pattern.compile
  2. 搜索次数多:是否使用 find() 在大文本中反复尝试;
  3. 单次匹配慢:是否存在嵌套量词、重叠分支或复杂断言;
  4. 结果处理慢:是否创建了大量替换结果或读取大量捕获组;
  5. 输入异常:是否包含超长无界字段或恶意失败后缀。

一个简单的测试程序可以比较成功输入和失败输入:

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 把校验拆成结构和业务规则

例如,邮箱、日期、用户名等字段不应仅靠一个巨大正则完成所有语义校验。可以先做:

  1. 长度限制;
  2. 字符集限制;
  3. 基本结构匹配;
  4. 类型解析和范围校验。

日期示例:

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 正则表达式的关键因果链可以概括为:

  1. 正则字符串先经过 Java 字符串转义;
  2. Pattern.compile 解析并编译模式;
  3. Pattern.matcher 创建有状态的 Matcher
  4. matcheslookingAtfind 选择不同的匹配范围;
  5. 分组保存匹配边界,重复分组通常只保留最后一次捕获;
  6. 贪婪量词、惰性量词和分支产生不同的候选路径;
  7. 后续失败会触发回溯,重新选择之前的路径;
  8. 嵌套重复和重叠分支会使候选路径呈指数级增长;
  9. 攻击者控制长输入时,这种增长可能形成 ReDoS;
  10. 解决方案必须同时包含模式重写、输入边界和执行隔离,而不是只缓存 Pattern 或简单依赖线程中断。

Pattern 解决的是模式的编译和复用,Matcher 解决的是一次具体输入上的状态化执行;分组解决的是结果定位和引用,回溯解释了表达式如何在失败时寻找替代路径,而 ReDoS 则是这些执行特征在不受控输入下产生的安全后果。只有把这几层放在同一个模型中,才能既写出语义正确的正则,也能判断它是否适合运行在生产请求路径上。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
、`\\A`、`\\z`\n\n常见锚点包括:\n\n- `^`:输入或行的开头,是否按行解释受 `MULTILINE` 影响;\n- ` Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS - WR Blog
WR Blog 加载中...
返回文章
JavaJava 25 LTS正则

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS封面

Java 基础体系 · 第 48/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS

正则表达式同时包含两层含义:

  1. 模式语言:用字符、字符类、量词、分支和断言描述一组字符串。
  2. 执行机制: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]

匹配 abc 中的一个字符。

[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}

可以匹配 aaaaaaaaa,但不能匹配 aaaaaa

量词默认是贪婪的:在不导致整体失败的前提下,优先消耗更多字符。

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

执行过程大致是:

  1. a+? 先取一个 a
  2. 后面的 a 成功;
  3. 整体已经成功,因此不再继续扩展。

惰性不等于高效。它仍可能通过回溯尝试大量长度。


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 MULTILINEDOTALL

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

执行过程可以抽象为:

  1. a+ 是贪婪量词,先尽可能多地匹配 aaa
  2. 下一个字面量 a 没有可匹配字符,因为当前位置已经到达 b 前;
  3. a+ 回溯,释放一个 a
  4. 此时 a+ 匹配 aa,后面的字面量 a 匹配第三个 a
  5. 最后的 b 匹配成功;
  6. 整体匹配成功。

回溯不是“重新从头随机尝试”,而是回到之前存在选择的节点,改变该节点的决定。

9.3 分支的回溯

考虑:

(ab|a)c

输入:

ac

执行过程:

  1. 先尝试分支 ab
  2. a 成功,但 b 试图匹配 c,失败;
  3. 回到分支节点;
  4. 尝试分支 a
  5. 后面的 c 成功;
  6. 整体成功。

如果没有后续失败,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 个独立的二选一决策,最坏情况下的路径数上界可能接近:

2k2^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

  1. a+ 先匹配两个 a
  2. 最后的 a 失败;
  3. a+ 释放一个 a
  4. 最后的 a 成功。

对于 a++a

  1. a++ 匹配两个 a
  2. 最后的 a 失败;
  3. a++ 不允许释放字符;
  4. 整体失败。

所以占有量词只有在“已经确定前面的字符不应交给后续模式”的地方才是语义正确的优化。

10.3 原子组

原子组使用:

(?>...)

组内一旦成功离开,就不允许回到组内重新选择。

例如:

System.out.println(Pattern.matches("(a|ab)c", "abc"));   // true
System.out.println(Pattern.matches("(?>a|ab)c", "abc")); // false

第一种模式:

  1. 分支 a 先成功;
  2. 后面的 c 试图匹配 b,失败;
  3. 回到分支内部;
  4. 尝试 ab
  5. c 成功。

第二种模式:

  1. 原子组先选择 a
  2. 离开原子组后,不能回到内部尝试 ab
  3. 后面的 c 失败;
  4. 整体失败。

原子组和占有量词都是限制回溯的工具,但不能机械地“到处加”。它们可能消除本来需要的合法回退,从而把正确匹配变成错误失败。


11. 性能:编译成本、搜索成本和结果成本

正则处理的总成本可以分解为:

Ttotal=Tcompile+Tmatch+TresultT_{\text{total}} = T_{\text{compile}} + T_{\text{match}} + T_{\text{result}}

其中:

  • TcompileT_{\text{compile}}:解析和编译 Pattern 的成本;
  • TmatchT_{\text{match}}:在输入上执行匹配的成本;
  • TresultT_{\text{result}}:读取分组、生成替换结果、创建返回字符串等成本。

重复编译会增加第一项;复杂回溯会增加第二项;大量捕获和替换会增加第三项。

11.1 find() 可能多次启动匹配

设输入长度为 nn,模式没有明确的前缀约束,find() 可能尝试许多起始位置。即使一次匹配平均很快,反复启动也可能使总成本接近:

O(nCattempt)O(n \cdot C_{\text{attempt}})

其中 CattemptC_{\text{attempt}} 是一次起始位置尝试的成本。

如果模式本身在一次尝试中还会产生指数级回溯,那么两者会叠加,而不是互相抵消。

11.2 捕获组不是免费功能

捕获组需要保存开始位置和结束位置。一个模式包含大量不需要读取的括号时,会增加状态管理复杂度。应将结构性括号写成非捕获组:

(?:https?|ftp)://

但不要把“减少捕获组”误认为解决回溯问题的方法。性能灾难的主要来源是可重新组合的匹配路径,而不是单纯的组数量。

11.3 正则不是所有解析任务的合适工具

以下任务通常更适合专用解析器或手写状态机:

  • 嵌套且需要递归层级的语言;
  • JSON、XML、SQL 等具有结构语法的输入;
  • 需要精确错误位置和恢复策略的编译器式解析;
  • 复杂协议解析和长度字段校验。

正则可以用于提取局部结构,但“用一个超长表达式完成整个语言解析”往往同时损害可读性、可测试性和性能可控性。


12. ReDoS:正则表达式拒绝服务

12.1 ReDoS 的本质

ReDoS 不是“正则表达式很长”这么简单。其核心条件是:

  1. 输入由攻击者或不可信来源控制;
  2. 模式包含多个可重复或可分支的选择;
  3. 这些选择对同一段输入存在多种解释;
  4. 输入最终失败,迫使引擎探索大量路径。

一个典型模式是:

^(a+)+$

Java 字符串写法:

"^(a+)+$"

攻击输入:

aaaaaaaaaaaaaaaaaaaaaaaaX

模式要求整段只能由 a 组成,但末尾的 X 使整体失败。

12.2 为什么 (a+)+ 会产生大量路径

设输入中有 nn 个连续的 a,并且最后有一个不匹配字符 X

外层 + 可以把这段 a 分成若干个非空块:

aaaaa

可能的划分包括:

aaaaa
a|aaaa
aa|aaa
aaa|aa
aaaa|a
a|a|aaa
a|aa|aa
...

每个相邻位置都可以选择“在这里切分”或“不在这里切分”。因此,正数长度为 nn 的连续字符存在:

2n12^{n-1}

种切分方式。

Java 的回溯执行会在外层重复、内层 a+ 和末尾 $ 失败之间反复调整匹配长度。不同 JDK 和不同模式细节可能触发优化,不能把上式当作 Java 的精确运行时间;但这种模式确实具有产生指数级回溯的结构风险。

12.3 另一个典型:重叠分支

^(a|aa)+$

aaa 都能匹配以 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)?$

重写后的结构表达的是:

  1. 先匹配固定的 foo
  2. 再选择是否匹配 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_INSENSITIVEUNICODE_CASEUNICODE_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

每个部分的作用是:

  1. \A\z 约束整行输入;
  2. (?<time>...) 创建命名组;
  3. [^\\]]+ 在 Java 字符串中表示正则 ^[^\]]+ 的局部形式,即读取直到 ] 前的内容;
  4. INFO|WARN|ERROR 限定日志级别;
  5. (?<message>.*) 读取剩余消息;
  6. 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. 诊断正则性能问题的方法

遇到疑似慢匹配时,应区分以下几类问题:

  1. 编译慢:是否在循环中重复 Pattern.compile
  2. 搜索次数多:是否使用 find() 在大文本中反复尝试;
  3. 单次匹配慢:是否存在嵌套量词、重叠分支或复杂断言;
  4. 结果处理慢:是否创建了大量替换结果或读取大量捕获组;
  5. 输入异常:是否包含超长无界字段或恶意失败后缀。

一个简单的测试程序可以比较成功输入和失败输入:

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 把校验拆成结构和业务规则

例如,邮箱、日期、用户名等字段不应仅靠一个巨大正则完成所有语义校验。可以先做:

  1. 长度限制;
  2. 字符集限制;
  3. 基本结构匹配;
  4. 类型解析和范围校验。

日期示例:

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 正则表达式的关键因果链可以概括为:

  1. 正则字符串先经过 Java 字符串转义;
  2. Pattern.compile 解析并编译模式;
  3. Pattern.matcher 创建有状态的 Matcher
  4. matcheslookingAtfind 选择不同的匹配范围;
  5. 分组保存匹配边界,重复分组通常只保留最后一次捕获;
  6. 贪婪量词、惰性量词和分支产生不同的候选路径;
  7. 后续失败会触发回溯,重新选择之前的路径;
  8. 嵌套重复和重叠分支会使候选路径呈指数级增长;
  9. 攻击者控制长输入时,这种增长可能形成 ReDoS;
  10. 解决方案必须同时包含模式重写、输入边界和执行隔离,而不是只缓存 Pattern 或简单依赖线程中断。

Pattern 解决的是模式的编译和复用,Matcher 解决的是一次具体输入上的状态化执行;分组解决的是结果定位和引用,回溯解释了表达式如何在失败时寻找替代路径,而 ReDoS 则是这些执行特征在不受控输入下产生的安全后果。只有把这几层放在同一个模型中,才能既写出语义正确的正则,也能判断它是否适合运行在生产请求路径上。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
:输入或行的结尾,是否按行解释受 `MULTILINE` 影响;\n- `\\A`:整个输入的开头;\n- `\\z`:整个输入的严格结尾;\n- `\\Z`:整个输入的结尾,但允许最后一个终止符存在。\n\n如果校验整个字符串,并且不希望末尾换行被特殊处理,可以优先考虑:\n\n```regex\n\\A[0-9]+\\z\n```\n\nJava 字符串写作:\n\n```java\n\"\\\\A[0-9]+\\\\z\"\n```\n\n例如:\n\n```java\nPattern p = Pattern.compile(\"\\\\A[0-9]+\\\\z\");\n\nSystem.out.println(p.matcher(\"123\").matches()); // true\nSystem.out.println(p.matcher(\"123\\n\").matches()); // false\n```\n\n实际工程中,`matches()` 已经要求整个区域匹配,因此通常不必再添加首尾锚点;但锚点在 `find()`、多行文本和嵌套表达式中仍然有明确作用。\n\n### 5.2 `MULTILINE` 和 `DOTALL`\n\n```java\nPattern.compile(\"^ERROR.*$\", Pattern.MULTILINE);\n```\n\n`MULTILINE` 改变 `^` 和 ` Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS - WR Blog
WR Blog 加载中...
返回文章
JavaJava 25 LTS正则

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS封面

Java 基础体系 · 第 48/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS

正则表达式同时包含两层含义:

  1. 模式语言:用字符、字符类、量词、分支和断言描述一组字符串。
  2. 执行机制: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]

匹配 abc 中的一个字符。

[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}

可以匹配 aaaaaaaaa,但不能匹配 aaaaaa

量词默认是贪婪的:在不导致整体失败的前提下,优先消耗更多字符。

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

执行过程大致是:

  1. a+? 先取一个 a
  2. 后面的 a 成功;
  3. 整体已经成功,因此不再继续扩展。

惰性不等于高效。它仍可能通过回溯尝试大量长度。


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 MULTILINEDOTALL

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

执行过程可以抽象为:

  1. a+ 是贪婪量词,先尽可能多地匹配 aaa
  2. 下一个字面量 a 没有可匹配字符,因为当前位置已经到达 b 前;
  3. a+ 回溯,释放一个 a
  4. 此时 a+ 匹配 aa,后面的字面量 a 匹配第三个 a
  5. 最后的 b 匹配成功;
  6. 整体匹配成功。

回溯不是“重新从头随机尝试”,而是回到之前存在选择的节点,改变该节点的决定。

9.3 分支的回溯

考虑:

(ab|a)c

输入:

ac

执行过程:

  1. 先尝试分支 ab
  2. a 成功,但 b 试图匹配 c,失败;
  3. 回到分支节点;
  4. 尝试分支 a
  5. 后面的 c 成功;
  6. 整体成功。

如果没有后续失败,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 个独立的二选一决策,最坏情况下的路径数上界可能接近:

2k2^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

  1. a+ 先匹配两个 a
  2. 最后的 a 失败;
  3. a+ 释放一个 a
  4. 最后的 a 成功。

对于 a++a

  1. a++ 匹配两个 a
  2. 最后的 a 失败;
  3. a++ 不允许释放字符;
  4. 整体失败。

所以占有量词只有在“已经确定前面的字符不应交给后续模式”的地方才是语义正确的优化。

10.3 原子组

原子组使用:

(?>...)

组内一旦成功离开,就不允许回到组内重新选择。

例如:

System.out.println(Pattern.matches("(a|ab)c", "abc"));   // true
System.out.println(Pattern.matches("(?>a|ab)c", "abc")); // false

第一种模式:

  1. 分支 a 先成功;
  2. 后面的 c 试图匹配 b,失败;
  3. 回到分支内部;
  4. 尝试 ab
  5. c 成功。

第二种模式:

  1. 原子组先选择 a
  2. 离开原子组后,不能回到内部尝试 ab
  3. 后面的 c 失败;
  4. 整体失败。

原子组和占有量词都是限制回溯的工具,但不能机械地“到处加”。它们可能消除本来需要的合法回退,从而把正确匹配变成错误失败。


11. 性能:编译成本、搜索成本和结果成本

正则处理的总成本可以分解为:

Ttotal=Tcompile+Tmatch+TresultT_{\text{total}} = T_{\text{compile}} + T_{\text{match}} + T_{\text{result}}

其中:

  • TcompileT_{\text{compile}}:解析和编译 Pattern 的成本;
  • TmatchT_{\text{match}}:在输入上执行匹配的成本;
  • TresultT_{\text{result}}:读取分组、生成替换结果、创建返回字符串等成本。

重复编译会增加第一项;复杂回溯会增加第二项;大量捕获和替换会增加第三项。

11.1 find() 可能多次启动匹配

设输入长度为 nn,模式没有明确的前缀约束,find() 可能尝试许多起始位置。即使一次匹配平均很快,反复启动也可能使总成本接近:

O(nCattempt)O(n \cdot C_{\text{attempt}})

其中 CattemptC_{\text{attempt}} 是一次起始位置尝试的成本。

如果模式本身在一次尝试中还会产生指数级回溯,那么两者会叠加,而不是互相抵消。

11.2 捕获组不是免费功能

捕获组需要保存开始位置和结束位置。一个模式包含大量不需要读取的括号时,会增加状态管理复杂度。应将结构性括号写成非捕获组:

(?:https?|ftp)://

但不要把“减少捕获组”误认为解决回溯问题的方法。性能灾难的主要来源是可重新组合的匹配路径,而不是单纯的组数量。

11.3 正则不是所有解析任务的合适工具

以下任务通常更适合专用解析器或手写状态机:

  • 嵌套且需要递归层级的语言;
  • JSON、XML、SQL 等具有结构语法的输入;
  • 需要精确错误位置和恢复策略的编译器式解析;
  • 复杂协议解析和长度字段校验。

正则可以用于提取局部结构,但“用一个超长表达式完成整个语言解析”往往同时损害可读性、可测试性和性能可控性。


12. ReDoS:正则表达式拒绝服务

12.1 ReDoS 的本质

ReDoS 不是“正则表达式很长”这么简单。其核心条件是:

  1. 输入由攻击者或不可信来源控制;
  2. 模式包含多个可重复或可分支的选择;
  3. 这些选择对同一段输入存在多种解释;
  4. 输入最终失败,迫使引擎探索大量路径。

一个典型模式是:

^(a+)+$

Java 字符串写法:

"^(a+)+$"

攻击输入:

aaaaaaaaaaaaaaaaaaaaaaaaX

模式要求整段只能由 a 组成,但末尾的 X 使整体失败。

12.2 为什么 (a+)+ 会产生大量路径

设输入中有 nn 个连续的 a,并且最后有一个不匹配字符 X

外层 + 可以把这段 a 分成若干个非空块:

aaaaa

可能的划分包括:

aaaaa
a|aaaa
aa|aaa
aaa|aa
aaaa|a
a|a|aaa
a|aa|aa
...

每个相邻位置都可以选择“在这里切分”或“不在这里切分”。因此,正数长度为 nn 的连续字符存在:

2n12^{n-1}

种切分方式。

Java 的回溯执行会在外层重复、内层 a+ 和末尾 $ 失败之间反复调整匹配长度。不同 JDK 和不同模式细节可能触发优化,不能把上式当作 Java 的精确运行时间;但这种模式确实具有产生指数级回溯的结构风险。

12.3 另一个典型:重叠分支

^(a|aa)+$

aaa 都能匹配以 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)?$

重写后的结构表达的是:

  1. 先匹配固定的 foo
  2. 再选择是否匹配 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_INSENSITIVEUNICODE_CASEUNICODE_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

每个部分的作用是:

  1. \A\z 约束整行输入;
  2. (?<time>...) 创建命名组;
  3. [^\\]]+ 在 Java 字符串中表示正则 ^[^\]]+ 的局部形式,即读取直到 ] 前的内容;
  4. INFO|WARN|ERROR 限定日志级别;
  5. (?<message>.*) 读取剩余消息;
  6. 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. 诊断正则性能问题的方法

遇到疑似慢匹配时,应区分以下几类问题:

  1. 编译慢:是否在循环中重复 Pattern.compile
  2. 搜索次数多:是否使用 find() 在大文本中反复尝试;
  3. 单次匹配慢:是否存在嵌套量词、重叠分支或复杂断言;
  4. 结果处理慢:是否创建了大量替换结果或读取大量捕获组;
  5. 输入异常:是否包含超长无界字段或恶意失败后缀。

一个简单的测试程序可以比较成功输入和失败输入:

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 把校验拆成结构和业务规则

例如,邮箱、日期、用户名等字段不应仅靠一个巨大正则完成所有语义校验。可以先做:

  1. 长度限制;
  2. 字符集限制;
  3. 基本结构匹配;
  4. 类型解析和范围校验。

日期示例:

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 正则表达式的关键因果链可以概括为:

  1. 正则字符串先经过 Java 字符串转义;
  2. Pattern.compile 解析并编译模式;
  3. Pattern.matcher 创建有状态的 Matcher
  4. matcheslookingAtfind 选择不同的匹配范围;
  5. 分组保存匹配边界,重复分组通常只保留最后一次捕获;
  6. 贪婪量词、惰性量词和分支产生不同的候选路径;
  7. 后续失败会触发回溯,重新选择之前的路径;
  8. 嵌套重复和重叠分支会使候选路径呈指数级增长;
  9. 攻击者控制长输入时,这种增长可能形成 ReDoS;
  10. 解决方案必须同时包含模式重写、输入边界和执行隔离,而不是只缓存 Pattern 或简单依赖线程中断。

Pattern 解决的是模式的编译和复用,Matcher 解决的是一次具体输入上的状态化执行;分组解决的是结果定位和引用,回溯解释了表达式如何在失败时寻找替代路径,而 ReDoS 则是这些执行特征在不受控输入下产生的安全后果。只有把这几层放在同一个模型中,才能既写出语义正确的正则,也能判断它是否适合运行在生产请求路径上。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
的含义,使它们可以匹配每一行的开头和结尾。\n\n```java\nPattern.compile(\".*\", Pattern.DOTALL);\n```\n\n`DOTALL` 使 `.` 也能匹配行终止符。\n\n这两个标志作用不同:\n\n- `MULTILINE` 影响锚点;\n- `DOTALL` 影响点号。\n\n把它们混为一谈会产生漏匹配或过度匹配。\n\n### 5.3 Matcher 的 region\n\n`Matcher` 可以只在输入的一部分区域内工作:\n\n```java\nPattern p = Pattern.compile(\"\\\\d+\");\nMatcher m = p.matcher(\"abc123xyz456\");\n\nm.region(3, 6);\n\nSystem.out.println(m.matches()); // true,区域是 \"123\"\n```\n\n这里区域采用半开区间 `[start, end)`:\n\n- `start` 包含;\n- `end` 不包含。\n\n因此 `region(3, 6)` 覆盖索引 `3、4、5`。\n\n`Matcher` 还提供:\n\n```java\nm.useAnchoringBounds(false);\nm.useTransparentBounds(true);\n```\n\n它们影响区域边界与锚点、前后查找的关系:\n\n- **anchoring bounds**:区域边界是否被视为 `^`、` Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS - WR Blog
WR Blog 加载中...
返回文章
JavaJava 25 LTS正则

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS封面

Java 基础体系 · 第 48/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS

正则表达式同时包含两层含义:

  1. 模式语言:用字符、字符类、量词、分支和断言描述一组字符串。
  2. 执行机制: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]

匹配 abc 中的一个字符。

[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}

可以匹配 aaaaaaaaa,但不能匹配 aaaaaa

量词默认是贪婪的:在不导致整体失败的前提下,优先消耗更多字符。

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

执行过程大致是:

  1. a+? 先取一个 a
  2. 后面的 a 成功;
  3. 整体已经成功,因此不再继续扩展。

惰性不等于高效。它仍可能通过回溯尝试大量长度。


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 MULTILINEDOTALL

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

执行过程可以抽象为:

  1. a+ 是贪婪量词,先尽可能多地匹配 aaa
  2. 下一个字面量 a 没有可匹配字符,因为当前位置已经到达 b 前;
  3. a+ 回溯,释放一个 a
  4. 此时 a+ 匹配 aa,后面的字面量 a 匹配第三个 a
  5. 最后的 b 匹配成功;
  6. 整体匹配成功。

回溯不是“重新从头随机尝试”,而是回到之前存在选择的节点,改变该节点的决定。

9.3 分支的回溯

考虑:

(ab|a)c

输入:

ac

执行过程:

  1. 先尝试分支 ab
  2. a 成功,但 b 试图匹配 c,失败;
  3. 回到分支节点;
  4. 尝试分支 a
  5. 后面的 c 成功;
  6. 整体成功。

如果没有后续失败,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 个独立的二选一决策,最坏情况下的路径数上界可能接近:

2k2^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

  1. a+ 先匹配两个 a
  2. 最后的 a 失败;
  3. a+ 释放一个 a
  4. 最后的 a 成功。

对于 a++a

  1. a++ 匹配两个 a
  2. 最后的 a 失败;
  3. a++ 不允许释放字符;
  4. 整体失败。

所以占有量词只有在“已经确定前面的字符不应交给后续模式”的地方才是语义正确的优化。

10.3 原子组

原子组使用:

(?>...)

组内一旦成功离开,就不允许回到组内重新选择。

例如:

System.out.println(Pattern.matches("(a|ab)c", "abc"));   // true
System.out.println(Pattern.matches("(?>a|ab)c", "abc")); // false

第一种模式:

  1. 分支 a 先成功;
  2. 后面的 c 试图匹配 b,失败;
  3. 回到分支内部;
  4. 尝试 ab
  5. c 成功。

第二种模式:

  1. 原子组先选择 a
  2. 离开原子组后,不能回到内部尝试 ab
  3. 后面的 c 失败;
  4. 整体失败。

原子组和占有量词都是限制回溯的工具,但不能机械地“到处加”。它们可能消除本来需要的合法回退,从而把正确匹配变成错误失败。


11. 性能:编译成本、搜索成本和结果成本

正则处理的总成本可以分解为:

Ttotal=Tcompile+Tmatch+TresultT_{\text{total}} = T_{\text{compile}} + T_{\text{match}} + T_{\text{result}}

其中:

  • TcompileT_{\text{compile}}:解析和编译 Pattern 的成本;
  • TmatchT_{\text{match}}:在输入上执行匹配的成本;
  • TresultT_{\text{result}}:读取分组、生成替换结果、创建返回字符串等成本。

重复编译会增加第一项;复杂回溯会增加第二项;大量捕获和替换会增加第三项。

11.1 find() 可能多次启动匹配

设输入长度为 nn,模式没有明确的前缀约束,find() 可能尝试许多起始位置。即使一次匹配平均很快,反复启动也可能使总成本接近:

O(nCattempt)O(n \cdot C_{\text{attempt}})

其中 CattemptC_{\text{attempt}} 是一次起始位置尝试的成本。

如果模式本身在一次尝试中还会产生指数级回溯,那么两者会叠加,而不是互相抵消。

11.2 捕获组不是免费功能

捕获组需要保存开始位置和结束位置。一个模式包含大量不需要读取的括号时,会增加状态管理复杂度。应将结构性括号写成非捕获组:

(?:https?|ftp)://

但不要把“减少捕获组”误认为解决回溯问题的方法。性能灾难的主要来源是可重新组合的匹配路径,而不是单纯的组数量。

11.3 正则不是所有解析任务的合适工具

以下任务通常更适合专用解析器或手写状态机:

  • 嵌套且需要递归层级的语言;
  • JSON、XML、SQL 等具有结构语法的输入;
  • 需要精确错误位置和恢复策略的编译器式解析;
  • 复杂协议解析和长度字段校验。

正则可以用于提取局部结构,但“用一个超长表达式完成整个语言解析”往往同时损害可读性、可测试性和性能可控性。


12. ReDoS:正则表达式拒绝服务

12.1 ReDoS 的本质

ReDoS 不是“正则表达式很长”这么简单。其核心条件是:

  1. 输入由攻击者或不可信来源控制;
  2. 模式包含多个可重复或可分支的选择;
  3. 这些选择对同一段输入存在多种解释;
  4. 输入最终失败,迫使引擎探索大量路径。

一个典型模式是:

^(a+)+$

Java 字符串写法:

"^(a+)+$"

攻击输入:

aaaaaaaaaaaaaaaaaaaaaaaaX

模式要求整段只能由 a 组成,但末尾的 X 使整体失败。

12.2 为什么 (a+)+ 会产生大量路径

设输入中有 nn 个连续的 a,并且最后有一个不匹配字符 X

外层 + 可以把这段 a 分成若干个非空块:

aaaaa

可能的划分包括:

aaaaa
a|aaaa
aa|aaa
aaa|aa
aaaa|a
a|a|aaa
a|aa|aa
...

每个相邻位置都可以选择“在这里切分”或“不在这里切分”。因此,正数长度为 nn 的连续字符存在:

2n12^{n-1}

种切分方式。

Java 的回溯执行会在外层重复、内层 a+ 和末尾 $ 失败之间反复调整匹配长度。不同 JDK 和不同模式细节可能触发优化,不能把上式当作 Java 的精确运行时间;但这种模式确实具有产生指数级回溯的结构风险。

12.3 另一个典型:重叠分支

^(a|aa)+$

aaa 都能匹配以 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)?$

重写后的结构表达的是:

  1. 先匹配固定的 foo
  2. 再选择是否匹配 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_INSENSITIVEUNICODE_CASEUNICODE_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

每个部分的作用是:

  1. \A\z 约束整行输入;
  2. (?<time>...) 创建命名组;
  3. [^\\]]+ 在 Java 字符串中表示正则 ^[^\]]+ 的局部形式,即读取直到 ] 前的内容;
  4. INFO|WARN|ERROR 限定日志级别;
  5. (?<message>.*) 读取剩余消息;
  6. 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. 诊断正则性能问题的方法

遇到疑似慢匹配时,应区分以下几类问题:

  1. 编译慢:是否在循环中重复 Pattern.compile
  2. 搜索次数多:是否使用 find() 在大文本中反复尝试;
  3. 单次匹配慢:是否存在嵌套量词、重叠分支或复杂断言;
  4. 结果处理慢:是否创建了大量替换结果或读取大量捕获组;
  5. 输入异常:是否包含超长无界字段或恶意失败后缀。

一个简单的测试程序可以比较成功输入和失败输入:

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 把校验拆成结构和业务规则

例如,邮箱、日期、用户名等字段不应仅靠一个巨大正则完成所有语义校验。可以先做:

  1. 长度限制;
  2. 字符集限制;
  3. 基本结构匹配;
  4. 类型解析和范围校验。

日期示例:

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 正则表达式的关键因果链可以概括为:

  1. 正则字符串先经过 Java 字符串转义;
  2. Pattern.compile 解析并编译模式;
  3. Pattern.matcher 创建有状态的 Matcher
  4. matcheslookingAtfind 选择不同的匹配范围;
  5. 分组保存匹配边界,重复分组通常只保留最后一次捕获;
  6. 贪婪量词、惰性量词和分支产生不同的候选路径;
  7. 后续失败会触发回溯,重新选择之前的路径;
  8. 嵌套重复和重叠分支会使候选路径呈指数级增长;
  9. 攻击者控制长输入时,这种增长可能形成 ReDoS;
  10. 解决方案必须同时包含模式重写、输入边界和执行隔离,而不是只缓存 Pattern 或简单依赖线程中断。

Pattern 解决的是模式的编译和复用,Matcher 解决的是一次具体输入上的状态化执行;分组解决的是结果定位和引用,回溯解释了表达式如何在失败时寻找替代路径,而 ReDoS 则是这些执行特征在不受控输入下产生的安全后果。只有把这几层放在同一个模型中,才能既写出语义正确的正则,也能判断它是否适合运行在生产请求路径上。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
等锚点;\n- **transparent bounds**:区域外的字符是否可以被前瞻、后顾等边界检查看到。\n\n这些设置只改变匹配器观察输入的边界,不会截断底层字符串。\n\n---\n\n## 6. 分组:编号、命名和捕获状态\n\n### 6.1 捕获组和组 0\n\n括号默认创建捕获组:\n\n```regex\n(\\d{4})-(\\d{2})-(\\d{2})\n```\n\n组编号按左括号出现顺序分配:\n\n- 组 `0`:整个匹配;\n- 组 `1`:第一个捕获组;\n- 组 `2`:第二个捕获组;\n- 组 `3`:第三个捕获组。\n\n```java\nPattern p = Pattern.compile(\"(\\\\d{4})-(\\\\d{2})-(\\\\d{2})\");\nMatcher m = p.matcher(\"2025-09-15\");\n\nif (m.matches()) {\n for (int i = 0; i \u003c= m.groupCount(); i++) {\n System.out.println(i + \": \" + m.group(i));\n }\n}\n```\n\n输出:\n\n```text\n0: 2025-09-15\n1: 2025\n2: 09\n3: 15\n```\n\n`start(i)` 和 `end(i)` 返回第 `i` 组的边界。如果某个可选组没有参与匹配,`group(i)` 返回 `null`,对应边界通常为 `-1`。\n\n```java\nMatcher m = Pattern.compile(\"(a)?b\").matcher(\"b\");\n\nif (m.matches()) {\n System.out.println(m.group(1)); // null\n System.out.println(m.start(1)); // -1\n System.out.println(m.end(1)); // -1\n}\n```\n\n在成功匹配前调用 `group()`、`start()` 等方法会抛出 `IllegalStateException`。组编号越界会抛出索引相关异常,因此生产代码不要假设组编号与模式永远同步;复杂模式更适合使用命名组。\n\n### 6.2 非捕获组\n\n如果只需要组织结构,不需要读取内容,应使用非捕获组:\n\n```regex\n(?:https?|ftp)://\n```\n\n`(?:...)` 参与匹配,但不分配捕获组编号。\n\n例如:\n\n```java\nPattern p = Pattern.compile(\"(?:GET|POST) /([A-Za-z0-9_/.-]+)\");\nMatcher m = p.matcher(\"GET /api/users\");\n\nif (m.matches()) {\n System.out.println(m.groupCount()); // 1\n System.out.println(m.group(1)); // api/users\n}\n```\n\n如果写成 `(GET|POST) /(...)`,前面的协议分支也会占用一个编号,后续修改模式更容易造成编号错位。非捕获组还有一个性能和内存上的小收益,但主要价值是表达意图和稳定组编号。\n\n### 6.3 命名捕获组\n\n命名组的形式是:\n\n```regex\n(?\u003cname>...)\n```\n\n示例:\n\n```java\nPattern p = Pattern.compile(\n \"(?\u003cyear>\\\\d{4})-(?\u003cmonth>\\\\d{2})-(?\u003cday>\\\\d{2})\"\n);\n\nMatcher m = p.matcher(\"2025-09-15\");\n\nif (m.matches()) {\n System.out.println(m.group(\"year\")); // 2025\n System.out.println(m.group(\"month\")); // 09\n System.out.println(m.group(\"day\")); // 15\n}\n```\n\n命名组名必须符合 `Pattern` 对命名组的语法约束,通常以英文字母开头,后面使用字母或数字。命名组不能重复定义。\n\n命名组仍然有编号,因此命名访问不是“另一种完全不同的分组”,而是更可读的访问方式。\n\n### 6.4 重复分组只保留最后一次捕获\n\n下面的模式匹配多个单词:\n\n```java\nPattern p = Pattern.compile(\"(\\\\w+\\\\s*)+\");\nMatcher m = p.matcher(\"one two three\");\n\nif (m.matches()) {\n System.out.println(m.group(0)); // one two three\n System.out.println(m.group(1)); // three\n}\n```\n\n组 1 位于重复结构中。它不是一个列表;每次重复都会覆盖之前的捕获,匹配结束后通常只能看到最后一次成功捕获的内容。\n\n因此,若要提取所有元素,应使用 `find()`:\n\n```java\nMatcher m = Pattern.compile(\"\\\\w+\").matcher(\"one two three\");\n\nwhile (m.find()) {\n System.out.println(m.group());\n}\n```\n\n输出:\n\n```text\none\ntwo\nthree\n```\n\n这是“用一个重复捕获组收集所有结果”的常见误解。捕获组保存边界,不提供集合语义。\n\n### 6.5 反向引用\n\n反向引用要求后续文本与前面捕获到的内容相同:\n\n```regex\n\\b([A-Za-z]+)\\s+\\1\\b\n```\n\n它可以匹配:\n\n```text\nhello hello\n```\n\n但不能匹配:\n\n```text\nhello world\n```\n\nJava 示例:\n\n```java\nPattern p = Pattern.compile(\"\\\\b([A-Za-z]+)\\\\s+\\\\1\\\\b\");\n\nSystem.out.println(p.matcher(\"hello hello\").find()); // true\nSystem.out.println(p.matcher(\"hello world\").find()); // false\n```\n\n反向引用依赖运行时捕获内容,不再是简单的“只根据当前位置和固定状态判断”的正则匹配。它会增加回溯复杂度,也使某些本来容易用有限自动机处理的问题变得更难优化。\n\n---\n\n## 7. 断言:检查上下文但不消耗字符\n\n断言验证条件,但不把验证的字符纳入当前匹配消耗范围。\n\n### 7.1 前瞻\n\n```regex\n\\d+(?=元)\n```\n\n含义是:匹配数字,但要求数字后面紧接着出现 `元`;`元` 本身不属于匹配结果。\n\n```java\nPattern p = Pattern.compile(\"\\\\d+(?=元)\");\nMatcher m = p.matcher(\"价格 100元\");\n\nif (m.find()) {\n System.out.println(m.group()); // 100\n}\n```\n\n### 7.2 否定前瞻\n\n```regex\nfoo(?!bar)\n```\n\n匹配 `foo`,但要求其后不能是 `bar`。\n\n### 7.3 后顾\n\n```regex\n(?\u003c=金额:)\\d+\n```\n\n匹配数字,但要求其前面紧接着是 `金额:`。后顾表达式不会消耗前面的字符。\n\n断言本身也可能包含量词、分支和嵌套结构。如果断言在每个输入位置重复执行,复杂断言会放大总体成本。因此,“没有消耗字符”不等于“没有执行成本”。\n\n---\n\n## 8. 替换 API 和分组引用\n\n匹配和替换使用同一个 `Matcher`,但替换字符串有自己的语法:\n\n- `$1`、`${name}`:引用捕获组;\n- `\\` 和 ` Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS - WR Blog
WR Blog 加载中...
返回文章
JavaJava 25 LTS正则

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS封面

Java 基础体系 · 第 48/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS

正则表达式同时包含两层含义:

  1. 模式语言:用字符、字符类、量词、分支和断言描述一组字符串。
  2. 执行机制: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]

匹配 abc 中的一个字符。

[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}

可以匹配 aaaaaaaaa,但不能匹配 aaaaaa

量词默认是贪婪的:在不导致整体失败的前提下,优先消耗更多字符。

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

执行过程大致是:

  1. a+? 先取一个 a
  2. 后面的 a 成功;
  3. 整体已经成功,因此不再继续扩展。

惰性不等于高效。它仍可能通过回溯尝试大量长度。


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 MULTILINEDOTALL

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

执行过程可以抽象为:

  1. a+ 是贪婪量词,先尽可能多地匹配 aaa
  2. 下一个字面量 a 没有可匹配字符,因为当前位置已经到达 b 前;
  3. a+ 回溯,释放一个 a
  4. 此时 a+ 匹配 aa,后面的字面量 a 匹配第三个 a
  5. 最后的 b 匹配成功;
  6. 整体匹配成功。

回溯不是“重新从头随机尝试”,而是回到之前存在选择的节点,改变该节点的决定。

9.3 分支的回溯

考虑:

(ab|a)c

输入:

ac

执行过程:

  1. 先尝试分支 ab
  2. a 成功,但 b 试图匹配 c,失败;
  3. 回到分支节点;
  4. 尝试分支 a
  5. 后面的 c 成功;
  6. 整体成功。

如果没有后续失败,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 个独立的二选一决策,最坏情况下的路径数上界可能接近:

2k2^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

  1. a+ 先匹配两个 a
  2. 最后的 a 失败;
  3. a+ 释放一个 a
  4. 最后的 a 成功。

对于 a++a

  1. a++ 匹配两个 a
  2. 最后的 a 失败;
  3. a++ 不允许释放字符;
  4. 整体失败。

所以占有量词只有在“已经确定前面的字符不应交给后续模式”的地方才是语义正确的优化。

10.3 原子组

原子组使用:

(?>...)

组内一旦成功离开,就不允许回到组内重新选择。

例如:

System.out.println(Pattern.matches("(a|ab)c", "abc"));   // true
System.out.println(Pattern.matches("(?>a|ab)c", "abc")); // false

第一种模式:

  1. 分支 a 先成功;
  2. 后面的 c 试图匹配 b,失败;
  3. 回到分支内部;
  4. 尝试 ab
  5. c 成功。

第二种模式:

  1. 原子组先选择 a
  2. 离开原子组后,不能回到内部尝试 ab
  3. 后面的 c 失败;
  4. 整体失败。

原子组和占有量词都是限制回溯的工具,但不能机械地“到处加”。它们可能消除本来需要的合法回退,从而把正确匹配变成错误失败。


11. 性能:编译成本、搜索成本和结果成本

正则处理的总成本可以分解为:

Ttotal=Tcompile+Tmatch+TresultT_{\text{total}} = T_{\text{compile}} + T_{\text{match}} + T_{\text{result}}

其中:

  • TcompileT_{\text{compile}}:解析和编译 Pattern 的成本;
  • TmatchT_{\text{match}}:在输入上执行匹配的成本;
  • TresultT_{\text{result}}:读取分组、生成替换结果、创建返回字符串等成本。

重复编译会增加第一项;复杂回溯会增加第二项;大量捕获和替换会增加第三项。

11.1 find() 可能多次启动匹配

设输入长度为 nn,模式没有明确的前缀约束,find() 可能尝试许多起始位置。即使一次匹配平均很快,反复启动也可能使总成本接近:

O(nCattempt)O(n \cdot C_{\text{attempt}})

其中 CattemptC_{\text{attempt}} 是一次起始位置尝试的成本。

如果模式本身在一次尝试中还会产生指数级回溯,那么两者会叠加,而不是互相抵消。

11.2 捕获组不是免费功能

捕获组需要保存开始位置和结束位置。一个模式包含大量不需要读取的括号时,会增加状态管理复杂度。应将结构性括号写成非捕获组:

(?:https?|ftp)://

但不要把“减少捕获组”误认为解决回溯问题的方法。性能灾难的主要来源是可重新组合的匹配路径,而不是单纯的组数量。

11.3 正则不是所有解析任务的合适工具

以下任务通常更适合专用解析器或手写状态机:

  • 嵌套且需要递归层级的语言;
  • JSON、XML、SQL 等具有结构语法的输入;
  • 需要精确错误位置和恢复策略的编译器式解析;
  • 复杂协议解析和长度字段校验。

正则可以用于提取局部结构,但“用一个超长表达式完成整个语言解析”往往同时损害可读性、可测试性和性能可控性。


12. ReDoS:正则表达式拒绝服务

12.1 ReDoS 的本质

ReDoS 不是“正则表达式很长”这么简单。其核心条件是:

  1. 输入由攻击者或不可信来源控制;
  2. 模式包含多个可重复或可分支的选择;
  3. 这些选择对同一段输入存在多种解释;
  4. 输入最终失败,迫使引擎探索大量路径。

一个典型模式是:

^(a+)+$

Java 字符串写法:

"^(a+)+$"

攻击输入:

aaaaaaaaaaaaaaaaaaaaaaaaX

模式要求整段只能由 a 组成,但末尾的 X 使整体失败。

12.2 为什么 (a+)+ 会产生大量路径

设输入中有 nn 个连续的 a,并且最后有一个不匹配字符 X

外层 + 可以把这段 a 分成若干个非空块:

aaaaa

可能的划分包括:

aaaaa
a|aaaa
aa|aaa
aaa|aa
aaaa|a
a|a|aaa
a|aa|aa
...

每个相邻位置都可以选择“在这里切分”或“不在这里切分”。因此,正数长度为 nn 的连续字符存在:

2n12^{n-1}

种切分方式。

Java 的回溯执行会在外层重复、内层 a+ 和末尾 $ 失败之间反复调整匹配长度。不同 JDK 和不同模式细节可能触发优化,不能把上式当作 Java 的精确运行时间;但这种模式确实具有产生指数级回溯的结构风险。

12.3 另一个典型:重叠分支

^(a|aa)+$

aaa 都能匹配以 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)?$

重写后的结构表达的是:

  1. 先匹配固定的 foo
  2. 再选择是否匹配 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_INSENSITIVEUNICODE_CASEUNICODE_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

每个部分的作用是:

  1. \A\z 约束整行输入;
  2. (?<time>...) 创建命名组;
  3. [^\\]]+ 在 Java 字符串中表示正则 ^[^\]]+ 的局部形式,即读取直到 ] 前的内容;
  4. INFO|WARN|ERROR 限定日志级别;
  5. (?<message>.*) 读取剩余消息;
  6. 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. 诊断正则性能问题的方法

遇到疑似慢匹配时,应区分以下几类问题:

  1. 编译慢:是否在循环中重复 Pattern.compile
  2. 搜索次数多:是否使用 find() 在大文本中反复尝试;
  3. 单次匹配慢:是否存在嵌套量词、重叠分支或复杂断言;
  4. 结果处理慢:是否创建了大量替换结果或读取大量捕获组;
  5. 输入异常:是否包含超长无界字段或恶意失败后缀。

一个简单的测试程序可以比较成功输入和失败输入:

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 把校验拆成结构和业务规则

例如,邮箱、日期、用户名等字段不应仅靠一个巨大正则完成所有语义校验。可以先做:

  1. 长度限制;
  2. 字符集限制;
  3. 基本结构匹配;
  4. 类型解析和范围校验。

日期示例:

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 正则表达式的关键因果链可以概括为:

  1. 正则字符串先经过 Java 字符串转义;
  2. Pattern.compile 解析并编译模式;
  3. Pattern.matcher 创建有状态的 Matcher
  4. matcheslookingAtfind 选择不同的匹配范围;
  5. 分组保存匹配边界,重复分组通常只保留最后一次捕获;
  6. 贪婪量词、惰性量词和分支产生不同的候选路径;
  7. 后续失败会触发回溯,重新选择之前的路径;
  8. 嵌套重复和重叠分支会使候选路径呈指数级增长;
  9. 攻击者控制长输入时,这种增长可能形成 ReDoS;
  10. 解决方案必须同时包含模式重写、输入边界和执行隔离,而不是只缓存 Pattern 或简单依赖线程中断。

Pattern 解决的是模式的编译和复用,Matcher 解决的是一次具体输入上的状态化执行;分组解决的是结果定位和引用,回溯解释了表达式如何在失败时寻找替代路径,而 ReDoS 则是这些执行特征在不受控输入下产生的安全后果。只有把这几层放在同一个模型中,才能既写出语义正确的正则,也能判断它是否适合运行在生产请求路径上。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
在替换文本中具有特殊含义。\n\n```java\nPattern p = Pattern.compile(\"(?\u003ckey>[A-Za-z]+)=(?\u003cvalue>[^&]+)\");\nMatcher m = p.matcher(\"name=alice&role=admin\");\n\nString result = m.replaceAll(\"${key}=[REDACTED]\");\nSystem.out.println(result);\n```\n\n输出:\n\n```text\n[REDACTED]&[REDACTED]\n```\n\n如果替换内容来自外部输入,不能直接把它当作替换模板。例如用户输入中的 `$1` 可能被解释为分组引用。应使用:\n\n```java\nString safeReplacement = Matcher.quoteReplacement(userText);\n```\n\n完整示例:\n\n```java\nString userText = \"$1 is not a group reference\";\nString result = Pattern.compile(\"secret\")\n .matcher(\"secret\")\n .replaceAll(Matcher.quoteReplacement(userText));\n\nSystem.out.println(result);\n```\n\n`Pattern.quote` 保护的是**正则模式**;`Matcher.quoteReplacement` 保护的是**替换字符串**。两者作用位置不同,不能互换。\n\n---\n\n## 9. 回溯:Matcher 如何处理选择和失败\n\n### 9.1 规范语义和实现机制要区分\n\nJava `Pattern` 文档定义的是正则语法和匹配结果语义。具体的内部执行算法属于实现细节,不应把某个 JDK 版本的内部类结构当作 API 契约。\n\n但 Java 的 `Pattern` 常见实现采用带回溯的正则执行策略。理解这种策略对解释以下现象非常有用:\n\n- 贪婪量词为什么会缩短;\n- 分支为什么会换另一条路径;\n- 某些模式为什么在失败输入上特别慢;\n- 原子组和占有量词为什么能限制搜索。\n\n### 9.2 一个完整的回溯过程\n\n考虑模式:\n\n```regex\na+ab\n```\n\n输入:\n\n```text\naaab\n```\n\n执行过程可以抽象为:\n\n1. `a+` 是贪婪量词,先尽可能多地匹配 `aaa`;\n2. 下一个字面量 `a` 没有可匹配字符,因为当前位置已经到达 `b` 前;\n3. `a+` 回溯,释放一个 `a`;\n4. 此时 `a+` 匹配 `aa`,后面的字面量 `a` 匹配第三个 `a`;\n5. 最后的 `b` 匹配成功;\n6. 整体匹配成功。\n\n回溯不是“重新从头随机尝试”,而是回到之前存在选择的节点,改变该节点的决定。\n\n### 9.3 分支的回溯\n\n考虑:\n\n```regex\n(ab|a)c\n```\n\n输入:\n\n```text\nac\n```\n\n执行过程:\n\n1. 先尝试分支 `ab`;\n2. `a` 成功,但 `b` 试图匹配 `c`,失败;\n3. 回到分支节点;\n4. 尝试分支 `a`;\n5. 后面的 `c` 成功;\n6. 整体成功。\n\n如果没有后续失败,Java 的分支通常按从左到右的顺序选择第一个可行分支:\n\n```java\nPattern p = Pattern.compile(\"(a|aa)\");\nMatcher m = p.matcher(\"aa\");\n\nif (m.find()) {\n System.out.println(m.group()); // a\n}\n```\n\n因为第一分支 `a` 已经成功,`find()` 不会为了追求更长结果自动选择 `aa`。如果后续结构迫使第一分支失败,回溯才可能尝试第二个分支。\n\n### 9.4 回溯树和复杂度直觉\n\n如果某个位置有两个选择,后续又有两个选择,失败时可能需要探索多个组合。假设有 `k` 个独立的二选一决策,最坏情况下的路径数上界可能接近:\n\n\\[\n2^k\n\\]\n\n这里:\n\n- `k` 是需要回退并重新选择的决策数;\n- `2^k` 表示每个决策有两种可能时的组合数量。\n\n这不是所有正则的精确运行时间公式,也不是 Java API 的复杂度保证;它用于说明为什么“每次只多一个字符”可能导致尝试路径大幅增加。\n\n---\n\n## 10. 贪婪、惰性、占有量词和原子组\n\n### 10.1 三种量词行为\n\n对基础量词 `*`、`+`、`?`、`{m,n}`,Java 支持三种常见形式:\n\n| 类型 | 示例 | 行为 |\n|---|---|---|\n| 贪婪 | `a+` | 尽量多取,失败时允许回退 |\n| 惰性 | `a+?` | 尽量少取,必要时扩展 |\n| 占有 | `a++` | 尽量多取,之后不允许回退 |\n\n占有量词通过放弃回退点减少搜索空间。\n\n### 10.2 占有量词改变的不只是性能\n\n```java\nSystem.out.println(Pattern.matches(\"a+a\", \"aa\")); // true\nSystem.out.println(Pattern.matches(\"a++a\", \"aa\")); // false\n```\n\n对于 `a+a`:\n\n1. `a+` 先匹配两个 `a`;\n2. 最后的 `a` 失败;\n3. `a+` 释放一个 `a`;\n4. 最后的 `a` 成功。\n\n对于 `a++a`:\n\n1. `a++` 匹配两个 `a`;\n2. 最后的 `a` 失败;\n3. `a++` 不允许释放字符;\n4. 整体失败。\n\n所以占有量词只有在“已经确定前面的字符不应交给后续模式”的地方才是语义正确的优化。\n\n### 10.3 原子组\n\n原子组使用:\n\n```regex\n(?>...)\n```\n\n组内一旦成功离开,就不允许回到组内重新选择。\n\n例如:\n\n```java\nSystem.out.println(Pattern.matches(\"(a|ab)c\", \"abc\")); // true\nSystem.out.println(Pattern.matches(\"(?>a|ab)c\", \"abc\")); // false\n```\n\n第一种模式:\n\n1. 分支 `a` 先成功;\n2. 后面的 `c` 试图匹配 `b`,失败;\n3. 回到分支内部;\n4. 尝试 `ab`;\n5. `c` 成功。\n\n第二种模式:\n\n1. 原子组先选择 `a`;\n2. 离开原子组后,不能回到内部尝试 `ab`;\n3. 后面的 `c` 失败;\n4. 整体失败。\n\n原子组和占有量词都是限制回溯的工具,但不能机械地“到处加”。它们可能消除本来需要的合法回退,从而把正确匹配变成错误失败。\n\n---\n\n## 11. 性能:编译成本、搜索成本和结果成本\n\n正则处理的总成本可以分解为:\n\n\\[\nT_{\\text{total}}\n=\nT_{\\text{compile}}\n+\nT_{\\text{match}}\n+\nT_{\\text{result}}\n\\]\n\n其中:\n\n- \\(T_{\\text{compile}}\\):解析和编译 `Pattern` 的成本;\n- \\(T_{\\text{match}}\\):在输入上执行匹配的成本;\n- \\(T_{\\text{result}}\\):读取分组、生成替换结果、创建返回字符串等成本。\n\n重复编译会增加第一项;复杂回溯会增加第二项;大量捕获和替换会增加第三项。\n\n### 11.1 `find()` 可能多次启动匹配\n\n设输入长度为 \\(n\\),模式没有明确的前缀约束,`find()` 可能尝试许多起始位置。即使一次匹配平均很快,反复启动也可能使总成本接近:\n\n\\[\nO(n \\cdot C_{\\text{attempt}})\n\\]\n\n其中 \\(C_{\\text{attempt}}\\) 是一次起始位置尝试的成本。\n\n如果模式本身在一次尝试中还会产生指数级回溯,那么两者会叠加,而不是互相抵消。\n\n### 11.2 捕获组不是免费功能\n\n捕获组需要保存开始位置和结束位置。一个模式包含大量不需要读取的括号时,会增加状态管理复杂度。应将结构性括号写成非捕获组:\n\n```regex\n(?:https?|ftp)://\n```\n\n但不要把“减少捕获组”误认为解决回溯问题的方法。性能灾难的主要来源是可重新组合的匹配路径,而不是单纯的组数量。\n\n### 11.3 正则不是所有解析任务的合适工具\n\n以下任务通常更适合专用解析器或手写状态机:\n\n- 嵌套且需要递归层级的语言;\n- JSON、XML、SQL 等具有结构语法的输入;\n- 需要精确错误位置和恢复策略的编译器式解析;\n- 复杂协议解析和长度字段校验。\n\n正则可以用于提取局部结构,但“用一个超长表达式完成整个语言解析”往往同时损害可读性、可测试性和性能可控性。\n\n---\n\n## 12. ReDoS:正则表达式拒绝服务\n\n### 12.1 ReDoS 的本质\n\nReDoS 不是“正则表达式很长”这么简单。其核心条件是:\n\n1. 输入由攻击者或不可信来源控制;\n2. 模式包含多个可重复或可分支的选择;\n3. 这些选择对同一段输入存在多种解释;\n4. 输入最终失败,迫使引擎探索大量路径。\n\n一个典型模式是:\n\n```regex\n^(a+)+$\n```\n\nJava 字符串写法:\n\n```java\n\"^(a+)+$\"\n```\n\n攻击输入:\n\n```text\naaaaaaaaaaaaaaaaaaaaaaaaX\n```\n\n模式要求整段只能由 `a` 组成,但末尾的 `X` 使整体失败。\n\n### 12.2 为什么 `(a+)+` 会产生大量路径\n\n设输入中有 \\(n\\) 个连续的 `a`,并且最后有一个不匹配字符 `X`。\n\n外层 `+` 可以把这段 `a` 分成若干个非空块:\n\n```text\naaaaa\n```\n\n可能的划分包括:\n\n```text\naaaaa\na|aaaa\naa|aaa\naaa|aa\naaaa|a\na|a|aaa\na|aa|aa\n...\n```\n\n每个相邻位置都可以选择“在这里切分”或“不在这里切分”。因此,正数长度为 \\(n\\) 的连续字符存在:\n\n\\[\n2^{n-1}\n\\]\n\n种切分方式。\n\nJava 的回溯执行会在外层重复、内层 `a+` 和末尾 ` Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS - WR Blog
WR Blog 加载中...
返回文章
JavaJava 25 LTS正则

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS封面

Java 基础体系 · 第 48/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS

正则表达式同时包含两层含义:

  1. 模式语言:用字符、字符类、量词、分支和断言描述一组字符串。
  2. 执行机制: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]

匹配 abc 中的一个字符。

[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}

可以匹配 aaaaaaaaa,但不能匹配 aaaaaa

量词默认是贪婪的:在不导致整体失败的前提下,优先消耗更多字符。

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

执行过程大致是:

  1. a+? 先取一个 a
  2. 后面的 a 成功;
  3. 整体已经成功,因此不再继续扩展。

惰性不等于高效。它仍可能通过回溯尝试大量长度。


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 MULTILINEDOTALL

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

执行过程可以抽象为:

  1. a+ 是贪婪量词,先尽可能多地匹配 aaa
  2. 下一个字面量 a 没有可匹配字符,因为当前位置已经到达 b 前;
  3. a+ 回溯,释放一个 a
  4. 此时 a+ 匹配 aa,后面的字面量 a 匹配第三个 a
  5. 最后的 b 匹配成功;
  6. 整体匹配成功。

回溯不是“重新从头随机尝试”,而是回到之前存在选择的节点,改变该节点的决定。

9.3 分支的回溯

考虑:

(ab|a)c

输入:

ac

执行过程:

  1. 先尝试分支 ab
  2. a 成功,但 b 试图匹配 c,失败;
  3. 回到分支节点;
  4. 尝试分支 a
  5. 后面的 c 成功;
  6. 整体成功。

如果没有后续失败,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 个独立的二选一决策,最坏情况下的路径数上界可能接近:

2k2^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

  1. a+ 先匹配两个 a
  2. 最后的 a 失败;
  3. a+ 释放一个 a
  4. 最后的 a 成功。

对于 a++a

  1. a++ 匹配两个 a
  2. 最后的 a 失败;
  3. a++ 不允许释放字符;
  4. 整体失败。

所以占有量词只有在“已经确定前面的字符不应交给后续模式”的地方才是语义正确的优化。

10.3 原子组

原子组使用:

(?>...)

组内一旦成功离开,就不允许回到组内重新选择。

例如:

System.out.println(Pattern.matches("(a|ab)c", "abc"));   // true
System.out.println(Pattern.matches("(?>a|ab)c", "abc")); // false

第一种模式:

  1. 分支 a 先成功;
  2. 后面的 c 试图匹配 b,失败;
  3. 回到分支内部;
  4. 尝试 ab
  5. c 成功。

第二种模式:

  1. 原子组先选择 a
  2. 离开原子组后,不能回到内部尝试 ab
  3. 后面的 c 失败;
  4. 整体失败。

原子组和占有量词都是限制回溯的工具,但不能机械地“到处加”。它们可能消除本来需要的合法回退,从而把正确匹配变成错误失败。


11. 性能:编译成本、搜索成本和结果成本

正则处理的总成本可以分解为:

Ttotal=Tcompile+Tmatch+TresultT_{\text{total}} = T_{\text{compile}} + T_{\text{match}} + T_{\text{result}}

其中:

  • TcompileT_{\text{compile}}:解析和编译 Pattern 的成本;
  • TmatchT_{\text{match}}:在输入上执行匹配的成本;
  • TresultT_{\text{result}}:读取分组、生成替换结果、创建返回字符串等成本。

重复编译会增加第一项;复杂回溯会增加第二项;大量捕获和替换会增加第三项。

11.1 find() 可能多次启动匹配

设输入长度为 nn,模式没有明确的前缀约束,find() 可能尝试许多起始位置。即使一次匹配平均很快,反复启动也可能使总成本接近:

O(nCattempt)O(n \cdot C_{\text{attempt}})

其中 CattemptC_{\text{attempt}} 是一次起始位置尝试的成本。

如果模式本身在一次尝试中还会产生指数级回溯,那么两者会叠加,而不是互相抵消。

11.2 捕获组不是免费功能

捕获组需要保存开始位置和结束位置。一个模式包含大量不需要读取的括号时,会增加状态管理复杂度。应将结构性括号写成非捕获组:

(?:https?|ftp)://

但不要把“减少捕获组”误认为解决回溯问题的方法。性能灾难的主要来源是可重新组合的匹配路径,而不是单纯的组数量。

11.3 正则不是所有解析任务的合适工具

以下任务通常更适合专用解析器或手写状态机:

  • 嵌套且需要递归层级的语言;
  • JSON、XML、SQL 等具有结构语法的输入;
  • 需要精确错误位置和恢复策略的编译器式解析;
  • 复杂协议解析和长度字段校验。

正则可以用于提取局部结构,但“用一个超长表达式完成整个语言解析”往往同时损害可读性、可测试性和性能可控性。


12. ReDoS:正则表达式拒绝服务

12.1 ReDoS 的本质

ReDoS 不是“正则表达式很长”这么简单。其核心条件是:

  1. 输入由攻击者或不可信来源控制;
  2. 模式包含多个可重复或可分支的选择;
  3. 这些选择对同一段输入存在多种解释;
  4. 输入最终失败,迫使引擎探索大量路径。

一个典型模式是:

^(a+)+$

Java 字符串写法:

"^(a+)+$"

攻击输入:

aaaaaaaaaaaaaaaaaaaaaaaaX

模式要求整段只能由 a 组成,但末尾的 X 使整体失败。

12.2 为什么 (a+)+ 会产生大量路径

设输入中有 nn 个连续的 a,并且最后有一个不匹配字符 X

外层 + 可以把这段 a 分成若干个非空块:

aaaaa

可能的划分包括:

aaaaa
a|aaaa
aa|aaa
aaa|aa
aaaa|a
a|a|aaa
a|aa|aa
...

每个相邻位置都可以选择“在这里切分”或“不在这里切分”。因此,正数长度为 nn 的连续字符存在:

2n12^{n-1}

种切分方式。

Java 的回溯执行会在外层重复、内层 a+ 和末尾 $ 失败之间反复调整匹配长度。不同 JDK 和不同模式细节可能触发优化,不能把上式当作 Java 的精确运行时间;但这种模式确实具有产生指数级回溯的结构风险。

12.3 另一个典型:重叠分支

^(a|aa)+$

aaa 都能匹配以 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)?$

重写后的结构表达的是:

  1. 先匹配固定的 foo
  2. 再选择是否匹配 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_INSENSITIVEUNICODE_CASEUNICODE_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

每个部分的作用是:

  1. \A\z 约束整行输入;
  2. (?<time>...) 创建命名组;
  3. [^\\]]+ 在 Java 字符串中表示正则 ^[^\]]+ 的局部形式,即读取直到 ] 前的内容;
  4. INFO|WARN|ERROR 限定日志级别;
  5. (?<message>.*) 读取剩余消息;
  6. 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. 诊断正则性能问题的方法

遇到疑似慢匹配时,应区分以下几类问题:

  1. 编译慢:是否在循环中重复 Pattern.compile
  2. 搜索次数多:是否使用 find() 在大文本中反复尝试;
  3. 单次匹配慢:是否存在嵌套量词、重叠分支或复杂断言;
  4. 结果处理慢:是否创建了大量替换结果或读取大量捕获组;
  5. 输入异常:是否包含超长无界字段或恶意失败后缀。

一个简单的测试程序可以比较成功输入和失败输入:

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 把校验拆成结构和业务规则

例如,邮箱、日期、用户名等字段不应仅靠一个巨大正则完成所有语义校验。可以先做:

  1. 长度限制;
  2. 字符集限制;
  3. 基本结构匹配;
  4. 类型解析和范围校验。

日期示例:

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 正则表达式的关键因果链可以概括为:

  1. 正则字符串先经过 Java 字符串转义;
  2. Pattern.compile 解析并编译模式;
  3. Pattern.matcher 创建有状态的 Matcher
  4. matcheslookingAtfind 选择不同的匹配范围;
  5. 分组保存匹配边界,重复分组通常只保留最后一次捕获;
  6. 贪婪量词、惰性量词和分支产生不同的候选路径;
  7. 后续失败会触发回溯,重新选择之前的路径;
  8. 嵌套重复和重叠分支会使候选路径呈指数级增长;
  9. 攻击者控制长输入时,这种增长可能形成 ReDoS;
  10. 解决方案必须同时包含模式重写、输入边界和执行隔离,而不是只缓存 Pattern 或简单依赖线程中断。

Pattern 解决的是模式的编译和复用,Matcher 解决的是一次具体输入上的状态化执行;分组解决的是结果定位和引用,回溯解释了表达式如何在失败时寻找替代路径,而 ReDoS 则是这些执行特征在不受控输入下产生的安全后果。只有把这几层放在同一个模型中,才能既写出语义正确的正则,也能判断它是否适合运行在生产请求路径上。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
失败之间反复调整匹配长度。不同 JDK 和不同模式细节可能触发优化,不能把上式当作 Java 的精确运行时间;但这种模式确实具有产生指数级回溯的结构风险。\n\n### 12.3 另一个典型:重叠分支\n\n```regex\n^(a|aa)+$\n```\n\n`a` 和 `aa` 都能匹配以 `a` 开头的输入。相同字符序列可以被分解成许多不同方式:\n\n```text\naaaa\na|a|a|a\naa|a|a\na|aa|a\na|a|aa\naa|aa\n...\n```\n\n如果末尾再增加一个不匹配字符,失败会触发对这些组合的探索。\n\n重叠分支的关键不是“分支数量多”,而是不同分支能够消费相同前缀。以下分支的重叠较小:\n\n```regex\n^(?:cat|dog|bird)$\n```\n\n每个分支首字符不同,失败时通常更容易排除候选;但这仍不是 Java API 对复杂度的保证。\n\n### 12.4 含通配符的嵌套量词\n\n例如:\n\n```regex\n^(.+)+$\n```\n\n或者:\n\n```regex\n^(.*a){10}$\n```\n\n这些模式让多个结构都可以消费相同字符。`.*` 本身就有大量长度选择,放进重复结构后,失败路径会显著增多。\n\n需要注意:`.*` 不必然导致 ReDoS。若其后有明确锚点、输入长度很小,或者后续结构能快速排除大多数路径,风险可能有限。风险需要结合:\n\n- 输入是否可控;\n- 最大输入长度;\n- 匹配调用频率;\n- 模式是否存在嵌套重复;\n- 分支是否有重叠;\n- 失败是常见情况还是成功是常见情况;\n- 是否在请求线程中同步执行。\n\n---\n\n## 13. 如何改写高风险模式\n\n### 13.1 直接消除嵌套量词\n\n高风险模式:\n\n```regex\n^(a+)+$\n```\n\n如果实际需求只是“整段只能包含一个或多个 `a`”,应改成:\n\n```regex\n^a+$\n```\n\n这不是性能微调,而是删除了不必要的语义自由度。\n\n### 13.2 使用互斥字符类\n\n高风险写法:\n\n```regex\n^(.+)+$\n```\n\n如果需求是“非空且不含换行”,可能应直接写:\n\n```regex\n^[^\\r\\n]+$\n```\n\n如果需求是“由小写字母组成”:\n\n```regex\n^[a-z]+$\n```\n\n越具体的字符类,越能减少引擎的选择空间。\n\n### 13.3 使用占有量词\n\n当后续模式不需要从前面的量词中取回字符时,可以使用占有量词:\n\n```regex\n^[a-z]++$\n```\n\nJava 示例:\n\n```java\nPattern safe = Pattern.compile(\"\\\\A[a-z]++\\\\z\");\n\nSystem.out.println(safe.matcher(\"abcxyz\").matches()); // true\nSystem.out.println(safe.matcher(\"abcXYZ\").matches()); // false\n```\n\n这里 `[a-z]++` 消耗的字符不可能被别的结构重新解释,因此禁止回溯是安全的。\n\n### 13.4 使用原子组控制边界\n\n例如,协议字段由逗号分隔时,可以让字段消费逻辑保持封闭:\n\n```regex\n\\G(?>[^,]*)(?:,|$)\n```\n\n但原子组是否正确取决于具体语法。它不是通用“安全开关”。如果字段内部本来允许多种解释,原子化可能导致合法输入被拒绝。\n\n### 13.5 让分支具有互斥前缀\n\n高重叠:\n\n```regex\n^(foo|foobar)+$\n```\n\n如果语法允许,通常应重新设计为共享公共前缀:\n\n```regex\n^foo(?:bar)?$\n```\n\n重写后的结构表达的是:\n\n1. 先匹配固定的 `foo`;\n2. 再选择是否匹配 `bar`。\n\n这比让引擎在两个完整分支之间反复切换更直接。\n\n---\n\n## 14. ReDoS 防护不能只依赖改模式\n\n### 14.1 限制输入长度\n\n如果字段业务上最多 256 个字符,应在进入正则前拒绝更长输入:\n\n```java\nboolean validUsername(String value) {\n if (value == null || value.length() > 32) {\n return false;\n }\n\n return USERNAME.matcher(value).matches();\n}\n```\n\n长度限制必须符合业务约束。它不是替代模式审查的理由,因为短输入也可能触发高成本路径;但它能限制最坏情况的规模。\n\n`String.length()` 返回 UTF-16 `char` 数量,不是 Unicode 码点数量,也不是用户感知字符数量。如果业务按码点限制,可以使用:\n\n```java\nint codePointCount =\n value.codePointCount(0, value.length());\n```\n\n如果业务按字素簇限制,则需要更高层的 Unicode 文本处理规则,不能把 `length()` 当作用户可见字符数。\n\n### 14.2 Java 标准 Matcher 没有通用超时参数\n\nJava SE 的标准 `Pattern`/`Matcher` API 没有类似“匹配最多执行 100 毫秒”的通用超时参数。因此不能写出一个标准 API 调用来可靠地终止任意正在执行的匹配。\n\n常见但不充分的做法是:\n\n```java\nFuture\u003cBoolean> future = executor.submit(\n () -> pattern.matcher(input).matches()\n);\n\ntry {\n return future.get(100, TimeUnit.MILLISECONDS);\n} catch (TimeoutException e) {\n future.cancel(true);\n return false;\n}\n```\n\n这段代码可以让调用线程停止等待,但 `future.cancel(true)` 只是发出中断请求。它不构成 Java `Matcher` 一定立即停止的保证。若把大量可能超时的任务继续留在线程池中,线程仍可能被占满,最终依然造成服务不可用。\n\n对高风险、不可信的正则匹配,更可靠的隔离边界通常是:\n\n- 预先审查和限制正则模式;\n- 限制输入长度;\n- 在独立进程或受控沙箱中执行;\n- 为执行进程设置 CPU、内存和生命周期限制;\n- 超时后销毁执行单元并记录诊断信息。\n\n### 14.3 不能把线程中断当作安全边界\n\n线程中断适合协作式取消。正则执行是否在所有路径上及时检查中断,不应被当作标准 API 的强制终止契约。尤其是服务端处理攻击输入时,必须考虑“取消等待”与“实际计算停止”之间的差异。\n\n---\n\n## 15. flags:改变语义,也可能改变成本\n\n常用编译标志包括:\n\n```java\nPattern.CASE_INSENSITIVE\nPattern.MULTILINE\nPattern.DOTALL\nPattern.COMMENTS\nPattern.UNICODE_CASE\nPattern.UNICODE_CHARACTER_CLASS\nPattern.LITERAL\n```\n\n也可以使用内联标志:\n\n```regex\n(?i)abc\n(?s:.*)\n```\n\n其中 `(?s:...)` 只对局部组启用 `DOTALL`。\n\n### 15.1 CASE_INSENSITIVE 与 Unicode\n\n```java\nPattern.compile(\"straße\", Pattern.CASE_INSENSITIVE);\n```\n\n大小写折叠的具体范围还受到 Unicode 相关标志影响。若业务需要跨语言、跨脚本的大小写不敏感匹配,应明确选择 `CASE_INSENSITIVE`、`UNICODE_CASE` 或 `UNICODE_CHARACTER_CLASS`,并使用代表性 Unicode 测试样本验证,而不要只用 ASCII 测试。\n\n### 15.2 COMMENTS 不是任意空白忽略\n\n`COMMENTS` 允许模式中的空白和注释采用更适合维护的写法,但字符类中的语义和转义规则仍需遵守正则语法。它不是“忽略输入文本空白”的标志;输入文本中的空白是否匹配,仍由模式决定。\n\n### 15.3 LITERAL 会关闭正则语法\n\n```java\nPattern p = Pattern.compile(\"a+b\", Pattern.LITERAL);\n\nSystem.out.println(p.matcher(\"a+b\").matches()); // true\nSystem.out.println(p.matcher(\"aaab\").matches()); // false\n```\n\n此时 `+` 不再是量词,而是普通字符。\n\n---\n\n## 16. 一个可运行的端到端示例:提取日志字段\n\n下面的程序提取日志中的时间、级别和消息:\n\n```java\nimport java.util.regex.Matcher;\nimport java.util.regex.Pattern;\n\npublic class LogParser {\n private static final Pattern LOG = Pattern.compile(\n \"\\\\A\\\\[(?\u003ctime>[^\\\\]]+)]\\\\s+\" +\n \"(?\u003clevel>INFO|WARN|ERROR)\\\\s+\" +\n \"(?\u003cmessage>.*)\\\\z\"\n );\n\n public static void main(String[] args) {\n String line = \"[2025-09-15T10:20:30Z] ERROR database unavailable\";\n\n Matcher matcher = LOG.matcher(line);\n\n if (!matcher.matches()) {\n System.out.println(\"invalid log line\");\n return;\n }\n\n System.out.println(\"time = \" + matcher.group(\"time\"));\n System.out.println(\"level = \" + matcher.group(\"level\"));\n System.out.println(\"message = \" + matcher.group(\"message\"));\n }\n}\n```\n\n输出:\n\n```text\ntime = 2025-09-15T10:20:30Z\nlevel = ERROR\nmessage = database unavailable\n```\n\n每个部分的作用是:\n\n1. `\\A` 和 `\\z` 约束整行输入;\n2. `(?\u003ctime>...)` 创建命名组;\n3. `[^\\\\]]+` 在 Java 字符串中表示正则 `^[^\\]]+` 的局部形式,即读取直到 `]` 前的内容;\n4. `INFO|WARN|ERROR` 限定日志级别;\n5. `(?\u003cmessage>.*)` 读取剩余消息;\n6. `matches()` 确保整行而不是某个子串符合格式。\n\n这个模式仍有一个业务边界:`.*` 默认不匹配行终止符,但示例输入是一行。如果日志消息允许包含换行,应明确决定是使用 `DOTALL`,还是逐行解析,而不能依赖默认行为。\n\n---\n\n## 17. 常见误解和失败表现\n\n### 17.1 “用了 `matches()`,所以 `^` 和 ` Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS - WR Blog
WR Blog 加载中...
返回文章
JavaJava 25 LTS正则

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS封面

Java 基础体系 · 第 48/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS

正则表达式同时包含两层含义:

  1. 模式语言:用字符、字符类、量词、分支和断言描述一组字符串。
  2. 执行机制: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]

匹配 abc 中的一个字符。

[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}

可以匹配 aaaaaaaaa,但不能匹配 aaaaaa

量词默认是贪婪的:在不导致整体失败的前提下,优先消耗更多字符。

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

执行过程大致是:

  1. a+? 先取一个 a
  2. 后面的 a 成功;
  3. 整体已经成功,因此不再继续扩展。

惰性不等于高效。它仍可能通过回溯尝试大量长度。


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 MULTILINEDOTALL

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

执行过程可以抽象为:

  1. a+ 是贪婪量词,先尽可能多地匹配 aaa
  2. 下一个字面量 a 没有可匹配字符,因为当前位置已经到达 b 前;
  3. a+ 回溯,释放一个 a
  4. 此时 a+ 匹配 aa,后面的字面量 a 匹配第三个 a
  5. 最后的 b 匹配成功;
  6. 整体匹配成功。

回溯不是“重新从头随机尝试”,而是回到之前存在选择的节点,改变该节点的决定。

9.3 分支的回溯

考虑:

(ab|a)c

输入:

ac

执行过程:

  1. 先尝试分支 ab
  2. a 成功,但 b 试图匹配 c,失败;
  3. 回到分支节点;
  4. 尝试分支 a
  5. 后面的 c 成功;
  6. 整体成功。

如果没有后续失败,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 个独立的二选一决策,最坏情况下的路径数上界可能接近:

2k2^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

  1. a+ 先匹配两个 a
  2. 最后的 a 失败;
  3. a+ 释放一个 a
  4. 最后的 a 成功。

对于 a++a

  1. a++ 匹配两个 a
  2. 最后的 a 失败;
  3. a++ 不允许释放字符;
  4. 整体失败。

所以占有量词只有在“已经确定前面的字符不应交给后续模式”的地方才是语义正确的优化。

10.3 原子组

原子组使用:

(?>...)

组内一旦成功离开,就不允许回到组内重新选择。

例如:

System.out.println(Pattern.matches("(a|ab)c", "abc"));   // true
System.out.println(Pattern.matches("(?>a|ab)c", "abc")); // false

第一种模式:

  1. 分支 a 先成功;
  2. 后面的 c 试图匹配 b,失败;
  3. 回到分支内部;
  4. 尝试 ab
  5. c 成功。

第二种模式:

  1. 原子组先选择 a
  2. 离开原子组后,不能回到内部尝试 ab
  3. 后面的 c 失败;
  4. 整体失败。

原子组和占有量词都是限制回溯的工具,但不能机械地“到处加”。它们可能消除本来需要的合法回退,从而把正确匹配变成错误失败。


11. 性能:编译成本、搜索成本和结果成本

正则处理的总成本可以分解为:

Ttotal=Tcompile+Tmatch+TresultT_{\text{total}} = T_{\text{compile}} + T_{\text{match}} + T_{\text{result}}

其中:

  • TcompileT_{\text{compile}}:解析和编译 Pattern 的成本;
  • TmatchT_{\text{match}}:在输入上执行匹配的成本;
  • TresultT_{\text{result}}:读取分组、生成替换结果、创建返回字符串等成本。

重复编译会增加第一项;复杂回溯会增加第二项;大量捕获和替换会增加第三项。

11.1 find() 可能多次启动匹配

设输入长度为 nn,模式没有明确的前缀约束,find() 可能尝试许多起始位置。即使一次匹配平均很快,反复启动也可能使总成本接近:

O(nCattempt)O(n \cdot C_{\text{attempt}})

其中 CattemptC_{\text{attempt}} 是一次起始位置尝试的成本。

如果模式本身在一次尝试中还会产生指数级回溯,那么两者会叠加,而不是互相抵消。

11.2 捕获组不是免费功能

捕获组需要保存开始位置和结束位置。一个模式包含大量不需要读取的括号时,会增加状态管理复杂度。应将结构性括号写成非捕获组:

(?:https?|ftp)://

但不要把“减少捕获组”误认为解决回溯问题的方法。性能灾难的主要来源是可重新组合的匹配路径,而不是单纯的组数量。

11.3 正则不是所有解析任务的合适工具

以下任务通常更适合专用解析器或手写状态机:

  • 嵌套且需要递归层级的语言;
  • JSON、XML、SQL 等具有结构语法的输入;
  • 需要精确错误位置和恢复策略的编译器式解析;
  • 复杂协议解析和长度字段校验。

正则可以用于提取局部结构,但“用一个超长表达式完成整个语言解析”往往同时损害可读性、可测试性和性能可控性。


12. ReDoS:正则表达式拒绝服务

12.1 ReDoS 的本质

ReDoS 不是“正则表达式很长”这么简单。其核心条件是:

  1. 输入由攻击者或不可信来源控制;
  2. 模式包含多个可重复或可分支的选择;
  3. 这些选择对同一段输入存在多种解释;
  4. 输入最终失败,迫使引擎探索大量路径。

一个典型模式是:

^(a+)+$

Java 字符串写法:

"^(a+)+$"

攻击输入:

aaaaaaaaaaaaaaaaaaaaaaaaX

模式要求整段只能由 a 组成,但末尾的 X 使整体失败。

12.2 为什么 (a+)+ 会产生大量路径

设输入中有 nn 个连续的 a,并且最后有一个不匹配字符 X

外层 + 可以把这段 a 分成若干个非空块:

aaaaa

可能的划分包括:

aaaaa
a|aaaa
aa|aaa
aaa|aa
aaaa|a
a|a|aaa
a|aa|aa
...

每个相邻位置都可以选择“在这里切分”或“不在这里切分”。因此,正数长度为 nn 的连续字符存在:

2n12^{n-1}

种切分方式。

Java 的回溯执行会在外层重复、内层 a+ 和末尾 $ 失败之间反复调整匹配长度。不同 JDK 和不同模式细节可能触发优化,不能把上式当作 Java 的精确运行时间;但这种模式确实具有产生指数级回溯的结构风险。

12.3 另一个典型:重叠分支

^(a|aa)+$

aaa 都能匹配以 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)?$

重写后的结构表达的是:

  1. 先匹配固定的 foo
  2. 再选择是否匹配 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_INSENSITIVEUNICODE_CASEUNICODE_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

每个部分的作用是:

  1. \A\z 约束整行输入;
  2. (?<time>...) 创建命名组;
  3. [^\\]]+ 在 Java 字符串中表示正则 ^[^\]]+ 的局部形式,即读取直到 ] 前的内容;
  4. INFO|WARN|ERROR 限定日志级别;
  5. (?<message>.*) 读取剩余消息;
  6. 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. 诊断正则性能问题的方法

遇到疑似慢匹配时,应区分以下几类问题:

  1. 编译慢:是否在循环中重复 Pattern.compile
  2. 搜索次数多:是否使用 find() 在大文本中反复尝试;
  3. 单次匹配慢:是否存在嵌套量词、重叠分支或复杂断言;
  4. 结果处理慢:是否创建了大量替换结果或读取大量捕获组;
  5. 输入异常:是否包含超长无界字段或恶意失败后缀。

一个简单的测试程序可以比较成功输入和失败输入:

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 把校验拆成结构和业务规则

例如,邮箱、日期、用户名等字段不应仅靠一个巨大正则完成所有语义校验。可以先做:

  1. 长度限制;
  2. 字符集限制;
  3. 基本结构匹配;
  4. 类型解析和范围校验。

日期示例:

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 正则表达式的关键因果链可以概括为:

  1. 正则字符串先经过 Java 字符串转义;
  2. Pattern.compile 解析并编译模式;
  3. Pattern.matcher 创建有状态的 Matcher
  4. matcheslookingAtfind 选择不同的匹配范围;
  5. 分组保存匹配边界,重复分组通常只保留最后一次捕获;
  6. 贪婪量词、惰性量词和分支产生不同的候选路径;
  7. 后续失败会触发回溯,重新选择之前的路径;
  8. 嵌套重复和重叠分支会使候选路径呈指数级增长;
  9. 攻击者控制长输入时,这种增长可能形成 ReDoS;
  10. 解决方案必须同时包含模式重写、输入边界和执行隔离,而不是只缓存 Pattern 或简单依赖线程中断。

Pattern 解决的是模式的编译和复用,Matcher 解决的是一次具体输入上的状态化执行;分组解决的是结果定位和引用,回溯解释了表达式如何在失败时寻找替代路径,而 ReDoS 则是这些执行特征在不受控输入下产生的安全后果。只有把这几层放在同一个模型中,才能既写出语义正确的正则,也能判断它是否适合运行在生产请求路径上。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
都必须写”\n\n错误。`matches()` 已经要求整个区域成功匹配:\n\n```java\nPattern.matches(\"\\\\d+\", \"123\"); // true\n```\n\n重复写成:\n\n```java\nPattern.matches(\"^\\\\d+$\", \"123\"); // 也正确,但通常冗余\n```\n\n锚点在模式被用于 `find()`、多行文本或嵌套上下文时仍然有价值。\n\n### 17.2 “`find()` 只调用一次就是匹配全部”\n\n错误。`find()` 查找一个子串。需要全部匹配时使用 `matches()`,或者使用明确的首尾边界并理解区域语义。\n\n```java\nPattern p = Pattern.compile(\"\\\\d+\");\n\nSystem.out.println(p.matcher(\"abc123\").find()); // true\nSystem.out.println(p.matcher(\"abc123\").matches()); // false\n```\n\n### 17.3 “捕获组可以保存重复匹配的所有值”\n\n错误。重复捕获组通常只保留最后一次成功捕获。需要所有结果时循环调用 `find()`,或在应用层构造集合。\n\n### 17.4 “惰性量词一定比贪婪量词快”\n\n错误。惰性量词只是改变尝试顺序。若后续条件直到很晚才失败,它仍可能尝试大量长度。\n\n### 17.5 “占有量词总是更好”\n\n错误。占有量词会改变可回溯性。只有在确认后续结构不需要取回已消费字符时才能使用。\n\n### 17.6 “编译一次就解决了性能问题”\n\n错误。复用 `Pattern` 只能消除重复编译成本,不能修复匹配阶段的指数级回溯。需要同时检查模式结构和输入边界。\n\n---\n\n## 18. 诊断正则性能问题的方法\n\n遇到疑似慢匹配时,应区分以下几类问题:\n\n1. **编译慢**:是否在循环中重复 `Pattern.compile`;\n2. **搜索次数多**:是否使用 `find()` 在大文本中反复尝试;\n3. **单次匹配慢**:是否存在嵌套量词、重叠分支或复杂断言;\n4. **结果处理慢**:是否创建了大量替换结果或读取大量捕获组;\n5. **输入异常**:是否包含超长无界字段或恶意失败后缀。\n\n一个简单的测试程序可以比较成功输入和失败输入:\n\n```java\nimport java.util.regex.Pattern;\n\npublic class RegexProbe {\n public static void main(String[] args) {\n Pattern pattern = Pattern.compile(\"^(a+)+$\");\n\n for (int length : new int[] {10, 15, 20, 25, 30}) {\n String input = \"a\".repeat(length) + \"X\";\n\n long start = System.nanoTime();\n boolean result = pattern.matcher(input).matches();\n long elapsed = System.nanoTime() - start;\n\n System.out.printf(\n \"length=%d, result=%s, time=%d ns%n\",\n input.length(), result, elapsed\n );\n }\n }\n}\n```\n\n这个程序的用途是观察趋势,不是生成跨机器稳定的性能结论。结果会受到以下因素影响:\n\n- JDK 实现;\n- CPU;\n- JVM 预热和 JIT 编译;\n- 垃圾回收;\n- 系统负载;\n- 输入长度。\n\n生产评估应使用固定数据集、多次预热、独立测量和超时保护。不能只测一个成功样本,因为许多回溯灾难恰恰发生在“几乎匹配但最终失败”的输入上。\n\n---\n\n## 19. 生产代码中的边界设计\n\n### 19.1 把校验拆成结构和业务规则\n\n例如,邮箱、日期、用户名等字段不应仅靠一个巨大正则完成所有语义校验。可以先做:\n\n1. 长度限制;\n2. 字符集限制;\n3. 基本结构匹配;\n4. 类型解析和范围校验。\n\n日期示例:\n\n```java\nprivate static final Pattern DATE =\n Pattern.compile(\"\\\\A(?\u003cyear>\\\\d{4})-(?\u003cmonth>\\\\d{2})-(?\u003cday>\\\\d{2})\\\\z\");\n```\n\n该模式能识别 `2025-02-31` 的结构,但不能证明它是有效日期。真正的日期合法性应交给 `java.time`:\n\n```java\nimport java.time.LocalDate;\nimport java.time.DateTimeException;\n\nstatic boolean isValidDate(String text) {\n Matcher matcher = DATE.matcher(text);\n if (!matcher.matches()) {\n return false;\n }\n\n try {\n LocalDate.of(\n Integer.parseInt(matcher.group(\"year\")),\n Integer.parseInt(matcher.group(\"month\")),\n Integer.parseInt(matcher.group(\"day\"))\n );\n return true;\n } catch (DateTimeException | NumberFormatException e) {\n return false;\n }\n}\n```\n\n正则负责文本结构,日期 API 负责日期语义。这样的边界比把闰年、月份天数全部塞进一个表达式更可靠。\n\n### 19.2 明确 null 策略\n\n`Pattern.matcher(null)` 会失败并抛出 `NullPointerException`,不会返回 `false`。如果输入来自请求、配置或数据库,是否允许 `null` 应由调用方明确处理:\n\n```java\nstatic boolean isUsername(String value) {\n return value != null && USERNAME.matcher(value).matches();\n}\n```\n\n这属于 API 调用边界,不是正则语义本身。\n\n### 19.3 不要让用户任意提供模式\n\n如果产品确实需要用户输入搜索条件,应优先提供有限语法,例如:\n\n- 固定字段;\n- 白名单操作符;\n- 最大长度;\n- 最大分支数;\n- 禁止反向引用、嵌套量词和复杂后顾;\n- 将用户条件转换成安全的字面匹配或受限 AST。\n\n直接把用户输入交给 `Pattern.compile`,不仅有 ReDoS 风险,还可能产生与用户预期不同的语义,例如 `.`、`.*`、反向引用和替换字符串中的 `$1`。\n\n---\n\n## 20. 核心关系总结\n\nJava 正则表达式的关键因果链可以概括为:\n\n1. 正则字符串先经过 Java 字符串转义;\n2. `Pattern.compile` 解析并编译模式;\n3. `Pattern.matcher` 创建有状态的 `Matcher`;\n4. `matches`、`lookingAt` 和 `find` 选择不同的匹配范围;\n5. 分组保存匹配边界,重复分组通常只保留最后一次捕获;\n6. 贪婪量词、惰性量词和分支产生不同的候选路径;\n7. 后续失败会触发回溯,重新选择之前的路径;\n8. 嵌套重复和重叠分支会使候选路径呈指数级增长;\n9. 攻击者控制长输入时,这种增长可能形成 ReDoS;\n10. 解决方案必须同时包含模式重写、输入边界和执行隔离,而不是只缓存 `Pattern` 或简单依赖线程中断。\n\n`Pattern` 解决的是模式的编译和复用,`Matcher` 解决的是一次具体输入上的状态化执行;分组解决的是结果定位和引用,回溯解释了表达式如何在失败时寻找替代路径,而 ReDoS 则是这些执行特征在不受控输入下产生的安全后果。只有把这几层放在同一个模型中,才能既写出语义正确的正则,也能判断它是否适合运行在生产请求路径上。\n\n---\n\n## 系列导航与关联阅读\n\n- 系列入口:[Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付](https://wrblog.cn/articles/ee791cb5-6de0-5903-b22b-047cf231e387)\n- 上一篇:[Java Optional:创建、组合、空值边界、性能与错误用法](https://wrblog.cn/articles/905505e2-eb7d-5bce-8744-29c0cbb6a9a4)\n- 下一篇:[Java 国际化:Locale、ResourceBundle、日期数字和消息格式](https://wrblog.cn/articles/fd6ce49d-9c5e-52f6-8d31-669062a3c1f3)\n\n## 官方资料\n\n- [Java SE 25 Documentation](https://docs.oracle.com/en/java/javase/25/)\n- [Java Language Specification](https://docs.oracle.com/javase/specs/jls/se25/html/)\n\n> 本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。\n","tags":["Java","Java 25 LTS","正则"],"likeCount":0,"commentCount":0,"createdByUserId":"10000000000","createdByDisplayName":"小郝","createdByAvatar":"/public/profile/10000000000/avatar/2026/08/04/db02b81c-42f2-441b-8a80-61370cdbb581.webp","publishTime":"2026-09-01 13:51:20","updateTime":"2026-09-01 13:51:20"}},"status":200,"locale":"zh-CN","theme":"light"}