Когда я выбираю тему для исследования, я думаю о пользе для специалиста, особенно для тех, кто недавно в профессии.

Однако, если ты опытный специалист и постоянно используешь стримы в своей работе, возможно даже для тебя будет изюминка, ради которой тебе стоит прочитать статью. Я подпишу блок для тебя как ИЗЮМИНКА 

Захотелось рассмотреть важную тему 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). Мы хотим:

  1. Пройти по всем записям

  2. Для каждого сотрудника с грейдом Junior изменить значение на Middle

  3. Добавить приписку «(повышен)»

  4. Вывести результат


Кстати я всегда для себя комментариями пишу пошагавшую инструкцию, как я сделал выше, для того чтобы мне самому было легче разрабатывать. Так что берите на заметку и не бойтесь писать черновые наброски для себя.


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()) {
    // та же логика
}

И это было бы нормально. Но стрим даёт нам:

  1. Декларативность — мы говорим «что сделать», а не «как сделать»

  2. Цепочки — можно добавить filter, потом map, потом collect, и это читается как предложение

  3. Параллельную обработку — достаточно добавить .parallelStream() вместо .stream(), и Java сама разложит задачу по ядрам

  4. Ленивость — если мы напишем 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)


  1. MishaBucha
    14.04.2026 14:37

    Как по мне очень много лишней информации, которая в статье вводная

    Про функциональные интерфейсы, набор методов, а по сути разница была в последних 2-3 строчках, что Flux - когда данные придут не сразу, а в стримах сразу.

    Реактивщину вообще не очень легко использовать, ведь по сути весь стек должен быть таковым. Если у тебя нет end-to-end реактивности или вся логика блокирующая, то смысл вообще теряется. Также для всех таких объектов нельзя вызывать тот же самый block() для получения объекта, смысл тоже потеряется. Да и компетенция разработчиков должна быть выше, так как эту шутку вообще нужно с умом применять, в 95% случаев, можно обойтись и без нее)

    Как по мне нужно было описать, что в каком случае использовать, а не просто написать разницу в конце ну и раскрыть вообще немного тему


    1. KarimAbushaev Автор
      14.04.2026 14:37

      Я указываю статью как простую. Я пишу для тех, кто хочет познакомиться со Stream разобраться как работает инструмент. Пишу так, как я бы хотел, чтобы мне объяснили работу Stream, когда я начинал.


      1. MishaBucha
        14.04.2026 14:37

        Тогда статья должна называться

        Как работает Stream api и в чем разница с Flux

        Название в таком случае подобрано вообще неправильно


        1. KarimAbushaev Автор
          14.04.2026 14:37

          переименовал


  1. aleksandy
    14.04.2026 14:37

    терминальная операция закрывает стрим

    Нет. Stream закрывает соответствующий метод, а не любая терминальная операция.

    Анонимный метод — это…

    Только что придуманный автором термин.

    Функциональный интерфейс — это интерфейс с одним методом.

    И снова вынужден вас огорчить. (ц) Функциональеый интерфейс - это интерфейс с одним нереализованным методом. Методов может быть сколько угодно.


    1. MishaBucha
      14.04.2026 14:37

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


      1. aleksandy
        14.04.2026 14:37

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


    1. KarimAbushaev Автор
      14.04.2026 14:37

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


      1. aleksandy
        14.04.2026 14:37

        Да нет в яве анонимных методов.

        это именно то что делает лямбда

        Вообще нет.


        1. KarimAbushaev Автор
          14.04.2026 14:37

          Ты прав! Поправил