在Java里做字符串匹配、校验和提取,java.util.regex包下的Pattern和Matcher是最核心的两个类。不少人对它们的理解停留在复制一段正则表达式然后调用的层面,一旦遇到匹配不到、分组取错、性能卡顿等问题就无从下手。这篇文章把两个类的职责、常用API、匹配模式差异以及实际开发中的注意点完整梳理一遍,帮助你真正掌握Java正则的正确打开方式。

Pattern和Matcher各自负责什么
先厘清两个类的分工。Pattern可以理解为编译后的正则表达式对象,它代表一份“匹配规则”;Matcher则是某次具体匹配过程的执行者,它绑定了一个待匹配的输入字符串,负责按照Pattern的规则去扫描这个字符串。一个Pattern是线程安全的,可以被多个线程同时使用;而Matcher不是线程安全的,每次匹配都应该通过pattern.matcher(input)创建新的实例。
这种设计带来的最大好处是“一次编译,多次使用”。正则表达式编译成Pattern对象是有开销的,涉及语法解析和内部结构构建。如果把正则写在循环里反复调用Pattern.matches(regex, input)这种静态方法,等于每轮循环都编译一次,性能损耗非常明显。正确做法是把Pattern提取为静态常量,在类加载时编译一次:
public class RegexHolder {
// 预编译,全程序复用
private static final Pattern PHONE_PATTERN =
Pattern.compile("^1[3-9]\\d{9}$");
public static boolean isPhone(String s) {
if (s == null) {
return false;
}
return PHONE_PATTERN.matcher(s).matches();
}
}还要注意一个细节:String.matches()内部其实就是Pattern.matches(regex, this),每次调用都会重新编译正则。偶尔校验一次没问题,高频调用场景务必换成预编译的Pattern。
matches、find和lookingAt的区别
这三个方法是最容易混淆的地方。matches()要求整个输入字符串完全符合正则,相当于自带了^和$的锚定效果;find()则是在输入中查找下一个匹配的子串,找到返回true,可以连续调用依次取出所有匹配;lookingAt()只要求从字符串开头开始匹配,但不要求匹配到末尾,介于两者之间。
举个具体例子,输入字符串是"abc123def",正则是\d+。用matches()返回false,因为整串不是纯数字;用find()返回true,能定位到中间的"123";用lookingAt()返回false,因为开头是字母不是数字。三者的语义差异直接决定了你在什么场景用哪个:全量校验用matches,文本提取用find加循环,前缀校验用lookingAt。
String input = "订单号A100,订单号B200,订单号C300";
Pattern p = Pattern.compile("[A-Z]\\d{3}");
Matcher m = p.matcher(input);
// find配合循环,提取所有匹配项
while (m.find()) {
System.out.println("找到: " + m.group() + ",位置: " + m.start() + "-" + m.end());
}执行后会输出三个订单号及其在原串中的起止位置。start()和end()返回的是匹配内容在输入中的索引,end是开区间,即最后一个字符索引加一。这个索引信息在需要高亮、替换或切割原文时非常有用。
另外一个高频坑点:调用group()之前必须先成功执行一次matches()、find()或lookingAt(),否则会抛出IllegalStateException。所以代码结构上要先判断find的返回值,再进if块里取group。
分组捕获与group的用法
正则里用圆括号包裹的部分叫捕获组,按左括号出现的顺序从1开始编号,组0始终代表整个匹配。通过group(int group)可以取出指定组的内容,groupCount()返回的是除组0以外的组数量。分组是做数据提取的基础,比如从一段日志里解析出时间、级别、消息三部分:
String log = "2024-05-12 10:30:45 ERROR 连接数据库超时";
Pattern p = Pattern.compile("(\\d{4}-\\d{2}-\\d{2}) (\\d{2}:\\d{2}:\\d{2}) (\\w+) (.+)");
Matcher m = p.matcher(log);
if (m.matches()) {
String date = m.group(1);
String time = m.group(2);
String level = m.group(3);
String msg = m.group(4);
System.out.println(level + " -> " + msg + " @ " + date + " " + time);
}当分组数量多的时候,靠数字索引容易出错,此时推荐命名分组。写法是(?<name>...),取值时用group("name"),可读性会好很多。另外,如果只想用括号做逻辑分组而不捕获内容,可以写成(?:...),这样不会占用组编号,也能减少一点匹配开销。
还有一个进阶知识点:反向引用。在正则内部可以用\1引用第一个捕获组匹配到的内容,典型应用是查找重复单词,比如(\\w+) \\1可以匹配"hello hello"这种连续重复的词。在替换字符串里则用$1引用分组,例如把日期格式改写:
String result = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})")
.matcher("发布日期:2024-05-12")
.replaceAll("$1年$2月$3日");
System.out.println(result); // 输出:发布日期:2024年05月12日使用$引用时要注意,如果替换内容里恰好需要输出字面量的$符号,要写成\$转义,否则会报异常或者产生错误替换。
贪婪、勉强与占有三种匹配模式
量词的匹配行为分为三种。默认的贪婪模式(如.*)会尽可能多地匹配字符,匹配失败后再回退;勉强模式(如.*?)尽可能少地匹配,遇到第一个能满足后续条件的位置就停;占有模式(如.*+)和贪婪一样先尽可能多匹配,但绝不回退。看一个经典例子,提取HTML标签内的文本:
String html = "<div>第一段</div><div>第二段</div>";
// 贪婪模式:.* 会吃到最后一对标签的结束符
Matcher greedy = Pattern.compile("<div>(.*)</div>").matcher(html);
greedy.find();
System.out.println(greedy.group(1)); // 输出:第一段</div><div>第二段
// 勉强模式:.*? 遇到第一个结束标签就停止
Matcher lazy = Pattern.compile("<div>(.*?)</div>").matcher(html);
lazy.find();
System.out.println(lazy.group(1)); // 输出:第一段这个例子解释了为什么很多人用.*提取内容时总是多拿一截——贪婪匹配把中间的结束标签也吞了进去,直到遇到最后一个结束标签才满足整体匹配。做内容提取时,.*?往往才是想要的行为。
占有模式在性能优化上有价值。对于一些容易产生回溯灾难的正则(例如嵌套量词(a+)+这类写法),把内层量词改成占有形式或者使用原子分组,可以避免指数级回溯,防止一次恶意输入就把CPU打满。这在对外提供搜索、校验功能的系统中尤其重要,正则引发的ReDoS(正则拒绝服务)攻击是真实存在的风险。
常见踩坑点与优化建议
第一个坑是matches的整串语义。很多人写了\d+去匹配,发现"abc123"用find能匹配到但matches返回false,误以为正则写错了,其实是忘了matches要求全串匹配。第二个坑是Matcher的region和reset,Matcher默认从头匹配整个输入,find执行多轮后状态会前移,如果想重新从头匹配,需要调用m.reset()。
第三个坑是特殊字符转义。在Java字符串字面量里,反斜杠本身要写成\\,所以正则里的\d在代码中要写成\\d。如果想把用户输入的任意字符串当作普通文本去匹配,务必先调用Pattern.quote(text)做转义,否则用户输入中的点号、星号会被当成正则元字符,导致匹配结果不可预期甚至异常。
String userInput = "1+1=2.java*";
// 安全做法:把用户输入当字面量处理
Pattern literal = Pattern.compile(Pattern.quote(userInput));
System.out.println(literal.matcher("公式1+1=2.java*正确").find()); // true性能方面,除了前面提到的预编译,还可以善用Pattern.CASE_INSENSITIVE、Pattern.DOTALL等编译标志,它们在编译期生效,比在正则里写内联标志更清晰。对于超长文本的流式处理,可以把文本分块配合region使用。最后养成一个习惯:复杂的正则一定要写单元测试,覆盖正常、异常和边界输入,正则的隐晦语法决定了它一旦出错很难靠肉眼排查,一组完整的测试用例是最可靠的保障。