Учебник по API Java 8 Stream

Учебное пособие по API Java 8 Stream

1. обзор

В этом подробном руководстве мы рассмотрим практическое использование Java 8 Streams от создания до параллельного выполнения.

Чтобы понять этот материал, читатели должны иметь базовые знания Java 8 (лямбда-выражения, ссылки на методыOptional,) и Stream API. Если вы не знакомы с этими темами, ознакомьтесь с нашими предыдущими статьями -New Features in Java 8 иIntroduction to Java 8 Streams.

Дальнейшее чтение:

Лямбда-выражения и функциональные интерфейсы: советы и лучшие практики

Советы и рекомендации по использованию лямбда-кодов Java 8 и функциональных интерфейсов.

Read more

Руководство по коллекционерам Java 8

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

Read more

2. Создание потока

Есть много способов создать потоковый экземпляр из разных источников. После создания экземплярwill not modify its source,, следовательно, позволяет создавать несколько экземпляров из одного источника.

2.1. Пустой поток

Методempty() следует использовать в случае создания пустого потока:

Stream streamEmpty = Stream.empty();

Часто бывает, что методempty() используется при создании, чтобы избежать возвратаnull для потоков без элемента:

public Stream streamOf(List list) {
    return list == null || list.isEmpty() ? Stream.empty() : list.stream();
}

2.2. ПотокCollection

Также можно создать поток любого типаCollection (Collection, List, Set):

Collection collection = Arrays.asList("a", "b", "c");
Stream streamOfCollection = collection.stream();

2.3. Поток массива

Массив также может быть источником потока:

Stream streamOfArray = Stream.of("a", "b", "c");

Они также могут быть созданы из существующего массива или части массива:

String[] arr = new String[]{"a", "b", "c"};
Stream streamOfArrayFull = Arrays.stream(arr);
Stream streamOfArrayPart = Arrays.stream(arr, 1, 3);

2.4. Stream.builder()с

When builder is usedthe desired type should be additionally specified in the right part of the statement,, иначе методbuild() создаст экземплярStream<Object>:

Stream streamBuilder =
  Stream.builder().add("a").add("b").add("c").build();

2.5. Stream.generate()с

Методgenerate() принимаетSupplier<T> для генерации элемента. Поскольку результирующий поток бесконечен, разработчик должен указать желаемый размер, иначе методgenerate() будет работать, пока не достигнет предела памяти:

Stream streamGenerated =
  Stream.generate(() -> "element").limit(10);

Приведенный выше код создает последовательность из десяти строк со значением -“element”.

2.6. Stream.iterate()с

Другой способ создания бесконечного потока - использовать методiterate():

Stream streamIterated = Stream.iterate(40, n -> n + 2).limit(20);

Первый элемент результирующего потока - это первый параметр методаiterate(). Для создания каждого следующего элемента указанная функция применяется к предыдущему элементу. В приведенном выше примере второй элемент будет 42.

2.7. Поток примитивов

Java 8 предлагает возможность создавать потоки из трех примитивных типов:int, long иdouble.. ПосколькуStream<T> является универсальным интерфейсом, и нет возможности использовать примитивы в качестве параметра типа с универсальными шаблонами, были созданы три новых специальных интерфейса:IntStream, LongStream, DoubleStream.

Использование новых интерфейсов избавляет от ненужного автобокса, позволяет повысить производительность:

IntStream intStream = IntStream.range(1, 3);
LongStream longStream = LongStream.rangeClosed(1, 3);

Методrange(int startInclusive, int endExclusive) создает упорядоченный поток от первого параметра ко второму параметру. Увеличивает значение последующих элементов с шагом, равным 1. Результат не включает последний параметр, это просто верхняя граница последовательности.

МетодrangeClosed(int startInclusive, int endInclusive) делает то же самое с одним отличием - второй элемент включен. Эти два метода могут использоваться для генерации любого из трех типов потоков примитивов.

Начиная с Java 8, классRandom предоставляет широкий спектр методов для генерации потоков примитивов. Например, следующий код создаетDoubleStream,, состоящий из трех элементов:

Random random = new Random();
DoubleStream doubleStream = random.doubles(3);

2.8. ПотокString

String также можно использовать как источник для создания потока.

С помощью методаchars() классаString. Поскольку в JDK нет интерфейсаCharStream, вместо этогоIntStream используется для представления потока символов.

IntStream streamOfChars = "abc".chars();

В следующем примереString разбивается на подстроки в соответствии с указаннымRegEx:

Stream streamOfString =
  Pattern.compile(", ").splitAsStream("a, b, c");

2.9. Поток файла

Класс Java NIOFiles позволяет сгенерироватьStream<String> текстового файла с помощью методаlines(). Каждая строка текста становится элементом потока:

Path path = Paths.get("C:\\file.txt");
Stream streamOfStrings = Files.lines(path);
Stream streamWithCharset =
  Files.lines(path, Charset.forName("UTF-8"));

Charset можно указать как аргумент методаlines().

3. Ссылка наa Stream

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

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

Stream stream =
  Stream.of("a", "b", "c").filter(element -> element.contains("b"));
Optional anyElement = stream.findAny();

Но попытка повторно использовать ту же ссылку после вызова операции терминала вызоветIllegalStateException:

Optional firstElement = stream.findFirst();

ПосколькуIllegalStateException являетсяRuntimeException, компилятор не будет сигнализировать о проблеме. Итак, очень важно помнить, чтоJava 8streams can’t be reused.

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

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

List elements =
  Stream.of("a", "b", "c").filter(element -> element.contains("b"))
    .collect(Collectors.toList());
Optional anyElement = elements.stream().findAny();
Optional firstElement = elements.stream().findFirst();

4. Stream Pipeline

Для выполнения последовательности операций над элементами источника данных и агрегирования их результатов необходимы три части -source,intermediate operation(s) иterminal operation..

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

Stream onceModifiedStream =
  Stream.of("abcd", "bbcd", "cbcd").skip(1);

Если требуется более одной модификации, промежуточные операции могут быть связаны. Предположим, что нам также нужно заменить каждый элемент текущегоStream<String> на подстроку из первых нескольких символов. Это будет сделано путем объединения методовskip() иmap():

Stream twiceModifiedStream =
  stream.skip(1).map(element -> element.substring(0, 3));

Как видите, методmap() принимает лямбда-выражение в качестве параметра. Если вы хотите узнать больше о лямбдах, посмотрите наш учебникLambda Expressions and Functional Interfaces: Tips and Best Practices.

Поток сам по себе бесполезен, реальная вещь, которая интересует пользователя, является результатом операции терминала, которая может быть значением некоторого типа или действием, применяемым к каждому элементу потока. Only one terminal operation can be used per stream.с

Правильный и наиболее удобный способ использования потоков - этоstream pipeline, which is a chain of stream source, intermediate operations, and a terminal operation.. Например:

List list = Arrays.asList("abc1", "abc2", "abc3");
long size = list.stream().skip(1)
  .map(element -> element.substring(0, 3)).sorted().count();

5. Ленивый призыв

Intermediate operations are lazy. Это означает, чтоthey will be invoked only if it is necessary for the terminal operation execution.

Чтобы продемонстрировать это, представьте, что у нас есть методwasCalled(),, который увеличивает внутренний счетчик каждый раз при его вызове:

private long counter;

private void wasCalled() {
    counter++;
}

Пусть метод вызова былCalled() из операцииfilter():

List list = Arrays.asList(“abc1”, “abc2”, “abc3”);
counter = 0;
Stream stream = list.stream().filter(element -> {
    wasCalled();
    return element.contains("2");
});

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

Давайте немного перепишем этот код, добавив операциюmap() и операцию терминала -findFirst().. Мы также добавим возможность отслеживать порядок вызовов методов с помощью журналирования:

Optional stream = list.stream().filter(element -> {
    log.info("filter() was called");
    return element.contains("2");
}).map(element -> {
    log.info("map() was called");
    return element.toUpperCase();
}).findFirst();

Результирующий журнал показывает, что методfilter() был вызван дважды, а методmap() - только один раз. Это так, потому что конвейер работает вертикально. В нашем примере первый элемент потока не удовлетворяет предикату фильтра, затем методfilter() был вызван для второго элемента, который прошел фильтр. Не вызываяfilter() для третьего элемента, мы по конвейеру перешли к методуmap().

ОперацияfindFirst() удовлетворяется только одним элементом. Итак, в этом конкретном примере ленивый вызов позволил избежать двух вызовов методов - одного дляfilter() и одного дляmap().

6. Порядок исполнения

С точки зрения производительностиthe right order is one of the most important aspects of chaining operations in the stream pipeline:

long size = list.stream().map(element -> {
    wasCalled();
    return element.substring(0, 3);
}).skip(2).count();

Выполнение этого кода увеличит значение счетчика на три. Это означает, что методmap() потока был вызван трижды. Но значениеsize равно единице. Итак, в результирующем потоке всего один элемент, и мы дважды из трех без причины выполнили дорогостоящие операцииmap().

Если мы изменим порядок методовskip() иmap(),,counter увеличится только на единицу. Итак, методmap() будет вызываться только один раз:

long size = list.stream().skip(2).map(element -> {
    wasCalled();
    return element.substring(0, 3);
}).count();

Это подводит нас к правилу:intermediate operations which reduce the size of the stream should be placed before operations which are applying to each element. Итак, оставьте такие методы, как skip(), filter(), distinct() в верхней части конвейера потока.

7. Уменьшение потока

В API есть много терминальных операций, которые объединяют поток в тип или примитив, напримерcount(), max(), min(), sum(),, но эти операции работают в соответствии с предопределенной реализацией. И что такоеif a developer needs to customize a Stream’s reduction mechanism? Есть два метода, которые позволяют это сделать -reduce() и методcollect().

7.1. Методreduce()

Существует три варианта этого метода, которые отличаются своими сигнатурами и возвращаемыми типами. Они могут иметь следующие параметры:

identity – начальное значение для аккумулятора или значение по умолчанию, если поток пуст и нечего накапливать;

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

combiner – функция, которая объединяет результаты аккумулятора. Combiner вызывается только в параллельном режиме, чтобы уменьшить результаты аккумуляторов из разных потоков.

Итак, давайте посмотрим на эти три метода в действии:

OptionalInt reduced =
  IntStream.range(1, 4).reduce((a, b) -> a + b);

reduced = 6 (1 + 2 + 3)

int reducedTwoParams =
  IntStream.range(1, 4).reduce(10, (a, b) -> a + b);

reducedTwoParams = 16 (10 + 1 + 2 + 3)

int reducedParams = Stream.of(1, 2, 3)
  .reduce(10, (a, b) -> a + b, (a, b) -> {
     log.info("combiner was called");
     return a + b;
  });

Результат будет таким же, как в предыдущем примере (16), и не будет входа в систему, что означает, что объединитель не был вызван. Для работы комбайнера поток должен быть параллельным:

int reducedParallel = Arrays.asList(1, 2, 3).parallelStream()
    .reduce(10, (a, b) -> a + b, (a, b) -> {
       log.info("combiner was called");
       return a + b;
    });

Результат здесь другой (36), и объединитель был вызван дважды. Здесь сокращение работает по следующему алгоритму: аккумулятор запускался три раза, добавляя каждый элемент потока вidentity к каждому элементу потока. Эти действия выполняются параллельно. В результате они имеют (10 + 1 = 11; 10 + 2 = 12; 10 + 3 = 13;). Теперь объединитель может объединить эти три результата. Для этого нужно две итерации (12 + 13 = 25; 25 + 11 = 36).

7.2. Методcollect()

Сокращение потока также может быть выполнено другой терминальной операцией - методомcollect(). Он принимает аргумент типаCollector,, который определяет механизм сокращения. Уже созданы предопределенные коллекторы для наиболее распространенных операций. Доступ к ним можно получить с помощью типаCollectors.

В этом разделе мы будем использовать следующиеList в качестве источника для всех потоков:

List productList = Arrays.asList(new Product(23, "potatoes"),
  new Product(14, "orange"), new Product(13, "lemon"),
  new Product(23, "bread"), new Product(13, "sugar"));

Преобразование потока вCollection (Collection, List илиSet):

List collectorCollection =
  productList.stream().map(Product::getName).collect(Collectors.toList());

Снижение доString:

String listToString = productList.stream().map(Product::getName)
  .collect(Collectors.joining(", ", "[", "]"));

Методjoiner() может иметь от одного до трех параметров (разделитель, префикс, суффикс). Самая удобная вещь в использованииjoiner() - разработчику не нужно проверять, достигает ли поток своего конца, чтобы применить суффикс, а не применять разделитель. Collector позаботится об этом.

Обработка среднего значения всех числовых элементов потока:

double averagePrice = productList.stream()
  .collect(Collectors.averagingInt(Product::getPrice));

Обработка суммы всех числовых элементов потока:

int summingPrice = productList.stream()
  .collect(Collectors.summingInt(Product::getPrice));

МетодыaveragingXX(), summingXX() иsummarizingXX() могут работать как с примитивами (int, long, double), так и с их классами-оболочками (Integer, Long, Double). Еще одна мощная особенность этих методов - обеспечение отображения. Таким образом, разработчику не нужно использовать дополнительную операциюmap() перед методомcollect().

Сбор статистической информации об элементах потока:

IntSummaryStatistics statistics = productList.stream()
  .collect(Collectors.summarizingInt(Product::getPrice));

Используя полученный экземпляр типаIntSummaryStatistics, разработчик может создать статистический отчет, применив методtoString(). Результатом будетString, общий для этого“IntSummaryStatistics\{count=5, sum=86, min=13, average=17,200000, max=23}”.

Из этого объекта также легко извлечь отдельные значения дляcount, sum, min, average с помощью методовgetCount(), getSum(), getMin(), getAverage(), getMax().. Все эти значения могут быть извлечены из одного конвейера.

Группировка элементов потока по заданной функции:

Map> collectorMapOfLists = productList.stream()
  .collect(Collectors.groupingBy(Product::getPrice));

В приведенном выше примере поток был сокращен доMap, который группирует все продукты по их цене.

Разделение элементов потока на группы по некоторому предикату:

Map> mapPartioned = productList.stream()
  .collect(Collectors.partitioningBy(element -> element.getPrice() > 15));

Толкаем коллектор на дополнительное преобразование:

Set unmodifiableSet = productList.stream()
  .collect(Collectors.collectingAndThen(Collectors.toSet(),
  Collections::unmodifiableSet));

В этом конкретном случае сборщик преобразовал поток вSet, а затем создал из него неизменяемыеSet.

Сборщик кастомов:

Если по какой-то причине необходимо создать собственный сборщик, наиболее простой и менее подробный способ сделать это - использовать методof() типаCollector.

Collector> toLinkedList =
  Collector.of(LinkedList::new, LinkedList::add,
    (first, second) -> {
       first.addAll(second);
       return first;
    });

LinkedList linkedListOfPersons =
  productList.stream().collect(toLinkedList);

В этом примере экземплярCollector был сокращен доLinkedList<Persone>.

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

До Java 8 распараллеливание было сложным. ПоявлениеExecutorService иForkJoin немного упростило жизнь разработчику, но им все равно следует помнить, как создать конкретного исполнителя, как его запустить и так далее. Java 8 представила способ достижения параллелизма в функциональном стиле.

API позволяет создавать параллельные потоки, которые выполняют операции в параллельном режиме. Когда источником потока являетсяCollection илиarray, это может быть достигнуто с помощью методаparallelStream():

Stream streamOfCollection = productList.parallelStream();
boolean isParallel = streamOfCollection.isParallel();
boolean bigPrice = streamOfCollection
  .map(product -> product.getPrice() * 12)
  .anyMatch(price -> price > 200);

Если источник потока отличается отCollection илиarray, следует использовать методparallel():

IntStream intStreamParallel = IntStream.range(1, 150).parallel();
boolean isParallel = intStreamParallel.isParallel();

Под капотом Stream API автоматически использует платформуForkJoin для параллельного выполнения операций. По умолчанию будет использоваться общий пул потоков, и нет способа (по крайней мере, на данный момент) назначить ему некоторый пользовательский пул потоков. This can be overcome by using a custom set of parallel collectors.

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

Поток в параллельном режиме можно преобразовать обратно в последовательный режим с помощью методаsequential():

IntStream intStreamSequential = intStreamParallel.sequential();
boolean isParallel = intStreamSequential.isParallel();

Выводы

Stream API - это мощный, но простой в понимании набор инструментов для обработки последовательности элементов. Это позволяет нам сократить огромное количество стандартного кода, создавать более читаемые программы и повышать производительность приложений при правильном использовании.

В большинстве примеров кода, показанных в этой статье, потоки не использовались (мы не применяли методclose() или терминальную операцию). В реальном приложенииdon’t leave an instantiated streams unconsumed as that will lead to memory leaks.

Доступны полные примеры кода, прилагаемые к статьеover on GitHub.