Почему многопоточность с CompletableFuture медленная по сравнению с однопоточным кодом? - PullRequest
0 голосов
/ 22 января 2019

Я пытаюсь улучшить производительность текущего кода в моем проекте, который выполняется в одном потоке.Код делает что-то вроде этого: 1. Получить первый список из 10000000 объектов.2. Получить второй список из 10000000 объектов.3. Объедините эти два (после некоторых изменений) в третий список.

   Instant s = Instant.now();
    List<Integer> l1 = getFirstList();
    List<Integer> l2 = getSecondList();
    List<Integer> l3 = new ArrayList<>();
    l3.addAll(l1);
    l3.addAll(l2);
    Instant e = Instant.now();
    System.out.println("Execution time: " + Duration.between(s, e).toMillis());

Вот примеры методов для получения и объединения списков

    private static List<Integer> getFirstList() {
    System.out.println("First list is being created by: "+ Thread.currentThread().getName());
    List<Integer> l = new ArrayList<>();
    for (int i = 0; i < 10000000; i++) {
        l.add(i);
    }
    return l;
}

private static List<Integer> getSecondList() {

    System.out.println("Second list is being created by: "+ Thread.currentThread().getName());
    List<Integer> l = new ArrayList<>();
    for (int i = 10000000; i < 20000000; i++) {
        l.add(i);
    }
    return l;
}
private static List<Integer> combine(List<Integer> l1, List<Integer> l2) {

    System.out.println("Third list is being created by: "+ Thread.currentThread().getName());
   ArrayList<Integer> l3 = new ArrayList<>();
   l3.addAll(l1);
   l3.addAll(l2);
    return l3;
}

Я пытаюсь переписатьприведенный выше код выглядит следующим образом:

    ExecutorService executor = Executors.newFixedThreadPool(10);
    Instant start = Instant.now();
    CompletableFuture<List<Integer>> cf1 = CompletableFuture.supplyAsync(() -> getFirstList(), executor);
    CompletableFuture<List<Integer>> cf2 = CompletableFuture.supplyAsync(() -> getSecondList(), executor);

    CompletableFuture<Void> cf3 = cf1.thenAcceptBothAsync(cf2, (l1, l2) -> combine(l1, l2), executor);
    try {
        cf3.get();
    } catch (InterruptedException e) {
        e.printStackTrace();
    } catch (ExecutionException e) {
        e.printStackTrace();
    }
    Instant end = Instant.now();
    System.out.println("Execution time: " + Duration.between(start, end).toMillis());

    executor.shutdown();

Однопоточный код выполняется за 4-5 секунд, а многопоточный код занимает более 6 секунд.Я что-то не так делаю?

Ответы [ 2 ]

0 голосов
/ 22 января 2019

Вы выполняете эти методы в первый раз, поэтому они запускаются в интерпретированном режиме. Чтобы ускорить их первое выполнение, оптимизатор должен заменить их во время работы (это называется заменой в стеке), что не всегда дает ту же производительность, что и при повторном вводе оптимизированного результата. Делать это одновременно кажется еще хуже, по крайней мере для Java 8, поскольку я получил совершенно другие результаты для Java 11.

Таким образом, первым шагом будет вставка явного вызова, например getFirstList(); getSecondList();, чтобы увидеть, как он будет работать, когда не вызывается в первый раз.

Другим аспектом является сборка мусора. Некоторые JVM начинаются с небольшой начальной кучи и будут выполнять полный сборщик мусора при каждом расширении кучи, что влияет на все потоки.

Таким образом, второй шаг будет начинаться с -Xms1G (или даже лучше, -Xms2G), чтобы начать с разумного размера кучи для количества объектов, которые вы собираетесь создать.

Но обратите внимание, что 3-й шаг добавления промежуточных списков результатов в список окончательных результатов (который происходит последовательно в любом случае) оказывает значительное влияние на производительность.

Таким образом, третьим шагом будет замена построения окончательного списка на l3 = new ArrayList<>(l1.size() + l2.size()) для обоих вариантов, чтобы гарантировать, что список имеет соответствующую начальную емкость.

Сочетание этих шагов дало менее секунды для последовательного выполнения и менее половины секунды для многопоточного выполнения под Java 8.

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

0 голосов
/ 22 января 2019

в однопоточном варианте l3.addAll(l1); l3.addAll(l2); извлекает элементы l1 и l2 из кэша процессора (они были помещены туда во время выполнения getFirstList и getSecondList).

Параллельновариант, метод combine() работает на другом ядре процессора с пустым кешем и получает все элементы из основной памяти, что намного медленнее.

...