🌊 Розділ 8 · Питання #7

Що робить операція flatMap()

Головна відмінність від звичайного map():


🟢 Junior Level

Коротка відповідь для співбесіди (30 секунд)

flatMap() — це проміжна (intermediate) операція Stream API, яка перетворює кожен елемент вихідного потоку на окремий незалежний потік за допомогою функції-мапера (T -> Stream<R>), а потім «сплощує» (flatten) усі отримані підпотоки в єдиний результуючий потік Stream<R>.

Головна відмінність від звичайного map():

  • map() виконує відображення «один до одного» (T -> R), повертаючи потік елементів типу R (якщо мапер повертає список, на виході буде Stream<List<R>>).
  • flatMap() виконує відображення «один до багатьох» (або «один до нуля») (T -> Stream<R>), об’єднуючи всі вкладені елементи в пласку структуру Stream<R>.

Базові приклади на Java 21

import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

public class FlatMapBasicsDemo {

    public record OrderItem(String product, int quantity, double price) {}
    public record Order(long id, List<OrderItem> items) {}

    public static void main(String[] args) {
        List<Order> orders = List.of(
            new Order(101L, List.of(
                new OrderItem("Клавіатура", 1, 3500.0),
                new OrderItem("Миша", 2, 1800.0)
            )),
            new Order(102L, List.of(
                new OrderItem("Монітор", 1, 24000.0),
                new OrderItem("Миша", 1, 1800.0)
            ))
        );

        // 1. Вилучення всіх куплених товарів з усіх замовлень у єдиний список
        List<String> allProducts = orders.stream()
            .flatMap(order -> order.items().stream()) // Stream<Order> -> Stream<OrderItem>
            .map(OrderItem::product)
            .toList();

        System.out.println("Усі позиції: " + allProducts);
        // [Клавіатура, Миша, Монітор, Миша]

        // 2. Отримання унікальних товарів із замовлень
        Set<String> uniqueProducts = orders.stream()
            .flatMap(order -> order.items().stream())
            .map(OrderItem::product)
            .collect(Collectors.toSet());

        System.out.println("Унікальні товари: " + uniqueProducts);
        // [Клавіатура, Миша, Монітор]
    }
}

🟡 Middle Level

Особливості роботи та примітивні спеціалізації

Сигнатура методу в інтерфейсі Stream<T>:

<R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);

Ключові аспекти поведінки:

  1. Фільтрація через Stream.empty(): Якщо функція повертає порожній потік Stream.empty(), вихідний елемент безслідно виключається з результуючого потоку (відображення «один до нуля»).
  2. Захист від NPE: Функція-мапер ніколи не повинна повертати null. Повернення null негайно призводить до NullPointerException під час виконання конвеєра.
  3. Примітивні спеціалізації: Для уникнення накладних витрат на автобоксинг використовуються методи flatMapToInt(), flatMapToLong(), flatMapToDouble().
import java.util.List;
import java.util.Optional;
import java.util.stream.IntStream;

public class FlatMapAdvancedDemo {

    public static void main(String[] args) {
        // Примітивна спеціалізація flatMapToInt (без пакування в Integer)
        List<String> rawTokens = List.of("10,20,30", "40,50");
        int sum = rawTokens.stream()
            .flatMapToInt(s -> {
                String[] parts = s.split(",");
                return IntStream.range(0, parts.length).map(i -> Integer.parseInt(parts[i].trim()));
            })
            .sum();
        System.out.println("Сума: " + sum); // 150

        // Інтеграція з Optional (Java 9+): розгортання наявних значень
        List<Optional<String>> optionalList = List.of(
            Optional.of("Alfa"),
            Optional.empty(),
            Optional.of("Beta")
        );

        List<String> presentValues = optionalList.stream()
            .flatMap(Optional::stream) // Optional::stream розгортає в Stream з 0 або 1 елементом
            .toList();
        System.out.println("Присутні значення: " + presentValues); // [Alfa, Beta]
    }
}

🔴 Senior Level

Внутрішня будова: ReferencePipeline, Sink і втрата характеристик Spliterator

Метод flatMap() повертає анонімний підклас ReferencePipeline.StatelessOp. Коли термінальна операція запускає обробку, створюється ланцюжок об’єктів Sink.

Для flatMap вхідний Sink.accept(T t) влаштований так:

  1. Викликає функцію mapper.apply(t).
  2. Отримує вкладений Stream<R> resultStream.
  3. Якщо resultStream != null, запускає обхід його Spliterator і для кожного вкладеного елемента викликає downstream.accept(r).
  4. У блоці try-finally гарантовано викликає resultStream.close().
Pipeline Flow:
[Order 1] ---> flatMap Sink.accept(order1)
                  ├── mapper.apply(order1) -> subStream
                  ├── subStream.forEachRemaining(downstream::accept) -> [Item 1, Item 2]
                  └── subStream.close()
[Order 2] ---> flatMap Sink.accept(order2)
                  ├── mapper.apply(order2) -> subStream
                  ├── subStream.forEachRemaining(downstream::accept) -> [Item 3]
                  └── subStream.close()

Проблема скидання прапорців SIZED та SUBSIZED:

Сплітератор, породжений flatMap, втрачає прапорці SIZED і SUBSIZED, а його estimateSize() стає рівним Long.MAX_VALUE. Рантайм стріма принципово не може заздалегідь знати, скільки елементів згенерує кожен вкладений потік.

Наслідки для паралельних стрімів (parallelStream()):

  • Неможливо оптимально збалансувати завдання у ForkJoinPool.
  • Рантайм стріма змушений розбивати вхідний масив, але дерево злиття не знає точних меж і виділяє буфери змінної місткості (SpinedBuffer).
  • Паралельний flatMap практично завжди демонструє значно менше прискорення порівняно з паралельним map або filter.

Альтернатива в Java 16+: Stream.mapMulti()

Головна архітектурна проблема класичного flatMap — тиск на збирач сміття (Garbage Collector Overhead). Для кожного елемента вхідного потоку створюється проміжний об’єкт Stream, його внутрішній сплітератор і вузол конвеєра, навіть якщо всередині знаходиться лише 1–2 елементи.

У Java 16 з’явився метод mapMulti(), який реалізує модель push-споживання через Consumer:

<R> Stream<R> mapMulti(BiConsumer<? super T, ? super Consumer<R>> mapper);

Порівняння продуктивності та алокацій:

import java.util.List;

public class MapMultiComparisonDemo {

    public record Department(String name, List<String> employees) {}

    public static void main(String[] args) {
        List<Department> departments = List.of(
            new Department("IT", List.of("Іван", "Петро")),
            new Department("Sales", List.of("Анна", "Ольга"))
        );

        // 1. Класичний flatMap: алокує Stream для кожного Department
        List<String> listFlatMap = departments.stream()
            .flatMap(d -> d.employees().stream())
            .toList();

        // 2. Високопродуктивний mapMulti (Java 16+):
        // Нуль алокацій проміжних об'єктів Stream!
        List<String> listMapMulti = departments.stream()
            .<String>mapMulti((dept, consumer) -> {
                for (String employee : dept.employees()) {
                    consumer.accept(employee);
                }
            })
            .toList();

        System.out.println("mapMulti результат: " + listMapMulti);
    }
}

Senior Insight: У high-load сервісах з обробкою мільйонів повідомлень заміна flatMap на mapMulti усуває мільйони ефемерних об’єктів Stream і Spliterator, суттєво зменшуючи частоту пауз Young Gen GC.


Керування ресурсами та AutoCloseable

Якщо вкладений потік відкриває системні ресурси (файли через Files.lines(), з’єднання з БД або мережеві сокети), стрім повинен бути закритий через try-with-resources.

Stream API пов’язує життєвий цикл вкладених стрімів:

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;

public class ResourceClosingFlatMapDemo {

    public static void readAllFiles(List<Path> files) {
        // Зовнішній try-with-resources закриває зовнішній стрім
        try (Stream<Path> pathStream = files.stream()) {
            pathStream.flatMap(path -> {
                try {
                    // Повернений Stream<String> буде автоматично закритий
                    // реалізацією flatMap одразу після вичерпання його сплітератора!
                    return Files.lines(path);
                } catch (IOException e) {
                    return Stream.empty();
                }
            }).forEach(System.out::println);
        }
    }
}

ReferencePipeline.StatelessOp всередині flatMap реєструє обробники закриття: якщо зовнішній потік переривається винятком або закривається, закриваються і вкладені активні потоки.


🎯 Шпаргалка для інтерв’ю

30-секундна відповідь

«flatMap() — проміжна операція із семантикою 1-до-багатьох. Вона приймає функцію T -> Stream<R> і схлопує отримані підпотоки в єдиний плаский Stream<R>. На відміну від map(), який повертає структуру «як є», flatMap() розгортає вкладені колекції. Починаючи з Java 16, для уникнення алокацій проміжних стрімів рекомендовано використовувати mapMulti(). Важливо пам’ятати, що flatMap() скидає прапорець SIZED, що робить паралельні стріми менш ефективними.»


Відмінності map(), flatMap() та mapMulti()

Характеристика map() flatMap() mapMulti() (Java 16+)
Відношення 1 до 1 1 до $N$ (де $N \ge 0$) 1 до $N$ (де $N \ge 0$)
Сигнатура мапера T -> R T -> Stream<R> (T, Consumer<R>) -> void
Алокація об’єктів Stream Немає Створює Stream на кожен елемент Немає (використовує імперативний Consumer)
Збереження розміру (SIZED) Так Ні (скидає в Long.MAX_VALUE) Ні
Ефективність у parallel Висока Низька (нерівномірний спліт) Середня

Топ-4 каверзних запитання на інтерв’ю

1. Чому flatMap скидає прапорець SIZED у стрімі і як це погіршує продуктивність у parallelStream?

  • Прапорець SIZED означає, що точна кількість елементів відома до початку обробки. Оскільки лямбда flatMap може повернути стрім будь-якого розміру (порожній, з 1 елемента чи з 10 000 елементів), рантайм не може обчислити count() без повного обчислення всіх елементів.
  • У паралельних стрімах поділ завдань у Spliterator (метод trySplit()) спирається на розмір підзавдання. Втрата розміру призводить до нерівномірного розділення чанків між потоками ForkJoinPool і необхідності динамічно реалокувати вузли зв’язних буферів (SpinedBuffer).

2. У чому перевага методу Stream.mapMulti() (Java 16+) перед flatMap(), і як він влаштований всередині?

  • mapMulti() усуває накладні витрати на алокацію проміжних об’єктів Stream для кожного вхідного елемента.
  • Замість повернення нового Stream<R>, користувацька лямбда отримує елемент і системний Consumer<R>. Вона передає елементи безпосередньо в downstream-приймач (consumer.accept(...)) через звичайний цикл for. Це значно знижує навантаження на збирач сміття (GC) у високонавантажених сценаріях.

3. Що станеться, якщо лямбда у flatMap поверне null замість Stream? Як правильно обробляти відсутні значення?

  • Метод негайно викине NullPointerException при спробі рантайму викликати stream.spliterator().
  • Якщо для елемента немає значень, лямбда зобов’язана повертати Stream.empty(). У Java 9+ при роботі з Optional рекомендовано використовувати виклик optional.stream().

4. Як закриваються ресурси (AutoCloseable), відкриті всередині стрімів, які повертаються лямбдою flatMap?

  • Реалізація flatMap у ReferencePipeline огортає споживання кожного вкладеного стріма в конструкцію try-finally. Щойно елементи підпотоку вичитані, рантайм викликає subStream.close(). Якщо стрім був отриманий через Files.lines(), файловий дескриптор коректно звільняється.

Типові помилки та Red Flags

  • 🚩 Повернення null з лямбди flatMap() замість Stream.empty(), що призводить до NPE у рантаймі.
  • 🚩 Використання flatMap() там, де достатньо звичайного map() (наприклад, просто для виклику гетера поля).
  • 🚩 Масове створення коротких стрімів Stream.of(item) всередині flatMap під високим навантаженням замість використання mapMulti().
  • 🚩 Спроба паралелізації важких обчислень через flatMap().parallel() без перевірки профілювальником перекосу за потоками ForkJoinPool через втрату SIZED.

Пов’язані теми