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

Що роблять операції distinct(), sorted(), limit(), skip()

Усі чотири методи є проміжними (intermediate) операціями зі станом (Stateful), які змінюють склад, порядок або кількість елементів у потоці:


🟢 Junior Level

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

Усі чотири методи є проміжними (intermediate) операціями зі станом (Stateful), які змінюють склад, порядок або кількість елементів у потоці:

  • distinct() — відфільтровує дублікати, залишаючи лише унікальні елементи (порівняння йде за контрактом equals() та hashCode()).
  • sorted() — сортує елементи в природному порядку (Comparable) або за допомогою переданого Comparator.
  • limit(long maxSize) — усікає потік, пропускаючи далі не більше перших maxSize елементів (операція короткого замикання).
  • skip(long n) — відкидає перші n елементів потоку, пропускаючи вниз по конвеєру ті, що залишилися.

Приклади використання на Java 21

import java.util.Comparator;
import java.util.List;

public class StatefulOpsDemo {

    public record Product(String name, double price) {}

    public static void main(String[] args) {
        List<Integer> numbers = List.of(5, 2, 8, 2, 9, 1, 5, 8, 3);

        // 1. Комбінування distinct, sorted, skip та limit
        List<Integer> result = numbers.stream()
            .distinct()            // [5, 2, 8, 9, 1, 3] (видалено дублікати)
            .sorted()              // [1, 2, 3, 5, 8, 9] (відсортовано за зростанням)
            .skip(1)               // [2, 3, 5, 8, 9]    (пропустили мінімальний елемент)
            .limit(3)              // [2, 3, 5]          (взяли наступні 3 елементи)
            .toList();

        System.out.println("Результат обробки чисел: " + result);

        // 2. Сортування об'єктів з Comparator
        List<Product> products = List.of(
            new Product("Клавіатура", 3500.0),
            new Product("Миша", 1800.0),
            new Product("Монітор", 24000.0)
        );

        List<Product> cheapToExpensive = products.stream()
            .sorted(Comparator.comparingDouble(Product::price))
            .toList();

        System.out.println("Сортування за ціною: " + cheapToExpensive);
    }
}

🟡 Middle Level

Поділ на Stateless та Stateful операції

В архітектурі Stream API проміжні операції фундаментально діляться на два типи:

Stateless (map, filter):
Елемент 1 ──► [Filter] ──► [Map] ──► Downstream (потокова передача без буферів)

Stateful (sorted, distinct):
Елемент 1 ──┐
Елемент 2 ──┼──► [ Внутрішній буфер пам'яті ] ──► (Бар'єр: очікування кінця стріма)
Елемент 3 ──┘
  1. Stateless (без стану): filter, map, flatMap, peek. Для кожного елемента рішення приймається на льоту. Пам’ять не витрачається ($O(1)$).
  2. Stateful (зі станом):
    • distinct(): зберігає стан уже побачених елементів у внутрішній хеш-множині (LinkedHashSet / ConcurrentHashMap). Вимагає $O(N)$ додаткової пам’яті.
    • sorted(): виступає повним бар’єром конвеєра (Pipeline Barrier). Метод не може віддати жодного елемента downstream, поки не вичитає абсолютно всі елементи джерела в масив і не відсортує його (через TimSort / Dual-Pivot Quicksort).
    • limit(n) / skip(n): зберігають лічильник оброблених елементів і підтримують коротке замикання.

Оптимізації Stream Flags (StreamOpFlag)

Якщо джерело даних уже володіє необхідними прапорцями, рантайм Stream API пропускає операції без виконання (no-op):

import java.util.List;
import java.util.TreeSet;

public class StreamFlagsOptimizationDemo {

    public static void main(String[] args) {
        TreeSet<String> sortedSet = new TreeSet<>(List.of("C", "A", "B"));

        // TreeSet з самого початку має прапорці DISTINCT та SORTED у Spliterator:
        List<String> list = sortedSet.stream()
            .distinct() // NO-OP: JVM пропускає операцію (колекція вже Set)
            .sorted()   // NO-OP: JVM пропускає операцію (колекція вже відсортована)
            .toList();

        System.out.println(list); // [A, B, C]
    }
}

🔴 Senior Level

3 критичні пастки продуктивності у Production

1. Антипатерн «Top-N через sorted().limit(K)»

Класична помилка — сортування мільйона об’єктів заради топ-5:

// ❌ ПОГАНО: Сортує ВСІ 1 000 000 елементів зі складністю $O(N \log N)$
List<User> top5 = users.stream()
    .sorted(Comparator.comparing(User::getRating).reversed())
    .limit(5)
    .toList();

Senior-рішення: Для малих $K$ ($K \ll N$) використовувати PriorityQueue (Min-Heap) фіксованого розміру $K$. Складність знижується з $O(N \log N)$ до $O(N \log K)$, а споживання пам’яті — з $O(N)$ до $O(K)$.

2. Зависання на нескінченних стрімах (Infinite Stream Hang)

// 💥 Катастрофа: OutOfMemoryError / зависання потоку!
Stream.iterate(0, i -> i + 1)
    .sorted()  // Бар'єр зависне назавжди, намагаючись буферизувати нескінченність!
    .limit(10)
    .toList();

// ✅ ПРАВИЛЬНО: limit() ЗОБОВ'ЯЗАНИЙ стояти ДО бар'єра sorted():
Stream.iterate(0, i -> i + 1)
    .limit(10) // Робить стрім скінченним!
    .sorted()
    .toList();

3. Пагінація через skip(N).limit(M)

Використання stream.skip(100_000).limit(20) для пагінації в коді змушує стрім поштучно протягнути 100 000 елементів крізь усі фільтри. Пагінація повинна виконуватися на рівні СУБД (LIMIT/OFFSET або Keyset Pagination).


Поведінка в parallelStream()

У паралельному режимі stateful-операції втрачають ефективність:

  • distinct() у parallel: вимагає синхронізації через ConcurrentHashMap або дорогого злиття часткових сетів у combiner.
  • sorted() у parallel: використовує Arrays.parallelSort(), який дає виграш лише на обсягах від $100\,000$ елементів.
  • limit() та skip() у parallel: якщо джерело впорядковане (ORDERED), потоки змушені буферизувати результати і координуватися для дотримання порядку. Виклик .unordered() перед limit() знімає це блокування, дозволяючи повернути будь-які перші $N$ готових елементів.

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

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

«distinct(), sorted(), limit() та skip() — це проміжні stateful-операції. На відміну від stateless-операцій (map, filter), вони вимагають збереження внутрішнього стану. distinct() використовує equals/hashCode і вимагає $O(N)$ пам’яті під хеш-таблицю. sorted() — повний бар’єр конвеєра, що накопичує всі елементи перед сортуванням; виклик sorted() на нескінченному стрімі без попереднього limit() призводить до OutOfMemoryError. У parallelStream для прискорення limit() на впорядкованих колекціях рекомендується викликати .unordered().»


Матриця характеристик операцій

Операція Складність за часом Пам’ять Чи є бар’єром? Вплив на нескінченний стрім
distinct() $O(N)$ $O(N)$ (хеш-таблиця) Ні (віддає в міру знаходження) Може зависнути при пошуку унікальних
sorted() $O(N \log N)$ $O(N)$ (масив елементів) Так (повний бар’єр) Зависає / OOM (не можна викликати)
limit(n) $O(1)$ per item $O(1)$ Ні (коротке замикання) Робить нескінченний стрім скінченним
skip(n) $O(1)$ per item $O(1)$ Ні Пропускає перші $n$ елементів

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

1. Чому sorted() називають бар’єром конвеєра (pipeline barrier)?

  • Тому що алгоритм сортування не може визначити, який елемент має бути першим, доки не порівняє його з усіма іншими елементами стріма. Тому sorted() повністю зупиняє вертикальне проходження елементів (Loop Fusion), накопичує всі елементи джерела у внутрішній масив, сортує його і лише потім починає передавати елементи наступному вузлу.

2. Що станеться при виклику Stream.generate(Math::random).sorted().limit(5)?

  • Програма зависне на 100% завантаженні CPU і завершиться з OutOfMemoryError. Операція sorted() спробує вичитати нескінченний стрім до кінця перед тим, як віддати бодай один елемент у limit(5). Якщо ж змінити порядок на .limit(5).sorted(), код відпрацює миттєво, оскільки limit зробить стрім скінченним до потрапляння в бар’єр сортування.

3. Як distinct() визначає рівність елементів і які вимоги висуваються до об’єктів?

  • distinct() використовує стандартні методи equals() та hashCode(), зберігаючи унікальні елементи у внутрішньому наборі. Якщо у класу не перевизначені equals та hashCode, порівняння відбуватиметься за посиланням (==), і логічно однакові об’єкти не будуть відфільтровані як дублікати.

4. Навіщо викликати .unordered() перед .limit() у паралельному стрімі?

  • Якщо стрім упорядкований (ORDERED), limit(n) зобов’язаний повернути рівно ті елементи, які йшли першими у вихідній колекції. Це змушує паралельні гілки ForkJoinPool синхронізуватися і скидати результати правих гілок. Виклик .unordered() дозволяє стріму повернути будь-які $n$ елементів, які першими встигли обробити паралельні потоки, що в рази прискорює виконання.

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

  • 🚩 Виклик sorted() на нескінченному стрімі до виклику limit().
  • 🚩 Використання sorted().limit(K) для знаходження топ-3 елементів на мільйонних колекціях замість PriorityQueue.
  • 🚩 Використання distinct() для класів із некоректним або відсутнім hashCode() та equals().
  • 🚩 Спроба використовувати stream.skip(offset).limit(pageSize) для пагінації великих таблиць БД на рівні бекенда замість SQL LIMIT/OFFSET.

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