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.
  • Проблемы с thread safety: операции с побочными эффектами (изменение внешних коллекций/переменных) приводят к гонкам данных.
  • Не все операции подходят: зависимые вычисления (например, последовательное накопление) могут работать не так, как ожидается.

Когда использовать параллельные стримы?

  • Большие коллекции (десятки тысяч элементов и больше).
  • Тяжёлые операции на каждом элементе.
  • Не критичен строгий порядок.
  • Нет побочных эффектов (чистые функции).

Когда НЕ использовать?

  • Мало элементов.
  • Код изменяет внешние переменные или коллекции.
  • Важно сохранить порядок обработки.
  • Источник данных плохо делится (например, 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 — все spliterator-ы после 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 часто делится неэффективно — параллелизм может только замедлить выполнение.

1
Задача
JAVA 25 SELF, 33 уровень, 3 лекция
Недоступна
Обработка первой поступившей посылки на складе 📦
Обработка первой поступившей посылки на складе 📦
1
Задача
JAVA 25 SELF, 33 уровень, 3 лекция
Недоступна
Разделение задач между командами на стратегическом совещании 🎯
Разделение задач между командами на стратегическом совещании 🎯
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ