Производительность TreeSet и Java 8 Streams

Какой способ наиболее эффективен для обработки отдельной и отсортированной коллекции?

1. Улучшенный цикл с TreeSet

Set<MyObj> ret = new TreeSet<>();
for (Foo foo : foos)
    ret.add(new MyObj(foo));

2. Простой поток

List<MyObj> ret = foos.stream().map(MyObj::new)
                      .distinct().sorted()
                      .collect(Collectors.toList());

3. поток TreeSet

Set<MyObj> ret = foos.stream().map(MyObj::new)
                     .collect(Collectors.toCollection(TreeSet::new));

Первый способ кажется наименее элегантным, но легко читаемым. Второй способ заставляет меня опасаться, что distinct и sorted будут обрабатывать поток два раза. Последний способ чувствует себя хорошо, но что такое служебные данные TreeSet в потоке?

Любые подсказки? Спасибо.

Ответ 1

Первоначальный анализ

Судя по исходному коду Stream API, моим первоначальным предположением было бы: для многих элементов простой поток (2) должен быть самым быстрым, значительно превосходящим версию TreeSet (1), а затем поток TreeSet (3) должен следовать немного за. Для коротких наборов данных (1), вероятно, будет лучше, чем (2), что лучше, чем (3), потому что создание потока добавляет некоторые накладные расходы. Четко отсортированный поток работает примерно так:

Set<MyObj> set = new HashSet<>();
List<MyObj> result = new ArrayList<>();
for (Foo foo : foos) {
    MyObj myObj = new MyObj(foo);
    if(set.add(myObj))
        result.add(myObj);
}
result.sort(null);
return result;

Добавьте эту реализацию как (4). Он использует HashSet, чтобы проверить, отличны ли результаты, добавив их в промежуточный контейнер, а затем сортирует их. Это должно быть быстрее, чем поддерживать TreeSet, поскольку нам не нужно сохранять порядок после каждой вставки (что TreeSet делает, возможно, перебалансирует дерево). Реализация фактического потока будет несколько менее эффективной, поскольку не может сортировать полученный список на месте. Вместо этого он создает промежуточный контейнер, сортирует его, а затем выдает результат в конечный список, используя серию вызовов list.add.

Результат может зависеть от количества элементов в исходной коллекции foos, а также от количества отдельных элементов. Я называю это разнообразием: разнесение = 1 означает, что примерно каждый элемент отличается; Разнесение = 0,5 означает, что каждый элемент повторяется примерно два раза. Также результат может сильно зависеть от начального порядка элементов: алгоритмы сортировки могут быть на порядок быстрее, если входные данные предварительно заданы или почти прерваны.

Экспериментальная установка

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

  • размер (количество элементов в foos): 10, 1000, 100000
  • разнообразие (доля разных): 1, 0.5, 0.2
  • presorted: true, false

Я предполагаю, что Foo содержит только одно поле int. Конечно, результат может сильно зависеть от compareTo, equals и hashCode реализации класса Foo, потому что версии (2) и (4) используют equals и hashCode, а версии (1) и ( 3) используйте compareTo. Мы сделаем это просто:

@Override
public int hashCode() {
    return x;
}

@Override
public boolean equals(Object o) {
    return this == o || (o != null && getClass() == o.getClass() && x == ((Foo) o).x);
}

@Override
public int compareTo(Foo o) {
    return Integer.compare(x, o.x);
}

Предварительно созданные элементы могут быть сгенерированы с помощью:

foos = IntStream.range(0, size)
                .mapToObj(x -> new Foo((int)(x*diversity)))
                .collect(Collectors.toList());

Случайные элементы могут быть сгенерированы с помощью:

foos = new Random().ints(size, 0, (int) (size * diversity))
                   .mapToObj(Foo::new)
                   .collect(Collectors.toList());

Использование JMH 1.13 и JDK 1.8.0_101, VM 25.101-b13 64 бит для выполнения измерений

Результаты

Предварительно (все время находится в мкс):

diversity size      (1)      (2)      (3)      (4)
  1         10      0.2      0.5      0.3      0.2
  1       1000     48.0     36.0     53.0     24.2
  1     100000  14165.7   4759.0  15177.3   3341.6
0.5         10      0.2      0.3      0.2      0.2
0.5       1000     36.9     23.1     41.6     20.1
0.5     100000  11442.1   2819.2  12508.7   2661.3
0.2         10      0.1      0.3      0.2      0.2
0.2       1000     32.0     13.0     29.8     16.7
0.2     100000   8491.6   1969.5   8971.9   1951.7

Не рекомендуется:

diversity size      (1)      (2)      (3)      (4)
  1         10      0.2      0.4      0.2      0.3
  1       1000     72.8     77.4     73.6     72.7
  1     100000  21599.9  16427.1  22807.8  16322.2
0.5         10      0.2      0.3      0.2      0.2
0.5       1000     64.8     46.9     69.4     45.5
0.5     100000  20335.2  11190.3  20658.6  10806.7
0.2         10      0.1      0.3      0.2      0.2
0.2       1000     48.0     19.6     56.7     22.2
0.2     100000  16713.0   5533.4  16885.0   5930.6

Обсуждение

Мои первоначальные предположения были в целом правильными. Для предварительных данных (2) и (4) времена лучше, когда у нас есть 100 000 элементов. Разница становится еще больше, когда у нас много дубликатов, так как они не увеличивают время сортировки, а повторная вставка в HashSet намного эффективнее, чем повторная вставка в TreeSet. Для случайных данных разница менее впечатляющая, так как производительность TreeSet намного меньше зависит от порядка ввода данных, чем алгоритм TimSort (который используется Java для сортировки списков и массивов). Для небольших наборов данных простой TreeSet выполняется быстро, но использование (4) версии также может быть конкурентоспособным.

Исходный код эталона вместе с исходными результатами доступен здесь.

Ответ 2

Трудно дать хороший ответ, не анализируя ввод. В любом случае я поделюсь своими результатами:

Я сделал Foo контейнер для одного long и MyObj контейнера для одиночного Foo. Также я закончил все тесты с копированием данных в простой массив. Также я добавил два подхода:

4). Простой массив

@Benchmark
public void simpleArray(Blackhole bh) {
    MyObj[] ret = new MyObj[foos.size()];
    for (int i=0;i<foos.size();i++)
        ret[i] = new MyObj(foos.get(i));
    Arrays.sort(ret);
    int lastDistinct = 0;
    for (int i = 1; i < ret.length; i++) {
        if (ret[i].equals(ret[lastDistinct])) {
            continue;
        }
        lastDistinct++;
        ret[lastDistinct] = ret[i];
    }
    MyObj[] ret2 = new MyObj[lastDistinct + 1];
    System.arraycopy(ret, 0, ret2, 0, lastDistinct + 1);
    bh.consume(ret2);
}

5). Обратный порядок distinct и order of (2):

@Benchmark
public void simpleStream_distinctAfterSort(Blackhole bh) {
    List<MyObj> ret = foos.stream().map(MyObj::new)
            .sorted().distinct()
            .collect(Collectors.toList());
    bh.consume(ret.toArray(new MyObj[ret.size()]));
}

Настройка тестов:

public static final int MAX_SIZE = 10_000;
public static final long ELEM_THRESHOLD = MAX_SIZE * 10;
private List<Foo> foos;

@Setup
public void init() throws IOException, IllegalAccessException, InstantiationException {
    foos = new ArrayList<>(MAX_SIZE);
    for (int i = 0; i < MAX_SIZE; i++) {
        foos.add(new Foo(ThreadLocalRandom.current().nextLong(ELEM_THRESHOLD)));
    }
}

Теперь результаты с разным размером и порогом:

Size=10_000
Threshold=Size*10
Benchmark                                         Mode  Cnt    Score   Error  Units
StreamBenchmark5.enhancedLoop_TreeSet            thrpt    2  478,978          ops/s
StreamBenchmark5.simpleArray                     thrpt    2  591,287          ops/s
StreamBenchmark5.simpleStream                    thrpt    2  407,556          ops/s
StreamBenchmark5.simpleStream_distinctAfterSort  thrpt    2  533,091          ops/s
StreamBenchmark5.treeSetStream                   thrpt    2  492,709          ops/s

Size=10_000
Threshold=Size/10
StreamBenchmark5.enhancedLoop_TreeSet            thrpt    2   937,908          ops/s
StreamBenchmark5.simpleArray                     thrpt    2   593,983          ops/s
StreamBenchmark5.simpleStream                    thrpt    2  3344,508          ops/s
StreamBenchmark5.simpleStream_distinctAfterSort  thrpt    2   560,652          ops/s
StreamBenchmark5.treeSetStream                   thrpt    2  1000,585          ops/s

Size=500_000
Threshold=Size*10
Benchmark                                         Mode  Cnt  Score   Error  Units
StreamBenchmark5.enhancedLoop_TreeSet            thrpt    2  1,809          ops/s
StreamBenchmark5.simpleArray                     thrpt    2  4,009          ops/s
StreamBenchmark5.simpleStream                    thrpt    2  2,443          ops/s
StreamBenchmark5.simpleStream_distinctAfterSort  thrpt    2  4,141          ops/s
StreamBenchmark5.treeSetStream                   thrpt    2  2,040          ops/s

Size=500_000
Threshold=Size/10
Benchmark                                         Mode  Cnt   Score   Error  Units
StreamBenchmark5.enhancedLoop_TreeSet            thrpt    2   5,724          ops/s
StreamBenchmark5.simpleArray                     thrpt    2   4,567          ops/s
StreamBenchmark5.simpleStream                    thrpt    2  19,001          ops/s
StreamBenchmark5.simpleStream_distinctAfterSort  thrpt    2   4,840          ops/s
StreamBenchmark5.treeSetStream                   thrpt    2   5,407          ops/s

Size=1_000_000
Threshold=Size/100
Benchmark                                         Mode  Cnt   Score   Error  Units
StreamBenchmark5.enhancedLoop_TreeSet            thrpt    2   4,529          ops/s
StreamBenchmark5.simpleArray                     thrpt    2   2,402          ops/s
StreamBenchmark5.simpleStream                    thrpt    2  35,699          ops/s
StreamBenchmark5.simpleStream_distinctAfterSort  thrpt    2   2,232          ops/s
StreamBenchmark5.treeSetStream                   thrpt    2   4,889          ops/s

Как вы можете видеть в зависимости от количества дубликатов предпочтительных изменений алгоритма. Самый сбалансированный подход - TreeSet (3), однако самый быстрый из них - почти всегда простой поток (с order и distinct, расположенный в соответствии с входными данными).

Здесь источник теста, если вы готовы немного поиграть. Вам понадобится JMH.

Ответ 3

Если вам нужен только отсортированный массив Foo, и вы хотите сделать это быстро, вы не должны использовать TreeSet. Это переоснащение. Использование потоков, когда вам нужна высокая скорость, также не очень хорошая идея.

Далее. Существует метод List.sort, который эффективно работает для ArrayList (по умолчанию он копирует элементы во временном массиве и сортирует его, но ArrayList уже имеет массив внутри).

Таким образом, эффективный код является вариацией первого:

List<MyObj> ret = new ArrayList<>(foos.size());
for (Foo foo : foos) {
    ret.add(new MyObj(foo));
}
ret.sort();

Извините за отсутствие тестов, сделайте это сами.