Статьи

Как убить Java с помощью регулярного выражения

Недавно мы столкнулись с феноменом, о котором мы абсолютно не знали: вы можете уничтожить любую Java IDE, а также любой процесс Java с помощью простого регулярного выражения…

Еще в университете меня учили, что регулярные выражения, которые называются регулярными грамматиками или грамматиками типа 3, всегда оказываются конечным автоматом и поэтому могут обрабатываться за линейное время (длина ввода удваивается, время обработки удваивается). Однако это верно только для «здравомыслящих» выражений. Регулярное выражение также может привести к недетерминированному автомату конечных состояний, и все может быть очень плохо.

Рассмотрим выражение: (0 *) * A Это будет любое количество нулей, за которым следует заглавная буква A. Теперь, если вы используете Matcher.find () для этого выражения, все в порядке, если во входных данных есть совпадение , Однако, если вы вызовете это, указав «00000000000000000000» в качестве входных данных, ваша программа будет зависать (как и консоль регулярных выражений в Eclipse или IntelliJ и в каждой (основанной на Java) онлайн-службе регулярных выражений).

То, что на первый взгляд выглядит как бесконечный цикл, превращается в катастрофическое возвращение назад . Это в основном означает, что устройство сопоставления обнаруживает, что в конце ввода не было найдено A. Теперь внешний квантификатор переходит на шаг назад — внутренний вперед и снова — безрезультатно. Таким образом, устройство сопоставления шаг за шагом повторяет все комбинации, чтобы найти совпадение. В конечном итоге он вернется (без совпадения), но сложность (и, следовательно, время выполнения) этого является потенциальной (добавление одного символа к входу удваивает время выполнения). Подробное описание можно найти здесь: катастрофический откат

Вот некоторые показатели времени выполнения, которые я измерил (которые почти точно удваиваются для каждого добавленного символа):

01
02
03
04
05
06
07
08
09
10
11
12
13
14
0000000000: 0.1ms
00000000000: 0.2ms
000000000000: 0.7ms
0000000000000: 1.3ms
00000000000000: 1.7ms
000000000000000: 3.5ms
0000000000000000: 7.2ms
00000000000000000: 13.9ms
000000000000000000: 27.5ms
0000000000000000000: 55.5ms
00000000000000000000: 113.0ms
000000000000000000000: 226.4ms
0000000000000000000000: 439.1ms
00000000000000000000000: 886.0ms

Небольшое замечание: для таких микро-тестов вам всегда нужно «прогреть» JVM, так как JIT HotSpot в какой-то момент подключится и оптимизирует код. Поэтому первый запуск выглядит так:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
0000000000: 6.8ms
00000000000: 11.8ms
000000000000: 25.5ms
0000000000000: 39.5ms
00000000000000: 6.3ms   <- JIT jumped in and started to translate
000000000000000: 5.4ms     to native code.
0000000000000000: 7.1ms
00000000000000000: 14.2ms
000000000000000000: 26.8ms
0000000000000000000: 54.4ms
00000000000000000000: 109.6ms
000000000000000000000: 222.1ms
0000000000000000000000: 439.2ms
00000000000000000000000: 885.6ms

Итак, что здесь на вынос? Если вы используете серверное приложение или что-то критическое, используемое многими пользователями, не позволяйте им вводить регулярные выражения, если вы действительно им не доверяете. Существуют реализации регулярных выражений, которые обнаруживают эту проблему и отменяют ее, но Java (до JDK 8) этого не делает.

Примечание. Вы можете проверить это с помощью своей локальной среды IDE или небольшой Java-программы, но, пожалуйста, не начинайте выбивать все веб-сайты тестировщиков regex. Эти ребята предоставляют хороший инструмент бесплатно, так что это было бы совершенно несправедливо.

Вот крошечный тест, который я использовал:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Test {
    public static void main(String[] args) {
        for (int runs = 0; runs < 2; runs++) {
            Pattern pattern = Pattern.compile("(0*)*A");
            // Run from 5 to 25 characters
            for (int length = 5; length < 25; length++) {
                // Build input of specified length
                String input = "";
                for (int i = 0; i < length; i++) { input += "0"; }
                
                // Measure the average duration of two calls...
                long start = System.nanoTime();
                for (int i = 0; i < 2; i++) {
                    pattern.matcher(input).find();
                }
                System.out.println(input + ": "
                       + ((System.nanoTime() - start) / 2000000d)
                       + "ms");
            }
        }
    }
}