Java. Короткий огляд еволюції багатопотоковості

Зміст

🤖 Вступ: потоки Java та їх проблеми

У перших версіях Java багатопоточність реалізовувалася за допомогою класу Thread, який дозволяв створювати нові потоки. Проте ця модель мала багато недоліків:

  • Високі витрати пам'яті. Кожен потік використовував окремий стек виконання, що могло призвести до швидкого переповнення пам'яті (“Out of Memory”).

  • Складність управління. Потоки були важкі в управлінні, і розробники часто стикалися з проблемами дедлоків, блокування ресурсів і неправильного завершення потоків.

🕰️ Синхронізація за допомогою wait/notify

На ранніх етапах основним способом синхронізації потоків у Java було використання методів wait(), notify() та notifyAll(). Ці методи використовувалися для координації взаємодії між потоками, що працювали з одним ресурсом. Наприклад, у випадку, коли один потік додає дані до черги, а інший споживає їх, wait/notify допомагали синхронізувати цей процес. Проте такий підхід був складним і схильним до помилок:

  • Дедлоки. Потоки могли залишатися в стані очікування, якщо не було коректно викликано notify().

  • Складність у відстеженні стану. Управління синхронізацією вручну часто призводило до помилок та труднощів у підтримці коду.

⏩ Пули потоків (Java 5)

Щоб спростити управління потоками та покращити ефективність, у Java 5 було введено пули потоківExecutorService. Пул потоків дозволяв створити фіксовану кількість потоків і багаторазово використовувати їх для виконання задач, що знижувало витрати на створення нових потоків і зменшувало ризик переповнення пам'яті.

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

ExecutorService executor = Executors.newFixedThreadPool(10);
executor.submit(() -> {
    // Виконання задачі
    System.out.println("Задача виконується в потоці " + Thread.currentThread().getName());
});
executor.shutdown();

Однак цей підхід мав свої недоліки, такі як голодування пулу потоків — коли всі потоки зайняті, і нові задачі не можуть бути виконані до завершення вже запущених. Це могло призвести до затримок і втрати продуктивності.

⚙️ Використання Future (Java 5)

З Java 5 також було введено клас Future, який дозволив отримувати результат виконання задачі в майбутньому. Це допомогло зменшити потребу у явній синхронізації, проте використання методу get() все ще блокувало потік до завершення задачі, що могло призвести до проблем під великим навантаженням.

Приклад використання Future:

Future<Integer> future = executor.submit(() -> {
    // Довготривала задача
    Thread.sleep(1000);
    return 42;
});
try {
    Integer result = future.get(); // Блокуюче очікування результату
    System.out.println("Результат: " + result);
} catch (InterruptedException | ExecutionException e) {
    e.printStackTrace();
}

Цей підхід був кращим, але не вирішував проблему блокування потоків.

🚀 CompletableFuture та асинхронність (Java 8)

З Java 8 з'явився CompletableFuture, який став потужним інструментом для реалізації асинхронного програмування без блокування потоків. CompletableFuture дозволяє створювати складні ланцюжки задач, використовуючи методи на кшталт thenApply(), thenCombine(), exceptionally(). Це дозволило виконувати кілька задач паралельно та об'єднувати їх результати.

Приклад використання CompletableFuture:

CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
    // Довготривала задача
    return 42;
});

future.thenApply(result -> result * 2)
      .thenAccept(result -> System.out.println("Результат: " + result));

Це дозволяє виконувати обчислення асинхронно, не блокуючи головний потік, і легко комбінувати результати кількох асинхронних операцій.

🔄 Реактивне програмування (RxJava / Reactor)

Реактивне програмування стало наступним кроком у розвитку асинхронності. Бібліотеки, такі як RxJava та Project Reactor, дозволяють працювати з потоком подій, де кожна подія обробляється без блокування. Це ідеально підходить для роботи з великими об'ємами даних або запитів, коли потрібно забезпечити високу продуктивність.

Під капотом реактивне програмування базується на паттерні спостерігача (Observer Pattern), де є обсервабл (observable) або паблішер (publisher), що генерує події, та спостерігач (subscriber), який підписується на ці події та реагує на них.

Ключовим аспектом реалізації RxJava та Reactor є асинхронні потоки даних, які обробляються без блокування, використовуючи планувальники (Schedulers). Це дозволяє виконувати обробку на різних потоках залежно від завдань: IO, обчислення тощо. Завдяки цьому реактивні потоки дозволяють ефективно керувати ресурсами, розподіляючи завдання між потоками відповідно до їх типу.

Реалізація RxJava під капотом використовує пул потоків для керування асинхронними завданнями. Наприклад, Schedulers.io() використовує пул потоків для виконання завдань, що вимагають багато вводу-виводу, тоді як Schedulers.computation() — для обчислювальних завдань. Цей підхід дозволяє уникнути перевантаження основного потоку та забезпечити ефективну обробку великої кількості запитів.

Приклад реактивного програмування з RxJava:

Observable<Integer> observable = Observable.just(1, 2, 3, 4, 5);
observable.map(i -> i * 2)
          .subscribe(i -> System.out.println("Отримано: " + i));

У цьому прикладі Observable створює потік даних, який потім обробляється оператором map(), що множить кожен елемент на 2, і передає результат підписнику (subscribe()), який друкує отримані значення.

У Reactor, аналогічно, є Flux (для кількох елементів) і Mono (для одного елементу). Реактивний підхід дозволяє обробляти дані у вигляді потоку подій, що автоматично масштабуються та обробляються при надходженні, що забезпечує ефективність навіть під великим навантаженням.

Приклад з Reactor:

Flux<Integer> flux = Flux.just(1, 2, 3, 4, 5);
flux.map(i -> i * 2)
    .subscribe(i -> System.out.println("Отримано: " + i));

Цей підхід дозволяє зменшити затримки та покращити продуктивність систем, що працюють із великою кількістю асинхронних запитів або подій.

🧵 Віртуальні потоки (Java 21)

У Java 21 було введено віртуальні потоки (Virtual Threads), які значно полегшують роботу з багатопоточністю. Віртуальні потоки — це легковагові потоки, які створюються і управляються JVM, що дозволяє запускати тисячі або навіть мільйони потоків одночасно без значних витрат пам'яті.

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

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> System.out.println("Привіт з віртуального потоку!"));
}

Це робить можливим написання багатопоточного коду з простотою, порівнянною зі звичайними потоками, але без проблем, пов'язаних із використанням ресурсів.

Наприклад, коли віртуальний потік стикається з блокувальною операцією, JVM "знімає" цей потік з потоку ОС і звільняє ресурс для інших віртуальних потоків, що дозволяє уникнути затримок.

🏗️ Структурована багатопоточність (Java 21, 25 LTS)

Структурована багатопоточність — це новий підхід, який допомагає організовувати потоки так, щоб вони автоматично завершувалися після виходу з певного блоку коду. Це дозволяє значно полегшити управління життєвим циклом потоків та зменшити ризик помилок, пов'язаних із неправильним завершенням потоків.

Приклад використання структурованої багатопоточності:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<String> future = scope.fork(() -> "Результат роботи");
    scope.join();
    System.out.println(future.resultNow());
}

Цей підхід стане стандартом для багатопоточного програмування у Java 25 LTS.

📌 Закріплення потоків (Thread Pinning)

Закріплення потоків (thread pinning) у віртуальних потоках є одним із потенційних підводних каменів, яких слід уникати. Віртуальний потік може бути закріпленим за певним ОС-потоком (carrier thread), якщо він виконує код у синхронізованому блоці або методі, або якщо виконує нативний метод чи викликає іноземну функцію (наприклад, через Foreign Function and Memory API). Це може знижувати масштабованість, оскільки віртуальний потік не може бути “знятий/звільнений“ під час блокувальних операцій.

Приклад проблеми закріплення потоків:

public class TaskExecutor {
  public void execute() throws InterruptedException {
    synchronized (this) {
      Thread.sleep(4000); // Потік закріплений і не може бути відпущений
    }
  }
}

Закріплення потоків може негативно впливати на продуктивність, якщо потоки залишаються заблокованими надто довго, особливо коли кількість потоків перевищує кількість доступних ядер. Для уникнення таких проблем слід уникати довготривалих синхронізованих блоків або використовувати ReentrantLock для кращого контролю за блокуванням.

🚨 Виклики та обмеження

Попри всі переваги, сучасні інструменти багатопоточності мають свої виклики:

  • CompletableFuture може бути складним для читання і розуміння, особливо при складних ланцюжках обробки.

  • Реактивне програмування потребує зміни мислення та вивчення нових бібліотек і концепцій.

  • Віртуальні потоки можуть мати проблеми із закріпленням (pinning), що впливає на їхню масштабованість, особливо при використанні синхронізованих блоків.

  • Структурована багатопоточність вимагає переходу на нові версії Java та адаптації існуючого коду.

💻 Висновки

Еволюція багатопотоковості у Java показує, як індустрія адаптується до вимог більшої продуктивності та асинхронного виконання задач. Сучасні інструменти, такі як CompletableFuture, реактивні бібліотеки, віртуальні потоки та структурована багатопотоковість, дозволяють створювати ефективні та надійні програми, але важливо розуміти, як уникати можливих пасток, таких як блокування потоків або голодування пулу.


🎥 Відео

Детальніше ознайомитися з деякими топіками довгочиту можете у моєму відео по багатопотоковості

Поділись своїми ідеями в новій публікації.
Ми чекаємо саме на твій довгочит!
Oleksandr Klymenko
Oleksandr Klymenko@overpathz

Java Software Engineer

4.6KПрочитань
1Автори
71Читачі
На Друкарні з 19 квітня

Більше від автора

  • Secure networking. Deep Dive

    Глибоке занурення в протоколи TLS/SSL та інфраструктуру відкритих ключів (PKI). Основні поняття, процес встановлення захищеного з'єднання, роль сертифікатів та ланцюжка довіри

    Теми цього довгочиту:

    Security
  • Поширені помилки у дизайні REST API

    У довгочиті розглядаються поширені помилки при проектуванні REST API та способи їх уникнення: версіонування, використання DTO, підхід CQRS, робота з мікросервісами, та інші практики для підвищення продуктивності, безпеки й зручності API

    Теми цього довгочиту:

    Java
  • 10 “маловідомих” концепцій тестування продуктивності

    Довгочит висвітлює 10 маловідомих концепцій тестування продуктивності. Описано їхнє значення, причини виникнення, методи вирішення та моніторингу, що допомагає оптимізувати роботу систем.

    Теми цього довгочиту:

    Programming

Вам також сподобається

Коментарі (0)

Підтримайте автора першим.
Напишіть коментар!

Вам також сподобається