【问题标题】:Why is performance of ^(?:x+y){5}$ slower than ^x+yx+yx+yx+yx+y$为什么 ^(?:x+y){5}$ 的性能比 ^x+yx+yx+yx+yx+y$ 慢
【发布时间】:2026-01-30 19:10:01
【问题描述】:

我让以下编译的正则表达式匹配一堆字符串,包括 .net (N) 和 Java (J)。通过多次运行,在 .net 和 Java 中,正则表达式 1 和正则表达式 2 之间存在一致的差异:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  #  regex                             N secs   N x  J secs   J x 
──────────────────────────────────────────────────────────────────
  1  ^[^@]+@[^@]+@[^@]+@[^@]+@[^@]+@$    8.10     1    5.67     1 
  2  ^(?:[^@]+@){5}$                    11.07  1.37    6.48  1.14 
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

正则表达式编译器可以而且应该不展开或以其他方式将等效结构规范化为性能最佳的形式吗?

如果他们“可以并且应该”,那么至少可以编写一个正则表达式优化器,它会在正则表达式字符串被编译之前对其进行修改。

所用代码的关键部分:

.net

// init
regex = new Regex(r, RegexOptions.Compiled | RegexOptions.CultureInvariant);

// test
sw = Stopwatch.Start();
foreach (var s in strs)
  if (regex.isMatch(s))
    matches++;
elapsed = sw.Elapsed;

Java

// init
pat = Pattern.compile(r);

// test
before = System.currentTimeMillis();
for (String s : strs)
  if (pat.matcher(s).matches())
    matches++;
elapsed = System.currentTimeMillis() - before;

【问题讨论】:

  • 能否请您在 Gist 上发布您的完整测试程序或其他内容?我们不知道您的基准是什么(例如字符串的长度,或者您是否先预热引擎)。

标签: java .net regex performance


【解决方案1】:

我不了解.NET,因为我没有详细研究过它的源代码。

但是,在 Java,尤其是 Oracle/Sun 实现中,我可以说这很可能是由于循环结构开销

在这个答案中,每当我提到 Java 中正则表达式的实现时,我指的是 Oracle/Sun 实现。其他的实现我还没有研究过,所以真的不能说什么。

贪婪量词

我只是意识到这部分与问题无关。不过这里介绍了贪心量词是如何实现的,这里就不说了。

给定一个原子A 和一个贪婪量词A*(重复的次数在这里并不重要),贪婪量词将尝试匹配尽可能多的A,然后尝试续集(无论在A* 之后出现),失败时,一次回溯一个重复,然后重试续集。

问题是回溯到哪里。仅仅为了找出位置而重新匹配整个事物是非常低效的,因此我们需要为每次重复存储重复完成匹配的位置。重复的次数越多,保存到目前为止所有状态以进行回溯所需的内存就越多,更不用说捕获组(如果有的话)。

按照问题中的方式展开正则表达式并不能逃避上述内存要求。

固定长度原子的简单案例

但是,对于[^@]*这样的简单情况,你知道原子A(在这种情况下为[^@])只能匹配固定长度的字符串(长度为1),只能匹配最后一个位置和长度match 是有效执行匹配所必需的。 Java 的实现包括一个study 方法来检测像这样的固定长度模式以编译成循环实现(Pattern.CurlyPattern.GroupCurly)。

这是第一个正则表达式^[^@]+@[^@]+@[^@]+@[^@]+@[^@]+@$ 在编译成Pattern 类中的节点链后的样子:

Begin. \A or default ^
Curly. Greedy quantifier {1,2147483647}
  CharProperty.complement. S̄:
    BitClass. Match any of these 1 character(s):
      @
  Node. Accept match
Single. Match code point: U+0040 COMMERCIAL AT
Curly. Greedy quantifier {1,2147483647}
  CharProperty.complement. S̄:
    BitClass. Match any of these 1 character(s):
      @
  Node. Accept match
Single. Match code point: U+0040 COMMERCIAL AT
Curly. Greedy quantifier {1,2147483647}
  CharProperty.complement. S̄:
    BitClass. Match any of these 1 character(s):
      @
  Node. Accept match
Single. Match code point: U+0040 COMMERCIAL AT
Curly. Greedy quantifier {1,2147483647}
  CharProperty.complement. S̄:
    BitClass. Match any of these 1 character(s):
      @
  Node. Accept match
Single. Match code point: U+0040 COMMERCIAL AT
Curly. Greedy quantifier {1,2147483647}
  CharProperty.complement. S̄:
    BitClass. Match any of these 1 character(s):
      @
  Node. Accept match
Single. Match code point: U+0040 COMMERCIAL AT
Dollar(multiline=false). \Z or default $
java.util.regex.Pattern$LastNode
Node. Accept match

循环结构开销

在长度不固定的情况下,例如问题中的正则表达式 ^(?:[^@]+@){5}$ 中的原子 (?:[^@]+@),Java 的实现会切换到递归来处理匹配 (Pattern.Loop)。

Begin. \A or default ^
Prolog. Loop wrapper
Loop[1733fe5d]. Greedy quantifier {5,5}
  java.util.regex.Pattern$GroupHead
  Curly. Greedy quantifier {1,2147483647}
    CharProperty.complement. S̄:
      BitClass. Match any of these 1 character(s):
        @
    Node. Accept match
  Single. Match code point: U+0040 COMMERCIAL AT
  GroupTail. --[next]--> Loop[1733fe5d]
Dollar(multiline=false). \Z or default $
java.util.regex.Pattern$LastNode
Node. Accept match

这会在每次重复通过节点 GroupTail --> Loop --> GroupHead 时产生额外的开销。

您可能会问为什么即使重复次数是固定的,实现也会这样做。既然重复次数是固定的,按理说根本没有回溯的余地,那我们就不能只跟踪重复前的状态和当前重复中的状态吗?

好吧,这里有一个反例:^(?:a{1,5}?){5}$ 的字符串长度只有 a 的 15。回溯仍然可以在原子内部发生,因此我们需要像往常一样存储每次重复的匹配位置。

实际时间

我上面讨论的都是 Java 源代码(以及字节码)级别。虽然源代码在实现中可能会暴露出某些问题,但性能最终取决于JVM如何生成机器码并进行优化。

这是我用来测试的源代码:

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.regex.Pattern;


public class SO28161874 {
    // Makes sure the same set of strings is generated between different runs
    private static Random r = new Random();

    public static void main(String args[]) {
        final int rep = 5;

        // String r1 = "^[^@]+@[^@]+@[^@]+@[^@]+@[^@]+@$";
        String r1 = genUnroll(rep);
        // String r2 = "^(?:[^@]+@){5}$";
        String r2 = genQuantifier(rep);

        List<String> strs = new ArrayList<String>();
        // strs.addAll(generateRandomString(500, 40000, 0.002, false));
        // strs.addAll(generateRandomString(500, 40000, 0.01, false));
        // strs.addAll(generateRandomString(500, 40000, 0.01, true));
        // strs.addAll(generateRandomString(500, 20000, 0, false));
        // strs.addAll(generateRandomString(500, 40000, 0.002, true));
        strs.addAll(generateNearMatchingString(500, 40000, rep));

        /*
        // Assertion for generateNearMatchingString
        for (String s: strs) {
            assert(s.matches(r1.replaceAll("[+]", "*")));
        }
        */

        System.out.println("Test string generated");

        System.out.println(r1);
        System.out.println(test(Pattern.compile(r1), strs));

        System.out.println(r2);
        System.out.println(test(Pattern.compile(r2), strs));
    }

    private static String genUnroll(int rep) {
        StringBuilder out = new StringBuilder("^");

        for (int i = 0; i < rep; i++) {
            out.append("[^@]+@");
        }

        out.append("$");
        return out.toString();
    }

    private static String genQuantifier(int rep) {
        return "^(?:[^@]+@){" + rep + "}$";
    }

    /*
     * count -- number of strings
     * maxLength -- maximum length of the strings
     * chance -- chance that @ will appear in the string, from 0 to 1
     * end -- the string appended with @
     */
    private static List<String> generateRandomString(int count, int maxLength, double chance, boolean end) {
        List<String> out = new ArrayList<String>();

        for (int i = 0; i < count; i++) {
            StringBuilder sb = new StringBuilder();
            int length = r.nextInt(maxLength);
            for (int j = 0; j < length; j++) {
                if (r.nextDouble() < chance) {
                    sb.append('@');
                } else {
                    char c = (char) (r.nextInt(96) + 32);
                    if (c != '@') {
                        sb.append(c);
                    } else {
                        j--;
                    }
                }
            }

            if (end) {
                sb.append('@');
            }

            out.add(sb.toString());

        }

        return out;
    }

    /*
     * count -- number of strings
     * maxLength -- maximum length of the strings
     * rep -- number of repetitions of @
     */
    private static List<String> generateNearMatchingString(int count, int maxLength, int rep) {
        List<String> out = new ArrayList<String>();

        int pos[] = new int[rep - 1]; // Last @ is at the end

        for (int i = 0; i < count; i++) {
            StringBuilder sb = new StringBuilder();
            int length = r.nextInt(maxLength);

            for (int j = 0; j < pos.length; j++) {
                pos[j] = r.nextInt(length);
            }
            Arrays.sort(pos);

            int p = 0;

            for (int j = 0; j < length - 1; j++) {
                if (p < pos.length && pos[p] == j) {
                    sb.append('@');
                    p++;
                } else {
                    char c = (char) (r.nextInt(95) + 0x20);
                    if (c != '@') {
                        sb.append(c);
                    } else {
                        j--;
                    }
                }
            }

            sb.append('@');

            out.add(sb.toString());

        }

        return out;
    }

    private static long test(Pattern re, List<String> strs) {
        int matches = 0;

        // 500 rounds warm-up
        for (int i = 0; i < 500; i++) {
            for (String s : strs)
              if (re.matcher(s).matches());
        }

        long accumulated = 0;

        long before = System.currentTimeMillis();
        for (int i = 0; i < 1000; i++) {
            matches = 0;
            for (String s : strs)
              if (re.matcher(s).matches())
                matches++;
        }

        accumulated += System.currentTimeMillis() - before;

        System.out.println("Found " + matches + " matches");

        return accumulated;
    }
}

注释/取消注释不同的生成行以进行测试,并弄乱数字。我在每次测试之前通过执行正则表达式 500 次来预热 VM,然后计算运行正则表达式 1000 次的累积时间。

我没有具体的数字要发布,因为我发现自己的结果相当不稳定。但是,根据我的测试,我通常发现第一个正则表达式比第二个正则表达式快。

通过生成 500 个字符串,每个字符串最多可以有 40000 个字符长,我发现当输入导致它们在不到 10 秒的时间内运行时,2 个正则表达式之间的差异更加突出(大约 1 到 2 秒)。当输入导致它们运行更长时间(40 多秒)时,两个正则表达式的运行时间大致相同,最多相差几百毫秒。

【讨论】:

  • 感谢您的深入研究和有关循环的详细信息,尤其是反例。所以你认为因为在某些情况下可能不得不退回到回溯,编译器会放弃优化任何固定量词
  • @Eugene:如果你在谈论展开重复(这是我在这里唯一能考虑优化的事情),它会膨胀Pattern 的大小(这与我在上面说的匹配过程中的内存要求)对于大重复或大原子。我不太确定技术上的困难((^a|\1a){5} 之类的东西可能会导致问题),但很明显,当您的代码中有多个路径(优化和未优化)时,就会产生维护成本。他们可能考虑了收益和成本,并选择不针对这种情况进行优化。
  • 或者换句话说:“是”。我感觉你是对的,虽然我希望不是这样。 (但现在即使是.net is open source,也没有什么能阻止我尝试使其成为 Java 或 .net 的一部分...... :)
【解决方案2】:

MSDN 在他们的页面上Backtracking in Regular Expressions

一般来说,非确定性有限自动机 (NFA) 引擎,如 .NET Framework 正则表达式引擎负责 在 开发者。

这听起来更像是一个蹩脚的借口,而不是技术上合理的解释为什么不执行优化 - 如果它是为了包括问题中的情况,“一般”似乎暗示。

【讨论】:

    【解决方案3】:

    为什么^(?:x+y){5}$ 的性能比^x+yx+yx+yx+yx+y$ 慢?

    因为这里出现了Backtracking的概念。

    正则表达式 1:

    ^[^@]+@[^@]+@[^@]+@[^@]+@[^@]+@$
    

    这里没有回溯,因为每个单独的模式都匹配字符串的特定部分。

    正则表达式 2:

    ^(?:[^@]+@){5}$
    

    【讨论】:

    • @nhahtdh 抱歉,我不知道您所说的 [^@]+ which presents in both expressions should overstep to the next character and backtrack. 是什么意思。你能提供一个答案,我会删除我的..
    • 我已经删除了我的 cmets,因为经过仔细考虑,它不会影响屏幕截图中的最终结果。但是,我会继续投反对票,因为回溯在这里并不是真正的问题。相反,我认为是设置循环所涉及的开销导致速度变慢(在这些情况下,生成的代码通常会涉及递归,或使用外部堆栈来保存状态)。
    • @AvinashRaj 如果这两个正则表达式是等价的,为什么一个需要回溯而另一个不需要回溯?由于交替集[^@]@ 是互斥的(至少这是我所希望的,尽管Unicode 可能会像它经常发生的那样有一些丑陋的惊喜),所以永远不需要回溯。除非您说这两个表达式不等价,否则我的问题(因为 emboldened)是:正则表达式编译器是否可以并且应该不展开或以其他方式将等效构造规范化为性能最佳的形式?
    • @AvinashRaj:我已经发布了我自己的答案。 2 正则表达式的回溯行为应该没有任何区别。这里唯一的因素是开销。
    最近更新 更多