JavaRush /Курси /JAVA 25 SELF /Spliterator і паралельні стріми

Spliterator і паралельні стріми

JAVA 25 SELF
Рівень 33 , Лекція 3
Відкрита

1. Знайомство зі Spliterator

Якщо ви думали, що колекції в Java перебираються лише через Iterator, то до Java 8 ви були цілком праві. Із появою Stream API та зі зростанням інтересу до паралелізму з’явився новий герой — Spliterator.

Spliterator — це інтерфейс, який дає змогу не лише перебирати елементи колекції, а й розділяти джерело даних на частини для паралельної обробки. Назва — злиття слів split і iterator.

Уявіть великий торт. Звичайний Iterator ріже його на шматочки й їсть по черзі. Spliterator може розрізати торт навпіл, віддати половину другові — і ви обоє почнете їсти одночасно. Якщо друзів багато — ділимо далі!

Інтерфейс Spliterator — основні методи

public interface Spliterator<T> {
    boolean tryAdvance(java.util.function.Consumer<? super T> action);
    Spliterator<T> trySplit();
    long estimateSize();
    int characteristics();
    // ... ще кілька методів, але ці — найважливіші
}
  • tryAdvance — виконує дію над черговим елементом (аналог next() + дія).
  • trySplit — намагається розділити джерело на дві частини й повернути новий Spliterator для «відколотої» частини.
  • estimateSize — оцінює, скільки елементів залишилося.
  • characteristics — повертає бітову маску характеристик (упорядкованість, унікальність, незмінність тощо).

2. Використання Spliterator: ручне перебирання й розбиття

Отримання Spliterator з колекції

Будь-яка колекція, що реалізує Collection, може надати свій Spliterator:

import java.util.List;
import java.util.Spliterator;

List<String> names = List.of("Вася", "Петро", "Марія", "Олена");
Spliterator<String> spliterator = names.spliterator();

Ручне перебирання елементів

Spliterator<String> spliterator = names.spliterator();
while (spliterator.tryAdvance(name -> System.out.println("Імʼя: " + name))) {
    // Усе відбувається всередині tryAdvance
}

Розбиття колекції

Найцікавіший метод — trySplit():

Spliterator<String> spliterator1 = names.spliterator();
Spliterator<String> spliterator2 = spliterator1.trySplit();

System.out.println("Перша частина:");
spliterator1.forEachRemaining(System.out::println);

System.out.println("Друга частина:");
if (spliterator2 != null) {
    spliterator2.forEachRemaining(System.out::println);
}

Що відбудеться: Spliterator спробує розділити колекцію на дві частини (не обов’язково порівну — залежить від реалізації). Тепер ви можете обробляти обидві частини незалежно — наприклад, у різних потоках.

3. Паралельні стріми: навіщо і як це працює

Паралельний стрім (parallelStream()) — це стрім, який обробляє елементи не послідовно, а одночасно в кількох потоках. Особливо корисно для великих обсягів даних і на багатоядерних процесорах.

import java.util.List;

List<String> names = List.of("Вася", "Петро", "Марія", "Олена");
// Звичайний стрім:
names.stream().forEach(System.out::println);
// Паралельний стрім:
names.parallelStream().forEach(System.out::println);

У чому суть?
У звичайному стрімі елементи обробляються в одному потоці. У паралельному — джерело ділиться на частини (за допомогою Spliterator), і кожну частину обробляють в окремому потоці.

Як це працює всередині?

  1. Spliterator ділить колекцію на частини — зазвичай за кількістю доступних ядер (або трохи більше).
  2. Кожну частину обробляють у власному потоці — використовується спільний ForkJoinPool.
  3. Результати збираються назад — об’єднуються в підсумкову колекцію або значення.

Схема роботи паралельного стріму

flowchart LR
    A[Колекція] --> B{Spliterator}
    B --> C1[Частина 1] --> D1[Потік 1]
    B --> C2[Частина 2] --> D2[Потік 2]
    B --> C3[Частина 3] --> D3[Потік 3]
    D1 & D2 & D3 --> E[Збирання результату]

4. Переваги та обмеження паралельних стрімів

Переваги

  • Прискорення обробки великих колекцій: для важких обчислень паралельний стрім помітно пришвидшує виконання.
  • Простота: не потрібно писати багатопотоковий код вручну — достатньо замінити stream() на parallelStream().

Обмеження та підводні камені

  • Не завжди швидше: для маленьких колекцій накладні витрати можуть «з’їсти» вигоду.
  • Порядок не гарантується: у forEach/map/filter порядок може відрізнятися. Якщо потрібен порядок — використовуйте forEachOrdered.
  • Проблеми з потокобезпечністю: операції з побічними ефектами (зміна зовнішніх колекцій/змінних) призводять до гонок даних.
  • Підходять не всі операції: залежні обчислення (наприклад, послідовне накопичення) можуть працювати не так, як очікується.

Коли використовувати паралельні стріми?

  • Великі колекції (десятки тисяч елементів і більше).
  • Важкі операції на кожному елементі.
  • Строгий порядок не критичний.
  • Немає побічних ефектів (чисті функції).

Коли НЕ використовувати?

  • Невелика кількість елементів.
  • Код змінює зовнішні змінні або колекції.
  • Важливо зберегти порядок обробки.
  • Джерело даних погано ділиться (наприклад, LinkedList).

5. Практичні приклади

Приклад 1: Порівняння часу виконання

import java.util.*;
import java.util.stream.*;

public class ParallelStreamDemo {
    public static void main(String[] args) {
        List<Integer> numbers = IntStream.range(0, 10_000_000)
                                         .boxed()
                                         .collect(Collectors.toList());

        long start = System.currentTimeMillis();
        long count = numbers.stream()
                .filter(n -> isPrime(n))
                .count();
        long time = System.currentTimeMillis() - start;
        System.out.println("Звичайний стрім: " + time + " мс, знайдено простих: " + count);

        start = System.currentTimeMillis();
        count = numbers.parallelStream()
                .filter(n -> isPrime(n))
                .count();
        time = System.currentTimeMillis() - start;
        System.out.println("Паралельний стрім: " + time + " мс, знайдено простих: " + count);
    }

    // Найпростіша перевірка на просте число (для прикладу)
    public static boolean isPrime(int n) {
        if (n < 2) return false;
        for (int i = 2, sqrt = (int)Math.sqrt(n); i <= sqrt; i++)
            if (n % i == 0) return false;
        return true;
    }
}

Що вийде: на великих обсягах даних паралельний стрім часто швидший (особливо на багатоядерних процесорах). На маленьких — різниці може не бути або паралельний варіант виявиться повільнішим.

Приклад 2: Проблема з порядком

import java.util.List;

List<String> names = List.of("Вася", "Петро", "Марія", "Олена");
System.out.println("Звичайний стрім:");
names.stream().forEach(System.out::println);

System.out.println("Паралельний стрім:");
names.parallelStream().forEach(System.out::println);

System.out.println("Паралельний стрім із forEachOrdered:");
names.parallelStream().forEachOrdered(System.out::println);

Висновок: у звичайному стрімі та при використанні forEachOrdered порядок зберігається, а в паралельному без нього — ні.

Приклад 3: Небезпека побічних ефектів

import java.util.*;
import java.util.stream.*;

List<Integer> numbers = IntStream.range(1, 1000).boxed().collect(Collectors.toList());
List<Integer> results = new ArrayList<>();

// НЕБЕЗПЕЧНО! Так робити не можна!
numbers.parallelStream().forEach(n -> results.add(n * n));

System.out.println("Розмір списку: " + results.size());

Що може статися? Розмір списку може бути меншим за очікуваний, а інколи може виникнути ConcurrentModificationException. Причина — ArrayList не потокобезпечний, а паралельний стрім запускає кілька потоків одночасно.

6. Spliterator: особливості та характеристики

Характеристики Spliterator

Spliterator описує свої властивості через бітову маску:

  • ORDERED — елементи йдуть у певному порядку (наприклад, у списку).
  • DISTINCT — усі елементи унікальні (наприклад, у множині).
  • SORTED — елементи відсортовані.
  • SIZED — відомий розмір.
  • IMMUTABLE — колекція незмінна.
  • CONCURRENT — колекція потокобезпечна.
  • SUBSIZED — усі створені після trySplit() спліт-ітератори теж знають свій розмір.
Spliterator<String> spliterator = names.spliterator();
int characteristics = spliterator.characteristics();
System.out.println(Integer.toBinaryString(characteristics));

Навіщо це знати? Stream API і паралельні стріми використовують ці ознаки для оптимізацій. Наприклад, якщо джерело незмінне й відсортоване, його можна безпечніше та ефективніше ділити й збирати результат.

7. Коли і як використовувати Spliterator безпосередньо?

У повсякденній розробці рідко доводиться писати власні Spliterator-и: стандартні колекції вже це реалізують. Але якщо ви створюєте власне джерело даних або хочете детально контролювати перебирання/розбиття, Spliterator стане в пригоді.

Приклад: ручне перебирання з tryAdvance

import java.util.List;
import java.util.Spliterator;

List<String> names = List.of("Вася", "Петро", "Марія", "Олена");
Spliterator<String> spliterator = names.spliterator();
spliterator.tryAdvance(name -> System.out.println("Перший елемент: " + name));
spliterator.forEachRemaining(name -> System.out.println("Решта: " + name));

Приклад: розбиття колекції

Spliterator<String> spliterator1 = names.spliterator();
Spliterator<String> spliterator2 = spliterator1.trySplit();

if (spliterator2 != null) {
    spliterator2.forEachRemaining(name -> System.out.println("Частина 2: " + name));
}
spliterator1.forEachRemaining(name -> System.out.println("Частина 1: " + name));

8. Типові помилки під час роботи зі Spliterator і паралельними стрімами

Помилка № 1: Використання паралельних стрімів для маленьких колекцій. Замість прискорення ви отримаєте уповільнення — накладні витрати на поділ і планування завдань переважать вигоду.

Помилка № 2: Очікування збереження порядку елементів. Паралельні стріми порядок не гарантують. Якщо він важливий — використовуйте forEachOrdered, але частину паралельної ефективності буде втрачено.

Помилка № 3: Побічні ефекти в лямбда-виразах. Усередині паралельного стріму не можна безпечно змінювати зовнішні змінні/колекції — отримаєте гонки даних і важковловимі помилки.

Помилка № 4: Використання непотокобезпечних колекцій усередині паралельного стріму. Додавання до звичайного ArrayList із кількох потоків — прямий шлях до помилок на кшталт ConcurrentModificationException.

Помилка № 5: Очікування миттєвого прискорення. Паралельні стріми — не чарівна паличка. Профілюйте: якщо даних мало або операція легка — звичайний стрім швидший.

Помилка № 6: Паралельні стріми з джерелами, що погано діляться. Наприклад, LinkedList часто ділиться неефективно — паралелізм може лише уповільнити виконання.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ