Java 正则表达式很慢怎么办:Pattern 预编译只快 2~5 倍,真正卡死线程的是回溯(JDK 8、11、17 实测)

0 阅读8分钟

导读:掘金后端热榜这两天排第一的是一篇讲正则 Pattern 预编译的文章,知乎上"为什么使用正则表达式会慢?"也有 13 万浏览。网上关于正则性能的说法很多,有的说一定要预编译,有的说 String.split 也会编译正则,有的说正则能把 CPU 打满。我在本机用 JDK 17 跑了每种写法 100 万次,又拿几个经典的"回溯炸弹"在 JDK 8、11、17 上各跑了一遍,看看哪些说法成立、哪些早就不成立了。


一、先说清楚:正则慢在哪

Java 的正则分两步:先把正则字符串编译成一个 Pattern 对象(可以理解成把规则翻译成一台状态机),再拿这台机器去匹配字符串。

慢可能出在两个地方:

  1. 编译:String.matches、replaceAll 这类方法每调一次都要重新编译一遍。编译一次只要几百纳秒,平时感觉不到,放在每秒几万次的循环里就是白花的 CPU
  2. 匹配时回溯:有些正则写法遇到特定输入,匹配引擎会把所有可能的组合挨个试一遍,耗时随字符串长度指数增长。几十个字符就能让一个线程卡几秒甚至几分钟,这就是常说的 ReDoS(正则表达式拒绝服务)

不管会怎样:第一种只是慢一点,接口 RT 多几百纳秒到几微秒;第二种是事故级别的,一个用户提交一段精心构造的字符串,Tomcat 线程就被占住出不来,请求一多线程池耗尽。

什么时候值得动它:

  • 校验、拆分、替换写在高频循环里(批量导入、日志解析、每条消息都要过一遍的 MQ 消费者),值得改成预编译
  • 正则作用在用户能控制的输入上(注册表单、搜索框、上传文件名),要检查有没有回溯风险
  • 还在 JDK 8 上的项目,回溯问题比 11、17 严重得多,下面第四节有对比

二、测试环境和方法

项值
机器Apple M1 Pro,macOS
JDK17.0.12(arm64)为主;回溯对比另用 11.0.2 和 1.8.0_321
方法每种写法跑 100 万个字符串,先预热 5 轮,再跑 5 轮取最快一轮,换算成每次调用的纳秒数

⚠️ 没用 JMH,是手写循环加预热。绝对值会有几纳秒到几十纳秒的误差,看倍数关系就行。另外本机的 JDK 8 是 x86_64 版本,靠 Rosetta 转译跑,它的毫秒数比原生慢,只适合看"随长度怎么增长",不能和 17 直接比绝对值。


三、编译这一层:预编译能快多少

3.1 String.matches 每次都在重新编译

String.matches 的源码就一行(JDK 17 java/lang/String.java):

public boolean matches(String regex) {
    return Pattern.matches(regex, this);
}

Pattern.matches 内部就是 Pattern.compile(regex).matcher(input).matches(),没有任何缓存。所以下面两种写法完全等价,都会每次编译:

// 两种写法等价:每次调用都编译一次正则
boolean ok1 = phone.matches("^1[3-9]\\d{9}$");
boolean ok2 = Pattern.compile("^1[3-9]\\d{9}$").matcher(phone).matches();

预编译的写法是把 Pattern 提成常量,Pattern 是不可变对象,多线程共用一个没问题:

// Pattern 线程安全,放成 static final 全局复用
private static final Pattern MOBILE = Pattern.compile("^1[3-9]\\d{9}$");

boolean ok = MOBILE.matcher(phone).matches();

⚠️ 能共用的是 Pattern,不是 Matcher。Pattern 的 Javadoc 写明 Matcher 实例不是线程安全的,别把 Matcher 也提成成员变量。

3.2 实测:手机号校验 100 万次

写法每次耗时
phone.matches(regex)138.7 ns
Pattern.compile(regex).matcher(phone).matches()139.4 ns
static final Pattern + matcher().matches()61.4 ns
手写 for 循环判断每个字符8.3 ns

预编译快了 2.3 倍。手写循环又快了 7 倍,但可读性差,一般不值得,除非这段代码真在火焰图上排得很靠前。

3.3 正则越长,预编译收益越大

编译耗时和正则的长度、复杂度有关。我挑了四个业务里常见的校验正则,比较"每次编译 + 匹配"和"预编译后只匹配",各跑 50 万次:

正则长度每次编译 + 匹配预编译倍数
手机号 ^1[3-9]\d{9}$13151.8 ns71.8 ns2.1
邮箱48441.0 ns208.1 ns2.1
18 位身份证(含年月日校验)74729.9 ns160.7 ns4.5
日期时间 yyyy-MM-dd HH:mm:ss.SSS98982.7 ns192.7 ns5.1

测试用的四个正则如下,方便你在自己机器上对照:

// 四个校验正则(Java 字符串写法)
String mobile   = "^1[3-9]\\d{9}$";
String email    = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$";
String idCard   = "^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[\\dXx]$";
String dateTime = "^(\\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[ T]([01]\\d|2[0-3]):([0-5]\\d):([0-5]\\d)(\\.\\d{1,3})?$";

结论:预编译一定要做,但别指望它有数量级的提升,常见校验正则大概是 2~5 倍。一次编译的成本在 80~800 纳秒之间,一个接口里调几次根本感觉不到;只有在每秒几十万次的循环里才明显。

3.4 匹配本身重的时候,预编译几乎没用

反过来,如果匹配本身就很花时间,编译那几十纳秒占比就小了:

写法(每个字符串约 40 个字符)每次耗时
s.split("\\s+")402.4 ns
static final Pattern WS = Pattern.compile("\\s+") + WS.split(s)413.2 ns
s.replaceAll("\\d", "*")386.1 ns
static final Pattern DIGITS + DIGITS.matcher(s).replaceAll("*")375.5 ns

split("\\s+") 预编译之后反而略慢了几纳秒,这是测量误差范围内的波动,结论是基本没差别。这时候要优化,得从"少匹配几次"或"别用正则"入手。


四、一个不用改的地方和一个该改的地方

4.1 split 单个字符:JDK 已经帮你优化了

很多文章说 String.split 也会每次编译正则,这话只对一半。JDK 17 的 String.split(String regex, int limit) 开头有一段快速路径,注释原文是:

/* fastpath if the regex is a
 * (1) one-char String and this character is not one of the
 *     RegEx's meta characters ".$|()[{^?*+\\", or
 * (2) two-char String and the first char is the backslash and
 *     the second is not the ascii digit or ascii letter.
 */

翻译过来:按单个普通字符拆(逗号、分号、冒号、制表符),或者按"反斜杠 + 一个非字母数字字符"拆("\\|"、"\\."),直接走 indexOf 循环,根本不编译正则。

写法(4 段的字符串)每次耗时走快速路径
s.split(",")103.3 ns是
按竖线拆(写法见下)98.5 ns是
竖线写成字符类(写法见下)305.9 ns否,字符类要编译
按逗号或分号拆(正则见下)555.9 ns否,真正的正则

第二行用的是 s.split("\\|"),测试字符串换成了竖线分隔;第三行是同样的数据用 s.split("[|]");最后一行用的是 s.split(",|;"),逗号或分号都拆,这是真正的正则,每次都要编译。竖线和字符类这两行是另一轮测的,只预热了一遍、测了一轮,数字看量级就好。

所以 split(",")、split("\\|") 不用改成预编译,改了也不会更快。要注意的是 split("|"):竖线是元字符,不走快速路径,而且语义也错了(它表示"空串或空串",会把字符串拆成一个个字符)。

💡 我也看了本机 JDK 8(1.8.0_321)的 String.java,同样有这段快速路径,JDK 8 的项目一样适用。

4.2 replace 和 replaceAll:只替换固定文本就别用 replaceAll

写法每次耗时
s.replace("138", "***")11.3 ns
s.replaceAll("138", "***")109.7 ns

两行代码结果一样,差了快 10 倍。replace(CharSequence, CharSequence) 按字面量替换,不走正则;replaceAll 不管你传的是不是正则,都会编译一遍。代码里只是想把固定文本换掉的 replaceAll,可以放心改成 replace,它同样是全部替换,不是只换第一个。

⚠️ 改之前看一眼参数里有没有 .、$、\\ 这些字符。replaceAll(".", "") 会把所有字符都删光,replace(".", "") 只删点号,两者语义不同。


五、真正卡死线程的:回溯

5.1 什么是灾难性回溯

拿 ^([a-zA-Z0-9]+\s?)*$ 举例,它想表达"若干个单词,单词之间可以有一个空格"。问题在于括号里有 +,括号外又有 *,同一串字母可以被切成很多种组合:"aaaa" 可以是一个单词,也可以是 "a"+"aaa"、"aa"+"aa"、"a"+"a"+"aa"……

输入是能匹配的字符串时没事,引擎找到一种切法就返回。输入最后差一个字符匹配不上(比如一串字母后面跟个感叹号),引擎就会把所有切法都试一遍才敢说"不匹配",组合数随长度指数增长。

5.2 实测:同一个正则,JDK 8 和 17 天差地别

输入统一是 n 个字母(或数字)加一个匹配不上的结尾字符,单次匹配耗时,超过 4 秒就停止加长。先列出测的 6 个正则:

① ^([a-zA-Z0-9]+\s?)*$                          单词加可选空格,嵌套量词
② ^([a-zA-Z0-9_\-.]+)+@[a-zA-Z0-9]+\.[a-z]+$    网上常见的邮箱写法,嵌套量词
③ ^(a|a?)+$                                      分支重叠
④ ^(\d+|\d+\.)+$                                分支重叠,输入是数字
⑤ ^(.*a){12}$                                    固定次数重复,里面有 .*
⑥ ^(\w|\d)+$                                     分支重叠
正则JDK 8(n=16 → 28)JDK 11(n=28)JDK 17(n=28)
①23 → 145 → 625 → 2384 → 8756 ms0 ms0 ms
②33 → 312 → 867 → 3611 → 14387 ms0 ms0 ms
③35 → 104 → 1708 → 6888 ms(n=26)0 ms0 ms
④8 → 93 → 1560 → 5905 ms(n=26)0 ms0 ms
⑤18 → 66 → 636 → 1773 → 4660 ms1835 ms1034 ms
⑥0 ms0 ms0 ms

JDK 8 那一列的 n 依次是 16、20、24、26、28。几点结论:

  1. 在 JDK 8 上,网上那些"回溯炸弹"都是真的。一个 28 个字符的字符串就能让常见的邮箱正则跑 14 秒(Rosetta 转译让绝对值偏大,但每加两个字符翻几倍的趋势不会变)
  2. JDK 11 和 17 把嵌套量词这一大类修好了,同样的输入都是 0 毫秒。我没查到具体是哪个版本、哪个改动做的,只能说 11.0.2 已经是这样
  3. (.*a){n} 这种"重复 n 次、每次里面又有 .*"的写法,11 和 17 照样指数增长,28 个字符就要 1 秒,长度再加几个就是几十秒
  4. ⑥ 在三个版本上都是 0 毫秒,说明不是所有分支重叠都会爆,和具体写法有关
  5. 所以"升级到 JDK 17 就不用管回溯了"这话不成立,只是踩中的概率小了

5.3 怎么改:让每个字符只有一种走法

回溯的根源是"同一段文本有多种匹配方式"。改写的思路是让各部分的字符集互不重叠,每个字符只能被一个地方吃掉。

拿 ^(.*a){12}$ 来说,它的意思是"至少 12 个 a,并且以 a 结尾"。.* 也能吃 a,所以每个 a 归哪一段有很多种分法。改成 [^a]*a,"非 a 的字符"和"a"不重叠:

// 原写法:.* 也能匹配 a,每个 a 归哪一段有很多种分法
Pattern slow  = Pattern.compile("^(.*a){12}$");
// 改写:前 11 段用 [^a]*a,每段恰好吃一个 a;最后一段 .*a 保证"至少 12 个、以 a 结尾"
Pattern fixed = Pattern.compile("^([^a]*a){11}.*a$");

改正则最怕改完语义变了。我拿几组输入对照了一下两个写法的结果:

输入原写法改写
12 个 atruetrue
xa 重复 12 次truetrue
13 个 atruetrue
40 个 atruetrue
11 个 afalsefalse
12 个 a 再加 bfalsefalse

第一版我改成了 ^([^a]*a){12}$,结果 13 个 a 时返回 false,原写法是 true,语义变了,才换成上面这个。改正则之前,先把正反例写成单元测试再动手。

40 个 a 加一个 b,改写后的版本 119 微秒就返回了。

另外两个常用手段:

手段写法说明
占有量词X*+、X++吃进去的字符不再吐出来,不回溯
原子组(?>X)组内匹配完就锁定,不回溯

这两个会改变语义:吃进去不吐,可能导致本来能匹配的输入匹配失败,用之前一样要对照测试。

5.4 兜底:给正则匹配加超时

Java 的正则 API 没有超时参数。有个常用的办法:Matcher 每读一个字符都会调 CharSequence.charAt(),自己包一层 CharSequence,在 charAt 里检查时间,超时就抛异常:

// 给正则匹配加超时:超过截止时间就在 charAt 里抛异常,打断匹配
public final class DeadlineCharSequence implements CharSequence {
    private final CharSequence s;
    private final long deadlineNanos;

    public DeadlineCharSequence(CharSequence s, long deadlineNanos) {
        this.s = s;
        this.deadlineNanos = deadlineNanos;
    }

    @Override
    public char charAt(int index) {
        if (System.nanoTime() > deadlineNanos) {
            throw new IllegalStateException("regex timeout");
        }
        return s.charAt(index);
    }

    @Override
    public int length() {
        return s.length();
    }

    @Override
    public CharSequence subSequence(int start, int end) {
        return new DeadlineCharSequence(s.subSequence(start, end), deadlineNanos);
    }

    @Override
    public String toString() {
        return s.toString();
    }
}

调用时把输入包一层,给 200 毫秒:

// 最多匹配 200 毫秒,超时抛 IllegalStateException
Pattern slow = Pattern.compile("^(.*a){12}$");
String input = "a".repeat(40) + "b";
long deadline = System.nanoTime() + 200_000_000L;
boolean ok = slow.matcher(new DeadlineCharSequence(input, deadline)).matches();

实测在 JDK 17 上,40 个 a 加 b 这个输入,200 毫秒时准时抛出 regex timeout。不加这层的话,这个输入按上表的增长速度要跑很久,我没等它跑完。

⚠️ 每读一个字符都调一次 System.nanoTime(),有额外开销。只建议用在"正则作用于用户输入、而且正则是配置出来的或者你改不动"的地方,自己写死的正则还是优先改写。


六、速查表

场景建议
高频调用的 matches / replaceAll提成 static final Pattern,常见校验快 2~5 倍
Matcher不要共享,每次 pattern.matcher(s) 新建
split(",")、split("\\.") 这类单字符或转义单字符走快速路径,不用改
字符类、split("\\s+") 这类真正的正则会编译正则,高频时可预编译,但匹配本身重的话收益很小
替换固定文本用 replace,比 replaceAll 快约 10 倍
嵌套量词 (X+)*、(X+)+JDK 8 上必改;11、17 上本文测的几个已不会指数增长
(.*X){n}、.* 和后面的字符重叠11、17 上依然会指数增长,改成互不重叠的字符集
正则作用在用户输入上限制输入长度 + 必要时加 DeadlineCharSequence 超时

七、常见问题

Q:Spring 的 @Pattern 校验注解要不要管?

编译开销这次没测,不下结论。回溯风险是一样的:用户提交的字段就是输入,注解里的正则照样要按第五节检查写法。

Q:有没有不会回溯的正则引擎?

Google 的 RE2/J 是线性时间的引擎,代价是不支持反向引用这类特性。我没在项目里用过,只提一下这个方向。

Q:为什么我在 JDK 17 上测网上的"回溯炸弹"都很快?

就是第五节表格里那几行:嵌套量词那一类在 11 和 17 上已经不会指数增长了,网上很多示例是 JDK 8 时代写的。换成 ^(.*a){12}$ 这种试试。

Q:把正则改成手写循环值不值?

手机号那个例子快了 7 倍,但一次也就省 50 纳秒。除非火焰图里它真的排在前面,否则正则的可读性更重要。


参考链接


你们项目现在跑在 JDK 8 还是 11/17 上?代码里有没有 ([a-zA-Z0-9_\-.]+)+@ 这种写法的邮箱正则?评论区说说你的 JDK 版本和遇到过的慢正则,我挑典型的补进 FAQ。

觉得有用的话点个赞、收个藏,下次排查接口卡顿时翻得到。