pre: 感谢 九任 对我的支持~
情况回放:
上周预发机器出了一个问题,CPU不定时会近100%满负载运行。重启以后就会恢复,之后又会到达100%,而且不会自恢复。
首先想到的是程序出现了死循环,于是用jstack把栈打印出来,发现业务线程都停在了regex相关的代码上,有死循环的样子。
查看栈,发现一切都是由ClientFilter这个类开始,其使用了matcher.matches()方法。这样一来,就很可能是由于输入了不规范的正则导致的了。于是查看输入日志,发现这么一个输入:
也就是说输入的正则表达式为:******Deliver …,我们的代码会将这种代码规范成:.*.*.*.*.*.*.*Deliver。在java试了一下,试着匹配 "sssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss",果然会假死。
那么问题是:为什么输入这种正则会导致假死?
这里的原因是:java使用的是greedy模式来匹配 .*。为了让分析简单,我们将输入改成:.*.*.*.*D,正则需要匹配的字符串为:abcdefghijklmnopqrstuvwxyz0123456789,共36个字符。首先,我们将正则转换成 ”有限自动机(Finite-State Machine)“
那么greedy模式(可参看:java.util.regex.Pattern.Curly.match0(...),另两个是possessive与lazy,分别对应 + 与 ?)的意思就是:最大可能的匹配当前状态(优先匹配粗的路径),当不能匹配时再回溯配置下一个(虚线所示),直到,回溯到cmin个匹配(对于 .* 这个cmin为0)。比如说 .*D,如果想匹配 testDdev,那么Java首先将 .* 转成 .{0, MAX}(这里的MAX应该是2亿多,具体可以看代码),那么 .{0, MAX} 得到的匹配是(java会自动在string后加上一个终止字符,这个字符只能java.util.regex.Pattern.LastNode匹配):
testDev$
RED: 已匹配的部分
当到最后时,java会调用 next.match(matcher, i, seq)
testDev$
RED: 已匹配的部分BLUE: 回溯部分
显然这里 D 不匹配,所以又需要回溯
testDev$
RED: 已匹配的部分BLUE: 回溯部分
显然这里 e 也不匹配,所以还需要回溯,直到回溯到 D,才会正式进入到下一个状态:
testDev$
RED: {0 MAX} 配置的部分BLUE: 回溯部分GREEN: D 配置的部分
testDdev
RED: 已匹配的部分
如下面的代码所示(java.util.regex.Pattern.Curly.match0(...)):
看了上面的示例我想大家应该有点头绪了。现在我们回到 .*.*.*.*D 这里,其在java内经过Pattern.compile之后是这个样子:
type=0,表示使用的就是greedy方式。那么这里面有4个curly,我们用C1-4代表之。首先是C1满匹配:
abcdefghijklmnopqrstuvwxyz0123456789$
我们省略前面几步,看看回溯到5字符有什么特别
abcdefghijklmnopqrstuvwxyz0123456789$
这时候,C1释放出了5个字符,那么这里就相当于 用 .*.*.*D 去配置6789$,那么老样子C2会首先满匹配
abcdefghijklmnopqrstuvwxyz0123456789$
然后next.match(matcher, i, seq),不匹配,再next.match(matcher, i, seq),‘D’也不匹配。只能回溯,我们看看回溯4个字符是什么样子
abcdefghijklmnopqrstuvwxyz0123456789$
这时就相当于用 .*.*D 去匹配 789$ 了,又满匹配,next又不匹配,再回溯,如下:
abcdefghijklmnopqrstuvwxyz0123456789$
就成了用 .*.*D 去match 89$,当 C2-4 都失败后,C1才会再退一个字符,再进行递归:
abcdefghijklmnopqrstuvwxyz0123456789$
我们到底需要多少步才能将这些数字match完?
设字符长度为n(加上终止符),正则长度为 m(这里是有效节点,如 .* 是一个节点)。从上面的例子,我们能总结出递归的步骤为:
1、若m=1,返回 1;若m>1,步数 + n
2、回溯 i=1到n-1个字符,对于每个i 取 m=m-1, n=n-i 回1,并把所有的结果求合;
总的来说,用数学公式表示的话,就是这个样子:
这里我写了个简单的实现:
(注:这里的 depth 并不是递归深度,而是递归次数,当时搞错了)
private static long findTotalWays(int regexLength, int targetLength) {
++recursionDepth;
if (regexLength == 1) {
return 1;
}
long totalWays = targetLength;
for (int i = 0; i < targetLength; i++) {
totalWays += findTotalWays(regexLength - 1, targetLength - i);
}
return totalWays;
}
好了,现在我们来验证一下我们的结果,通过看jdk源码,我们知道,.* 在匹配时调用的是java/util/regex/Pattern$CharProperty.match 方法,而 D 调用的是java/util/regex/Pattern$BmpCharProperty.match 。由于我们不能更改源代码,我们使用ASM 字节注入工具,分别在这两个方法上埋点,部分代码如下:
package com.alibaba.taobao.tinyprofiler;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;
import org.objectweb.asm.ClassAdapter;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
public class MethodCallCountTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
try {
if (!"java/util/regex/Pattern$CharProperty".equals(className)
&& !"java/util/regex/Pattern$BmpCharProperty".equals(className)) {
return classfileBuffer;
}
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
ClassAdapter adapter = new MethodCallClassAdapter(writer, className);
ClassReader reader = new ClassReader(classfileBuffer);
reader.accept(adapter, 0);
// 生成新类字节码
return writer.toByteArray();
} catch (Exception e) {
e.printStackTrace();
// 返回旧类字节码
return classfileBuffer;
}
}
}
package com.alibaba.taobao.tinyprofiler;
import org.objectweb.asm.MethodAdapter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
public class CountCallMethodAdapter extends MethodAdapter {
private String methodName;
private String className;
public CountCallMethodAdapter(MethodVisitor visitor, String className, String methodName) {
super(visitor);
this.methodName = methodName;
this.className = className;
}
@Override
public void visitCode() {
visitLdcInsn(className);
visitLdcInsn(methodName);
visitMethodInsn(Opcodes.INVOKESTATIC, "com/alibaba/taobao/tinyprofiler/Counter", "printAndIncCount", "(Ljava/lang/String;Ljava/lang/String;)V");
super.visitCode();
}
}
package com.alibaba.taobao.tinyprofiler;
import java.util.concurrent.atomic.AtomicInteger;
public class Counter {
private static AtomicInteger methodCallCount = new AtomicInteger(0);
public static void printAndIncCount(String className, String methodName) {
System.out.println(className + "." + methodName + " called, total times " + methodCallCount.incrementAndGet());
}
}
OK,现在我们输入:
String regex = ".*.*.*D";
String target = "22asdvasdx";
Pattern.compile(regex).matcher(target).matches();
System.out.println("Xuanyin's estimated count: " + findTotalWays(4, target.length()) + "; depth: " + recursionDepth);
输出结果(注:这里的 depth 并不是递归深度,而是递归次数,当时搞错了):
肿么样,分毫不差~OK,那么我们现在回到最开始的问题,输入 .*.*.*.*.*.*D 去匹配 com.taobao.binary.bogda.query.service.RulesInfoQueryService:1.0.0.daily
结果显示需要 5 亿 匹配, 还要进出栈近 2.5 亿次哦
这里我的机器是i7-2600K 超4.5G,结果显示需要5秒,这还是不是最差的情况哦~
而每次用户查询要匹配近600个这样的字符串,~你说匹配得完嘛
设字符长度为n,.* 的个数为m(C1-m) ,且需要match的字符串不匹配(为什么?),那么C1到满mach首先需要n步,之后回溯一个字符。那么回溯的字符无论怎么样总会被一个C给匹配,被Ci匹配了就不会被Cj 匹配(i <> j),那么可能的情况有:
C1C2C3C4
n000
---------------------------------
n-1100
n-1010
n-1001
即3种,当回溯2个字符时
C1C2C3C4
---------------------------------
n-1100
n-1010
n-1001
---------------------------------
n-2200
n-2110
n-2101
n-2020
n-2011
n-2002
即6种
好了,现在我们知道其规律了,这个问题就可以简化为:有n’个相同的球,投向m个槽,有多少种投法?注意这里是同相的球,其结果显然不是 m^n。
那么是多少?我们可以这样理解:
1、先算第一层,所有的球最多可以填

2、第二层的球实际上就只有 i 个槽可选了,这时有 n'- i 个球。若 i = 1,返回 n’ - i,若n’-i <=1 返回 1,否则递归,m=i,n’ = n’ - i,回1
总的说来这个递归还是很简单的,整理一下,如下:

这里我写了一个简单的实现:
private static long findTotalWays(int stackSize, int ballCount) {
if (stackSize == 1) {
return ballCount;
}
if (ballCount == 0) {
return 1;
}
int maxSlotsCanFill = stackSize > ballCount ? ballCount : stackSize;
long totalWays = 0;
while (maxSlotsCanFill > 0) {
totalWays += combination(stackSize, maxSlotsCanFill)
* findTotalWays(maxSlotsCanFill, ballCount - maxSlotsCanFill);
maxSlotsCanFill--;
}
return totalWays;
}
private static long combination(int n, int m) {
if (m == 0) {
return 0;
}
if (m > n) {
return 1;
}
return factorial(n) / (factorial(m) * factorial(n - m));
}
private static long factorial(int n) {
if (n <= 1) {
return 1;
}
return n * factorial(n - 1);
}
f(3, 36) = 8119
好,8k种左右,完了吗?当然不是!这只是第n-1次回溯,那么应该从0(因为*号可以匹配空)开始到n。全部回溯的数量应该是:
其中k=N - [C1最低需要匹配的字符数],。那么如上所述,现在计算C1完成第一个字符需要match的步骤,将k=36, m=3 代入得:
g(3, 36) = 78331
好,近8万种,完了吗?当然不是,由于每个字符必会被C1-4之中的一个匹配,所以对于每种情况,需要match的步数为N。同时细心的同学可以发现其实g(m,k)=f(m+1,k),当然我们表示成g(m,n)还是有其它原因的。所以最终的式子为:
N是字符串的总长度,M 就是 [总.*个数] 。现在将N=36 M=4代入看看
sum(4, 36) = 78331
好,7万的样子,现在我们看看Java匹配这个正则需要多长时间:
long startTime = System.nanoTime();
Pattern.compile(".*.*.*.*D").matcher("abcdefghijklmnopqrstuvwxyz0123456789").matches();
System.out.println("time used: " + (System.nanoTime() - startTime));
时间显示是3961571ns,也就是7万3.96ms。好,现在我们来输入导至问题的正则:
也就是 .*.*.*.*.*.*Deliver,有6个星号。预发环境下需要匹配的字母串为:com.taobao.matrix.apps.cooperate.service.CooperateShareService:2.0.0.daily,其长度为:74
套公式,sum(6, 74) = 268010793
soga,2.68亿的样子,那么 (300500199/91389)*3.85ms= 12659.355 ms,也就是12.659秒
但实际上执行上面的程序在同样的配置上只花了6.482秒的样子?咦?差距这么多,难道计算有错?实现上我们只算了种类数量,对应的匹配数量还没有计算呢