导读:掘金后端热榜这两天排第一的是一篇讲正则 Pattern 预编译的文章,知乎上"为什么使用正则表达式会慢?"也有 13 万浏览。网上关于正则性能的说法很多,有的说一定要预编译,有的说 String.split 也会编译正则,有的说正则能把 CPU 打满。我在本机用 JDK 17 跑了每种写法 100 万次,又拿几个经典的"回溯炸弹"在 JDK 8、11、17 上各跑了一遍,看看哪些说法成立、哪些早就不成立了。
一、先说清楚:正则慢在哪
Java 的正则分两步:先把正则字符串编译 成一个 Pattern 对象(可以理解成把规则翻译成一台状态机),再拿这台机器去匹配字符串。
慢可能出在两个地方:
- 编译 :
String.matches、replaceAll这类方法每调一次都要重新编译一遍。编译一次只要几百纳秒,平时感觉不到,放在每秒几万次的循环里就是白花的 CPU - 匹配时回溯:有些正则写法遇到特定输入,匹配引擎会把所有可能的组合挨个试一遍,耗时随字符串长度指数增长。几十个字符就能让一个线程卡几秒甚至几分钟,这就是常说的 ReDoS(正则表达式拒绝服务)
不管会怎样:第一种只是慢一点,接口 RT 多几百纳秒到几微秒;第二种是事故级别的,一个用户提交一段精心构造的字符串,Tomcat 线程就被占住出不来,请求一多线程池耗尽。
什么时候值得动它:
- 校验、拆分、替换写在高频循环里(批量导入、日志解析、每条消息都要过一遍的 MQ 消费者),值得改成预编译
- 正则作用在用户能控制的输入上(注册表单、搜索框、上传文件名),要检查有没有回溯风险
- 还在 JDK 8 上的项目,回溯问题比 11、17 严重得多,下面第四节有对比
二、测试环境和方法
| 项 | 值 |
|---|---|
| 机器 | Apple M1 Pro,macOS |
| JDK | 17.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):
java
public boolean matches(String regex) {
return Pattern.matches(regex, this);
}
Pattern.matches 内部就是 Pattern.compile(regex).matcher(input).matches(),没有任何缓存。所以下面两种写法完全等价,都会每次编译:
java
// 两种写法等价:每次调用都编译一次正则
boolean ok1 = phone.matches("^1[3-9]\\d{9}$");
boolean ok2 = Pattern.compile("^1[3-9]\\d{9}$").matcher(phone).matches();
预编译的写法是把 Pattern 提成常量,Pattern 是不可变对象,多线程共用一个没问题:
java
// 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}$ |
13 | 151.8 ns | 71.8 ns | 2.1 |
| 邮箱 | 48 | 441.0 ns | 208.1 ns | 2.1 |
| 18 位身份证(含年月日校验) | 74 | 729.9 ns | 160.7 ns | 4.5 |
日期时间 yyyy-MM-dd HH:mm:ss.SSS |
98 | 982.7 ns | 192.7 ns | 5.1 |
测试用的四个正则如下,方便你在自己机器上对照:
java
// 四个校验正则(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) 开头有一段快速路径,注释原文是:
java
/* 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 个正则:
text
① ^([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 ms | 0 ms | 0 ms |
| ② | 33 → 312 → 867 → 3611 → 14387 ms | 0 ms | 0 ms |
| ③ | 35 → 104 → 1708 → 6888 ms(n=26) | 0 ms | 0 ms |
| ④ | 8 → 93 → 1560 → 5905 ms(n=26) | 0 ms | 0 ms |
| ⑤ | 18 → 66 → 636 → 1773 → 4660 ms | 1835 ms | 1034 ms |
| ⑥ | 0 ms | 0 ms | 0 ms |
JDK 8 那一列的 n 依次是 16、20、24、26、28。几点结论:
- 在 JDK 8 上,网上那些"回溯炸弹"都是真的。一个 28 个字符的字符串就能让常见的邮箱正则跑 14 秒(Rosetta 转译让绝对值偏大,但每加两个字符翻几倍的趋势不会变)
- JDK 11 和 17 把嵌套量词这一大类修好了,同样的输入都是 0 毫秒。我没查到具体是哪个版本、哪个改动做的,只能说 11.0.2 已经是这样
(.*a){n}这种"重复 n 次、每次里面又有.*"的写法,11 和 17 照样指数增长,28 个字符就要 1 秒,长度再加几个就是几十秒- ⑥ 在三个版本上都是 0 毫秒,说明不是所有分支重叠都会爆,和具体写法有关
- 所以"升级到 JDK 17 就不用管回溯了"这话不成立,只是踩中的概率小了
5.3 怎么改:让每个字符只有一种走法
回溯的根源是"同一段文本有多种匹配方式"。改写的思路是让各部分的字符集互不重叠,每个字符只能被一个地方吃掉。
拿 ^(.*a){12}$ 来说,它的意思是"至少 12 个 a,并且以 a 结尾"。.* 也能吃 a,所以每个 a 归哪一段有很多种分法。改成 [^a]*a,"非 a 的字符"和"a"不重叠:
java
// 原写法:.* 也能匹配 a,每个 a 归哪一段有很多种分法
Pattern slow = Pattern.compile("^(.*a){12}$");
// 改写:前 11 段用 [^a]*a,每段恰好吃一个 a;最后一段 .*a 保证"至少 12 个、以 a 结尾"
Pattern fixed = Pattern.compile("^([^a]*a){11}.*a$");
改正则最怕改完语义变了。我拿几组输入对照了一下两个写法的结果:
| 输入 | 原写法 | 改写 |
|---|---|---|
| 12 个 a | true | true |
xa 重复 12 次 |
true | true |
| 13 个 a | true | true |
| 40 个 a | true | true |
| 11 个 a | false | false |
| 12 个 a 再加 b | false | false |
第一版我改成了 ^([^a]*a){12}$,结果 13 个 a 时返回 false,原写法是 true,语义变了,才换成上面这个。改正则之前,先把正反例写成单元测试再动手。
40 个 a 加一个 b,改写后的版本 119 微秒就返回了。
另外两个常用手段:
| 手段 | 写法 | 说明 |
|---|---|---|
| 占有量词 | X*+、X++ |
吃进去的字符不再吐出来,不回溯 |
| 原子组 | (?>X) |
组内匹配完就锁定,不回溯 |
这两个会改变语义:吃进去不吐,可能导致本来能匹配的输入匹配失败,用之前一样要对照测试。
5.4 兜底:给正则匹配加超时
Java 的正则 API 没有超时参数。有个常用的办法:Matcher 每读一个字符都会调 CharSequence.charAt(),自己包一层 CharSequence,在 charAt 里检查时间,超时就抛异常:
java
// 给正则匹配加超时:超过截止时间就在 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 毫秒:
java
// 最多匹配 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 17 Pattern 文档(线程安全说明、占有量词、原子组)
- JDK 17 String.split 文档
- OWASP:Regular expression Denial of Service - ReDoS
- RE2/J:Java 版线性时间正则引擎
你们项目现在跑在 JDK 8 还是 11/17 上?代码里有没有
([a-zA-Z0-9_\-.]+)+@这种写法的邮箱正则?评论区说说你的 JDK 版本和遇到过的慢正则,我挑典型的补进 FAQ。觉得有用的话点个赞、收个藏,下次排查接口卡顿时翻得到。