Полное руководство по Java 8 Stream

Это руководство дает не только общее представление о Stream в Java 8, но и полезные знания которые будут востребованы в будущем. Когда я впервые узнал о Stream API, я был удивлен таким названием, поскольку это звучит очень похоже на InputStream или OutputStream из Java I/O. Но Java 8 Stream это совсем другое. Streams (стримы/потоки) являются Монадами, которые играют большую роль в обеспечении функционального программирования в Java.

В функциональном программировании монада является структурой, которая представляет вычисления в виде последовательности шагов.

Это руководство научит вас основам работы с Java 8 Streams, а также научит использовать операции над потоками Streams. Вы узнаете о порядке обработки и о том, как упорядочение потока операций влияет на производительность во время выполнения. Более мощные операции Stream API: reduce, collect и flatMap также будут подробно описаны. Руководство заканчивается углубленным изучением параллельных потоков.

Как потоки работают?

Поток представляет собой последовательность элементов и поддерживает различные виды операций для выполнения вычислений:

Операции над потоком относятся либо к промежуточным, либо к терминальным. Все промежуточные операции возвращают поток, так что мы можем объединять несколько промежуточных операций без использования точки с запятой. Терминальные операции возвращают void или непотоковый результат. В приведенном выше примере filter, map и sorted являются промежуточными операциями, тогда как forEach является терминальной операцией. Для получения полного списка всех доступных операций потока смотрите Javadoc по Stream.

Большинство операций потока принимают в качестве параметров какие-то лямбда-выражения, в функциональный интерфейс точное поведение по каждой операции. Большинство этих операций должны быть как неинтерферирующими (non-interfering), так и лишенными состояния (stateless). Что это значит?

Неинтерферирующуя функция не изменяет основной источник данных потока. Например, в приведенном выше примере лямбда выражение не изменяет mList путем добавления или удаления элементов из коллекции.

Лишенная состояния функция — выполнение операции является детерминированным, например, в приведенном выше примере лямбда-выражение не зависит от какой-либо изменяемой переменной или состояния из внешней среды, которая могла бы измениться во время выполнения.

Различные виды потоков (Streams)

Потоки могут быть созданы из различных источников данных, особенно коллекций. Коллекции List и Set поддерживают новые методы stream() и parallelStream(). Параллельные потоки способны работать на нескольких нитях и будут рассмотрены в следующем разделе этого руководства. Мы ориентируемся на последовательные потоки:

Вызов метода stream() по перечню объектов возвращает поток. Но мы не должны создавать коллекции для того, чтобы работать с потоками. Смотрим на следующем примере кода:

Просто используйте Stream.of(), чтобы создать поток с кучей ссылок на объекты.

Кроме регулярных объектов потоков Java 8, еще предоставляются особые виды потоков для работы с примитивными типами данных int, long и double. Как вы уже догадались, это IntStream , LongStream и DoubleStream .

IntStreams может заменить обычный цикл for на изящный IntStream.range() :

Все эти примитивные потоки работают так же, как и обычные объектные потоки, но со следующими отличиями:

Примитивные потоки используют специализированные лямбда-выражения, например IntFunction Function или IntPredicate вместо Predicate . И примитивные потоки поддерживают дополнительные терминальные операции sum() и average():

Иногда это полезно преобразовать поток объекта к примитивному потоку или наоборот. Для этой цели потоки объектов поддерживают специальные операций картирования mapToInt(), mapToLong() и mapToDouble() :

Примитивные потоки могут быть преобразованы в объектные потоки с помощью метода mapToObj():

Вот комбинированный пример: поток с double сначала преобразуется в int’овый поток и затем в объектный поток строк:

 Порядок обработки

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

Посмотрите на этот образец, где отсутствует терминальная операция:

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

Давайте расширим предыдущий пример использованием forEach:

Выполнив этот код, на консоль выведется следующее:

Порядок выполнения может вас удивить. На первый взгляд все операции будут выполняться по горизонтали одна за другой по всем элементам потока. Но в нашем примере первая строка «dd2» полностью проходит фильтр forEach, потом обрабатывается вторая строка «aa2» и так далее.

Такое поведение может привести к снижению фактического количества операций, выполняемых на каждом элементе. Это мы видим на следующем примере:

Операция anyMatch возвращает true, как только предикат применится к входному элементу. Это подходит для второго элемента «аа2». В связи с вертикальным исполнением цепи потока, map будет выполнен два раза.

Почему порядок выполнения в stream имеет значение

Следующий пример состоит из двух промежуточных операций map и filter, а также выполнение терминала forEach. Давайте еще раз посмотрим порядок выполнения этих операций:

Как вы уже догадались, map и filter называются пять раз для каждой строки в базовой коллекции, тогда как forEach вызывается только один раз.

Мы можем сильно уменьшить фактическое количество выполнений, если мы изменим порядок операций, передвинув filter в начало цепочки:

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

Давайте расширим предыдущий пример с дополнительной операцией, sorted:

Сортировка является особым видом промежуточных операций. Это так называемые операции состояния. 

Выполнение этого примера приводит следующий вывод консоли:

Во-первых, операция сортировки выполняется на всей совокупности входных данных. Другими словами, sorted выполнен в горизонтальном направлении. Таким образом, sorted вызывается 8 раз для нескольких комбинаций на каждом элементе во входной коллекции.

Теперь мы можем оптимизировать производительность:

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

Повторное использование Потоков (Streams)

Потоки в Java 8 не могут быть использованы повторно. Как только вы называете какую-нибудь терминальную операция, поток закрывается

Вызов noneMatch после anyMatch в одном и том же stream вызовет следующее исключение:

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

Каждый вызов конструктора get() создает новый поток, с которым мы можем безопасно работать.

Продвинутые операции

Потоки поддерживает множество различных операций. Мы уже узнали о наиболее важных операциях, таких как filter или map. Чтобы ознакомиться с другими доступными операции, загляните в Stream Javadoc.
А теперь давайте глубже познакомимся в более сложными операциями collectflatMap и reduce.
Для работы с этими операциями напишем следующий код:

Наверняка многие из вас обратили внимание на очень удобный трюк создания и инициализации List в одну строку.

Операция Collect

Collect является чрезвычайно полезной операцией, чтобы превратить элементы потока в List, Set или Map. Collect принимает Collector, который состоит из четырех различных операций: поставщик, аккумулятор, объединитель и финишер. Это звучит очень сложно, но это только на первый взгляд. Фишкой Java 8 является поддержка различных встроенных коллекторов через класс Collectors. Именно поэтому работа с ними будет намного проще.
Давайте начнем с наиболее распространенного случая:
Как видите, у нас получилось всего 6 строчек кода.

Следующий пример группирует всех по возрасту:

Collectors — чрезвычайно универсальные. Вы также можете создавать группы элементов потока, например, определение среднего возраста всех лиц:

Если вы заинтересованы в более полной статистике, то collectors возвращает специальный встроенный объект со сводной статистикой. Таким образом, мы можем просто определить минимальный, максимальный и средний арифметический возраст, а также найти сумму и количество.

Следующий пример соединяет всех людей в одну строку:

Коллектор join принимает разделитель, а также дополнительный префикс и суффикс.

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

 

Теперь, когда мы знаем, некоторые мощные встроенные коллекторы, давайте попробуем построить свой собственный специальный коллектор. Мы хотим превратить всех людей в потоке в одну строку, состоящую из всех имен в верхнем регистре, разделенных знаком "|".

Для достижения этого мы создаем новый коллектор через Collector.of(). Мы должны пройти четыре этапа использования collectors: supplieraccumulator, combiner и finisher.

Так как строки в Java неизменные, нам нужен вспомогательный класс StringJoiner, чтобы коллектор мог построить нашу строку. Supplier изначально создает такой StringJoiner с соответствующим разделителем. Accumulator используется для добавления имени каждого человека в верхний регистр. Combiner знает как объединить два StringJoiner в один. На последнем этапе Finisher строит желаемую строку из StringJoiner.

FlatMap

Мы уже научились преобразовывать объекты потока в другой тип объектов, используя операции с map. Map ограничена, потому что каждый объект может быть отображен только одним объектом. Но что, если мы хотим преобразовать один объект в нескольких других? На помощь здесь приходит flatMap.

FlatMap преобразует каждый элемент потока в поток других объектов. Таким образом, каждый объект будет преобразован в ноль, один или несколько других объектов, поддерживаемых потоком. Содержание этих потоков будет затем помещают в возвращаемом потоке flatMap операции.

Прежде, чем мы увидим flatMap в действии мы должны соответствующий тип иерархии:

Далее, мы используем наши знания о потоках для того, чтобы создать несколько объектов:

Теперь у нас есть список из трех функций, каждая из которых состоит из трех баров. FlatMap принимает функцию, которая должна возвращать поток объектов:

 

Как видите, мы успешно преобразовали поток трех объектов Foo в поток девяти объектов.

Наконец, код выше можно упростить:

FlatMap также доступна для Optional класса, введенного в Java 8. Optional операции класса flatMap возвращает дополнительный объект другого типа..

Посмотрите на иерархическую структуру типа этой:

Далее нужно добавить многочисленные проверки на нулевые значения.

Такое же поведение можно получить, используя операцию optionalflatMap :

Каждый вызов flatMap возвращает Optional обертку объекта.

Reduce

Операция Reduce сочетает в себе все элементы потока в единый результат. Java 8 поддерживает три различных вида reduce метода.  Давайте посмотрим, как мы можем использовать один метод для определения самого старшего человека:

Первый Reduce метод

Reduce метод принимает функцию аккумулятора BinaryOperator. Это на самом деле BiFunction, когда оба операнда имеют один и тот же тип, в этом случае Person. BiFunctions похожи на Function, но принимает два аргумента. Пример функции сравнивает людей по возрасту и возвращает самого старшего.

Второй Reduce метод

Второй Reduce метод принимает идентифицирующее значение и BinaryOperator. Этот метод может быть использован для «создания» нового человека с агрегированным имен и возрастом других человек в потоке:

Третий Reduce

Третий Reduce  метод принимает три параметра: значение идентификатора,BiFunction аккумулятор и объединитель функции типа BinaryOperator. Поскольку идентифицирующее значение не ограничивает тип Person, мы можем использовать это сокращение для определения суммы возрасте от всех лиц:

Как видим, результат получился 78, но что же произошло под катом? Давайте посмотрим вывод с подробным описанием:

Как видим, аккумулирующая функция делает всю работу. Сначала вызывается инициализирующая значение 0 и первый человек Андрей. В следующих трех вызовах «sum» увеличивается возраст до суммарного 78.

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

Выполнение этого потока параллельно приведет к совершенно иным результатам. Теперь комбайнер действительно вызывается. С тех пор как аккумулятор вызывается параллельно, комбайнеру необходимо суммировать отдельные значения.

Давайте глубже погрузимся в мир параллельных потоков в следующей главе.

Параллельные потоки

Потоки могут быть выполнены параллельно, чтобы увеличить производительность выполнения на большом количестве входных элементов. Параллельные потоки используют общий ForkJoinPool доступный через статический ForkJoinPool.commonPool() метод. Размер основного пула потоков использует до пяти нитей — в зависимости от количества доступных физических ядер процессора:

На моей машине общий пул инициализируется с параллелизмом 3 по-умолчанию. Это значение может быть уменьшено или увеличено путем установки следующих параметров JVM:

Коллекции поддерживает метод parallelStream(), чтобы создать параллельный поток элементов. Кроме того, вы можете вызвать промежуточный метод parallel() на данном потоке, чтобы преобразовать последовательный поток в параллельной копии.

Для того, чтобы занизить поведение параллельного выполнения параллельного потока Следующий пример печатает информацию о текущем потоке в Sout :

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

Как вы можете видеть, параллельный поток использует все доступные темы из общей ForkJoinPool для выполнения операций потока. Вывод может отличаться при последовательном запуске, потому что поведение, которое конкретный поток использует не является детерминированным.

Давайте расширим например с помощью дополнительной операции потока — sort:

Результат может быть странным на первый взгляд:

Кажется, что sort выполняется последовательно только основной нитью. Но это не так. На самом деле, sort на параллельном потоке использует новый Java 8 метод Arrays.parallelSort() под капотом. Имейте в виду, что отладочный вывод относится только к исполнению переданного лямбда-выражения. Так, sort компаратор выполнен только на главном потоке, но фактическое алгоритм сортировки выполняется параллельно.

Возвращаясь к reduce, например, из последней секции. Мы уже выяснили, что функция комбайнер вызывается только параллельно, но не в последовательных потоках. Давайте посмотрим, какие потоки фактически участвуют:

Вывод на консоль показывает, что оба аккумулятора и комбайнера функции выполняются параллельно на всех доступных потоках:

Таким образом, можно констатировать, что параллельные потоки могут дать хороший прирост производительности в потоках с большим количеством входных элементов. Но имейте в виду, что некоторые параллельные операции потока reduce и collect требуют дополнительные расчеты (комбинированные операции), которые не нужны при последовательном выполнении.

Итоги

Руководство по программированию стримов на Java 8 закончилось. Если Вас заинтересовали новые возможности Java 8, рекомендую ознакомится с документацией Stream Javadoc.

Делитесь этой статьей с друзьями и коллегами, а также следите за обновлениями на Prologistic.com.ua

P.S. Подробнее о других особенностях Java 8 вы можете ознакомиться в этой статье.

Перевод статьи winterbe.com

7 Комментарии “Полное руководство по Java 8 Stream

  1. Код который следует после фразы «Выполнив этот код, на консоль выведется следующее:»
    есть опечатка, отсутствует цифра 4 в выводе в консоль
    должно быть так:
    Фильтр: cc4
    Печать с использованием forEach: cc4

  2. В последнем предложении раздела «Почему порядок выполнения в stream имеет значение» опечатка:
    «В этом случае производительность значительно снижается для больших входных коллекций.». Нужно так:
    «В этом случае производительность значительно УВЕЛИЧИВАЕТСЯ для больших входных коллекций.»

  3. На первый взгляд, выглядит как имплементация паттерна билдер:
    mList
    .stream()
    .filter(s -> s.startsWith(«a»))
    .map(String::toUpperCase)
    .sorted()

    Так ли это?

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *