线上 CPU 飙到 98% 还没人调用?jstack 三分钟定位到一行 split 的正则回溯 周四下午 14:23告警群里炸了订单服务的 CPU 使用率从 15% 直线拉到 98%两台实例都红了。登服务器一看top里 java 进程占了 380% CPU但接口 QPS 没涨、内存没异常、日志没有任何报错——这就很诡异了没人调用它它凭什么跑这么满这轮排查花了三个小时最后定位到的问题说出来都脸红一行正则写错了。过程挺典型的写下来给做 Java 后端的朋友当参考。第一步top 确认进程线程级定位先top -Hp看线程发现是多个线程同时 99%不是单个线程死循环单个的话更可能是 GC 或者某个热点方法多个一起飙更像并行任务集体卡死。# 先找到 java 进程 pidtop-c-p$(pgrep-forder-service)# 线程级视图记录 CPU 最高的几个线程号十进制top-Hp23871# 把线程号转十六进制jstack 里对应 nidprintf%x\n25130# 输出 0x623a我记下三个 CPU 最高的线程25130、25156、25199转成十六进制后准备 jstack。第二步jstack 抓堆栈连续抓两次jstack抓的是瞬间快照单次看不出趋势我抓了三次间隔 10 秒jstack23871/tmp/stack1.txtsleep10jstack23871/tmp/stack2.txtsleep10jstack23871/tmp/stack3.txtgrep-A300x623a/tmp/stack1.txt三次快照里0x623a线程的堆栈几乎一模一样都停在同一个位置pool-3-thread-7 #87 prio5 os_prio0 tid0x00007f... nid0x623a runnable java.lang.String.split(String.java:1234) java.util.regex.Pattern.split(Pattern.java:1098) com.qkl.order.service.OrderParseService.parse(OrderParseService.java:88) ...线程停在String.split里还是 runnable 状态——这就是重点。正则、split 这类 CPU 密集操作如果每次都一样基本可以断定是死循环或者灾难性回溯。第三步定位到代码问题在 split 里的正则找到OrderParseService.parse第 88 行代码是// 解析订单备注里的标签格式如#加急#代收#大件String[]tagsremark.split(#);看起来人畜无害问题出在split(#)的另一个重载当备注字符串是以 # 开头比如#加急#代收split返回数组第一位是空串业务代码里tag.isEmpty()过滤掉了没毛病。真正炸的是另一条路径——备注里出现连续井号或者超长无分隔符内容时JDK 的Pattern.split在处理某些输入下会走回溯匹配最坏情况 O(n²) 甚至更糟。我们线上那条备注有 3.2 万字符用户从 Excel 里整段粘进来的split直接把这台 4 核机器的 CPU 烧穿了。具体来说split(#)底层编译成Pattern.compile(#).split()对 3 万字符跑一遍匹配本身不至于 99%。但我们的代码是循环里对每一条备注都 split某几个大字符串触发了正则引擎的回溯加上线程池里 8 个线程同时在干这活CPU 就被吃满了。确认手段把那条备注单独截出来本地跑一个while循环复现一跑 CPU 就 100%复现成功。第四步修复一行 split 换成 indexOf正则处理大字符串的风险是它的不确定性而split的这种简单场景完全可以用确定性的indexOf代替privateListparseTags(Stringremark){ListtagsnewArrayList();if(remarknull||remark.isEmpty()){returntags;}intstart0;intidx;while((idxremark.indexOf(#,start))0){if(idxstart){tags.add(remark.substring(start,idx));}startidx1;}if(startremark.length()){tags.add(remark.substring(start));}returntags;}indexOfsubstring是纯线性扫描O(n) 封顶3 万字符也就毫秒级。同时把入口加了长度校验备注字段超过 2000 字符直接截断业务上备注就没理由超过这个数从源头堵住大字符串。改完重启CPU 回到 15%再没飙过。事后复盘这种问题靠 jstack 定位不难难的是看到String.split不要想当然觉得它安全——它不是indexOf它是正则引擎正则引擎就有回溯风险。正则回溯到底是怎么发生的懂点正则的人都知道回溯但知道和被它烧掉 CPU是两回事这里把机制讲清楚。Pattern.split(#)编译出的匹配器在扫描字符串时如果当前位置匹配不上它会尝试所有可能的分支再退回重来——这就是回溯。普通字符串几乎不存在分支几微秒扫完但像连续井号这种分隔符、或者字符串里出现大量需要丢弃的候选位时回溯次数会随输入长度呈平方级增长。3 万字符的备注最坏情况要尝试上亿次匹配状态CPU 直接被吃死。更隐蔽的是split返回数组本身也要分配内存3 万字符拆出几百个片段每个片段一个 String 对象一次调用就要几十 MB 的临时对象GC 压力翻倍。所以这种大字符串 正则 高并发组合任何一个单拎出来都不致命三个叠一起就是事故。线上这种事故往往不报错、不告警日志只有 CPU 曲线和用户投诉能让你警觉。顺带说个排查技巧jstack 里看到线程卡在java.util.regex或者String.split且是 runnable几乎可以闭眼断定是回溯问题不用再看别的。总结清单可直接抄排查套路top -Hp拿线程号 →printf %x转十六进制 →jstack连续抓三次对比 → 堆栈位置一致即死循环/回溯热点。String.split底层是Pattern.compile().split大量数据场景换成indexOf/substring或StringTokenizer。正则回溯是 CPU 飙升的隐形杀手线上输入不可控时能不用正则就别用。入口永远校验长度和格式异常数据在源头截断别指望后面层层兜底。告警响应顺序QPS 没涨 CPU 满 多线程同时高 → 优先怀疑正则/序列化/死循环而不是 GCGC 一般伴随内存异常或单线程热点。