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


140

Я новачок у 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
ви спробували це IntStreamзамість цього?
Марк Ротвевель

2
Ви можете, будь ласка, правильно виміряти? Якщо все, що ви робите, це один запуск, то, звичайно, ваші орієнтири будуть вимкнені.
skiwi

2
@MisterSmith Чи можемо ми мати певну прозорість щодо того, як ви розігріли свій JVM, також за допомогою тестів 1K?
skiwi

1
А для тих, хто зацікавлений у написанні правильних мікро-показників, ось питання: stackoverflow.com/questions/504103/…
Містер Сміт

2
@assylias Використання toListмає запускатися паралельно, навіть якщо він збирається до небезпечного списку, оскільки різні потоки збиратимуться до проміжних списків, обмежених потоками, перш ніж об'єднати.
Стюарт відзначає

Відповіді:


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

  2. Перестаньте писати код бенчмаркінгу вручну, використовуйте 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
Дякуємо, що знайшли час для вирішення цього питання. Я не думаю, що зміна LinkedList для ArrayList нічого не змінить, оскільки до цього слід додати обидва тести, час не повинен впливати. У будь-якому випадку, чи можете ви пояснити результати? Важко сказати, що ви вимірюєте тут (одиниці кажуть нс / оп, але що вважається оп?).
Містер Сміт

52
Ваш висновок про ефективність, хоча він дійсний, завищений. Існує маса випадків, коли потоковий код швидше, ніж ітераційний код, значною мірою тому, що витрати на доступ за один елемент дешевші за потоки, ніж для звичайних ітераторів. І в багатьох випадках версія потоків вказує на те, що еквівалентно рукописній версії. Звичайно, чорт у деталях; будь-який заданий біт коду може вести себе по-різному.
Брайан Гец

26
@BrianGoetz, чи можете ви вказати випадки використання, коли потоки швидші?
Олександр

1
В останній версії FMH: використовуйте @Benchmarkзамість@GenerateMicroBenchmark
pdem

3
@BrianGoetz, Чи можете ви вказати випадки використання, коли потоки швидші?
kiltek

17

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().


Потрібно мікробензувати цей тест, тобто його слід спочатку прогріти багато разів, а потім виконати багато тем і усереднити.
skiwi

@skiwi впевнений, ти маєш рацію, тим більше, що в вимірах є великі відхилення. Я провів лише базове дослідження і не претендую на точність результатів.
Сергій Федоров

JIT в серверному режимі починається після виконання 10 к. А потім потрібно якийсь час, щоб скласти код і поміняти його.
pveentjer

Про це речення: " Ви маєте collect(Collectors.toList())команду у вас, тобто може виникнути ситуація, коли вам потрібно буде звертатися до однієї колекції багатьма потоками ". Я майже впевнений, що toListзбирає паралельно декілька різних примірників списку. Тільки як останній крок колекції елементи переносяться в один список і потім повертаються. Тож не повинно бути синхронізації накладних витрат. Ось чому колектори мають як постачальника, акумулятора, так і комбайнера. (Звичайно, це може бути повільно з інших причин.)
Лій

@Lii Я так само думаю і про collectреалізацію тут. Зрештою, декілька списків повинні бути об'єднані в один, і, схоже, об'єднання - найважча операція в даному прикладі.
Сергій Федоров

4
    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);
}

Я трохи змінив код, побіг на моєму mac book pro, який має 8 ядер, і отримав розумний результат:

Колекції: минулий час: 1522036826 нс (1,522037 секунд)

Потоки: минулий час: 4315833719 нс (4,315834 секунди)

Паралельні потоки: минулий час: 261152901 нс (0,261153 секунди)


Я думаю, що ваш тест справедливий, вам просто потрібна машина, яка має більше процесорних ядер.
Меллон

3

Для того, що ви намагаєтеся зробити, я б не використовував звичайні java api. Зараз багато тон боксу / розблокування, тому великі накладні показники.

Особисто я вважаю, що багато розроблених API - це лайно, оскільки вони створюють багато об'єкта.

Спробуйте використовувати примітивні масиви double / int і спробуйте зробити це однопотоково і подивіться, яка продуктивність.

PS: Ви можете поглянути на JMH, щоб подбати про те, щоб зробити тест. Він піклується про деякі типові підводні камені, такі як розігрівання JVM.


LinkedLists навіть гірші, ніж ArrayLists, тому що вам потрібно створити всі об’єкти вузла. Оператор моди також собака повільний. Я вважаю, що на кшталт 10/15 циклів + це виснажує інструкцію. Якщо ви хочете зробити дуже швидке ділення на 2, просто змістіть число 1 біт праворуч. Це основні хитрощі, але я впевнений, що існують вдосконалені трюкові режими для прискорення роботи, але це, мабуть, більш конкретні проблеми.
pveentjer

Я знаю про бокс. Це лише неформальний орієнтир. Ідея полягає в тому, щоб мати однакову кількість боксу / розблокування як в колекціях, так і в потокових тестах.
Містер Сміт

Спочатку я би переконався, що це не вимірювальна помилка. Спробуйте запустити тест кілька разів, перш ніж робити справжній тест. Тоді принаймні у вас є розминка JVM, і код правильно ВСТАНОВЛЕНО. Без цього ви, мабуть, робите неправильні висновки.
pveentjer

Гаразд, я опублікую нові результати за вашими порадами. Я переглянув JMH, але він вимагає Maven, і для його налаштування потрібен певний час. Все одно, дякую.
Містер Сміт

Я думаю, що краще уникати думки про тестові показники з точки зору "Для того, що ти намагаєшся зробити". тобто зазвичай такі види вправ досить спрощені, щоб бути наочними, але досить складні, щоб виглядати так, що можуть / повинні бути спрощені.
ryvantage
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.