Java 8: производительность потоков и коллекций

Я новичок в Java 8. Я до сих пор не знаю API, но я сделал небольшой неофициальный тест для сравнения производительности нового API Streams и старых добрых коллекций.

Тест состоит в фильтрации списка Integer чисел и для каждого четного числа вычисляет квадратный корень и сохраняет его в результирующем List Double чисел.

Вот код:

    public static void main(String[] args) {
        //Calculating square root of even numbers from 1 to N       
        int min = 1;
        int max = 1000000;

        List<Integer> sourceList = new ArrayList<>();
        for (int i = min; i < max; i++) {
            sourceList.add(i);
        }

        List<Double> result = new LinkedList<>();


        //Collections approach
        long t0 = System.nanoTime();
        long elapsed = 0;
        for (Integer i : sourceList) {
            if(i % 2 == 0){
                result.add(Math.sqrt(i));
            }
        }
        elapsed = System.nanoTime() - t0;       
        System.out.printf("Collections: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


        //Stream approach
        Stream<Integer> stream = sourceList.stream();       
        t0 = System.nanoTime();
        result = stream.filter(i -> i%2 == 0).map(i -> Math.sqrt(i)).collect(Collectors.toList());
        elapsed = System.nanoTime() - t0;       
        System.out.printf("Streams: Elapsed time:\t\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


        //Parallel stream approach
        stream = sourceList.stream().parallel();        
        t0 = System.nanoTime();
        result = stream.filter(i -> i%2 == 0).map(i -> Math.sqrt(i)).collect(Collectors.toList());
        elapsed = System.nanoTime() - t0;       
        System.out.printf("Parallel streams: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));      
    }.

И вот результаты для двухъядерного компьютера:

    Collections: Elapsed time:        94338247 ns   (0,094338 seconds)
    Streams: Elapsed time:           201112924 ns   (0,201113 seconds)
    Parallel streams: Elapsed time:  357243629 ns   (0,357244 seconds)

Для этого конкретного теста потоки примерно в два раза медленнее, чем коллекции, и параллелизм не помогает (или я использую его неправильно?).

Вопросы:

  • Справедлив ли этот тест? Я сделал какую-нибудь ошибку?
  • Потоки медленнее коллекций? Кто-нибудь сделал хороший формальный тест на это?
  • К какому подходу я должен стремиться?

Обновленные результаты.

Я выполнил тест 1к раз после прогрева JVM (1к итераций), как советовал @pveentjer:

    Collections: Average time:      206884437,000000 ns     (0,206884 seconds)
    Streams: Average time:           98366725,000000 ns     (0,098367 seconds)
    Parallel streams: Average time: 167703705,000000 ns     (0,167704 seconds)

В этом случае потоки более производительны. Интересно, что будет наблюдаться в приложении, где функция фильтрации вызывается только один или два раза во время выполнения.

Ответ 1

  • Прекратить использование LinkedList для чего угодно, кроме тяжелого удаления из середины списка с помощью итератора.

  • Остановите запись кода бенчмаркинга вручную, используйте JMH.

Правильные тесты:

@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
@OperationsPerInvocation(StreamVsVanilla.N)
public class StreamVsVanilla {
    public static final int N = 10000;

    static List<Integer> sourceList = new ArrayList<>();
    static {
        for (int i = 0; i < N; i++) {
            sourceList.add(i);
        }
    }

    @Benchmark
    public List<Double> vanilla() {
        List<Double> result = new ArrayList<>(sourceList.size() / 2 + 1);
        for (Integer i : sourceList) {
            if (i % 2 == 0){
                result.add(Math.sqrt(i));
            }
        }
        return result;
    }

    @Benchmark
    public List<Double> stream() {
        return sourceList.stream()
                .filter(i -> i % 2 == 0)
                .map(Math::sqrt)
                .collect(Collectors.toCollection(
                    () -> new ArrayList<>(sourceList.size() / 2 + 1)));
    }
}

Результат:

Benchmark                   Mode   Samples         Mean   Mean error    Units
StreamVsVanilla.stream      avgt        10       17.588        0.230    ns/op
StreamVsVanilla.vanilla     avgt        10       10.796        0.063    ns/op

Так же, как я ожидал, что реализация потока будет довольно медленной. JIT может встраивать все элементы лямбды, но не представляет собой совершенно лаконичный код в виде ванильной версии.

Как правило, потоки Java 8 не являются волшебными. Они не могли ускорить уже хорошо реализованные вещи (возможно, с помощью простых итераций или Java 5 для каждого оператора, замененного вызовами Iterable.forEach() и Collection.removeIf()). Потоки - это больше о удобстве и безопасности кодирования. Здесь работает удобство - скорость компромисса.

Ответ 2

1) Вы видите время менее 1 секунды, используя ваш тест. Это означает, что на ваши результаты могут оказать сильное влияние побочные эффекты. Итак, я увеличил вашу задачу в 10 раз

    int max = 10_000_000;

и побежал ваш тест. Мои результаты:

Collections: Elapsed time:   8592999350 ns  (8.592999 seconds)
Streams: Elapsed time:       2068208058 ns  (2.068208 seconds)
Parallel streams: Elapsed time:  7186967071 ns  (7.186967 seconds)

без редактирования (int max = 1_000_000) результаты были

Collections: Elapsed time:   113373057 ns   (0.113373 seconds)
Streams: Elapsed time:       135570440 ns   (0.135570 seconds)
Parallel streams: Elapsed time:  104091980 ns   (0.104092 seconds)

Это как ваши результаты: поток медленнее, чем сбор. Вывод: много времени было потрачено на инициализацию потока/передачу значений.

2) После увеличения поток задач стал быстрее (это нормально), но параллельный поток оставался слишком медленным. Что не так? Примечание: у вас есть команда collect(Collectors.toList()) в вашей команде. Сбор в единую коллекцию существенно снижает производительность и снижает производительность при одновременном выполнении. Можно оценить относительную стоимость накладных расходов, заменив

collecting to collection -> counting the element count

Для потоков это можно сделать с помощью collect(Collectors.counting()). Я получил результаты:

Collections: Elapsed time:   41856183 ns    (0.041856 seconds)
Streams: Elapsed time:       546590322 ns   (0.546590 seconds)
Parallel streams: Elapsed time:  1540051478 ns  (1.540051 seconds)

Это для большой задачи! (int max = 10000000) Вывод: сбор предметов для сбора занял большую часть времени. Самая медленная часть - это добавление в список. Кстати, простой ArrayList используется для Collectors.toList().

Ответ 3

    public static void main(String[] args) {
    //Calculating square root of even numbers from 1 to N       
    int min = 1;
    int max = 10000000;

    List<Integer> sourceList = new ArrayList<>();
    for (int i = min; i < max; i++) {
        sourceList.add(i);
    }

    List<Double> result = new LinkedList<>();


    //Collections approach
    long t0 = System.nanoTime();
    long elapsed = 0;
    for (Integer i : sourceList) {
        if(i % 2 == 0){
            result.add( doSomeCalculate(i));
        }
    }
    elapsed = System.nanoTime() - t0;       
    System.out.printf("Collections: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


    //Stream approach
    Stream<Integer> stream = sourceList.stream();       
    t0 = System.nanoTime();
    result = stream.filter(i -> i%2 == 0).map(i -> doSomeCalculate(i))
            .collect(Collectors.toList());
    elapsed = System.nanoTime() - t0;       
    System.out.printf("Streams: Elapsed time:\t\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


    //Parallel stream approach
    stream = sourceList.stream().parallel();        
    t0 = System.nanoTime();
    result = stream.filter(i -> i%2 == 0).map(i ->  doSomeCalculate(i))
            .collect(Collectors.toList());
    elapsed = System.nanoTime() - t0;       
    System.out.printf("Parallel streams: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));      
}

static double doSomeCalculate(int input) {
    for(int i=0; i<100000; i++){
        Math.sqrt(i+input);
    }
    return Math.sqrt(input);
}

Я немного изменил код, побежал на моем macbook pro, который имеет 8 ядер, я получил разумный результат:

Коллекции: Истекшее время: 1522036826 нс (1.522037 секунд)

Потоки: Истекшее время: 4315833719 нс (4.315834 секунды)

Параллельные потоки: Истекшее время: 261152901 нс (0,261153 секунды)

Ответ 4

Для того, что вы пытаетесь сделать, я бы не использовал обычный java api в любом случае. Существует тонна бокса/распаковки, поэтому накладные расходы высоки.

Лично я считаю, что много разработанных API - это дерьмо, потому что они создают много помех для объекта.

Попробуйте использовать примитивные массивы double/int и попытайтесь сделать это однопоточным и посмотреть, что такое производительность.

PS: Возможно, вам стоит взглянуть на JMH, чтобы позаботиться о выполнении теста. Он заботится о некоторых типичных ловушках, таких как разогревание JVM.