Когда я выбираю тему для исследования, я думаю о пользе для специалиста, особенно для тех, кто недавно в профессии.
Однако, если ты опытный специалист и постоянно используешь стримы в своей работе, возможно даже для тебя будет изюминка, ради которой тебе стоит прочитать статью. Я подпишу блок для тебя как ИЗЮМИНКА
Захотелось рассмотреть важную тему 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), река пересыхает. Попытка снова вызвать операцию упадёт с ошибкой.
Правило о том, что стрим нельзя использовать второй раз, предлагаю вам запомнить. У меня был собес в Сбере, и собеседующий задал мне вопрос: можем ли мы обратиться к стриму снова? Я недолго думая сказал: «Да». Однако терминальная операция закрывает стрим, и мы больше не имеем доступа к этому стриму. Но вы можете заново запустить стрим у той же коллекции, если необходимо, или у новой коллекции, которую отфильтровали.
Предлагаю на пальцах рассмотреть пример работы стрима с преобразованием данных. Чтобы точно получилось понять всем как это работает я буду максимально подробен, чуть не забыл!
ИЗЮМИНКА — разбирая код мы рассмотрим что такое -> лямбда и как она создает анонимная метод. Почему именно это я считаю изюминкой, просто потому что сам долго пользовался стримами, но совсем недавно понял до конца что такое анонимный метод.
Что нам понадобится для понимания
Прежде чем писать код, разберём термины, которые часто пугают.
Анонимный метод — это метод без имени. Ты не объявляешь его отдельно, а пишешь прям на месте, где он нужен. Как если бы тебе сказали: «дай сюда действие, которое я выполню для каждого элемента». Ты берёшь и даёшь это действие тут же, не создавая отдельную функцию.
Лямбда — это способ записать анонимный метод коротко. Вместо того чтобы писать:
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
ссылка на оригинал статьи https://habr.com/ru/articles/1023438/