🌊 Розділ 8 · Питання #2
В чому різниця між intermediate та terminal операціями
Усі операції в Stream API суворо поділяються на дві фундаментальні категорії:
🟢 Junior Level
Коротка відповідь для співбесіди (30 секунд)
Усі операції в Stream API суворо поділяються на дві фундаментальні категорії:
- Проміжні (Intermediate): Завжди повертають новий об’єкт
Stream<T>і є лінивими (Lazy). Вони не виконують реальних обчислень і не обходять елементи джерела, а лише декларують наступний крок конвеєра. У ланцюжку їх може бути скільки завгодно ($0, 1, 2 \dots N$). - Термінальні (Terminal): Повертають конкретний результат, відмінний від
Stream(колекцію, примітив,Optional, абоvoid), і є жадібними (Eager). Виклик термінальної операції запускає реальне проходження даних через увесь конвеєр, після чого стрім вважається спожитим (закритим) і повторно використаний бути не може. У ланцюжку може бути суворо одна термінальна операція.
Порівняльна таблиця
| Критерій | Intermediate (Проміжні) | Terminal (Термінальні) |
|---|---|---|
| Повертаний тип | Завжди Stream<T> (або примітивний IntStream тощо) |
Результат не-Stream (List, Map, long, boolean, void) |
| Час виконання | Ліниві (Lazy): відкладаються до термінального виклику | Жадібні (Eager): негайно запускають обхід елементів |
| Кількість у ланцюжку | Будь-яка кількість ($0, 1, 2 \dots N$) | Суворо одна наприкінці ланцюжка |
| Життєвий цикл | Конструюють граф стадій конвеєра | Споживають («спалюють») стрім назавжди |
| Приклади методів | filter(), map(), flatMap(), sorted(), distinct(), limit() |
collect(), toList(), forEach(), count(), reduce(), findFirst() |
Базовий приклад на Java 21: Доказ лінивості
import java.util.List;
public class StreamLifecycleDemo {
public static void main(String[] args) {
List<String> names = List.of("alex", "anna", "boris");
System.out.println("--- Крок 1: Конструюємо проміжні операції ---");
var stream = names.stream()
.filter(name -> {
System.out.println("Фільтрація елемента: " + name);
return name.startsWith("a");
})
.map(name -> {
System.out.println("Трансформація елемента: " + name);
return name.toUpperCase();
});
// На цьому етапі в консолі АБСОЛЮТНО ПОРОЖНЬО! Жоден елемент не був оброблений!
System.out.println("--- Крок 2: Викликаємо термінальну операцію toList() ---");
List<String> result = stream.toList();
System.out.println("--- Результат: " + result + " ---");
}
}
Вивід програми наочно демонструє, що обчислення починаються лише на кроці 2:
--- Крок 1: Конструюємо проміжні операції ---
--- Крок 2: Викликаємо термінальну операцію toList() ---
Фільтрація елемента: alex
Трансформація елемента: alex
Фільтрація елемента: anna
Трансформація елемента: anna
Фільтрація елемента: boris
--- Результат: [ALEX, ANNA] ---
🟡 Middle Level
Внутрішня класифікація операцій Stream API
┌─── Stateless (filter, map, flatMap, peek)
┌─── Intermediate ────┤
│ (Ліниві) └─── Stateful (sorted, distinct, limit, skip)
Stream API ─┤
│ ┌─── Eager (collect, reduce, forEach, toList, count)
└─── Terminal ────────┤
(Запускаючі) └─── Short-circuit (findFirst, findAny, anyMatch, allMatch)
1. Проміжні: Stateless vs Stateful
- Stateless (Без збереження стану): Обробляють кожен елемент незалежно один від одного в потоковому режимі («на льоту»). Елемент одразу передається на наступну стадію конвеєра (
filter,map,flatMap). Чудово масштабуються в паралельних стрімах. - Stateful (Зі збереженням стану): Для обробки поточного елемента потребують інформації про інші елементи:
distinct()— зберігає хеш-таблицю вже зустрінутих елементів.limit(n)таskip(n)— підтримують внутрішній лічильник.sorted()— бар’єр конвеєра (Pipeline Barrier): не може передати жодного елемента downstream, доки не накопичить абсолютно всі $N$ елементів джерела в тимчасовий масив і не відсортує їх.
2. Термінальні: Eager vs Short-circuit
- Eager (Повні): Зобов’язані обійти все джерело даних до останнього елемента (
collect(),reduce(),count()). - Short-circuit (Коротке замикання): Здатні достроково завершити обхід, щойно отримано необхідний результат:
findFirst(),findAny()— зупиняються при знаходженні першого відповідного елемента.anyMatch(Predicate)— негайно повертаєtrue, щойно предикат повернувtrue.- Критична роль: Лише операції з коротким замиканням здатні коректно завершити обробку нескінченних стрімів (
Stream.generate()абоStream.iterate()).
🔴 Senior Level
Архітектура конвеєра: AbstractPipeline та ланцюжок Sink
Усередині JDK реалізація стрімів побудована на двозв’язному списку стадій конвеєра (java.util.stream.ReferencePipeline успадковує AbstractPipeline):
- Виклики
filter(),map()конструюють зв’язані вузли конвеєра (previousStage,nextStage), зберігаючи прапорці характеристик (Stream Flags:SIZED,DISTINCT,ORDERED). - У момент виклику термінальної операції запускається метод
AbstractPipeline.evaluate():- Метод
wrapSink()обходить конвеєр у зворотному порядку (від термінальної стадії до джерела) і будує єдиний ланцюжок функціональних інтерфейсівSink<T>. - Інтерфейс
Sink<T>містить 4 ключові методи:begin(long size)— підготовка до прийому даних (виділення точної пам’яті, якщо розмір відомий).accept(T value)— передача одного елемента наступній стадії конвеєра.cancellationRequested()— механізм короткого замикання (Short-Circuiting): опитується джерелом перед передачею кожного наступного елемента.end()— завершення роботи конвеєра.
- Pipeline Fusion: Усі проміжні stateless-стадії об’єднуються в один прохід: елемент передається через
Sink.filter.accept(), який усередині викликаєSink.map.accept(). JIT-компілятор HotSpot C2 агресивно інлайнить ці виклики, перетворюючи весь стрім на монолітний плоский машинний цикл.
- Метод
Архітектурна пастка: Витік лінивого стріма крізь межі транзакцій (Leaking Stream)
Одна з найпоширеніших помилок в enterprise-застосунках на Spring Boot / JPA:
// ❌ ГРУБИЙ АНТИПАТЕРН: Стрім повертається назовні транзакційного сервісу
@Service
public class UserService {
@Transactional(readOnly = true)
public Stream<UserEntity> getActiveUsers() {
// Запит до БД повертає Stream (через JPA / Spring Data)
return userRepository.streamAllByStatus(Status.ACTIVE)
.filter(UserEntity::isEmailVerified); // Intermediate операція!
}
}
// У Web-контролері:
@RestController
public class UserController {
@Autowired private UserService userService;
@GetMapping("/users")
public List<UserDto> getUsers() {
Stream<UserEntity> userStream = userService.getActiveUsers();
// 💥 ВИБУХ У RUNTIME: LazyInitializationException / JDBC Connection Closed!
// Метод getActiveUsers() завершився -> транзакцію закрито -> JDBC Connection закрито!
// Термінальна операція toList() намагається вичитати дані із закритого з'єднання!
return userStream.map(UserDto::from).toList();
}
}
Ідіоматичне рішення:
Стрім має бути повністю закритий термінальною операцією всередині того шару, який відкрив транзакцію або I/O-ресурс:
@Transactional(readOnly = true)
public List<UserDto> getActiveUsersDto() {
try (Stream<UserEntity> stream = userRepository.streamAllByStatus(Status.ACTIVE)) {
return stream.filter(UserEntity::isEmailVerified)
.map(UserDto::from)
.toList(); // Термінальна операція викликана ДО закриття транзакції!
}
}
🎯 Шпаргалка для інтерв’ю
Питання для швидкої перевірки
- Що станеться, якщо в стрімі викликати кілька проміжних операцій, але не викликати термінальну? Відповідь: Не станеться нічого. Жоден елемент джерела не буде вичитаний, і жодна лямбда не виконається, оскільки проміжні операції ліниві (lazy) і лише формують конвеєр стадій.
- Чому операція
sorted()називається бар’єром конвеєра (Pipeline Barrier)? Відповідь:sorted()є stateful-операцією. Неможливо визначити, який елемент повинен бути першим, поки не прочитані всі елементи джерела. Томуsorted()повністю зупиняє потокову передачу даних downstream, накопичує весь потік у буфер пам’яті, виконує сортування і лише потім передає елементи далі. - Як механізм
Sinkреалізує дострокову зупинку стріма (Short-Circuiting) в таких операціях, якfindFirst()абоlimit()? Відповідь: Через методSink.cancellationRequested(). Джерело даних (або попередня стадія) перед передачею чергового елемента перевіряє цей прапорець. Якщо термінальна операція знайшла потрібний елемент, прапорець повертаєtrue, і генерація елементів негайно припиняється. - Чи можна використовувати один і той самий об’єкт
Streamповторно після виклику термінальної операції? Відповідь: Ні, не можна. Стрім є одноразовим об’єктом. При спробі повторного виклику будь-якої операції на вже закритому стрімі буде викинутоIllegalStateException: stream has already been operated upon or closed.
Типові помилки та Red Flags
- ❌ Твердження: “Intermediate операція створює проміжний список
ArrayListу пам’яті”: Стрім використовує Pipeline Fusion і передає по одному елементу крізь ланцюжокSinkбез проміжних буферів (за винятком stateful операцій на кшталтsorted). - ❌ Вважати
forEach()проміжною операцією:forEach()повертаєvoidі є термінальною операцією, що споживає стрім. - ❌ Повернення незакритого
Stream<T>з@Transactionalметодів репозиторіїв/сервісів: Призводить до падінь через закриття з’єднань до БД до виклику термінальної операції. - ❌ Спроба застосувати жадібну операцію (
sorted(),count()) до нескінченного стріма: Призводить до зависання потоку або падіння черезOutOfMemoryError.