Java 正则表达式
在Java中我们一定都使用过正则表达式,因为正则在很多语言中都有实现,但是可能在使用中有些不同,这里我们就打开java.util.regex包,来看看相关源码,了解一些背后的原理,一起拾起我们曾经忽略的点点滴滴。
一、Pattern类
看Pattern的源码,我们首先能看到Pattern是被final修饰的类,说明它不可以被子类继承,同时也是线程安全的,可以在多线程的情况下安全的使用。
那么我们经常使用的方式一般都是先创建一个Pattern对象,然后通过他对输入的字符串进行正则匹配,比如下面这种:
Pattern pattern = Pattern.compile("a*b");
这里的compile是将正则表达式进行编译,为后面进行Matcher的匹配做准备,但是Pattern.compile有两个构造方法:
1. public static Pattern compile(String regex) { return new Pattern(regex, 0); } 2. public static Pattern compile(String regex, int flags) { return new Pattern(regex, flags); }
这里可以看到这两个构造方法都是调用了同一个私有的构造方法,只是传递的第二个参数是不一样的,那这第二个参数是做什么的呢?
这里通过源码整理了一个表格汇总一下:
| Flag | Describe | value | embedded flag |
UNIX_LINES |
unix行模式,大多数系统的行都是以\n结尾的,但是少数系统,比如Windows,却是以\r\n组合来结尾的 |
0x01 |
(?d) |
CASE_INSENSITIVE |
默认情况下,大小写不敏感的匹配只适用于US-ASCII字符集。这个标志能让表达式忽略大小写进行匹配。 |
0x02 |
(?i) |
COMMENTS |
这种模式下,匹配时会忽略(正则表达式里的)空格字符(不是指表达式里的”//s”,而是指表达式里的空格,tab,回车之类)和注释(从#开始,一直到这行结束)。 |
0x04 |
(?x) |
MULTILINE |
默认情况下,输入的字符串被看作是一行,即便是这一行中包好了换行符也被看作一行。当匹配“^”到“$”之间的内容的时候,整个输入被看成一个一行。 |
0x08 |
(?m) |
LITERAL |
启用字面值解析模式,指定此标志后,指定模式的输入字符串就会作为字面值字符序列来对待。输入序列中的元字符或转义序列不具有任何特殊意义。 |
0x10 |
- |
DOTALL |
在这种模式中,表达式 .可以匹配任何字符,包括行结束符。默认情况下,此表达式不匹配行结束符。 |
0x20 |
(?s) |
UNICODE_CASE |
在这个模式下,如果你还启用了CASE_INSENSITIVE标志,那么它会对Unicode字符进行大小写不敏感的匹配。 |
0x40 |
(?u) |
CANON_EQ |
当且仅当两个字符的正规分解(canonical decomposition)都完全相同的情况下,才认定匹配。比如用了这个标志之后,表达式a/u030A会匹配? |
0x80 |
- |
UNICODE_CHARACTER_CLASS |
启用Unicode版本的预定义字符类 |
0x100 |
(?U) |
下面我们对一些比较常用的模式进行说明:
1.UNIX_LINES
在此模式中,.、^ 和 $ 的行为中仅识别 '\n'行结束符,通过嵌入式标志表达式 (?d) 也可以启用 Unix 行模式。
这里简单介绍一下换行和回车的渊源
回车”(Carriage Return)和 “换行”(Line Feed)这两个概念的来历和区别。
符号 ASCII码 意义
\n 10 换行
\r 13 回车CR
在计算机还没有出现之前,有一种叫做电传打字机(Teletype Model 33,Linux/Unix下的tty概念也来自于此)的玩意,每秒钟可以打10个字符。但是它有一个问题,就是打完一行换行的时候,要用去0.2秒,正好可以打两个字符。要是在这0.2秒里面,又有新的字符传过来,那么这个字符将丢失。
于是,研制人员想了个办法解决这个问题,就是在每行后面加两个表示结束的字符。一个叫做“回车”,告诉打字机把打印头定位在左边界;另一个叫做“换行”,告诉打字机把纸向下移一行。这就是“换行”和“回车”的来历,从它们的英语名字上也可以看出一二。
后来,计算机发明了,这两个概念也就被般到了计算机上。那时,存储器很贵,一些科学家认为在每行结尾加两个字符太浪费了,加一个就可以。于是,就出现了分歧。
在Windows中:
'\r' 回车,回到当前行的行首,而不会换到下一行,如果接着输出的话,本行以前的内容会被逐一覆盖;
'\n' 换行,换到当前位置的下一行,而不会回到行首;
Unix系统里,每行结尾只有“<换行>”,即"\n";Windows系统里面,每行结尾是“<回车><换行>”,即“\r\n”;Mac系统里,每行结尾是“<回车>”,即"\r";。一个直接后果是,Unix/Mac系统下的文件在Windows里打开的话,所有文字会变成一行;而Windows里的文件在Unix/Mac下打开的话,在每行的结尾可能会多出一个^M符号
2.CASE_INSENSITIVE
直接看个例子:
public static void main(String[] args) { String tempRegex = "[+-]?(\\d)+(.(\\d)*)?(\\s)*(?i)[CF]"; Pattern p = Pattern.compile(tempRegex); System.out.println("-3.33c " + p.matcher("-3.33c").matches()); System.out.println("-3.33C " + p.matcher("-3.33C").matches()); }
这里通过嵌入表达式 (?i) 来表达,通过下面这种方式也能达到同样的效果:
public static void main(String[] args) { String tempRegex = "[+-]?(\\d)+(.(\\d)*)?(\\s)*[CF]"; Pattern p = Pattern.compile(tempRegex,Pattern.CASE_INSENSITIVE); System.out.println("-3.33c " + p.matcher("-3.33c").matches()); System.out.println("-3.33C " + p.matcher("-3.33C").matches()); }
3.COMMENTS
启用注释,开启之后,正则表达式中的空格以及#号行将被忽略
Pattern p = Pattern.compile("(\\d)+#this is comments.", Pattern.COMMENTS);
System.out.println("1234 " + p.matcher("1234").matches());
通过这种方式我们可以在编写一些比较难理解的正则表达式的时候,进行一些注释,方便日后理解。
4.MULTILINE
根据描述我们可知如果没有 MULTILINE 标志的话, ^ 和 $ 只能匹配输入序列的开始和结束;采用多行模式之后,就可以匹配输入序列内部的行结束符,下面也是来看一个例子:
String str = "hello world\r\n" + "hello java\r\n" + "hello java"; System.out.println("===========匹配字符串开头(非多行模式)==========="); Pattern p = Pattern.compile("^hello"); Matcher m = p.matcher(str); while (m.find()) { System.out.println(m.group() + " 位置:[" + m.start() + "," + m.end() + "]"); } System.out.println("===========匹配字符串开头(多行模式)==========="); p = Pattern.compile("^hello", Pattern.MULTILINE); m = p.matcher(str); while (m.find()) { System.out.println(m.group() + " 位置:[" + m.start() + "," + m.end() + "]"); }
这里我们采用的是windows下的换行符,运行的结果就是:
===========匹配字符串开头(非多行模式)=========== hello 位置:[0,5] ===========匹配字符串开头(多行模式)=========== hello 位置:[0,5] hello 位置:[12,17] hello 位置:[23,28]
可以看出,如果是采用默认的模式,那么就会把输入按照一行来处理
5.LITERAL
启用这个模式之后,所有元字符、转义字符都被看成普通的字符,不再具有其他意义
System.out.println(Pattern.compile("\\d", Pattern.LITERAL).matcher("\\d").matches());// true
System.out.println(Pattern.compile("\\d", Pattern.LITERAL).matcher("2").matches());// false
可见之前表达数字的元字符,在这里已经无法匹配数字了
6.DOTALL
一般情况下,点号(.)匹配任意字符,但不匹配换行符,启用这个模式之后,点号还能匹配换行符,还是看个例子:
System.out.println("===========API启用DOTALL===========");
String dotall = "<xml>(.)*</xml>";
Pattern p = Pattern.compile(dotall, Pattern.DOTALL);
System.out.println("<xml>\\r\\n</xml> " + p.matcher("<xml>\r\n</xml>").matches());
System.out.println("===========不启用DOTALL===========");
dotall = "<xml>(.)*</xml>";
p = Pattern.compile(dotall);
System.out.println("<xml>\\r\\n</xml> " + p.matcher("<xml>\r\n</xml>").matches());
运行结果可以看出,启用了 DOTALL 模式的是可以正确匹配的,没有启用的,则匹配不成功。
二、Matcher类
在java.util.regex中最重要的另一个类就是Matcher了,Pattern编译正则表达式后创建一个匹配模式,Matcher使用Pattern实例提供的正则表达式对目标字符串进行匹配,搜索对象等。
首先我们先看一下Matcher类的源码:
public final class Matcher implements MatchResult
我们可以看到也是final修饰的,同样是不可被继承的,我们看一下Matcher的构造方法:
/** * No default constructor. */ Matcher() { } /** * All matchers have the state used by Pattern during a match. */ Matcher(Pattern parent, CharSequence text) { this.parentPattern = parent; this.text = text; // Allocate state storage int parentGroupCount = Math.max(parent.capturingGroupCount, 10); groups = new int[parentGroupCount * 2]; locals = new int[parent.localCount]; // Put fields into initial states reset(); }
构造方法中创建了两个数组:groups 和 locals
首先是获取parentGroupCount,这个变量的意思是此模式中捕获组的数量。由匹配器用于*分配执行匹配所需的存储空间。
其次是localCount变量,意思是解析树使用的局部变量计数。由匹配器用于*分配执行匹配所需的存储空间。
可见这连个数组都是用与在解析和匹配字符串的时候使用的对象,这里只是创建一下。
然后下面的reset()方法是对标记变量和数组进行了初始化,供后面对字符串进行匹配的时候使用
因为Matcher的构造方法是一个default的权限的,所以只能通过Pattern的 matcher 方法来获得,简单看一下Pattern中的matcher方法:
public Matcher matcher(CharSequence input) { if (!compiled) { synchronized(this) { if (!compiled) compile(); } } Matcher m = new Matcher(this, input); return m; }
只是做了一个编译的检查,然后就直接调用的Matcher的构造方法创建了一个Matcher对象,后面我们的字符串匹配,查找等操作都是要通过这个Matcher对象来完成。
Matcher里面的方法还是挺多的,这里就不一一的举例子了,就来一个字符串提取的吧
public static void main(String[] args) { Pattern p = Pattern.compile("(\\d{6})(\\d{8})(\\d{4})");
String str = "234123199006111221"; Matcher m = p.matcher(str); while (m.find()) {
System.out.println(m.group(2));
}
}
str是一个身份证号,前六位是地区编码,之后是一个8位的出生年月日,之后是一个4位的校验码,这里没有考虑最后以为是X和老身份证号的情况,只是做个例子
这里我们用圆括号进行了分组,六位的地区码为第一个分组,然后8位的出生年月日是第二组,最后的校验码为第三组。
所以上面的运行结果就是 : 19900611 ,也就是我们要提取的出生年月日了。
三、DFA(确定型有穷自动机) & NFA(不确定型有穷自动机)
这里想聊一点正则表达式背后的东西,因为正则的语法和使用方法很容易就能查到。但是正则表达式其实不是写写这么简单,如果我们不了解背后的原理,可能出了问题却摸不到头绪。
首先DFA和NFA是正则表达式的两种引擎,NFA主要是对正则表达式主导的匹配,NFA主导的是对文本的匹配,下面通过一个例子来简单说明一下:
String text = "after tonight";
String regx = "to(nite|nighta|night)";
text是我们要进行匹配的文本,regx是我们的正则表达式,下面针对DFA和NFA两种引擎的工作方式进行说明:
1.DFA:采用的是文本来匹配正则表达式的方式,首先从text的a开始匹配t,失败,直到第一个t跟正则的t匹配,但是text后面的e和regx中的o匹配失败,继续扫描文本往下走,直到文本里面的第二个t跟正则中的t匹配,然后紧接着text中的o也与正则中的o匹配,紧接着到文本的n的时候,发现正则里面有三个可选的匹配,接下来就开始进行匹配,文本中的n与三个正则都能够匹配成功,然后继续是i,同样是与三个正则表达式都能够匹配成功,直到g与第一个正则表达式无法匹配,那么现在就剩下两个了,就这样一直匹配下去,直到最后。
2.NFA:NFA采用的是正则主导的方式,也就是根据正则来匹配文本。还是上面的例子,只不过这次是从regx中的t开始匹配,首先是匹配text中的a , 匹配失败。继续匹配直到文本中出现第一个t,接着用regx中的o去匹配文本中的e,发现匹配失败,直接回退到regx中的t,从text刚才失败的位置,继续往下进行匹配,直到匹配到下一个t,然后再用正则中的o与text中的o进行匹配,发现也是能够匹配的上。之后发现正则是三个可选条件,然后依次的选择正则,与text剩下的部分进行匹配操作,直到最终匹配完成。
通过上面的说明我们可以知道DFA的方式文本只匹配一次,没有重来的操作。而NFA如果正则匹配失败了是从正则表达式的头开始,然后遇到了可选项是要对文本进行多次匹配。所以简单来看,DFA的匹配速度应该是比NFA的要快一些。还有就是不管正则表达式怎么写,对于DFA,文本的匹配过程都是一致的,都是对文本的字符从左到右依次进行匹配,所以DFA在匹配过程中与正则表达式无关,但是对于NFA却不同,可能是最终效果相同的表达式,但是匹配过程完全不同,消耗的时间可能也会查出好几个数量级。
所以DFA的时间复杂度是线性的,更加稳定,但是功能有限,不支持捕获组、各种引用等等。而NFA的时间复杂度比较不稳定,有的时候可能很好,有的时候却有很差,好与不好其实完全取决于正则表达式是什么样的。但是NFA的功能更加的强大,所以包括Java,Python,Ruby , PHP等都采用的是NFA的引擎。
四、回溯与三种模式
聊完了引擎,接下来我们来看看正则表达式能够给我们带来什么样的麻烦?
大家之前可能没有遇到过,但是肯定听说过因为正则表达式导致CPU飙升,明明都是测试通过没有问题的代码,为什么一上线就产生这种奇怪的问题了呢?
其实很可能就是因为语言本身可能采用了NFA的引擎,然后在对一些字符串的匹配过程中产生了回溯的现象,最终导致了CPU飙升的现象,下面我们一起来看看什么是回溯现象。
这里也是通过一个例子来说明:
String text = "abbc"
String regx = "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 进行对比,发现匹配。于是读取下一个操作符,到这里已经结束了。
这里把字符c与正则b{1,3}进行匹配失败后,将指针回退的过程就叫做回溯,但是这样也不会有很大的问题啊?下面我们看一个例子:
String text = "http://www.fapiao.com/dzfp-web/pdf/download?request=6e7JGm38jfjghVrv4ILd-kEn64HcUX4qL4a4qJ4-CHLmqVnenXC692m74H5oxkjgdsYazxcUmfcOH2fAfY1Vw__%5EDadIfJgiEf";
String regx = "^([hH][tT]{2}[pP]://|[hH][tT]{2}[pP][sS]://)(([A-Za-z0-9-~]+).)+([A-Za-z0-9-~\\\\/])+$";
如果我们本地运行这个代码,就会发现CPU的使用率会突然上升,那么具体原因是什么呢?
我们把这个正则表达式分为三个部分:
- 第一部分:校验协议。
^([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 是有下划线(_)和百分号(%)的,但是对应第三部分的正则表达式里面却没有。这样就会导致前面匹配了一长串的字符之后,发现不匹配,最后回溯回去。
这是这个正则表达式存在的第二个问题。
明白了回溯是导致问题的原因之后,其实就是减少这种回溯,你会发现如果我在第三部分加上下划线和百分号之后,程序就正常了。
String regx = "^([hH][tT]{2}[pP]://|[hH][tT]{2}[pP][sS]://)(([A-Za-z0-9-~]+).)+([A-Za-z0-9-~_%\\\\/])+$";
下面要说说正则匹配的三种模式:贪婪模式,懒惰模式,独占模式,在关于数量的匹配中,有 + ? * {min,max} 四种两次,如果只是单独使用,那么它们就是贪婪模式。
如果在他们之后加多一个 ? 符号,那么原先的贪婪模式就会变成懒惰模式,即尽可能少地匹配。但是懒惰模式还是会发生回溯现象的。例如下面这个例子:
String text ="abbc"
String rege="ab{1,3}?c"
- 首先匹配正则中的a 与文本中的 a ,匹配成功
- 正则的第二个操作符b{1,3} 和 文本中的第二个字符b,匹配成功
- 因为是懒惰模式,所以第二个匹配成功之后,就会用正则中的第三个字符 c 与 文本中的第三个字符 b 进行匹配
- 发现匹配失败,正则发生回溯,再读取第二个正则字符b{1,3} 与文本的第三个字符b进行匹配 ,匹配成功
- 用正则的第三个字符c 与 文本的第四个字符c进行匹配,匹配成功
所以就是在NFA机制下,正则和文本都可能发生回溯,只要是不匹配就会通过回溯的方式进行尝试,那么如果此时要匹配的字符比较多,可能会发生多次交叉的回溯,所以耗费的时间就可能会非常的长,CPU使用率飙升也就可以理解了。
所以通过对正则表达式中三种模式的了解之后,我们可以再对上面的例子进行修改,来加快它的匹配
String regx = "^([hH][tT]{2}[pP]://|[hH][tT]{2}[pP][sS]://)(([A-Za-z0-9-~]+).)++([A-Za-z0-9-~_%\\\\/])+$";
就是在域名匹配那里再添加一个+号,那么原先的贪婪模式就会变成独占模式,即尽可能多地匹配,但是不回溯。

浙公网安备 33010602011771号