Статьи

Учебник по производительности Java — Насколько быстры потоки Java 8?

В этом предварительном просмотре JAX Magazine спикер JAX London Анджелика Лангер ответит на самый важный вопрос для всех, кто использует потоки Java: действительно ли они быстрее?

Java 8 стала основным дополнением к структуре сбора JDK, а именно потоковым API. Подобно коллекциям, потоки представляют собой последовательности элементов. Коллекции поддерживают такие операции, как add() , remove() и contains() которые работают с одним элементом. Потоки, напротив, имеют массовые операции, такие как forEach() , filter() , map() и reduce() которые обращаются ко всем элементам в последовательности. Идея потока Java вдохновлена ​​функциональными языками программирования, где соответствующая абстракция обычно называется последовательностью, в которой также есть операции filter-map-Reduce. Из-за этого сходства Java 8 — по крайней мере, до некоторой степени — допускает функциональный стиль программирования в дополнение к объектно-ориентированной парадигме, которую он поддерживал все время.

Возможно, вопреки распространенному мнению, разработчики языка программирования Java не расширили Java и его JDK, чтобы разрешить функциональное программирование на Java или превратить Java в гибридный «объектно-ориентированный и функциональный» язык программирования. Фактической мотивацией для изобретения потоков для Java была производительность или — точнее — создание параллелизма, более доступного для разработчиков программного обеспечения (см. Brian Goetz, State of Lambda ). Эта цель имеет для меня большой смысл, учитывая то, как развивается аппаратное обеспечение. Наше оборудование сегодня имеет десятки процессорных ядер и, вероятно, через несколько сотен будет в будущем. Чтобы эффективно использовать аппаратные возможности и, таким образом, достичь самых современных характеристик исполнения, мы должны распараллелить. В конце концов — какой смысл запускать один поток на многоядерной платформе? В то же время многопоточное программирование считается сложным и подверженным ошибкам, и это правильно. Потоки, которые бывают двух видов (как последовательные и параллельные), предназначены для того, чтобы скрыть сложность запуска нескольких потоков. Параллельные потоки позволяют чрезвычайно легко выполнять массовые операции параллельно — волшебным образом, без усилий и таким образом, который доступен каждому разработчику Java.

Параллельные операции потока быстрее чем последовательные операции?

Итак, давайте поговорим о производительности. Насколько быстры потоки Java 8? Общее ожидание состоит в том, что параллельное выполнение потоковых операций выполняется быстрее, чем последовательное выполнение только с одним потоком. Это правда? Потоки улучшают производительность?

Чтобы ответить на вопросы, касающиеся производительности, мы должны измерить, то есть выполнить микро-тест. Бенчмаркинг сложен и подвержен ошибкам. Вам необходимо выполнить надлежащую разминку, следить за всеми видами искажающих эффектов от оптимизаций, применяемых JIT-компилятором виртуальной машины (устранение мертвого кода является пресловутым) до аппаратных оптимизаций (таких как увеличение частоты процессора на одно ядро, если остальные ядра простаивают). В общем, результаты теста должны быть взяты с крошкой соли. Каждый тест — это эксперимент. Его результаты зависят от контекста. Никогда не доверяйте контрольным показателям, которые вы сами не создали в своем контексте на своем оборудовании. Это сказал, давайте поэкспериментируем.

Сравнение потоков с циклами

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

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

Вот традиционное решение с циклом for:

1
2
3
4
int[] a = ints;
int e = ints.length;
int m = Integer.MIN_VALUE;
for(int i=0; i < e; i++)   if(a[i] > m) m = a[i];

Вот решение с последовательным IntStream:

1
2
int m = Arrays.stream(ints)
         .reduce(Integer.MIN_VALUE, Math::max);

Мы провели измерения на устаревшем оборудовании (двухъядерный, без динамического разгона) с надлежащим прогревом, и все, что нужно, чтобы получить надежные контрольные показатели на полпути. Это был результат в этом конкретном контексте:

1
2
int-array, for-loop : 0.36 ms
int-array, seq. stream: 5.35 ms

Результат отрезвляет: старый добрый цикл for в 15 раз быстрее, чем последовательный поток. Какое разочарование! Годы разработки затрачены на создание потоков для Java 8, а затем это?!?!? Но ждать! Прежде чем мы решим, что потоки ужасно медленны, давайте посмотрим, что произойдет, если мы заменим int- массив ArrayList <Integer>.

Вот цикл for:

1
2
3
int m = Integer.MIN_VALUE;
for (int i : myList)
     if (i>m) m=i;

Вот потоковое решение:

1
2
int m = myList.stream()
          .reduce(Integer.MIN_VALUE, Math::max);

Вот результаты:

1
2
ArrayList, for-loop : 6.55 ms
ArrayList, seq. stream: 8.33 ms

Опять же, цикл for быстрее, чем последовательная потоковая операция, но разница в ArrayList не так значительна, как в массиве.

Давайте подумаем об этом. Почему результаты так сильно отличаются? Есть несколько аспектов для рассмотрения.

Во-первых, доступ к элементам массива очень быстрый. Это доступ к памяти на основе индекса без каких-либо дополнительных затрат. Другими словами, это простой доступ к памяти. С другой стороны, элементы в коллекции, такие как ArrayList, доступны через итератор, и итератор неизбежно увеличивает издержки. Кроме того, существуют накладные расходы на упаковку и распаковку элементов коллекции, тогда как в массивах int используются простые примитивные типы. По сути, в измерениях для ArrayList преобладают итерации и накладные расходы, в то время как цифры для массива int иллюстрируют преимущество циклов.

Во-вторых, мы серьезно ожидали, что потоки будут быстрее, чем простые циклы for? Компиляторы имеют более чем 40-летний опыт оптимизации циклов, а JIT-компилятор виртуальной машины особенно подходит для оптимизации циклов по массивам с таким же успехом, как в нашем тесте. С другой стороны, потоки — это недавнее дополнение к Java, и JIT-компилятор (пока) не выполняет каких-либо особенно сложных оптимизаций для них.

В-третьих, мы должны помнить, что мы не слишком много делаем с элементами последовательности, как только мы их овладели. Мы тратим много усилий, пытаясь получить доступ к элементу, а затем мало что делаем с ним. Мы просто сравниваем два целых числа, которые после JIT-компиляции представляют собой всего несколько инструкций по сборке. По этой причине наши тесты иллюстрируют стоимость доступа к элементам, которая не обязательно должна быть типичной ситуацией. Показатели производительности существенно изменяются, если функциональность, применяемая к каждому элементу в последовательности, является интенсивной. Вы обнаружите, что больше нет ощутимой разницы между циклом for и последовательным потоком, если функциональность тесно связана с процессором.

Присоединяйтесь к нам на JAX London — конференции для новаторов Java и программного обеспечения. Получите скидку 10% на ваш билет с этим кодом: MP_JCG10

Окончательный вывод, который можно сделать из этого контрольного эксперимента, НЕ в том, что потоки всегда медленнее, чем циклы. Да, потоки иногда медленнее петель, но они также могут быть одинаково быстрыми; это зависит от обстоятельств. Дело в том, что последовательные потоки не быстрее, чем циклы. Если вы используете последовательные потоки, вы не делаете это из соображений производительности; Вы делаете это, потому что вам нравится стиль функционального программирования.

Итак, где были изобретены потоки улучшения производительности? До сих пор мы сравнивали только циклы с потоками. Как насчет распараллеливания? Суть потоков — это легкое распараллеливание для лучшей производительности.

Сравнение последовательных потоков с параллельными потоками

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

Мы используем тот же массив, заполненный 500 000 целочисленных значений. Вот последовательная операция потока:

1
2
int m = Arrays.stream(ints)
          .reduce(Integer.MIN_VALUE, Math::max);

Это операция параллельного потока:

1
2
int m = Arrays.stream(ints).parallel()
          .reduce(Integer.MIN_VALUE, Math::max);

Мы ожидаем, что параллельное выполнение должно быть быстрее, чем последовательное. Поскольку измерения проводились на двухъядерной платформе, параллельное выполнение может быть в два раза быстрее, чем последовательное выполнение. В идеале отношение последовательной / параллельной производительности должно составлять 2,0. Естественно, параллельное выполнение накладывает некоторые накладные расходы на разделение проблемы, создание подзадач, выполнение их в нескольких потоках, сбор их частичных результатов и получение общего результата. Соотношение будет меньше 2,0, но оно должно приблизиться.

Вот фактические результаты теста:

1
2
         sequential parallel seq./par.
int-array 5.35 ms    3.35 ms  1.60

Проверка реальности с помощью нашего эталонного теста дает соотношение (последовательное / параллельное) только 1,6 вместо 2,0, которое иллюстрирует количество накладных расходов, связанных с параллельной работой, и то, насколько (хорошо или плохо) они сверхкомпенсированы (на этой конкретной платформе).

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

Одним из них является разделимость источника потока. Массив хорошо разбивается; требуется только вычисление индекса, чтобы определить средний элемент и разбить массив на две половины. Там нет накладных расходов и, следовательно, почти никаких затрат на разделение. Насколько легко делятся коллекции по сравнению с массивом? Что нужно для разделения двоичного дерева или связанного списка? В определенных ситуациях вы можете наблюдать совершенно разные результаты производительности для разных типов коллекций.

Другой аспект — это состояние. Некоторые потоковые операции поддерживают состояние. Примером является операция distinct() . Это промежуточная операция, которая удаляет дубликаты из входной последовательности, т. Е. Возвращает выходную последовательность с различными элементами. Чтобы определить, является ли следующий элемент дубликатом, операция должна сравниваться со всеми элементами, с которыми она уже сталкивалась. Для этого он поддерживает некоторую структуру данных в качестве своего состояния. Если вы вызываете distinct() в параллельном потоке, его состояние будет доступно одновременно нескольким рабочим потокам, что требует некоторой формы координации или синхронизации, которая добавляет издержки, которые замедляют параллельное выполнение, до такой степени, что параллельное выполнение может быть значительно медленнее, чем последовательное выполнение.

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

Реализация такова: да, параллельные потоковые операции просты в использовании и часто выполняются быстрее, чем последовательные операции, но не ожидают чудес. Кроме того, не угадай; вместо этого, много тестировать.

Это был предварительный просмотр JAX Magazine — подпишитесь здесь, чтобы получить больше бесплатных советов для разработчиков, трендов и учебных пособий.