几天前,一个在线项目的监控系统忽然报告了一个例外。在检查相关资源的使用状况后,咱们发现CPU利用率接近100%。而后咱们使用Java附带的thread dump工具导出问题的堆栈信息。java
咱们能够看到全部堆栈都指向一个被调用的方法validateUrl
,它在堆栈上得到了100多条错误消息。经过对代码进行故障排除,咱们知道该方法的主要功能是验证URL是否合法。web
那么正则表达式如何致使高CPU利用率。为了重现问题,咱们提取关键代码并进行简单的单元测试。正则表达式
public static void main(String[] args) { String badRegex = "^([hH][tT]{2}[pP]://|[hH][tT]{2}[pP][sS]://)(([A-Za-z0-9-~]+).)+([A-Za-z0-9-~\\\\/])+$"; String bugUrl = "http://www.fapiao.com/dddp-web/pdf/download?request=6e7JGxxxxx4ILd-kExxxxxxxqJ4-CHLmqVnenXC692m74H38sdfdsazxcUmfcOH2fAfY1Vw__%5EDadIfJgiEf"; if (bugUrl.matches(badRegex)) { System.out.println("match!!"); } else { System.out.println("no match!!"); } }
当运行上面的示例时,经过资源监视器,咱们能够看到一个被调用的进程java
的CPU利用率飙升至91.4%。算法
如今几乎能够判断正则表达式就是致使CPU利用率高的缘由!api
因此,让咱们关注正则表达式:并发
^([hH][tT]{2}[pP]://|[hH][tT]{2}[pP][sS]://)(([A-Za-z0-9-~]+).)+([A-Za-z0-9-~\\/])+$
分解一下上面的正则表达式:工具
它匹配第一部分中的http
和https
协议,匹配www.
第二部分中的字符,并匹配第三部分中的其余字符。我盯着正则表达很长一段时间并无发现任何大问题。性能
实际上,这里CPU使用率高的关键缘由是Java正则表达式使用的引擎实现是NFA
,在执行字符匹配时执行回溯。一旦发生回溯,所需的时间将变得很是长。多是几分钟甚至几个小时。时间量取决于回溯的数量和复杂性。单元测试
顺便说一下,也许有些人仍然不清楚回溯是什么。不要紧,让咱们从正则表达式的原则入手。测试
正则表达式是一组方便的匹配符号。要实现这种复杂而强大的匹配语法,咱们必须拥有一组算法,而且算法的实现称为正则表达式引擎。简而言之,有两种方法能够实现正则表达式引擎:(DFA
肯定性最终自动机)和NFA
(非肯定性有限自动机)。
这两个自动机是不一样的,咱们不会深刻研究它们的原理。简单地说,时间复杂度DFA
是线性的,更稳定但功能有限。时间复杂度NFA
相对不稳定,因此有时它很是好,有时它不是,取决于你写的正则表达式。但其优势NFA
是其功能更强大,所以Java,.NET,Perl,Python,Ruby和PHP等语言使用NFA来实现其正则表达式。
如何在NFA
比赛?咱们使用如下字符和表达式做为示例。
text="Today is a nice day." regex="day"
请注意,NFA
匹配是基于正则表达式。也就是说,NFA
将读取正则表达式的一个字符并将其与目标字符串匹配。若是匹配成功,它将转到正则表达式的下一个字符,不然它将继续与目标字符串的下一个字符进行比较。
让咱们一步一步地看看上面的例子。
d
。而后将它与字符串的第一个字符进行比较,即T.
它不匹配,因此转到下一个字符 。第二个字符是 o
,它也不匹配。因此继续下一个, d
如今。它匹配。而后阅读常规的第二个字符: a
。a
。它将与字符串的第四个字符进行比较 a.
。它再次匹配。而后继续阅读正则表达式的第三个字符 y
。y
。让咱们继续将它与字符串的第五个字符匹配,而后匹配。而后尝试读取正则表达式的下一个字符,发现没有,因此匹配结束。以上是匹配过程,NFA
实际匹配过程要复杂得多。可是,匹配原则是同样的。
既然您已经学会了如何NFA
执行字符串匹配,那么让咱们来谈谈文章的重点:回溯。为了更好地解释回溯,咱们将使用如下示例。
text="abbc" regex="ab{1,3}c"
这是一个相对简单的例子。正则表达式 a
以及以它结尾 c
,而且在它们之间有一个1-3个b
字符的字符串 。匹配过程NFA
是这样的:
a,
其与字符串的第一个字符进行比较 a
。它匹配,因此移动到正则表达式的第二个字符。 b{1,3},
其与字符串的第二个字符进行比较 b.
再次匹配。可是因为 b{1,3}
表示1-3个 b
字符串和贪婪的性质NFA
(即尽量匹配),它此时不会读取正则表达式的下一个字符,但仍然b{1,3}
与字符串的第三个字符进行比较 ,这 b
也是。它也匹配。而后它将继续使用 b{1,3}
与字符串的第四个字符进行比较 c
,并发现它不匹配。 此时发生回溯 。c
已经读取的字符串的第四个字符(即 )将被吐出,指针将返回到字符串的第三个字符。以后,它将读取c
正则表达式的下一个字符 ,并将其与c
当前指针的下一个字符进行比较 ,并匹配。而后阅读下一篇,但结束了。让咱们回过头来看一下用于验证URL的正则表达式:
^([hH][tT]{2}[pP]://|[hH][tT]{2}[pP][sS]://)(([A-Za-z0-9-~]+).)+([A-Za-z0-9-~\\/])+$
发生问题的URL是:
http://www.fapiao.com/dzfp-web/pdf/download?request=6e7JGm38jfjghVrv4ILd-kEn64HcUX4qL4a4qJ4-CHLmqVnenXC692m74H5oxkjgdsYazxcUmfcOH2fAfY1Vw__%5EDadIfJgiEf
咱们将正则表达式分为三个部分:
^([hH][tT]{2}[pP]://|[hH][tT]{2}[pP][sS]://)
。(([A-Za-z0-9-~]+).)+
。([A-Za-z0-9-~\\/])+$
。能够发现正则表达式验证协议的部分没有问题 http://
,但在验证时www.fapiao.com
,它使用 xxxx.
验证方式。因此匹配过程是这样的:
www
。fapiao
。com/dzfp-web/pdf/download?request=6e7JGm38jf.....
,您将看到因为贪婪的性质,程序将始终尝试读取后续字符串以匹配,最后它发现没有点,所以它开始逐个字符回溯。这是正则表达式中的第一个问题。
另外一个问题是正则表达式的第三部分。能够发现有问题的URL有下划线(_
)和百分号(%
),但对应于第三部分的正则表达不具有。所以,只有在匹配一长串字符后,才会发现它不匹配,而后再回溯。
这是这个正则表达式中的第二个问题。
已经了解到回溯是致使问题的缘由。所以问题的解决方案是减小回溯。实际上,您会发现若是将下划线和百分号添加到第三部分,程序将变为正常。
public static void main(String[] args) { String badRegex = "^([hH][tT]{2}[pP]://|[hH][tT]{2}[pP][sS]://)(([A-Za-z0-9-~]+).)+([A-Za-z0-9-~_%\\\\/])+$"; String bugUrl = "http://www.fapiao.com/dddp-web/pdf/download?request=6e7JGxxxxx4ILd-kExxxxxxxqJ4-CHLmqVnenXC692m74H38sdfdsazxcUmfcOH2fAfY1Vw__%5EDadIfJgiEf"; if (bugUrl.matches(badRegex)) { System.out.println("match!!"); } else { System.out.println("no match!!"); } }
运行上面的程序,它将打印出来 match!!
。
若是未来还有其余包含杂乱字符的URL怎么办?再改一次?固然这不现实!
事实上,正则表达式有三种模式:贪婪模式,不情愿模式和占有模式。
若是?
在正则表达式中添加一个符号 ,则Greedy模式将变为Reluctant模式,也就是说,它将尽量少地匹配。可是,在Reluctant模式下仍会发生回溯。例如:
text="abbc" regex="ab{1,3}?c"
正则表达式a
的第一个字符:匹配字符串的第一个字符a
。正则表达式的第二个运算符 b{1,3}?
匹配b
字符串的第二个字符 。因为最小匹配原则,c
正则表达式的第三个运算符 b
与字符串的第三个字符不匹配 。因此它回溯并将正则表达式的第二个运算符 b{1,3}?
与b
字符串的第三个字符 进行比较,如今匹配成功。而后正则表达式的第三个字符 c
匹配c
字符串的第四个字符 。结束。
若是你添加一个符号+
,原来的贪婪模式将变为独占模式,也就是说,它将尽量匹配,但不会回溯。
所以,若是您想彻底解决问题,必须保证功能,同时确保不回溯。我在正则表达式的第二部分添加一个加号来验证上面的URL:
^([hH][tT]{2}[pP]:\/\/|[hH][tT]{2}[pP][sS]:\/\/) (([A-Za-z0-9-~]+).)++ --->>> (added + here) ([A-Za-z0-9-~_%\\\/])+$
如今运行该程序没有问题。
最后,我推荐一个网站,能够检查你写的正则表达式是否有问题以及相应的字符串匹配。
Online regex tester and debugger: PHP, PCRE, Python, Golang and JavaScript
例如,使用站点检查后将提示本文中存在问题的URL:灾难性的回溯。
单击左下角的“正则表达式调试器”时,它将告诉您已检查了多少步骤,并将列出全部步骤并指出回溯发生的位置。
本文中的正则表达式在110,000步尝试后自动中止。它代表正则表达式确实存在问题,须要改进。
可是当我用修改后的正则表达式测试它时以下:
^([hH][tT]{2}[pP]:\/\/|[hH][tT]{2}[pP][sS]:\/\/)(([A-Za-z0-9-~]+).)++([A-Za-z0-9-~\\\/])+$
提示完成检查只须要58步。
一个字符的差别会致使巨大的性能差距。
使人惊讶的是,一个小的正则表达式可让CPU死掉。当遇到正则表达式时它也给咱们敲响了警钟,使用时应该注意贪婪模式和回溯问题。