Stream API ·
‹ Предыдущий Следующий ›
⏱ 5 минут чтения Обновлено: 2026-09-15

Методы Stream API

Посмотрите на этот код и попробуйте угадать, сколько раз выполнится map():

Stream.of("sun", "pool", "beach", "kid", "island", "sea", "sand")
        .map(str -> {
            System.out.println("Mapping: " + str);
            return str.length();
        })
        .filter(i -> {
            System.out.println("Filtering: " + i);
            return i > 3;
        })
        .limit(2)
        .forEach(System.out::println);

В потоке семь строк, но map() отработает только для трёх: sun, pool, beach. Слова kid, island, sea и sand не будут прочитаны вообще — после второго подходящего элемента limit(2) обрывает обход. Такое поведение перестаёт быть магией, как только становится понятно, чем терминальные методы отличаются от промежуточных.

Этот урок — продолжение темы «Что такое Stream API», где разобраны определение потока, его свойства и способы создания. Здесь мы сразу переходим к операциям: какие методы у Stream есть, что каждый возвращает и когда его применять.

1. Два типа методов Stream API

Все методы Stream API делятся на две категории:

  • Терминальные (оконечные, terminal) — запускают конвейер, возвращают результат и закрывают поток.
  • Промежуточные (intermediate) — возвращают новый Stream и ничего не вычисляют до терминальной операции. Делятся на:
    • без сохранения состояния (stateless) — filter(), map();
    • с сохранением состояния (stateful) — sorted(), distinct(), limit().

Любая цепочка устроена одинаково: источник → ноль или больше промежуточных операций → ровно одна терминальная операция. Отличить одно от другого можно по возвращаемому типу: если метод возвращает Stream (или IntStream, LongStream, DoubleStream) — он промежуточный; если возвращает значение, коллекцию, Optional или void — терминальный.

2. Терминальные операции: таблица и примеры

Терминальная операция потребляет поток. После её выполнения повторное использование того же объекта Stream невозможно — любой следующий вызов бросит IllegalStateException.

Метод Что возвращает Когда использовать
forEach() void Выполнить действие для каждого элемента, порядок не важен
forEachOrdered() void То же, но с гарантией порядка источника даже в параллельном потоке
collect() R (List, Set, Map, String…) Собрать элементы в коллекцию или свернуть их коллектором
toList() (Java 16+) List<T> (неизменяемый) Короткая замена collect(Collectors.toList())
count() long Посчитать количество элементов
min(), max() Optional<T> Найти минимум или максимум по компаратору
findFirst() Optional<T> Взять первый элемент с учётом порядка
findAny() Optional<T> Взять любой элемент, эффективнее в параллельном потоке
allMatch() boolean Проверить, что условию удовлетворяют все элементы
anyMatch() boolean Проверить, что есть хотя бы один подходящий элемент
noneMatch() boolean Проверить, что подходящих элементов нет
reduce() T или Optional<T> Свернуть поток в одно значение: сумма, произведение, конкатенация
toArray() Object[] или T[] Получить массив вместо коллекции
iterator() Iterator<T> Перейти к ручному обходу, когда нужен императивный код или API принимает итератор
spliterator() Spliterator<T> Получить разделяемый итератор: для своих источников данных и параллельного обхода
sum(), average(), summaryStatistics() int/long/double, OptionalDouble, *SummaryStatistics Только у примитивных потоков: IntStream, LongStream, DoubleStream

Полный перечень сигнатур всегда можно свериться в официальном javadoc интерфейса Stream.

Поток одноразовый

Классическая ошибка: сохранить Stream в переменную и вызвать на нём две терминальные операции.

int[] digits = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
IntStream s = IntStream.of(digits);
long n = s.count();              // 10 - поток закрыт
System.out.println(s.findFirst());
// java.lang.IllegalStateException: stream has already been operated upon or closed

Чтобы пройти по данным второй раз, поток нужно создать заново:

int[] digits = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
long n = IntStream.of(digits).count();            // 10
System.out.println(IntStream.of(digits).findFirst()); // OptionalInt[0]

Важно

Если один и тот же набор данных нужно обойти несколько раз, храните не сам поток, а его поставщика: Supplier<Stream<T>> supplier = list::stream;. Тогда каждый вызов supplier.get() вернёт свежий поток, и IllegalStateException не возникнет.

Цепочка из нескольких операций

public class StreamDemo1 {
    public static void main(String[] args) {
        Stream.of("sun", "pool", "beach", "kid", "island", "sea", "sand")
                .map(String::length)
                .filter(i -> i > 3)
                .limit(2)
                .forEach(System.out::println);
    }
}
// 4
// 5

Фильтрация пустых и null-значений:

public class StreamDemo7 {
    public static void main(String[] args) {
        List<String> words = Arrays.asList("hello", null, "");
        words.stream()
                .filter(t -> t != null && !t.isEmpty())
                .forEach(System.out::println); // hello
    }
}

То же самое короче, плюс удаление дубликатов и сортировка:

public class StreamDemo8 {
    public static void main(String[] args) {
        List<String> words = Arrays.asList("hello", null, "world", "hi", "hello");
        words.stream()
                .filter(Objects::nonNull)
                .distinct()  // сначала убираем дубли,
                .sorted()    // потом сортируем меньше элементов
                .forEach(System.out::println); // hello, hi, world
    }
}

min(), max(), findFirst() и Optional

Методы поиска не могут вернуть элемент, если поток пуст, поэтому результат обёрнут в Optional. То же касается findAny(), max() и reduce() без начального значения.

public class StreamDemo3 {
    public static void main(String[] args) {
        List<String> strings = Arrays.asList("Stream", "Operations", "on", "Collections");
        Optional<String> optional = strings.stream()
                .min(Comparator.comparing(String::length));
        optional.ifPresent(System.out::println); // on
    }
}

Числовые операции: sum() и average()

sum(), average(), summaryStatistics() объявлены только у примитивных потоков. У обычного Stream<T> их нет — сначала нужен переход через mapToInt(), mapToLong() или mapToDouble().

public class StreamDemo4 {
    public static void main(String[] args) {
        System.out.println(IntStream.of(28, 4, 91, 30).sum());     // 153
        System.out.println(IntStream.of(28, 4, 91, 30).average()); // OptionalDouble[38.25]

        // чтобы получить число, а не обёртку:
        double avg = IntStream.of(28, 4, 91, 30).average().orElse(0); // 38.25
        System.out.println(avg);
    }
}

reduce(): свёртка потока в одно значение

reduce() последовательно применяет бинарную операцию к элементам, накапливая результат. У метода три формы.

// 1. С начальным значением - результат всегда есть, Optional не нужен
int sum = Stream.of(1, 2, 3, 4, 5)
        .reduce(0, Integer::sum);          // 15

// 2. Без начального значения - на пустом потоке возвращать нечего, отсюда Optional
Optional<Integer> max = Stream.of(1, 2, 3, 4, 5)
        .reduce(Integer::max);
System.out.println(max.orElse(0));         // 5

// 3. Конкатенация строк
String sentence = Stream.of("Stream", "API", "reduce")
        .reduce("", (a, b) -> a.isEmpty() ? b : a + " " + b);
System.out.println(sentence);              // Stream API reduce

Для чисел и строк специализированные методы обычно удобнее: IntStream.sum() вместо reduce(0, Integer::sum) и Collectors.joining(" ") вместо ручной склейки. reduce() нужен там, где готового коллектора нет.

toArray(): из потока в массив

// без аргумента получится Object[]
Object[] objects = Stream.of("a", "b", "c").toArray();

// со ссылкой на конструктор массива - типизированный массив
String[] letters = Stream.of("a", "b", "c").toArray(String[]::new);
System.out.println(Arrays.toString(letters)); // [a, b, c]

// у примитивных потоков toArray() сразу отдаёт int[] / long[] / double[]
int[] numbers = IntStream.rangeClosed(1, 5).toArray();
System.out.println(Arrays.toString(numbers)); // [1, 2, 3, 4, 5]

iterator() и spliterator()

Эти два метода тоже терминальные: они закрывают поток и отдают итератор, через который элементы читаются вручную. Нужны, когда результат надо передать в старый API, ожидающий Iterator, или когда обход должен прерываться по сложному условию.

Iterator<String> it = Stream.of("sun", "sea", "sand").iterator();
while (it.hasNext()) {
    System.out.println(it.next()); // sun, sea, sand
}

Spliterator<String> sp = Stream.of("sun", "sea", "sand").spliterator();
sp.tryAdvance(s -> System.out.println("first: " + s)); // first: sun
sp.forEachRemaining(System.out::println);              // sea, sand

Spliterator (splittable iterator) отличается от обычного итератора тем, что умеет делиться пополам методом trySplit() — именно на этом построена работа параллельных потоков.

forEach() и forEachOrdered()

В последовательном потоке эти методы работают одинаково. Разница проявляется при переходе на parallelStream(): forEach() не даёт никаких гарантий порядка, а forEachOrdered() обрабатывает элементы строго в порядке источника.

List<Integer> nums = List.of(1, 2, 3, 4, 5, 6, 7, 8);

nums.parallelStream().forEach(System.out::print);
// например 56781234 - порядок произвольный и меняется от запуска к запуску

nums.parallelStream().forEachOrdered(System.out::print);
// всегда 12345678

Обратите внимание

forEachOrdered() не бесплатен: он заставляет параллельный поток синхронизировать выдачу результатов и часто съедает весь выигрыш от параллелизма. Если порядок принципиален, чаще проще собрать результат в список через collect() — коллекторы сохраняют порядок источника сами.

3. Метод collect() и Collectors

collect() — самая универсальная терминальная операция: она принимает коллектор и превращает поток в List, Set, Map, строку или агрегат. Готовые коллекторы лежат в утилитном классе java.util.stream.Collectors.

public class StreamDemo5 {
    public static void main(String[] args) {
        List<String> phones = new ArrayList<>();
        Collections.addAll(phones, "iPhone 8", "HTC U12", "Huawei Nexus 6P",
                "Samsung Galaxy S9", "LG G6", "Xiaomi MI6", "ASUS Zenfone 2",
                "Sony Xperia Z5", "Meizu Pro 6", "Lenovo S850");

        List<String> filteredPhones = phones.stream()
                .filter(s -> s.length() < 10)
                .collect(Collectors.toList());

        System.out.println(filteredPhones); // [iPhone 8, HTC U12, LG G6, Lenovo S850]
    }
}

Начиная с Java 16 для самого частого случая есть короткая форма stream().toList(), которая возвращает неизменяемый список.

Collectors.toMap()

Превращение потока объектов в Map: первая функция извлекает ключ, вторая — значение.

record ColorBox(int width, int height, int depth, String color) { }

public class StreamDemo9 {
    public static void main(String[] args) {
        Stream<ColorBox> stream = Stream.of(
                new ColorBox(1, 1, 1, "red"),
                new ColorBox(2, 2, 2, "green"),
                new ColorBox(3, 3, 3, "blue"),
                new ColorBox(4, 4, 4, "black"));

        Map<String, ColorBox> map = stream
                .collect(Collectors.toMap(ColorBox::color, box -> box));

        map.forEach((k, v) -> System.out.println(k + " " + v));
    }
}

Если два элемента дадут одинаковый ключ, двухаргументный toMap() бросит IllegalStateException: Duplicate key. Чтобы этого избежать, добавляют третий аргумент — функцию слияния, например (oldValue, newValue) -> newValue.

Collectors.groupingBy()

Группировка элементов по признаку: ключ вычисляется функцией, значения складываются в список.

Map<Integer, List<Integer>> grouped = Stream.of(2, 34, 54, 23)
        .collect(Collectors.groupingBy(i -> i / 10 * 10));
// {0=[2], 20=[23], 30=[34], 50=[54]}

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

Map<Integer, Long> counted = Stream.of(2, 34, 54, 23)
        .collect(Collectors.groupingBy(i -> i / 10 * 10, Collectors.counting()));
// {0=1, 20=1, 30=1, 50=1}

Это лишь базовые коллекторы. Подробный разбор CollectorstoSet(), joining(), partitioningBy(), summingInt(), teeing() и написание собственного коллектора — вынесен в отдельный урок.

4. Промежуточные операции: таблица и примеры

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

Метод Тип Что делает
filter() Stateless Оставляет элементы, для которых предикат истинен
map() Stateless Преобразует каждый элемент в другой объект или тип
mapToInt(), mapToObj(), boxed() Stateless Переход между объектным и примитивным потоком
flatMap() Stateless Разворачивает вложенные структуры: поток потоков превращает в один плоский поток
peek() Stateless Подсматривает элементы, не меняя их. Инструмент отладки, не логики
takeWhile() (Java 9+) Stateful Берёт элементы с начала, пока предикат истинен, и обрывается на первом несовпадении
dropWhile() (Java 9+) Stateful Пропускает элементы, пока предикат истинен, и отдаёт весь остаток
distinct() Stateful Убирает дубликаты по equals()
sorted() Stateful Сортирует элементы по натуральному порядку или компаратору
limit() Stateful Ограничивает поток первыми n элементами
skip() Stateful Пропускает первые n элементов

Отдельно стоят parallel(), sequential() и unordered(). Формально они тоже возвращают Stream, но не обрабатывают элементы: parallel() и sequential() переключают режим выполнения конвейера, а unordered() снимает требование сохранять порядок, чтобы дать оптимизатору больше свободы.

takeWhile() и dropWhile()

Эти методы появились в Java 9 и часто путаются с filter(). Разница в том, что filter() проверяет каждый элемент, а takeWhile() и dropWhile() смотрят только на непрерывный префикс потока и останавливаются на первом нарушении условия.

List<Integer> nums = List.of(1, 2, 3, 10, 4, 5);

System.out.println(nums.stream().takeWhile(n -> n < 5).toList()); // [1, 2, 3]
System.out.println(nums.stream().dropWhile(n -> n < 5).toList()); // [10, 4, 5]
System.out.println(nums.stream().filter(n -> n < 5).toList());    // [1, 2, 3, 4, 5]

Практическая польза takeWhile() — работа с бесконечными и отсортированными потоками: он прерывает обход, как только условие перестало выполняться, и не перебирает остаток.

List<Integer> powers = Stream.iterate(1, n -> n * 2)
        .takeWhile(n -> n < 100)
        .toList(); // [1, 2, 4, 8, 16, 32, 64]

5. Stateless и stateful операции

Stateless (без сохранения состояния) — операция обрабатывает каждый элемент независимо и ничего не помнит о предыдущих: filter(), map(), flatMap(), peek(). Такие операции идеально распараллеливаются и не требуют дополнительной памяти.

Stateful (с сохранением состояния) — операция должна увидеть другие элементы, иногда весь поток целиком, прежде чем отдать результат: sorted(), distinct(), limit(), skip(), takeWhile(), dropWhile(). sorted() и distinct() вынуждены буферизовать данные, а limit() и skip() держат счётчик обработанных элементов.

Практический вывод: stateful-операции стоят дороже, а на бесконечных потоках sorted() и distinct() просто зависнут — они ждут конца данных, которого не будет.

words.stream()
     .filter(Objects::nonNull)  // stateless
     .distinct()                // stateful: помнит увиденные элементы
     .sorted()                  // stateful: буферизует весь поток
     .forEach(System.out::println);

6. Неочевидные моменты

  1. Цепочка без терминальной операции не выполняется. Код компилируется, ошибок нет, но и результата нет: промежуточные методы только строят конвейер.
  2. Порядок операций влияет на объём работы. filter().sorted() сортирует уже отфильтрованные элементы, а sorted().filter() — все. То же с distinct() и map(): сначала сужайте поток, потом преобразуйте.
  3. peek() может не сработать. В Java 9+ count() умеет посчитать элементы, не запуская конвейер, если размер известен заранее — тогда лямбда внутри peek() не вызовется ни разу.
  4. findAny() не обязан вернуть первый элемент. В последовательном потоке он обычно совпадает с findFirst(), но полагаться на это нельзя: контракт гарантирует только «какой-нибудь» элемент.
  5. allMatch() на пустом потоке возвращает true. Это правило пустого множества: Stream.of().allMatch(x -> false) даёт true, а anyMatch() на пустом потоке — false.
  6. Побочные эффекты в лямбдах. Вместо forEach(result::add) собирайте данные через collect() или toList() — в параллельном потоке запись в общий список приводит к гонке и потере элементов.

Совет

Не используйте peek() для изменения данных или записи в лог в продакшене. Javadoc прямо называет его инструментом отладки, а оптимизатор вправе вырезать всю цепочку, если результат операции не влияет на итог. Для логирования подойдёт map(), который возвращает элемент явно.

Часто задаваемые вопросы

Как понять, что метод терминальный?

По возвращаемому типу. Если метод возвращает Stream, IntStream, LongStream или DoubleStream, он промежуточный и ничего не вычисляет. Если возвращает конкретное значение, коллекцию, массив, Optional, Iterator, Spliterator или void, он терминальный: запускает весь конвейер и закрывает поток.

Можно ли вызвать две терминальные операции на одном потоке?

Нет. Поток одноразовый: после первой терминальной операции он закрыт, и любой следующий вызов бросит IllegalStateException с сообщением stream has already been operated upon or closed. Нужно либо создать поток заново от источника, либо хранить Supplier, который отдаёт новый поток на каждый вызов get().

Чем takeWhile отличается от filter?

filter проверяет каждый элемент потока и оставляет все подходящие, где бы они ни находились. takeWhile берёт элементы только с начала потока и прекращает обход на первом элементе, который не прошёл проверку, даже если дальше есть подходящие. Для списка 1, 2, 3, 10, 4, 5 и условия меньше пяти filter вернёт 1, 2, 3, 4, 5, а takeWhile - только 1, 2, 3.

Чем findFirst отличается от findAny?

findFirst всегда возвращает первый элемент в порядке источника. findAny возвращает любой доступный элемент и потому дешевле в параллельном потоке: ему не нужно дожидаться, пока отработает часть, отвечающая за начало данных. В последовательном потоке результаты обычно совпадают, но контракт этого не гарантирует. Оба метода возвращают Optional, потому что поток может оказаться пустым.

Комментарии

Зарегистрируйтесь или войдите, чтобы иметь возможность оставить комментарий.