Когда я выбираю тему для исследования, я думаю о пользе для специалиста, особенно для тех, кто недавно в профессии.
Однако, если ты опытный специалист и постоянно используешь стримы в своей работе, возможно даже для тебя будет изюминка, ради которой тебе стоит прочитать статью. Я подпишу блок для тебя как ИЗЮМИНКА
Захотелось рассмотреть важную тему Stream API, но чтобы сделать статью интереснее, я решил сравнить его с инструментами реактивного программирования — Flux и Mono из Project Reactor.
Начнём с того, что такое Stream и в чём его схожесть с Flux.

Stream — это тип данных в Java, который представляет собой поток элементов. Когда я объясняю ученикам, что такое поток данных, я предлагаю визуализацию: представьте реку, по которой плывут лодки. Река — это поток, лодки — это данные.
Технический момент: Stream не создаёт отдельный поток операционной системы (thread). Он выполняется в том потоке, который его вызвал (например, main). Данные идут друг за другом, последовательно. Обычно стримы конечны, хотя и есть возможность сделать их бесконечными с помощью Stream.iterate() или generate(). Тогда потоки нужно ограничивать оператором .limit(), иначе они будет работать вечно -> это приведет к сингулярности.
Мы можем преобразовать коллекции в поток данных (вызвать .stream() у коллекции) и с помощью стандартных команд обработать каждый элемент: отфильтровать (filter), изменить/преобразовать (map), ограничить количество (limit).
Небольшое отступление. Исходя из методов, можно заметить сходство Stream c Flux. Именно поэтому я и рассматриваю их в связке. Вы можете проработать 2+ года разработчикам и не разу не встретить такой тип данных как Flux и как раз чтобы у вас не было удивления, когда вы все таки встретитесь, я сравниваю именно эти инструменты. У Flux тоже есть filter, map, flatMap, take (аналог limit). И на первы взгляд вам может показаться, что это одно и тоже, но это не так, сегодня мы разберем разницу, чуть дальше по тексту.
Помним аналогию:
У нас есть река (Stream), по которой плывут лодки (данные).
А как работать с таким потоком?
О Стримах важно знать разделение между доступными операциями - это промежуточные(такие операции запускается сразу после вызова стрима у коллекции) и терминальные(финальные операции, обычно их используют чтобы собрать данные в новую коллекцию).
Промежуточные — это filter, map, flatMap, limit, skip. Они не запускают обработку, а только описывают, что нужно сделать. Их можно навешивать сколько угодно. Результат такой операции — новый стрим или новый Flux.
Терминальные — это collect, forEach, reduce, subscribe. Они запускают всю цепочку. После них поток данных закрывается. Повторно использовать тот же стрим или Flux нельзя.
В Stream терминальная операция одна — без неё ничего не выполнится. Во Flux терминальная операция — это subscribe(). Без неё Flux просто ничего не делает.
Ниже разберем основные методы у стримов:
filter — метод фильтрует данные по условиям, которые вы указываете.
java
List<Integer> numbers = List.of(1, 2, 3, 4, 5); numbers.stream() .filter(n -> n % 2 == 0) // оставляем чётные .forEach(System.out::println); // 2, 4
map — преобразует каждый элемент. Например, мы можем пройтись стримом по списку строковых значений, взять каждый элемент, привести к верхнему регистру, а потом распечатать каждый элемент или собрать в новый список.
java
List<String> words = List.of("cat", "dog", "mouse"); words.stream() .map(String::toUpperCase) .forEach(System.out::println); // CAT, DOG, MOUSE
flatMap — разворачивает вложенные потоки. Сильный инструмент. Иногда вам представится необходимость пройтись по списку, в котором каждый отдельный элемент тоже будет являться списком. И вам нужно будет эту вложенность развернуть наизнанку, взять каждый элемент и обработать его. Например, собрать в лист.
java
List<List<String>> nested = List.of(List.of("dog", "cat"), List.of("lion", "bird")); List<String> result = nested.stream() .flatMap(list -> list.stream()) .map(String::toUpperCase) .toList();
limit — берём только первые N элементов. Как если бы мы сказали: «Возьми только первые пять элементов, остальные не интересуют».
java
Stream.iterate(0, i -> i + 1) .limit(5) .forEach(System.out::println); // 0,1,2,3,4
skip — пропускаем первые N элементов. Смотрим на поток, но первые три не трогаем.
java
Stream.of(1,2,3,4,5) .skip(2) .forEach(System.out::println); // 3,4,5
collect — собираем все элементы в коллекцию (в List, Set, Map). Самый частый терминальный оператор.
java
List<String> result = words.stream() .filter(w -> w.length() > 3) .collect(Collectors.toList());
forEach — работает как цикл фор, перебирает элементы и можно описать любую логику для обработки элемента внутри forEach()
reduce — сворачивает весь поток в один результат. Например, считаем сумму всех элементов.
java
int sum = Stream.of(1,2,3,4) .reduce(0, (a, b) -> a + b); // 10
Важное правило: стрим можно использовать только один раз. После того как ты применил терминальную операцию (collect, forEach, reduce), река пересыхает. Попытка снова вызвать операцию упадёт с ошибкой.
Правило о том, что стрим нельзя использовать второй раз, предлагаю вам запомнить. У меня был собес в Сбере, и собеседующий задал мне вопрос: можем ли мы обратиться к стриму снова? Я недолго думая сказал: «Да».
Однако Stream закрывает соответствующий метод, и мы больше не имеем доступа к этому стриму. Но вы можете заново запустить стрим у той же коллекции, если необходимо, или у новой коллекции, которую отфильтровали.
Предлагаю на пальцах рассмотреть пример работы стрима с преобразованием данных. Чтобы точно получилось понять всем как это работает я буду максимально подробен, чуть не забыл!
ИЗЮМИНКА — разбирая код, мы рассмотрим, что такое -> (лямбда) и как она позволяет создавать поведение, скрывая метод под капотом. Я считаю это изюминкой, потому что сам долго пользовался стримами, но совсем недавно до конца понял, что лямбда — это компактный способ описать работу метода без указания имени.
Что нам понадобится для понимания
Прежде чем писать код, разберём термины, которые часто пугают.
Лямбда — это способ коротко записать экземпляр функционального интерфейса. Вместо того чтобы писать обычный метод:
java
public void doSomething(String s) { // вот так выглядит обычный метод System.out.println(s); }
Лямбда — это реализация единственного нереализованного абстрактного метода функционального интерфейса, обёрнутая в скрытый от глаз синтаксис.
java
s -> System.out.println(s) // если логики много обособляешь блок кода {}
Стрелочка -> разделяет параметры и тело метода. Слева то, что приходит на вход (аргументы вашего анонимного метода), справа — что делаем.
Функциональеый интерфейс - это интерфейс с одним нереализованным методом. Методов может быть сколько угодно.
Например, Function<T, R>(принимает T, возвращает R), Consumer<T> (принимает T, ничего не возвращает), Predicate<T>(принимает T, возвращает boolean). Именно такие интерфейсы можно заменить лямбдой.
Предлагаю решить простую задачу, чтобы закрепить знания по Stream
У нас есть карта (HashMap), где ключ — имя сотрудника, значение — его грейд (Junior, Middle, Senior). Мы хотим:
Пройти по всем записям
Для каждого сотрудника с грейдом Junior изменить значение на Middle
Добавить приписку «(повышен)»
Вывести результат
Кстати я всегда для себя комментариями пишу пошагавшую инструкцию, как я сделал выше, для того чтобы мне самому было легче разрабатывать. Так что берите на заметку и не бойтесь писать черновые наброски для себя.
java
// 1. Создаём HashMap и заполняем её Map<String, String> employees = new HashMap<>(); employees.put("Анна", "Junior"); employees.put("Борис", "Middle"); employees.put("Виктор", "Junior"); employees.put("Карим", "Senior"); // 2. Используем стрим, чтобы пройти по всем записям Map<String, String> updatedEmployees = employees.entrySet().stream() // .entrySet() — получаем набор пар (ключ + значение) // .stream() — открываем стрим для этих пар .map(entry -> { // 3. Лямбда для преобразования каждой пары, // передаем по очереди каждый элемент // entry — это один элемент стрима (пара "ключ=значение") String name = entry.getKey(); // достаём имя (ключ) String grade = entry.getValue(); // достаём грейд (значение) // 4. Если грейд Junior, то повышаем до Middle if ("Junior".equals(grade)) { grade = "Middle (повышен)"; } // 5. Возвращаем новую пару (такое же имя, обновлённый грейд) return Map.entry(name, grade); }) // 6. Собираем результат обратно в HashMap .collect(Collectors.toMap( Map.Entry::getKey, // ключ оставляем тот же Map.Entry::getValue // значение — возможно, изменённое ));
Остановимся на части кода, где вы могли заметить нетривиальную запись Map.Entry::getKey
Это ссылка на метод. Короткая запись вместо entry -> entry.getKey(). Тоже лямбда, просто ещё короче. То есть указываем объект и через :: указываем метод который будет применен.
А теперь просто представьте, как выглядел бы код, если бы мы вместо лямбд каждый раз писали обычный метод. Мы захламили бы весь код и разбираться в нем было бы значительно сложнее.
Финально закрепим как это выглядело бы на примере одного метода. Вернёмся к лямбде:
java
entry -> { String name = entry.getKey(); String grade = entry.getValue(); if ("Junior".equals(grade)) { grade = "Middle (повышен)"; } return Map.entry(name, grade); }
Под капотом Java превращает это в класс, примерно такой:
java
new Function<Map.Entry<String, String>, Map.Entry<String, String>>() { @Override public Map.Entry<String, String> apply(Map.Entry<String, String> entry) { String name = entry.getKey(); String grade = entry.getValue(); if ("Junior".equals(grade)) { grade = "Middle (повышен)"; } return Map.entry(name, grade); } }
В первом случае 7 строк кода, во втором — 11, плюс аннотации и модификаторы (new, Function, @Override, public, apply). Лямбда просто скрывает этот boilerplate. И оставляет синтетический сахар.
Внимательный читатель и разработчик, который первый раз встретился со стримами, подумает, почему бы не использовать просто цикл, чтобы перебирать элементы.
Мы могли бы написать:
java
for (Map.Entry<String, String> entry : employees.entrySet()) { // та же логика }
И это было бы нормально. Но стрим даёт нам:
Декларативность — мы говорим «что сделать», а не «как сделать»
Цепочки — можно добавить filter, потом map, потом collect, и это читается как предложение
Параллельную обработку — достаточно добавить .parallelStream() вместо .stream(), и Java сама разложит задачу по ядрам
Ленивость — если мы напишем limit(10), стрим не будет обрабатывать все элементы, а остановится после десятого
В нашем примере с мапой из 4 элементов разница незаметна. Однако со временем, когда с постоянным использованием стримов, конструкции работы с таким инструментом сформируются у вас. Вы поймете, что это удобно и быстро. Особенно стрим ценятся именно за декларативность, как я уже сказал выше. Теперь мы готовы рассмотреть Flux и разобраться в его ключевых отличиях от Stream

Flux — это интструмент из Project Reactor. Это неограниченная последовательность от 0 до N элементов. Работает по системе подписки, сначала нужно описать действия, что сделать посредством методов, а потом подписаться на последовательность Flux.
Главное отличие от Stream: Stream — это данные, которые уже есть в памяти. Flux — это данные, которые ещё только придут. Может, через секунду. Может, через час. Мы заранее не знаем, когда и сколько мы всего лишь открываем соединение.
java
Flux<Integer> flux = Flux.just(1, 2, 3, 4, 5); flux.map(i -> i * 2); // описали действия flux.subscribe(System.out::println); // Подписались, после чего запустился процесс обработки
Mono — брат-близнец Flux, только для одного элемента, что в принципе следует из его названия.
Если Flux — это последовательность от 0 до N элементов, то Mono — это 0 или 1 элемент. Всё остальное то же самое: подписка, ленивость, операторы.
java
Mono<String> mono = Mono.just("Привет"); mono.map(String::toUpperCase) .subscribe(System.out::println);
Где используется Mono? Там, где ты ожидаешь не более одного результата:
Запрос в БД по ID
GET запрос по одному ресурсу
Ответ от внешнего API, который возвращает один объект
Вот основные методы Flux:
Flux.just(1, 2, 3) — создаёт Flux из конкретных значений, которые ты перечисляешь.
Flux.fromIterable(list) — создаёт Flux из коллекции (List, Set). Всё, что есть в списке, станет элементами потока.
Flux.range(1, 10) — генерирует числа от 1 до 10. Первое число — старт, второе — сколько штук.
map(x -> x * 2) — применяет функцию к каждому элементу и возвращает новый элемент. Из числа делает число, из строки — другую строку.
filter(x -> x > 5) — пропускает дальше только те элементы, которые подходят под условие. Остальные отбрасываются.
flatMap(x -> anotherFlux(x)) — для каждого элемента вызывает асинхронную операцию (которая возвращает Flux), а потом все результаты склеивает в один общий поток. Самый мощный, но и самый сложный оператор. В целом тоже самое что и у Stream.
take(10) — берёт первые N элементов, остальные игнорирует. Если элементов меньше — возьмёт сколько есть.
collectList() — ждёт, когда поток закончится, и собирает все элементы в один List. Превращает Flux в Mono<List<T>>.
subscribe(x -> doSomething(x)) — самое главное. Подписывается на поток и запускает его. Без этого Flux ничего не делает.

Итоговые вопросы для проверки себя:
Чем метод map() отличается от flatMap()?
Почему Stream по умолчанию конечен, а Flux не имеет размера?
Без чего Flux не начнет работать?
Расскажи своими словами, что такое промежуточные и терминальные операции?
Теперь предлагаю тебе самостоятельно порешать задачки с помощью стримов, такие часто дают на собеседованиях. Мой личный опыт, каждая третья задача будет решаться с помощью Stream.
Мой тг -@karim_product на связи родной/родная ;-)
Если было полезно, поддержи подпиской. Мне будет мотивация продолжать в том же духе. Мой канал - https://t.me/+uH8Hm6kPWhU2OTc6
Комментарии (10)

aleksandy
14.04.2026 14:37терминальная операция закрывает стрим
Нет. Stream закрывает соответствующий метод, а не любая терминальная операция.
Анонимный метод — это…
Только что придуманный автором термин.
Функциональный интерфейс — это интерфейс с одним методом.
И снова вынужден вас огорчить. (ц) Функциональеый интерфейс - это интерфейс с одним нереализованным методом. Методов может быть сколько угодно.

MishaBucha
14.04.2026 14:37Ну кстати, я тоже слышал про термин анонимный метод, он существует, а так согласен с Вами)

aleksandy
14.04.2026 14:37Метод в яве не может быть анонимным, by, мать его, desugn. Функции - не first-class citizens тут. Анонимным может быть класс, коими, очень-очень грубо говоря, являются лямбды.

KarimAbushaev Автор
14.04.2026 14:37Спасибо за коммент, поправил неточности. Насчет Анонимного метода - этот термин придумал не я, это именно то что делает лямбда.

aleksandy
14.04.2026 14:37Да нет в яве анонимных методов.
это именно то что делает лямбда
Вообще нет.
MishaBucha
Как по мне очень много лишней информации, которая в статье вводная
Про функциональные интерфейсы, набор методов, а по сути разница была в последних 2-3 строчках, что Flux - когда данные придут не сразу, а в стримах сразу.
Реактивщину вообще не очень легко использовать, ведь по сути весь стек должен быть таковым. Если у тебя нет end-to-end реактивности или вся логика блокирующая, то смысл вообще теряется. Также для всех таких объектов нельзя вызывать тот же самый block() для получения объекта, смысл тоже потеряется. Да и компетенция разработчиков должна быть выше, так как эту шутку вообще нужно с умом применять, в 95% случаев, можно обойтись и без нее)
Как по мне нужно было описать, что в каком случае использовать, а не просто написать разницу в конце ну и раскрыть вообще немного тему
KarimAbushaev Автор
Я указываю статью как простую. Я пишу для тех, кто хочет познакомиться со Stream разобраться как работает инструмент. Пишу так, как я бы хотел, чтобы мне объяснили работу Stream, когда я начинал.
MishaBucha
Тогда статья должна называться
Как работает Stream api и в чем разница с Flux
Название в таком случае подобрано вообще неправильно
KarimAbushaev Автор
переименовал