Часто над коллекцией нужно сделать одно и то же: отобрать подходящие элементы, что-то из них достать, посчитать сумму. Раньше это всегда был цикл for. Лямбды и Stream API позволяют описать что мы хотим получить, а не как перебирать. Разберём оба механизма с нуля.
Зачем это нужно: «что», а не «как»
Задача: из списка пользователей оставить совершеннолетних и собрать их имена. Обычный цикл:
List<String> names = new ArrayList<>();
for (User u : users) {
if (u.age() >= 18) { // отбираем
names.add(u.name()); // достаём имя
}
}
Тут перемешаны две вещи: намерение (отобрать и достать имя) и механика (создать список, перебрать, добавить). Stream API даёт записать только намерение:
List<String> names = users.stream()
.filter(u -> u.age() >= 18) // отбираем
.map(User::name) // достаём имя
.toList();
До второго варианта нужны две вещи: функциональный интерфейс и лямбда.
Функциональные интерфейсы
Чтобы передать в метод кусок логики, у него должен быть тип, а тип в Java это класс или интерфейс. Отсюда функциональный интерфейс: интерфейс ровно с одним абстрактным методом. По единственному методу компилятор понимает, что означает переданная короткая функция. Четыре интерфейса из java.util.function встречаются постоянно и различаются только формой, что принимают и что возвращают:
живой пример
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
public class Basics {
public static void main(String[] args) {
Function<String, Integer> length = s -> s.length(); // строка -> её длина
Predicate<Integer> isPositive = n -> n > 0; // число -> true/false
Consumer<String> printer = s -> System.out.println(s); // строка -> печать
Supplier<String> greeting = () -> "привет"; // без входа -> значение
System.out.println(length.apply("hello")); // 5
System.out.println(isPositive.test(-3)); // false
printer.accept(greeting.get()); // привет
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Function<T, R> принимает T и возвращает R (метод apply), Predicate<T> отвечает boolean (test), Consumer<T> только принимает (accept), Supplier<T> только возвращает (get). Можно писать и свои: аннотация @FunctionalInterface не обязательна, но защищает — компилятор проверит, что метод действительно один.
Лямбды
Лямбда — это короткая запись реализации функционального интерфейса прямо на месте, без отдельного класса. Короткая формула: (аргументы) -> тело.
живой пример
public class Discounts {
@FunctionalInterface
interface Discount {
int applyTo(int price); // ровно один абстрактный метод
}
public static void main(String[] args) {
Discount half = price -> price / 2; // одно выражение — результат возвращается сам
System.out.println(half.applyTo(100)); // 50
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Несколько форм записи:
() -> 42 // без аргументов
x -> x + 1 // один аргумент, скобки можно опустить
(x, y) -> x + y // два аргумента — скобки обязательны
(int x, int y) -> x + y // можно указать типы явно
x -> { // тело из нескольких строк — нужны фигурные скобки и return
int doubled = x * 2;
return doubled + 1;
}
Тип параметра выводится из функционального интерфейса, поэтому его почти никогда не пишут.
Важная деталь: лямбда может использовать локальные переменные из окружающего кода, но только если они фактически не меняются (effectively final).
живой пример
import java.util.function.Function;
public class Capture {
public static void main(String[] args) {
int bonus = 10; // больше не присваиваем — effectively final
Function<Integer, Integer> add = x -> x + bonus; // лямбда «захватывает» bonus
System.out.println(add.apply(5)); // 15
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Checked-исключения в лямбде
Лямбда обязана точно соответствовать сигнатуре метода функционального интерфейса — а у Function.apply, Consumer.accept и остальных стандартных интерфейсов нет throws. Поэтому метод с checked-исключением внутри лямбды не компилируется:
List<String> texts = paths.stream()
.map(p -> Files.readString(p)) // ошибка: unreported exception IOException
.toList();
Обходов два: обернуть в unchecked прямо в лямбде (catch (IOException e) { throw new UncheckedIOException(e); }) или вынести чтение в метод с той же обёрткой и передать .map(this::readQuietly). Если ошибки нужно обрабатывать по-разному для разных элементов или прервать обход на первой — обычный цикл с try/catch читается проще.
Method reference
Если лямбда просто вызывает уже существующий метод, её можно записать ещё короче — через method reference (ссылку на метод) с оператором :: — поведение то же самое.
живой пример
import java.util.ArrayList;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
public class Refs {
public static void main(String[] args) {
Function<String, Integer> length = String::length; // вместо s -> s.length()
Consumer<String> printer = System.out::println; // вместо s -> System.out.println(s)
Supplier<ArrayList<String>> factory = ArrayList::new; // вместо () -> new ArrayList<>()
System.out.println(length.apply("stream")); // 6
printer.accept("напечатала ссылка на метод");
System.out.println(factory.get()); // []
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Четыре вида ссылок:
String::length— на метод экземпляра по типу: объект, у которого вызовут метод, придёт первым аргументом.System.out::println— на метод конкретного объекта.Integer::parseInt— на статический метод.ArrayList::new— на конструктор.
Правило: если лямбда только перенаправляет вызов в один метод без лишней логики — короче method reference; если внутри есть что-то ещё — остаётся лямбда.
Stream API: конвейер обработки
Stream — это конвейер для обработки последовательности элементов. Он не хранит данные (источник — коллекция, массив и т.д.) и не меняет источник: на каждом шаге получается новый поток.
Конвейер всегда состоит из трёх частей: источник, промежуточные операции, терминальная операция.
Источник отдаёт элементы по одному: не прошедший filter дальше не идёт, прошедший тут же проходит map и падает в результат. Пока терминальной операции нет, по конвейеру не двигается ничего.
Промежуточные vs терминальные операции
Это ключевое различие.
- Промежуточные операции (
filter,map,flatMap,sorted,distinct,limit) возвращают новый Stream и ничего не делают сразу — только записывают, что нужно сделать. - Терминальная операция (
collect,toList,count,reduce,findFirst— илиforEach, у которого только побочный эффект) запускает весь конвейер и возвращает результат. После неё поток использовать нельзя.
Из этого следует ленивость: пока нет терминальной операции, не выполняется ничего. Видно по порядку вывода:
живой пример
import java.util.List;
import java.util.stream.Stream;
public class Lazy {
public static void main(String[] args) {
List<String> words = List.of("kiwi", "banana", "fig");
Stream<String> pipeline = words.stream()
.filter(w -> { System.out.println("проверяю " + w); return w.length() > 3; })
.map(String::toUpperCase);
System.out.println("конвейер описан, но не выполнялся");
List<String> result = pipeline.toList(); // терминальная операция запускает всё
System.out.println(result);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Строка «конвейер описан» печатается первой — до неё не проверен ни один элемент. Ленивость ещё и экономит работу: элементы проходят конвейер по одному, и limit может остановить обработку, не перебирая весь источник.
Вторая половина выигрыша от ленивости — операции с коротким замыканием: они умеют остановить конвейер, не дождавшись конца источника. anyMatch и noneMatch отвечают, как только нашли первый подходящий или неподходящий элемент; findFirst и findAny забирают первый и всё; limit(n) пропускает ровно n и дальше не тянет. Для списка на миллион строк, где нужная третья, это разница между тремя проверками и миллионом. Замечать это стоит и в другую сторону: sorted и distinct замыкание ломают, потому что им нужен весь источник целиком, и sorted().findFirst() всё равно прочитает и отсортирует всё.
Через конвейер прошли только три элемента: на найденном findFirst остановил источник, и до остальных дело не дошло.
Терминальные операции, у которых результата может не быть, возвращают Optional: findFirst, min, max и reduce без начального значения. Это тот самый Optional из статьи про современную Java, и с ним обращаются так же: orElse, orElseThrow, map, а не get. Пустой стрим даёт пустой Optional, и это единственная причина, по которой users.stream().filter(...).findFirst() не может вернуть null.
Но у сохранённого в переменную конвейера есть цена. Стрим — одноразовый: после терминальной операции второй вызов pipeline.toList() не вернёт тот же результат, а упадёт с IllegalStateException: stream has already been operated upon or closed. Если данные нужны дважды, второй раз берут новый стрим от источника, а не старую переменную.
filter, map
filter оставляет элементы, подходящие под Predicate. map преобразует каждый элемент через Function.
живой пример
import java.util.List;
public class FilterMap {
public static void main(String[] args) {
List<String> result = List.of("apple", "kiwi", "banana", "fig").stream()
.filter(s -> s.length() > 3) // длиннее трёх символов: apple, kiwi, banana
.map(String::toUpperCase) // в верхний регистр
.toList();
System.out.println(result); // [APPLE, KIWI, BANANA]
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
flatMap
map меняет каждый элемент на один другой. А flatMap — на несколько сразу: возвращает не значение, а маленький поток, и все такие потоки склеиваются в один общий. Этим разбирают вложенность: список списков в один список, Optional в поток из нуля или одного элемента.
живой пример
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
public class Flat {
public static void main(String[] args) {
List<List<String>> orders = List.of(List.of("хлеб", "молоко"), List.of(), List.of("чай"));
System.out.println(orders.stream().flatMap(List::stream).toList()); // [хлеб, молоко, чай]
Stream<Optional<String>> found = Stream.of(Optional.of("Анна"), Optional.empty(), Optional.of("Вера"));
System.out.println(found.flatMap(Optional::stream).toList()); // [Анна, Вера]
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Вторая строчка — самый частый приём: Optional::stream превращает «значение или пусто» в поток, и пустые просто исчезают. Так Stream<Optional<User>> становится Stream<User> без единой проверки на null.
sorted
sorted без аргументов сортирует по натуральному порядку (числа по возрастанию, строки по алфавиту). Чаще нужен порядок по полю — его задают через Comparator.comparing, а reversed() разворачивает.
живой пример
import java.util.Comparator;
import java.util.List;
public class Sorted {
record Product(String name, Integer rating) {}
public static void main(String[] args) {
List<Product> products = List.of(
new Product("чай", 3), new Product("хлеб", 5), new Product("соль", null));
// так падает с NullPointerException: у соли оценки нет
// products.stream().sorted(Comparator.comparing(Product::rating).reversed()).toList();
products.stream()
.sorted(Comparator.comparing(Product::rating,
Comparator.nullsLast(Comparator.reverseOrder())))
.forEach(p -> System.out.println(p.name() + " " + p.rating()));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Закомментированная строка — та самая ловушка. Comparator.comparing(Product::rating) достаёт значение и сравнивает его, а null сравнивать не с чем. Лечится не проверкой в лямбде, а вторым аргументом comparing: nullsLast (или nullsFirst) говорит, куда девать пустые, а внутренний компаратор задаёт порядок для остальных.
reduce
reduce сворачивает поток в одно значение: берёт начальное значение и функцию, которая объединяет «накопленное» с очередным элементом. Для чисел есть специализированные потоки — IntStream, LongStream, DoubleStream: у них сразу готовы sum(), average(), max(), и внутри они возят примитивы, а не обёртки.
живой пример
import java.util.List;
public class Sum {
public static void main(String[] args) {
int sum = List.of(1, 2, 3, 4).stream()
.reduce(0, (acc, n) -> acc + n); // 0+1, потом +2, +3, +4
System.out.println(sum); // 10
int fast = List.of(1, 2, 3, 4).stream()
.mapToInt(Integer::intValue) // дальше по конвейеру — примитивы
.sum();
System.out.println(fast); // 10
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Выигрыш тут, честно говоря, в читаемости: элементы List.of(1, 2, 3, 4) уже упакованы в Integer при создании списка, и mapToInt их только распаковывает обратно. Экономия появляется там, где числа рождаются сразу примитивами — например IntStream.rangeClosed(1, 1_000_000).sum(): ни одного объекта на миллион значений.
collect и Collectors
collect собирает поток в коллекцию или другую структуру. Чаще всего используют готовые сборщики из класса Collectors: groupingBy раскладывает элементы по ключу, joining склеивает строки. Для простого списка с Java 16 есть короткий toList().
живой пример
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class Collect {
record User(String name, int age) {}
public static void main(String[] args) {
List<User> users = List.of(new User("Анна", 30), new User("Борис", 17), new User("Вера", 30));
Map<Integer, List<User>> byAge = users.stream()
.collect(Collectors.groupingBy(User::age));
System.out.println(byAge.get(30).size() + " пользователя с возрастом 30");
String joined = users.stream()
.map(User::name)
.collect(Collectors.joining(", "));
System.out.println(joined); // Анна, Борис, Вера
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
toMap и groupingBy со счётом
Самое частое реальное применение collect — не список, а словарь, и у двух сборщиков для него есть ловушки, о которые спотыкаются в первую же неделю.
живой пример
import java.util.*;
import java.util.stream.Collectors;
public class ToMapDemo {
record User(String login, String city) {}
public static void main(String[] args) {
List<User> users = List.of(new User("anna", "Казань"), new User("boris", "Москва"), new User("vera", "Казань"));
Map<String, User> byLogin = users.stream().collect(Collectors.toMap(User::login, u -> u));
System.out.println(byLogin.keySet());
Map<String, Long> perCity = users.stream().collect(Collectors.groupingBy(User::city, Collectors.counting()));
System.out.println(perCity);
try {
users.stream().collect(Collectors.toMap(User::city, User::login));
} catch (IllegalStateException e) {
System.out.println("toMap: " + e.getMessage());
}
Map<String, String> loginsByCity = users.stream()
.collect(Collectors.toMap(User::city, User::login, (a, b) -> a + "," + b, TreeMap::new));
System.out.println(loginsByCity);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
toMap(ключ, значение) падает с IllegalStateException: Duplicate key, как только два элемента дали один ключ: в примере это два пользователя из Казани. Это правильное поведение, оно не даёт молча потерять данные, но к нему надо быть готовым: либо ключ уникален по построению (логин), либо третьим аргументом передают функцию слияния, которая говорит, что делать с дублями, а четвёртым можно задать тип карты. groupingBy дубли ждёт по определению и раскладывает элементы по спискам, а второй аргумент говорит, что сделать с каждой группой: counting() даёт число, summingInt(User::age) сумму, mapping(User::login, toList()) список полей вместо объектов. Пара groupingBy плюс counting это ровно GROUP BY с COUNT из SQL, и читать её стоит так же.
Параллельные стримы
Замените stream() на parallelStream(), и конвейер разложится по ядрам процессора. Это первое, что пробует новичок, увидев стримы, и первое, что ломает программу. Три вещи, которые надо знать до того, как нажать.
Порядок пропадает: forEach на параллельном стриме печатает элементы вперемешку, и если порядок важен, нужен forEachOrdered или toList, который порядок сохраняет за счёт лишней работы по склейке. Общий пул один на всю JVM: параллельные стримы выполняются в общем ForkJoinPool, и один долгий конвейер с чтением из базы внутри займёт его потоки, а соседний код, который тоже надеялся на параллельность, будет ждать. И выигрыш есть только на большой чисто вычислительной работе: на списке из ста элементов накладные расходы на разбиение и слияние съедают больше, чем экономят, а на ввод-вывод параллельный стрим вообще не рассчитан. Правило: по умолчанию stream(), а parallelStream() только после замера и только на данных, которые в памяти и считаются долго.
Один поток разложен по корзинам: ключ решает, в какую корзину попадёт элемент, а в корзине лежит список всех попавших.
Когда стрим, а когда обычный цикл
Stream API — не замена циклу «всегда и везде». Ориентир простой.
Стрим уместен, когда есть цепочка преобразований над коллекцией (отобрать → преобразовать → собрать/посчитать) — он читается как описание намерения.
Обычный цикл лучше, когда:
- нужен побочный эффект на каждом шаге (запись в файл, в БД) — для этого цикл честнее, чем
forEach; - логика сложная, с ранним выходом, несколькими переменными состояния или вложенными условиями;
- важна отладка по шагам или нужен индекс элемента;
- это горячий участок, где упаковка/распаковка чисел в стриме создаёт лишнюю нагрузку.
Короткая формула: стрим — для трансформации данных, цикл — для управления ходом выполнения. И источник внутри стрима не меняют — это нарушает его модель и приводит к трудноуловимым ошибкам.
Коротко
- Функциональный интерфейс — интерфейс с одним абстрактным методом; базовые:
Function,Predicate,Consumer,Supplier. - Лямбда
(args) -> тело— реализация такого интерфейса прямо на месте; захватываемые локальные переменные должны быть effectively final. - Method reference (
String::length,System.out::println,ArrayList::new) — короткая форма лямбды, когда она лишь вызывает существующий метод. - Stream — конвейер: источник → промежуточные операции (
filter,map,flatMap,sorted) → терминальная (collect,count,reduce);Collectors(groupingBy,joining) задают, что соберётcollect, а для простого списка естьtoList(). - Промежуточные операции ленивы: ничего не выполняется, пока не вызвана терминальная, и элементы идут по конвейеру по одному. Стрим одноразовый: второй раз по нему не пройти.
flatMapразворачивает вложенность (список списков,Optional::stream);sortedсComparator.comparingсортирует по полю, а пустые значения раскладываетnullsLast.- Стрим — для трансформации данных; обычный цикл — для побочных эффектов, сложного управления и горячих участков.
- Короткое замыкание (
anyMatch,findFirst,limit) останавливает конвейер, не дочитывая источник;sortedиdistinctего ломают. Операции без гарантии результата (findFirst,min,max,reduceбез начального) возвращаютOptional. toMapпадает на дубле ключа (Duplicate key), если не передать функцию слияния;groupingByсcounting/summingInt/mappingэтоGROUP BYиз SQL.parallelStream()теряет порядок вforEach, делит общийForkJoinPoolсо всей программой и окупается только на большой вычислительной работе после замера.
Что почитать дальше
- Generics: обобщённые типы — почему
Function<T, R>иList<String>пишутся в угловых скобках. - Records и современный Java — компактные типы данных, которые удобно гонять через стримы.
- Коллекции — List, Set, Map: то, над чем чаще всего и строится Stream API.
- Исключения в Java — про checked/unchecked, из-за которых лямбда с
Files.readStringне компилируется.