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), і кожну частину обробляють в окремому потоці.
Як це працює всередині?
- Spliterator ділить колекцію на частини — зазвичай за кількістю доступних ядер (або трохи більше).
- Кожну частину обробляють у власному потоці — використовується спільний ForkJoinPool.
- Результати збираються назад — об’єднуються в підсумкову колекцію або значення.
Схема роботи паралельного стріму
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 часто ділиться неефективно — паралелізм може лише уповільнити виконання.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ