Java / JVM 排查 列表全部文章
Java / JVM 排查

CPU 飙到 800%:一条“无辜”的正则,把 8 核机器吃得只剩渣

告警说 Java 进程 CPU 800%,接口全部超时。top -Hp → printf 转十六进制 → jstack,三步定位到一条正则的灾难性回溯。附完整命令、根因分析和三种修法。

更多

先交代背景:某个周四下午,我正端着咖啡准备摸鱼,钉钉群里“叮”地弹出一条告警——订单服务 CPU 使用率 800%(8 核机器,满载)。接着是第二条:接口 P99 超时。第三条:产品经理的“在吗”。

这类问题我后来总结成一句话:CPU 高不可怕,可怕的是你不知道是哪个线程在烧。这篇把排查过程完整记下来,既当备忘录,也给以后的自己抄作业。

一、现象:机器在“发烧”,但没人认领

先别急着重启。重启是个好按钮,但按下去之后现场就没了,下次它还会再烧。我的第一个动作是:保留现场,再决定要不要摘流量。登上机器,先看整体:

$ top
top - 14:32:07 up 41 days,  3:12,  1 user,  load average: 9.87, 8.42, 5.10
%Cpu(s): 96.3 us,  1.2 sy,  0.0 ni,  2.1 id,  0.0 wa,  0.0 hi,  0.4 si,  0.0 st
  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
 8231 app       20   0 8123456   3.1g  24812 S 798.3 19.6  41:33.12 java

两个信息:us(用户态)96%,说明是应用代码在算,不是系统调用、也不是 IO 等待(wa 是 0);元凶是 PID 8231 这个 Java 进程。这一步已经排除了一大票“磁盘慢”“网络抖”的怀疑。

二、排查路线图

Java 进程 CPU 高,我固定走下面这条路,背下来能省很多时间:

① top找到高 CPU 的 PID ② top -Hp PID找到高 CPU 的线程 TID ③ printf %xTID 转十六进制 nid ④ jstack按 nid 找线程栈 ⑤ 看栈顶定位到代码行 若线程名是 “VM Thread / GC task thread” → 不是业务代码,去看 GC(见 Full GC 那篇)

三、动手:从进程一路钻到代码行

第 1 步:找出烧得最凶的线程

$ top -Hp 8231 -n 1 | head -15
  PID USER   PR  NI    VIRT    RES  SHR S  %CPU %MEM     TIME+ COMMAND
 8479 app    20   0 8123456   3.1g 24812 R  99.9 19.6   6:12.44 http-nio-8080-e
 8483 app    20   0 8123456   3.1g 24812 R  99.7 19.6   6:11.90 http-nio-8080-e
 8490 app    20   0 8123456   3.1g 24812 R  99.7 19.6   6:10.31 http-nio-8080-e
 ...(一共 8 个 http-nio-8080-exec 线程,全部 99%)

注意,不是一个线程烧,而是 8 个 Tomcat 工作线程同时满载,而且 TIME+ 都是 6 分钟左右——它们不是“忙”,是卡在某个计算里出不来了。这个细节很重要:线程时间累计和“开始出问题的时间”对得上,说明是同一批请求把线程一个个拖进去的。

第 2 步:线程号转十六进制

jstack 里线程 id 叫 nid,是十六进制。top 里显示的是十进制,要转一下(这一步我每次都忘,所以写进备忘录):

$ printf '%x\n' 8479
211f

第 3 步:jstack 找栈

$ jstack 8231 | grep -A 25 'nid=0x211f'
"http-nio-8080-exec-3" #41 daemon prio=5 os_prio=0 tid=0x00007f3c2c0a8800 nid=0x211f runnable [0x00007f3c0d7f5000]
   java.lang.Thread.State: RUNNABLE
	at java.util.regex.Pattern$Loop.match(Pattern.java:4785)
	at java.util.regex.Pattern$GroupTail.match(Pattern.java:4717)
	at java.util.regex.Pattern$BranchConn.match(Pattern.java:4568)
	at java.util.regex.Pattern$CharProperty.match(Pattern.java:3777)
	at java.util.regex.Pattern$Loop.match(Pattern.java:4785)
	at java.util.regex.Pattern$GroupTail.match(Pattern.java:4717)
	... (上面这几行反复出现几十次)
	at java.util.regex.Matcher.matches(Matcher.java:604)
	at com.demo.order.util.AddressParser.isValid(AddressParser.java:37)
	at com.demo.order.service.OrderService.create(OrderService.java:118)

看到 java.util.regex.Pattern$Loop.match 一层套一层,脑子里应该立刻响起警报:正则灾难性回溯(Catastrophic Backtracking)。业务栈帧在最下面:AddressParser.isValid,第 37 行。

顺手多抓几次栈(间隔 5 秒,抓 3 份),确认 8 个线程都停在同一处:一份 dump 只是快照,连续几份都卡在同一行,才叫“证据”。

四、根因:一条看起来人畜无害的正则

翻代码,第 37 行是这样的(已脱敏改写):

// 校验收货地址:由汉字、字母、数字、括号、连字符组成
private static final Pattern ADDR = Pattern.compile("^([\\u4e00-\\u9fa5A-Za-z0-9()()\\-]+\\s?)+$");

public static boolean isValid(String addr) {
    return ADDR.matcher(addr).matches();
}

问题出在 (X+\s?)+ 这个结构:内层 + 和外层 + 都能匹配同一段字符。当输入是合法的还好,一旦输入的末尾有个不匹配的字符(比如一个 emoji 或全角感叹号),引擎会把“一长串字符怎么切分给内外两层”的所有组合都试一遍——组合数是指数级的。

那天的“肇事”订单,收货地址是这样的:

浙江省杭州市西湖区文三路XXXX号XXXX大厦XXXX室XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX😀

一个 40 多字符的地址加个笑脸,正则引擎就要试几十亿种切法。而 Java 的正则引擎是回溯型的,没有超时机制,一个请求线程就能被它无限期霸占。Tomcat 默认 200 线程,我们这个服务配了 8 个 worker 相当紧张;用户还很勤奋地点了好几次“提交订单”——于是线程一个个陷进去,CPU 800%。

顺便一提,这不是我们独创的姿势。Stack Overflow 在 2016 年因为一个帖子里多余的空白字符触发了正则回溯,站点故障了 34 分钟;Cloudflare 在 2019 年也因为 WAF 规则里的一条正则,让全球边缘节点 CPU 耗尽——踩这个坑的人,从来都不孤单。

五、止血与修复

止血(先让业务活过来)

  1. 网关层临时把该接口限流到很低,让剩余线程别再被拖进去;
  2. 已经陷进去的线程不会自己恢复,正则不响应中断——只能滚动重启该实例。重启前,我把 3 份 jstack 和 top -Hp 的输出保存到了文件里;
  3. 重启后 CPU 立刻回落到 12%,接口恢复。

根治(三选一,我全用了)

// 方案 1:改写正则,避免嵌套量词(最好,直接消灭问题)
//    字符集本身就允许空白,不需要再套一层 (...\s?)+
Pattern ADDR = Pattern.compile("^[\\u4e00-\\u9fa5A-Za-z0-9()()\\-\\s]{1,100}$");

// 方案 2:正则前先做长度校验,把输入规模卡住
if (addr == null || addr.length() > 100) return false;

// 方案 3:给正则加“超时保险丝”——CharSequence 包一层,超时抛异常
static class TimeLimitedCharSequence implements CharSequence {
    private final CharSequence inner; private final long deadline;
    TimeLimitedCharSequence(CharSequence inner, long timeoutMs) {
        this.inner = inner; this.deadline = System.currentTimeMillis() + timeoutMs;
    }
    public char charAt(int i) {
        if (System.currentTimeMillis() > deadline) throw new IllegalStateException("regex timeout");
        return inner.charAt(i);
    }
    public int length() { return inner.length(); }
    public CharSequence subSequence(int s, int e) { return new TimeLimitedCharSequence(inner.subSequence(s, e), deadline - System.currentTimeMillis()); }
    public String toString() { return inner.toString(); }
}

方案 3 利用了一个冷门事实:正则引擎每读一个字符都会调用 charAt,所以从这里抛异常,就能中断回溯。JDK 21+ 也可以考虑换用带超时能力的三方库(如 RE2/J)。

六、复盘:这类坑怎么提前躲开

做法为什么
用户输入进正则前先限长度指数级复杂度在小输入下不显眼,长度是最便宜的保险
Code Review 盯住嵌套量词 (a+)+ (a|a)+ (.*)*这三种写法几乎必然存在回溯风险
所有“外部输入 → 正则”的地方,用长文本 + 尾部非法字符做一次压测回溯只在“匹配失败”的路径上爆炸,正常用例永远测不出来
线程池做隔离,别让一个接口拖死整个 Tomcat问题永远会发生,重要的是别扩散
把“top -Hp → printf → jstack”写进值班手册凌晨三点没人记得住十六进制转换

一句话备忘:CPU 高 → 先分清 us / sy / wa → top -Hp 找线程 → printf '%x' → jstack 看栈顶。栈顶是 regex,别怀疑,直接看正则。

最后说句掏心窝子的:正则这东西,写的时候觉得自己是天才,出问题的时候发现自己是段子手。从那以后,我看到正则里有两层加号,眼睛都会自己抽搐一下。

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人