блог разработчика о насущном

Что быстрее: апач vs регулярка

Поиск истины на пустом месте
Однажды мне на ревью передали небольшой Merge Request. Среди всех его изменений мне попалась одно интересное место.
Предыстория
Получив из БД относительный URI ресурса (картинки, на самом деле), я планировал его превратить в абсолютный URL, добавив в начале что-то типа https://some.example.com/.

Код выглядел так (утрированный вариант):
private static final Pattern URL_LIKE = Pattern.compile("^https?://");

// …

private String getPhotoLink(Long clientId) {
    return Optional.of(clientId)
        .map(photoRepository::findLastByClientId)
        .map(Photo::getLink)
        .map(link -> URL_LIKE.matcher(link).matches()
            ? link
            : String.format(photoUrlPattern, link))
        .orElse(null);
}
Ничего криминального в этом коде, конечно, нет, но в глубине души меня терзал вопрос — не будет ли использование даже скомпилированного регулярного выражения overkill-ом? Если бы этот код писал я, то воспользовался бы методом StringUtils#startsWithAny из библиотеки Apache Commons Lang. Я написал о своих терзаниях автору MR и началось.
Первый вариант
Моя коллега для сравнения быстродействия методов написала такой код:
public class TestRegexVsStringUtils {

    private static final String TEST_STRING = "https://some.example.com/api/images/9876543210";
    private static final Pattern PATTERN = Pattern.compile("^https?://");
    private static final String HTTP = "http://";
    private static final String HTTPS = "https://";

    public static void main(String[] args) {
        long t0 = System.currentTimeMillis();

        for (int i = 0; i < 1000; i++) {
            PATTERN.matcher(TEST_STRING).matches();
        }

        long t1 = System.currentTimeMillis();

        for (int i = 0; i < 1000; i++) {
            StringUtils.startsWithAny(TEST_STRING, HTTP, HTTPS);
        }

        long t2 = System.currentTimeMillis();

        System.out.println("Pattern: " + (t1 - t0) + " ms.");
        System.out.println("Apache:  " + (t2 - t1) + " ms.");
    }
}

Запуск теста выдал в консоли числа 3 и 10 соответственно. У коллеги Java 8, у меня — 14, и я попробовал запустить этот код на своей машине. Результат ожидаемо аналогичный: 6 против 14 мс. Такое ощущение, что StringUtils действительно проигрывает регулярке.

Но подождите, наверное, все эти циклы оптимизируются компилятором? Коллега модифицировала свой код:
    private static final String TEST_STRING = "https://some.example.com/api/images/9876543210";
    private static final Pattern PATTERN = Pattern.compile("^https?://");
    private static final String HTTP = "http://";
    private static final String HTTPS = "https://";
    private static final int SIZE = 1000;

    public static void main(String[] args) {
        boolean[] result = new boolean[SIZE];

        long t1 = System.currentTimeMillis();
        for (int i = 0; i < SIZE; i++) {
            result[i] = PATTERN.matcher(TEST_STRING).matches();
        }
        t1 = System.currentTimeMillis() - t1;

        for (boolean b : result) {
            if (!b) { throw new RuntimeException("pattern wrong."); }
        }

        long t2 = System.currentTimeMillis();
        for (int i = 0; i < 1000; i++) {
            StringUtils.startsWithAny(TEST_STRING, HTTP, HTTPS);
        }
        t2 = System.currentTimeMillis() - t2;

        for (boolean b : result) {
            if (!b) { throw new RuntimeException("apache wrong."); }
        }

        System.out.println("Pattern: " + t1 + " ms.");
        System.out.println("Apache:  " + t2 + " ms.");
    }
}
Exception in thread "main" java.lang.RuntimeException: pattern wrong.

Как это могло произойти? На самом деле всё правильно! Моя коллега нашла баг в своём коде: вместо метода .matches() нужно использовать .find(), поскольку первый проверяет соответствие всего аргумента регулярному выражению, а второй ищет вхождения искомого в переданной строке.

Исправляем:
public static void main(String[] args) {
    boolean[] result = new boolean[SIZE];

    long t1 = System.currentTimeMillis();
    for (int i = 0; i < SIZE; i++) {
        result[i] = PATTERN.matcher(TEST_STRING).find();
    }
    t1 = System.currentTimeMillis() - t1;

    for (boolean b : result) {
        if (!b) { throw new RuntimeException("pattern wrong."); }
    }

    long t2 = System.currentTimeMillis();
    for (int i = 0; i < 1000; i++) {
        StringUtils.startsWithAny(TEST_STRING, HTTP, HTTPS);
    }
    t2 = System.currentTimeMillis() - t2;

    for (boolean b : result) {
        if (!b) { throw new RuntimeException("apache wrong."); }
    }

    System.out.println("Pattern: " + t1 + " ms.");
    System.out.println("Apache:  " + t2 + " ms.");
}
На выходе получаем:
Pattern: 6 ms.
Apache:  8 ms.
Но я всё равно не верю в результат, поэтому пишу свой вариант бенчмарка.
Второй вариант
Я решил следовать лучшим практикам в области бенчмарканья, поэтому воспользовался Java Microbenchmark Harness. Юзал его впервые, но сложностей не возникло, так как в интернете достаточно информации о том, как его готовить: Baeldung, Хабр.
Создадим новый проект
Выполним следующую команду для создания подготовленного проекта:
mvn archetype:generate
          -DinteractiveMode=false
          -DarchetypeGroupId=org.openjdk.jmh
          -DarchetypeArtifactId=jmh-java-benchmark-archetype
          -DgroupId=ru.rcktsci.experiments
          -DartifactId=pattern-vs-apache-benchmark
          -Dversion=1.0
Будет создан каталог pattern-vs-apache-benchmark, открываем этот проект в IDEA.

Добавим в pom.xml зависимость на Apache Commons Lang 3:
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.10</version>
</dependency>
Код
Далее открываем единственный класс MyBenchmark и пишем код наших запусков.
@Warmup(iterations = 1, time = 10)
@Measurement(iterations = 1, time = 10)
@Fork(value = 1)
public class MyBenchmark {

    private static final String TEST_STRING = "https://some.example.com/api/images/9876543210";
    private static final Pattern PATTERN = Pattern.compile("^https?://");
    private static final String HTTP = "http://";
    private static final String HTTPS = "https://";

    @Benchmark
    public boolean testPattern() {
        return PATTERN.matcher(TEST_STRING).find();
    }

    @Benchmark
    public boolean testApache() {
        return StringUtils.startsWithAny(TEST_STRING, HTTP, HTTPS);
    }

    @Benchmark
    public boolean testManual() {
        return TEST_STRING.startsWith(HTTP)
               || TEST_STRING.startsWith(HTTPS);
    }
}
Тестируемые методы возвращают свой результат, чтобы оптимизатор не выбросил их совсем. Я также добавил аннотации @Fork, @Warmup и @Measurement для уменьшения времени запуска, так как мне не хочется ждать больше 15 минут (я делал длительные запуски и результаты не отличались). Кроме того, я добавил третий вариант, самый наивный: проверить вручную (уверен, если бы это был JavaScript, для этого тоже был бы свой пакет).
Запускаем измерения
Чтобы не собирать .jar и не запускать его руками, сэкономим время и нажмём в IDEA New configuration:
Достаточно только указать класс для запуска: org.openjdk.jmh.Main. Теперь подождём результаты выполнения и посмотрим на них.
Benchmark                 Mode  Cnt          Score   Error  Units
MyBenchmark.testApache   thrpt        32386558,554          ops/s
MyBenchmark.testManual   thrpt       378162645,117          ops/s
MyBenchmark.testPattern  thrpt        12985366,516          ops/s
Вот и всё. Оказалось, что всё-таки использование startsWithAny примерно в 2,5 раза выгоднее, чем проверка строки при помощи регулярного выражения. Но это требует подключения дополнительной зависимости, и точно не стоит этого делать только ради решения именно этой задачи.

С другой стороны, метод, написанный вручную, оказался в 11 раз быстрее StringUtils и в 29 раз быстрее регулярного выражения! Wow!
Заключение
Как вы могли понять, эта заметка не о том, как правильно проверить начало строки. В исходном MR остался вариант с регулярным выражением, так как он показался более читаемым. Нет совершенно никакой разницы, как решить поставленную задачу — скорее всего, во время работы в продакшене этот метод выполнится на порядки меньше раз, чем это произошло в данном бенчмарке. Он окружён походами в СУБД, мапперами и прочей логикой.

Но польза всё-таки есть. Во-первых, мы нашли 100% баг — использование метода matches() вместо find(). Это место в будущем покроет юнит-тест, но мы нашли его быстрее. Во-вторых, мы научились использовать ещё один современный инструмент для подтверждения своих гипотез.