Что такое Stream API в Java
Этот код компилируется, но падает в рантайме:
List<String> words = List.of("hello", "hola", "hallo");
Stream<String> stream = words.stream();
stream.forEach(System.out::println); // всё хорошо
long count = stream.count(); // java.lang.IllegalStateException:
// stream has already been operated upon or closed Причина не в опечатке: поток в Java одноразовый. Понимание того, чем Stream отличается от коллекции, снимает большинство таких вопросов сразу — и с этого мы начнём.
1. Что такое Stream API в Java
Stream API — это набор классов и интерфейсов пакета java.util.stream, появившийся в Java 8, который позволяет обрабатывать последовательности элементов в декларативном (функциональном) стиле. Stream (поток) — это последовательность элементов из источника данных (коллекции, массива, файла, генератора), поддерживающая последовательные и параллельные операции обработки.
Ключевая идея: вы описываете, что нужно сделать с данными (отфильтровать, преобразовать, собрать), а не как это сделать через циклы и временные переменные. Сам поток при этом не является структурой данных — он не хранит элементы, а лишь пропускает их через цепочку операций.
Важно не путать java.util.stream.Stream с потоками ввода-вывода (InputStream, OutputStream) и с потоками выполнения (Thread). Это три разные вещи, которые по-русски называются одним словом «поток».
2. Зачем нужен Stream API
- Меньше шаблонного кода. Фильтрация, сортировка и сбор результата умещаются в одну цепочку вместо десятка строк с циклом и промежуточным списком.
- Читаемость. Цепочка
filter().map().collect()читается как предложение и описывает намерение, а не механику обхода. - Параллельная обработка из коробки. Один вызов
parallelStream()вместо ручной работы сExecutorService. - Богатый набор операций. Фильтрация, преобразование, сортировка, группировка, агрегация, поиск — всё в стандартной библиотеке.
- Ленивость. Промежуточные операции не выполняются, пока не запрошен результат, поэтому лишняя работа не делается.
3. Ключевые свойства потоков
- Поток не хранит данные. Он получает элементы из источника и передаёт их дальше по цепочке. Источник живёт своей жизнью.
- Поток не изменяет источник.
sorted(),filter(),map()возвращают новый поток, а исходная коллекция остаётся нетронутой (в отличие отCollections.sort(), который сортирует список на месте). - Поток одноразовый. После терминальной операции он закрыт; повторное обращение бросает
IllegalStateException. - Операции ленивые. Промежуточные операции только строят конвейер; реальная обработка стартует лишь при вызове терминальной операции.
- Нет доступа по индексу. У потока нет
get(i)— если нужен произвольный доступ, работайте со списком или массивом. - Тесная интеграция с лямбда-выражениями и ссылками на методы: почти каждый метод Stream принимает функциональный интерфейс.
Важно
Цепочка без терминальной операции не выполняет вообще ничего. Вызов list.stream().filter(s -> { System.out.println(s); return true; }); не напечатает ни строки: лямбда внутри filter сработает только тогда, когда кто-то запросит результат.
4. Интерфейсы: Stream, IntStream, LongStream, DoubleStream
Stream API строится вокруг четырёх интерфейсов:
java.util.stream.Stream<T>— поток объектов любого ссылочного типа.IntStream,LongStream,DoubleStream— специализированные потоки примитивов.
Специализированные потоки существуют не ради красоты: Stream<Integer> вынужден упаковывать каждое число в объект (автобоксинг), а IntStream работает с примитивами напрямую. Кроме того, у примитивных потоков есть методы, которых нет у Stream<T>: sum(), average(), summaryStatistics().
// Stream<Integer>: боксинг на каждом элементе, метода sum() нет
int total1 = List.of(1, 2, 3).stream().mapToInt(Integer::intValue).sum();
// IntStream: примитивы, sum() есть
int total2 = IntStream.of(1, 2, 3).sum(); // 6
double avg = IntStream.of(1, 2, 3).average().orElse(0); // 2.0 Переходы между ними: mapToInt(), mapToLong(), mapToDouble() — из объектного потока в примитивный; boxed() или mapToObj() — обратно.
Полный список методов см. в официальной документации пакета java.util.stream.
5. Как создать Stream: 7 способов
1. Из коллекции — самый частый вариант, метод stream() есть у любой Collection:
List<String> words = Arrays.asList("hello", "hola", "hallo", "ciao");
Stream<String> stream = words.stream(); 2. Из перечисленных значений — Stream.of():
Stream<String> stream = Stream.of("hello", "hola", "hallo", "ciao"); 3. Из массива объектов:
String[] words = {"hello", "hola", "hallo", "ciao"};
Stream<String> stream = Arrays.stream(words); // или Stream.of(words) 4. Из массива примитивов — получится IntStream, а не Stream<Integer>:
int[] nums = {1, 2, 3, 4, 5};
System.out.println(Arrays.stream(nums).count()); // 5 int[] nums = {1, 2, 3, 4, 5};
System.out.println(IntStream.of(nums).count()); // 5 5. Генератором и итератором — такие потоки бесконечны, поэтому им нужен ограничитель:
Stream<Double> random = Stream.generate(Math::random).limit(5);
Stream<Integer> powers = Stream.iterate(1, n -> n * 2).limit(5); // 1, 2, 4, 8, 16
// Java 9+: iterate с условием остановки, limit не нужен
Stream<Integer> upTo100 = Stream.iterate(1, n -> n < 100, n -> n * 2); 6. Через билдер:
Stream<String> s = Stream.<String>builder()
.add("h").add("e").add("l").add("l").add("o")
.build();
s.forEach(System.out::print); // hello 7. Диапазоном чисел:
IntStream s1 = IntStream.range(1, 4); // 1, 2, 3 - верхняя граница не входит
IntStream s2 = IntStream.rangeClosed(1, 4); // 1, 2, 3, 4 - включительно Есть и другие источники: Files.lines(path) для строк файла, String.chars() для символов, Random.ints(), Pattern.splitAsStream(), Stream.empty() для пустого потока.
6. Промежуточные и терминальные операции
Любой конвейер Stream состоит из трёх частей: источник → промежуточные операции → терминальная операция. Разница между двумя типами операций принципиальная:
- Промежуточная операция (например,
filter,map,sorted,distinct,limit) возвращает новыйStream, ничего не вычисляя. Их можно цеплять сколько угодно. - Терминальная операция (например,
collect,forEach,count,reduce) возвращает результат (значение, коллекцию,Optional) илиvoid, запускает всю цепочку и закрывает поток.
Ленивость экономит работу: в примере ниже map отработает не для всех элементов, а ровно до первого совпадения.
Optional<String> first = Stream.of("apple", "banana", "avocado", "cherry")
.map(String::toUpperCase) // выполнится только для "apple"
.filter(s -> s.startsWith("A"))
.findFirst(); // терминальная операция, обход прерывается
System.out.println(first.orElse("не найдено")); // APPLE Полный список промежуточных и терминальных методов с таблицами и примерами (включая reduce(), toArray(), iterator()/spliterator(), разницу stateless/stateful) разобран в отдельном уроке «Промежуточные и оконечные операции Stream».
7. Примеры обработки коллекций
Задача: выбрать студентов с баллом выше 90 и отсортировать их по баллу.
Как это писали до Java 8:
List<Student> studentsScore = new ArrayList<>();
for (Student s : students) {
if (s.getScore() > 90.0) {
studentsScore.add(s);
}
}
studentsScore.sort(new Comparator<Student>() {
public int compare(Student s1, Student s2) {
return Double.compare(s1.getScore(), s2.getScore());
}
}); То же самое через Stream API — одна цепочка вместо цикла, временного списка и анонимного класса:
List<Student> studentsScore = students.stream()
.filter(s -> s.getScore() > 90.0)
.sorted(Comparator.comparing(Student::getScore))
.toList(); // Java 16+; раньше: .collect(Collectors.toList()) Ещё несколько типовых цепочек:
// Имена в верхнем регистре через запятую
String names = students.stream()
.map(Student::getName)
.map(String::toUpperCase)
.collect(Collectors.joining(", "));
// Средний балл
double average = students.stream()
.mapToDouble(Student::getScore)
.average()
.orElse(0.0);
// Группировка по городу
Map<String, List<Student>> byCity = students.stream()
.collect(Collectors.groupingBy(Student::getCity));
// Есть ли хоть один отличник
boolean hasExcellent = students.stream().anyMatch(s -> s.getScore() > 95.0); Совет
Начиная с Java 16 вместо collect(Collectors.toList()) можно писать просто toList(). Разница не только в длине: Stream.toList() возвращает неизменяемый список, а Collectors.toList() не гарантирует ни изменяемость, ни конкретный класс результата. Если список потом нужно менять, берите collect(Collectors.toCollection(ArrayList::new)).
8. На чём чаще всего спотыкаются
- Повторное использование потока. Ошибка из начала урока: после терминальной операции поток закрыт. Если нужен второй проход, создавайте поток заново или храните
Supplier<Stream<T>>:Supplier<Stream<String>> supplier = words::stream; supplier.get().forEach(System.out::println); long count = supplier.get().count(); // работает - Забытая терминальная операция. Цепочка из одних промежуточных операций не делает ничего и не выдаёт ошибку — код просто молча не выполняется.
Stream.of()с массивом примитивов.Stream.of(new int[]{1, 2, 3})даётStream<int[]>из одного элемента, а не поток чисел. Для примитивов используйтеArrays.stream(nums)илиIntStream.of(nums).- Бесконечный поток без ограничителя.
Stream.generate(Math::random).forEach(...)будет крутиться вечно. Потоки отgenerate()и двухаргументногоiterate()обязательно ограничивают черезlimit()илиtakeWhile(). - Изменение источника во время обхода. Добавление или удаление элементов коллекции внутри лямбды приводит к
ConcurrentModificationException. - Побочные эффекты вместо сбора результата. Вместо
forEach(result::add)используйтеcollect()илиtoList()— это безопаснее, особенно в параллельных потоках.
Обратите внимание
parallelStream() — не бесплатное ускорение. Все параллельные потоки по умолчанию делят общий ForkJoinPool.commonPool(), а на маленьких коллекциях накладные расходы на разбиение и слияние съедают выигрыш. Параллелизм оправдан на больших объёмах данных и независимых операциях без побочных эффектов — и только после замеров.
Часто задаваемые вопросы
Чем Stream отличается от коллекции?
Коллекция — это структура данных: она хранит элементы в памяти, знает свой размер, даёт доступ по индексу и позволяет добавлять или удалять элементы. Stream ничего не хранит: он получает элементы из источника, пропускает их через конвейер операций и закрывается. Коллекцию можно обходить сколько угодно раз, поток — только один раз.
Почему код внутри filter или map не выполняется?
Промежуточные операции ленивые: они только описывают, что нужно сделать. Пока в цепочке нет терминальной операции — collect, forEach, count, reduce, findFirst и подобных — ни одна лямбда не будет вызвана. Добавьте терминальную операцию, и конвейер запустится.
Чем IntStream отличается от Stream<Integer>?
IntStream работает с примитивами int напрямую, без упаковки в объекты, поэтому создаёт меньше нагрузки на память и сборщик мусора. У него есть числовые методы sum, average, max, min и summaryStatistics, которых нет у объектного Stream. Перейти из объектного потока в примитивный можно через mapToInt, обратно — через boxed.
Как из Stream получить List?
В Java 16 и новее проще всего вызвать toList() — он вернёт неизменяемый список. В более старых версиях используется collect(Collectors.toList()). Если результат нужно потом менять, соберите его через collect(Collectors.toCollection(ArrayList::new)).
Всегда ли parallelStream быстрее обычного потока?
Нет. Параллельный поток разбивает данные на части, обрабатывает их в общем пуле ForkJoinPool и объединяет результаты — на небольших коллекциях эти накладные расходы больше самой работы. Выигрыш появляется на больших объёмах данных, при независимых операциях без побочных эффектов и хорошо разбиваемом источнике вроде ArrayList или массива. Решение принимается по результатам замеров, а не по умолчанию.
Комментарии