Время от времени я наблюдаю или даже бываю втянутым в спор о перформансе стримов в джаве. Общеизвестно, что стримы это компромисс между перформансом и удобством. Однако я не нашел вменяемого набора бенчмарков, которые бы показали, насколько именно медленны (или быстры) стримы. Поэтому я решил написать эти бенчмарки сам.

Что и как проверяли

Я измерил перформанс нескольких типичных юзкейсов для цикла forEach: применение некой функции к множеству, суммирование, фильтрация и группировка. В общем рутинные задачи. Те же задачи были решены через стримы.

Над выбором тула для бенчмарка долго думать не пришлось. JMH - проверенное в бою решение, развиваемое под эгидой команды OpenJDK. Фреймворк очень гибкий и простой в использовании.

Полный код тестов можно найти на Github.

Бенчмарки запускалиcь на 4-ядерной виртуальной машине на Java 21 с параметрами JVM: -Xms2G -Xmx2G. Операции выполнялись на листах размером 1000, 10 000,100 000, 1 000 000. Для каждого кейса использованы как последовательные, так и параллельные стримы.

Результаты представлены на графиках как проценты от лучшего результата в пределах размера выборки. Графики нормализованы таким образом, что для заданного размера списка находится лучший результат среди различных реализаций, и каждый результат делится на него. Лучший результат – 100%. Оценка 50 % означает, что эта реализация наполовину "медленнее", чем лучшая в данном тесте.

Суммирование целых чисел

Код
@State(Scope.Benchmark)
public static class Params {
    @Param({"1000", "10000", "100000", "1000000"})
    public int size;
    public List<Integer> items;

    @Setup
    public void setUp() {
        items = IntStream.range(0, size)
                .mapToObj(i -> i)
                .collect(Collectors.toList());
    }
}

@Benchmark
public int forEach(Params params) {
    int res = 0;
    for (Integer item : params.items) {
        res += item;
    }
    return res;
}

@Benchmark
public int collect(Params params) {
    return params.items.stream()
            .collect(Collectors.summingInt(i -> i));
}

@Benchmark
public int collectPar(Params params) {
    return params.items.parallelStream()
            .collect(Collectors.summingInt(i -> i));
}

@Benchmark
public int reduce(Params params) {
    return params.items.stream()
      .reduce(0, Integer::sum);
}

@Benchmark
public int reducePar(Params params) {
    return params.items.parallelStream()
      .reduce(0, Integer::sum);
}

Суммирование целых чисел
Суммирование целых чисел

Это было ожидаемо. Мы имеем победителя в лице forEach, но его преимущество падает с ростом размера коллекции. Особенно в сравнении с параллельным стримом. Подход сreduce самый медленный и даже распараллеливание не спасает ситуацию.

Если ваша программа по какой-то причине крутится вокруг суммирования целых чисел, то отдайте предпочтение классике. Если это звучит саркастично, то так оно и есть. На практике весь выигрыш в перформансе теряется в мире REST API, микросервисов и event driven архитектур. Если мы заменим Stream.collect() на forEach в рестовом сервисе, который суммирует целые и складывает результат в базу данных, то мы, возможно, выиграем какие-то десятые доли микросекунд, которые потеряются на фоне сериализации, десериализации, передаче "по проводу", обращению к БД и т.д.

Вычисление и суммирование

Давайте рассмотрим чуть более реалистичный пример. Выполним математическую операцию на коллекции случайно сгенерированных чисел с плавающией точкой и сложим результат.

Код
@State(Scope.Benchmark)
public static class Params {
    @Param({"1000", "10000", "100000", "1000000"})
    public int size;
    public List<Double> items;

    @Setup
    public void setUp() {
        Random random = new Random();
        items = random.doubles(size).mapToObj(i -> i)
                .collect(Collectors.toList());
    }
}

private static double calculate(double value) {
    return Math.sqrt(Math.sin(Math.log(value)));
}

@Benchmark
public Double forEach(Params params) {
    Double res = 0d;
    for (Double item : params.items) {
        res += calculate(item);
    }
    return res;
}

@Benchmark
public Double collect(Params params) {
    return params.items.stream()
            .map(DoubleCalculationBenchmark::calculate)
            .collect(Collectors.summingDouble(i -> i));
}

@Benchmark
public Double collectPar(Params params) {
    return params.items.parallelStream()
            .map(DoubleCalculationBenchmark::calculate)
            .collect(Collectors.summingDouble(i -> i));
}

@Benchmark
public Double reduce(Params params) {
    return params.items.stream()
            .map(DoubleCalculationBenchmark::calculate)
            .reduce(0d, Double::sum);
}

@Benchmark
public Double reducePar(Params params) {
    return params.items.parallelStream()
            .map(DoubleCalculationBenchmark::calculate)
            .reduce(0d, Double::sum);
}

Вычисление и суммирование
Вычисление и суммирование

Что ж, сюрприз. Результат forEach плюс-минус такой же, как и у последовательного стрима. Параллельный же стрим вырывается вперед на больших коллекциях и работает в 3-5 раз быстрее. Это объясняется довольно просто.

Этого не было видно в предыдущем бенчмарке, т.к. чем бОльшая часть программы выполняется последовательно, тем такая программа хуже распараллеливается. См. закон Амдала. Это как раз случай суммирования. Сложение двух чисел - очень быстрая операция, поэтому мы потратили больше времени на распараллеливание и последующую синхронизацию результата. Особенно в случае небольших коллекций.

Тот же принцип привел к немного лучшему перформансу функции reduce в этом бенчмарке. Эта функция высшего порядка принимает бинарные ассоциативные функции без побочных эффектов, работающие с иммутабельными объектами. Double.sum() попадает под это определение. Если мы выполняет этот контракт, то объединение результатов отлично распараллеливается и не требует синхронизации.

Группировка

Группировка с помощью стримов поистине лаконична и наглядна, поэтому широко используется в повседневных задачах. На результат влияет много факторов: стрим может быть последовательным или паралельным, упорядоченным или неупорядоченным. Мы имеем несколько опций выбора коллектора, или вообще можем использовать Stream.reduce() вместо Stream.collect() . Также можем использовать разные вспомогательные структуры данных и контейнеры, куда будут помещены результаты группировки. Поэтому далее так много кода.

Собственно задача бенчмарка: взять лист случайно сгенерированных чисел с плавающей точкой и сгруппировать результат по результату деления на 10, округленного до целого. Тип результата - Map<Long, List<Double>>

Код
static final double DIVISOR = 100.0;

@State(Scope.Benchmark)
public static class Params {
    @Param({"1000", "10000", "100000", "1000000"})
    public int size;
    public List<Double> items;

    @Setup
    public void setUp() {
        Random random = new Random();
        items = random.doubles(size)
                .mapToObj(i -> i)
                .collect(Collectors.toList());
    }
}

@Benchmark
public Map<Long, List<Double>> forEach(Params params) {
    Map<Long, List<Double>> res = new HashMap<>();

    for (Double item : params.items) {
        Long key = Math.round(item / DIVISOR);
        List<Double> list = res.get(key);
        if (list != null) {
            list.add(item);
        } else {
            list = new ArrayList<>();
            list.add(item);
            res.put(key, list);
        }
    }
    return res;
}

@Benchmark
public Map<Long, List<Double>> forEachLinked(Params params) {
    Map<Long, List<Double>> res = new HashMap<>();

    for (Double item : params.items) {
        Long key = Math.round(item / DIVISOR);
        List<Double> list = res.get(key);
        if (list != null) {
            list.add(item);
        } else {
            list = new LinkedList<>();
            list.add(item);
            res.put(key, list);
        }
    }
    return res;
}

@Benchmark
public Map<Long, List<Double>> forEachLinkedCompute(Params params) {
    Map<Long, List<Double>> res = new HashMap<>();

    for (Double item : params.items) {
        res.compute(Math.round(item / DIVISOR), (k, v) -> {
            if (v == null) {
                LinkedList<Double> list = new LinkedList<>();
                list.add(item);
                return list;
            }
            v.add(item);
            return v;
        });
    }
    return res;
}

@Benchmark
public Map<Long, List<Double>> collect(Params params) {
    return params.items.stream()
            .collect(Collectors
                     .groupingBy(n -> Math.round(n / DIVISOR)));
}

@Benchmark
public Map<Long, List<Double>> collectLinked(Params params) {
    return params.items.stream()
            .collect(Collectors.groupingBy(
                    n -> Math.round(n / DIVISOR),
                    Collectors.toCollection(LinkedList::new)));
}

@Benchmark
public Map<Long, List<Double>> collectPar(Params params) {
    return params.items.parallelStream()
            .collect(Collectors
                     .groupingBy(n -> Math.round(n / DIVISOR)));
}

@Benchmark
public Map<Long, List<Double>> collectParLinked(Params params) {
    return params.items.parallelStream()
            .collect(Collectors.groupingBy(
                    n -> Math.round(n / DIVISOR),
                    Collectors.toCollection(LinkedList::new)));
}

@Benchmark
public Map<Long, List<Double>> collectParOpt(Params params) {
    return params.items.parallelStream()
            .unordered()
            .collect(Collectors.groupingByConcurrent(
                    n -> Math.round(n / DIVISOR),
                    Collectors.toCollection(LinkedList::new)));
}

@Benchmark
public io.vavr.collection.HashMap<Long, io.vavr.collection.List<Double>> reducePar(Params params) {
    return params.items.parallelStream()
            .reduce(
                    io.vavr.collection.HashMap.empty(),
                    (m, n) -> {
                        Long key = Math.round(n / DIVISOR);

                        return m.put(key, m.get(key)
                                .map(l -> l.prepend(n))
                                .getOrElse(() -> io.vavr.collection.List.of(n)));
                    },
                    (l, r) -> l.merge(r, io.vavr.collection.List::prependAll));
}

@Benchmark
public io.vavr.collection.HashMap<Long, io.vavr.collection.List<Double>> reduceParUnord(Params params) {
    return params.items.parallelStream()
            .unordered()
            .reduce(
                    io.vavr.collection.HashMap.empty(),
                    (m, n) -> {
                        Long key = Math.round(n / DIVISOR);

                        return m.put(key, m.get(key)
                                .map(l -> l.prepend(n))
                                .getOrElse(() -> io.vavr.collection.List.of(n)));
                    },
                    (l, r) -> l.merge(r, io.vavr.collection.List::prependAll));
}

Группировка
Группировка

Среди последовательных реализаций forEach немного быстрее стримов, группирующих результат в Map . Использование LinkedList в теории может улучшить результат, но не для параллельных стримов. На деле при использовании Collectors.toCollection(LinkedList::new) наблюдается серьезная потеря производительности.

Для испытания Stream.reduce() на параллельных стримах мы должны использовать иммутабельные структуры. Обычная HashMap - мутабельная, поэтому на помощь спешат Map и List из библиотеки Vavr. Правда, без особого успеха. Честно говоря, я ждал большего от reduce на параллельных стримах, но потом я понял, что ожидания были беспочвенными. У Map из Varv функции get и put имеют константную сложность, но нам приходится каждый раз вызывать их обе, т.к. нет удобной функции Map.compute() , как в стандартном API.

Мне не хотелось бы делать существенных выводов относительно причин достигнутых результатов. Собранных данных недостаточно. На вопрос "стримы или не стримы", похоже, есть следующие ответы. Если нас волнует производительность на уровне микросекунд, мы должны использовать последовательный forEach или параллельный Stream.collect() . И нам лучше оставить попытки преждевременной оптимизации, если мы не собираемся проверять их в реальных сценариях.

Фильтрация и сортировка

Еще один юзкейс для стримов - фильтрация, сортировка и выборка уникальных элементов из коллекции. Конечно, не всегда всё вместе. Бенчмарк берет случайные числа, фильтрует их по попаданию между минимальным и максимальным значениями, выбирает уникальные значения и сортирует в натуральном порядке.

Код

static final double MIN = 0.0;
static final double MAX = 10.0;

@State(Scope.Benchmark)
public static class Params {
    @Param({"1000", "10000", "100000", "1000000"})
    public int size;
    public List<Double> items;

    @Setup
    public void setUp() {
        Random random = new Random();
        List<Double> values = random.doubles(500, MIN, MAX + 5)
                .mapToObj(i -> i)
                .collect(Collectors.toList());
        items = random.ints(size, 0, values.size())
                .mapToObj(values::get)
                .collect(Collectors.toList());
    }
}

@Benchmark
public Collection<Double> forEach(Params params) {
    Set<Double> set = new HashSet<>();
    for (Double item : params.items) {
        if (item > MIN && item < MAX) {
            set.add(item);
        }
    }
    List<Double> res = new ArrayList<>(set);
    Collections.sort(res);
    return res;
}

@Benchmark
public Collection<Double> forEachTreeSet(Params params) {
    Set<Double> set = new TreeSet<>();
    for (Double item : params.items) {
        if (item > MIN && item < MAX) {
            set.add(item);
        }
    }
    return set;
}

@Benchmark
public Collection<Double> collect(Params params) {
    return params.items.stream()
            .filter(n -> n > MIN && n < MAX)
            .distinct()
            .sorted()
            .collect(Collectors.toList());
}

@Benchmark
public Collection<Double> collectPar(Params params) {
    return params.items.parallelStream()
            .filter(n -> n > MIN && n < MAX)
            .distinct()
            .sorted()
            .collect(Collectors.toList());
}

Фильтрация и сортировка
Фильтрация и сортировка

Видим знакомый паттерн: чем длиннее коллекция, тем эффективнее параллельные стримы. Попытка оптимизировать сортировку через TreeSet не имела успеха.

Заключение

Похоже, обойдемся без откровений. Стримы в разных ситуациях могут быть и быстрее и медленнее императивных циклов. И я действительно рад, что стримы в целом хороши. По крайней мере в области ежедневных задач в энтерпрайзе. Если бы я был вынужден прекратить использования стримов из-за слабого перфоманса, мне бы не хватало этой красивой и наглядной абстракции.

Один момент, оставшийся без внимания. Все параллельные стримы используют один и тот же ForkJoinPool (если только вы не задали пул явно - прим. пер.). Все слышали, что поэтому мы должны быть осторожнее при массовом использовании параллельных стримов - но что сказали бы цифры?

Апдейт

В комментариях меня спросили насчет влияния JIT-компилятора на перформанс forEach. Ниже результаты прогонов бенчмарка с выключенным JIT-компилятором (параметр JVM -Xint)

Суммирование. JIT выключен
Суммирование. JIT выключен
Вычисление и суммирование. JIT вылючен.
Вычисление и суммирование. JIT вылючен.
Группировка. JIT выключен.
Группировка. JIT выключен.
Фильтрация и сортировка. JIT выключен.
Фильтрация и сортировка. JIT выключен.

Комментарии (13)


  1. qvan
    16.04.2024 07:14

    А с занимаемой памятью что?)


    1. Vladimirsencov
      16.04.2024 07:14

      Да особо разницы не будет если stateless стрим конечно.


  1. DenSigma
    16.04.2024 07:14

    Для начала - где blackHole? Дальше в принципе можно не обсуждать.

    Второе - Java предназначена для энтерпрайза. В энетрепрайзе в стримы никогда не передаются массивы на тысячи и миллионы объектов. Подавляющее большинство - порядка десятка. У клиента не может быть тысячи счетов. История занимает три-четыре периода. И так далее. И везде стримы на таких маленьких массивчиках. Почему автор не сравнил обработку массивов на десять чисел? Нелицеприятные результаты не в пользу стримов?


    1. panzerfaust Автор
      16.04.2024 07:14

      У клиента не может быть тысячи счетов

      А энтерпрайз это только про клиентов и счета? Навскидку (из того, с чем я работал) в телекоме могут быть связи аккаунт - телефонные номера с сотнями тысяч номеров. Временные ряды со статистикой проездов по федеральной трассе - миллионы записей. Трейдинг - сотни тысяч котировок. Ну да, другой вопрос, что в коллекции все сразу они не окажутся. А то OOM будет.

      Нелицеприятные результаты не в пользу стримов?

      Так видно же, что как раз на малой коллекции форыч быстрее. Что у вас "власти скрывают"-то? И автор как раз радуется, что стримы не настолько плохи, чтобы был резон от них оказываться.


    1. domix32
      16.04.2024 07:14

      Какой-нибудь gitlab или appscreener под капотом тоже джаву содержат, которая потоково обрабатывают код. Там есть "жирненькие" места, которые могли бы получить буст по перформансу от перехода на что-то производительное.


    1. Maccimo
      16.04.2024 07:14

      Для начала - где blackHole?

      «Когда в руки попадает чёрная дыра, перестаёшь замечать происходящее за горизонтом событий.»

      Оно здесь не надо. Результат вычислений возвращается из забенчмаркленных методов во внешний мир.

      Current infrastructure generates the synthetic code that runs @Benchmark in a specially-constructed loop, and consumes the results into the Blackhole.

      https://mail.openjdk.org/pipermail/jmh-dev/2016-August/002294.html

      У Шипилёва в древних заметках, при желании, можно найти аналогичные утверждения.

      A Blackhole is used when it is not convenient to return a single object from a benchmark method. This happens when the benchmark produces several values, and we want to make sure that the virtual machine will not speculate based on the observation that the benchmark code does not make use of these.

      https://www.oracle.com/technical-resources/articles/java/architect-benchmarking.html

      Java предназначена для энтерпрайза.

      Java всю жизнь была Write Once Run Anywhere, не нужно тут этих ваших когнитивных искажений.

      В энетрепрайзе в стримы никогда не передаются массивы на тысячи и миллионы объектов.

      И SELECT * FROM ... тоже никто никогда не делает, да ;)

      Нелицеприятные результаты не в пользу стримов?

      Не смог распарсить предложение, извините.

      нелицеприя́тный
      Прилагательное, качественное
      Беспристрастный, справедливый.


  1. schernolyas
    16.04.2024 07:14

    Спасибо! Интересная статья!


  1. yarston
    16.04.2024 07:14

    1000000 Integers из бенчмарка влезут в кэш современных процессоров, тогда как в реальном приложении, скорее всего, читаться они будут не из кэша. В таком случае int (размер 4 байта) вместо Integer (24 байта) должен быть быстрее, но стримы с ним работать не умеют.


    1. konsoletyper
      16.04.2024 07:14
      +2

      А как же IntStream?


      1. yarston
        16.04.2024 07:14

        О, спасибо, не знал про такой.


    1. Vladimirsencov
      16.04.2024 07:14

      Умеют. Есть Stream для примитивов


  1. knagaev
    16.04.2024 07:14

    Перформанс :)


  1. kacetal
    16.04.2024 07:14

    Думаю forEach можно было бы улучшить если бы использовался обычный for, он бы не создавал лишний объект итератор.