Введение в Java Streams
Введение в Java Stream API для обработки последовательностей элементов с помощью операций в функциональном стиле.
Stream — это конвейер, который переносит элементы источника через последовательность операций и выдаёт результат. Это не структура данных — он ничего не хранит. Это декларативный рецепт обработки данных: вычисляется лениво и выполняется один раз. Streams появились в Java 8 вместе с лямбдами, и оба механизма были спроектированы совместно: каждая операция потока принимает функцию, а язык предоставил удобный способ её записать.
Конструкция, которую вы напишете сотни раз:
double avgAdultAge = people.stream()
.filter(p -> p.age() >= 18)
.mapToInt(Person::age)
.average()
.orElse(0.0);Три вещи, на которые стоит обратить внимание. Конвейер читается сверху вниз как шаги, описывающие что вы хотите, а не как выполнять итерацию. Каждый шаг принимает функцию — Predicate, ToIntFunction — именно тот словарь, который был заложен в предыдущих главах. Результат извлекается из единственной терминальной операции: без цикла, без аккумулятора, без раннего continue.
Форма конвейера: источник → промежуточные → терминальная
Каждый stream-конвейер состоит из трёх частей:
- Источник. Откуда берутся элементы. Обычно это коллекция (
coll.stream()), иногда — литерал (Stream.of(\"a\", \"b\")), массив (Arrays.stream(arr)), диапазонIntStream(IntStream.range(0, 100)), источник ввода-вывода (Files.lines(path)) или генератор (Stream.iterate,Stream.generate). Следующая глава посвящена всем вариантам. - Ноль или более промежуточных операций. Каждая возвращает другой поток, поэтому они выстраиваются в цепочку. Наиболее распространённые:
filter,map,flatMap,distinct,sorted,limit,skip,peek. Они ленивы — вызовfilterничего не проверяет; он лишь запоминает предикат. - Ровно одна терминальная операция. Запускает конвейер. Примеры:
forEach,collect,toList,count,sum,min,max,reduce,findFirst,anyMatch. Терминальная операция производит значение (или побочный эффект дляforEach) и потребляет поток — повторно использовать его нельзя.
list.stream() // SOURCE
.filter(...) // intermediate
.map(...) // intermediate
.sorted() // intermediate
.toList(); // TERMINAL — runs the pipelineБез терминальной операции ничего не происходит. Построенный и незавершённый поток — пустая трата: никакая работа не выполняется, побочные эффекты не срабатывают, лямбды не запускаются.
Ленивость по замыслу
Промежуточные операции ленивы, потому что JVM не знает, какие элементы действительно нужны, пока терминальная операция не запросит их. Это открывает две важные оптимизации:
Слияние (Fusion). Смежные промежуточные операции выполняются за один проход, а не по одному проходу на операцию. stream.filter(p).map(f) не строит промежуточный отфильтрованный список, а затем применяет к нему map; вместо этого элемент проверяется, и если он проходит, сразу преобразуется — всё в один шаг.
Короткое замыкание (Short-circuiting). Терминальные операции вроде findFirst, anyMatch или limit(n) останавливают конвейер, как только получают ответ. В сочетании с ленивостью это позволяет запускать конвейер «найти первый чётный квадрат больше 100» над бесконечным потоком и получать ответ за микросекунды:
int answer = Stream.iterate(1, n -> n + 1) // 1, 2, 3, 4, ...
.map(n -> n * n) // 1, 4, 9, 16, ...
.filter(n -> n % 2 == 0 && n > 100) // first match wins
.findFirst()
.orElseThrow();
// answer = 144Stream.iterate(1, n -> n + 1) бесконечен, но findFirst запрашивал элементы до первого совпадения. Конвейер проверил 12 квадратов (1, 4, 9, ..., 144) и остановился.
Однократное использование, как у Iterator
Stream можно пройти один раз. Терминальная операция потребляет его, после чего объект потока закрывается; вызов ещё одной терминальной операции бросает IllegalStateException:
Stream<String> s = list.stream();
long c1 = s.count(); // ok
long c2 = s.count(); // throws IllegalStateException — stream has already been operated uponЕсли нужно обработать те же данные дважды, создайте поток дважды:
long c1 = list.stream().count();
long c2 = list.stream().count();Это соответствует поведению Iterator. Объект потока — это скользящий курсор, а не данные. Данные — это источник; повторное создание потока ничего не стоит.
Streams vs коллекции — разные задачи
| Аспект | Коллекция | Stream |
|---|---|---|
| Хранит данные? | Да | Нет |
| Можно использовать повторно? | Да | Нет (одна терминальная) |
| Жадный или ленивый? | Жадный | Ленивый до терминальной |
| Изменяет источник? | Да (например, list.add) | Нет — конвейеры только для чтения |
| Итерирует явно? | Часто (for, iterator()) | Нет — конвейер управляет итерацией |
| Модель затрат | Учёт на каждый элемент | Один проход по источнику |
Коллекция — это контейнер; поток — это вычисление над контейнером (или другим источником). Они дополняют друг друга: берёте из коллекции, запускаете stream-конвейер, собираете обратно в (обычно другую) коллекцию.
Три небольших примера, которые вы будете писать постоянно
Подсчёт элементов, удовлетворяющих предикату:
long adults = people.stream().filter(p -> p.age() >= 18).count();Построение списка преобразованных значений:
List<String> names = people.stream().map(Person::name).toList();Свёртка к единственному значению:
int totalAge = people.stream().mapToInt(Person::age).sum();Эти три шаблона — подсчёт, map-в-список, свёртка-в-скаляр — охватывают большинство случаев использования API. Остальная часть раздела — тур по операциям, которые детализируют как реализуется каждый из них.
Три вещи, которыми streams не являются
- Не замена
for-циклов в общем случае. Цикл, строящий что-то с нетривиальным управляющим потоком, требующийbreakс побочными эффектами или мутирующий несколько переменных, по-прежнему понятнее как цикл. Streams блистают там, где работа сама по себе является конвейером чистых операций. - Не прирост производительности на малых данных. Stream-конвейер выделяет несколько небольших объектов; цикл из 10 элементов обгонит его. Выигрыш — в ясности при любом объёме данных и в параллелизме при большом.
- Не замена
Iterator/Iterable, когда этого ожидает другой код. Поток производит значения; если нужно перемежать потребление (расширенныйfor,List, возвращаемый из метода), сначала вызовитеtoList().
Последовательный по умолчанию, параллельный по запросу
Каждый поток в этой главе — последовательный: элементы проходят через конвейер по одному, по порядку. Существует также coll.parallelStream() (и stream.parallel()), который планирует конвейер на общий ForkJoinPool для многоядерной работы. Параллельные потоки рассматриваются в отдельной главе — они предъявляют ряд требований к конвейеру (ассоциативность, отсутствие состояния, отсутствие побочных эффектов), которым «вводные» конвейеры этой главы естественно удовлетворяют, поэтому переход обычно выражается в изменении одного токена.
Разобранный пример: полный конвейер, ленивость и правило однократного использования
Программа ниже строит небольшой список записей Person, запускает канонический конвейер (filter → map → sorted → collect), доказывает ленивость с помощью peek, демонстрирует короткое замыкание на бесконечном Stream.iterate и показывает IllegalStateException при повторном использовании потока.
Что следует вынести из запуска:
- Канонический четырёхшаговый конвейер —
stream→filter→map→toList— сформировал отсортированный список имён взрослых без явного цикла, без временной коллекции и без отслеживания null. peekпечатал по одному разу на каждый вытянутый элемент.findFirstвытягивал элементы, пока один не удовлетворил условиюn*n > 50(приn = 8, квадрат64), и затем остановился. Вот ленивость и короткое замыкание в действии: вышестоящие операции выполнили ровно столько работы, сколько потребовалось, и ни на шаг больше.- Конвейер «первый чётный квадрат больше 100» работал над бесконечным источником. Без короткого замыкания это был бы бесконечный цикл; с ним конвейер проверил 12 значений и вернул
144. - Второй вызов
s.count()бросилIllegalStateException. Потоки однократны; если нужен второй проход, создайте свежий поток из источника. - Конвейер «без терминальной» в конце не напечатал ничего из своего
peek. Без терминальной операции промежуточные не выполняются — поток лишь рецепт, который никто не попросил исполнить.
Что дальше
Вы знаете форму конвейера, разделение на источник/промежуточные/терминальные, контракт ленивости и правило однократного использования. Следующая глава, Создание Java Streams, — это каталог источников: Collection.stream(), Stream.of, Arrays.stream, IntStream.range, Stream.iterate, Stream.generate, Files.lines, String.chars(), Stream.empty и API Stream.Builder. После главы об источниках у вас будет всё необходимое для старта, а остальная часть раздела раскроет промежуточные и терминальные операции.