1. ExecutorService: керуємо потоками як дорослі
Чому не варто просто створювати потоки через new Thread
На перших порах багатопоточності все виглядає просто:
Thread t = new Thread(() -> {
// щось робимо
});
t.start();
Такий спосіб працює, але швидко стає обтяжливим, коли завдань стає багато. Кожен виклик new Thread() створює новий потік, а десятки чи сотні потоків починають перевантажувати систему. До того ж керувати ними незручно: потрібно стежити, коли вони закінчуються, що робити у разі помилок, як зупиняти та перевикористовувати їх.
Саме тут на сцену виходить ExecutorService — розумний диспетчер потоків. Ви просто передаєте йому завдання, а він сам вирішує, яким потоком і коли їх виконати. У результаті все працює швидше, стабільніше й без головного болю.
Як влаштований ExecutorService
ExecutorService працює за простим, але ефективним принципом.
- Усередині нього є пул потоків — заздалегідь створений набір робочих потоків (фіксований або динамічний).
- Завдання потрапляють у чергу і підхоплюються вільними потоками.
- Сервіс керує життєвим циклом: ви можете дочекатися завершення, коректно зупинити пул і звільнити ресурси.
Створення ExecutorService
Найпоширеніший спосіб — використовувати фабричні методи з класу Executors:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
ExecutorService executor = Executors.newFixedThreadPool(4); // 4 потоки
- newFixedThreadPool(N) — пул із N потоків (підходить для більшості завдань).
- newCachedThreadPool() — динамічний пул, створює потоки за потреби (обережно: можна «упертися» в пам’ять під лавиною завдань).
- newSingleThreadExecutor() — один потік (послідовне виконання).
Приклад: запуск Runnable через ExecutorService
executor.submit(() -> {
System.out.println("Привіт з пулу потоків!");
});
Після того як ви закінчили роботу з ExecutorService, його потрібно коректно завершити:
executor.shutdown(); // Забороняє додавати нові завдання, чекає завершення поточних
Важливо: якщо не викликати shutdown(), програма може не завершитися — потоки з пулу чекатимуть на нові завдання.
2. Runnable vs Callable: завдання бувають різні
До Java 5 якщо ви хотіли щось виконати в потоці, ви писали реалізацію інтерфейсу Runnable. Це завдання, яке нічого не повертає і не кидає перевірюваних винятків.
Runnable task = () -> {
System.out.println("Просто працюю, нічого не повертаю!");
};
executor.submit(task);
Callable: завдання з результатом (і винятками)
Іноді хочеться, щоб завдання не просто «щось робило», а повертало результат — наприклад, суму чисел, результат обчислень, дані з сервера. Для цього вигадали інтерфейс Callable<T>.
import java.util.concurrent.Callable;
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;
return sum;
};
- Метод call() повертає результат типу T.
- Метод call() може кидати перевірюваний виняток.
Аналогія: Runnable — «іди й помий посуд» (результат неважливий), Callable — «іди й принеси чай і скажи, якої він температури» (результат важливий).
Запуск Callable: щоб отримати результат, використовуйте executor.submit(...). Він поверне об’єкт Future<T>.
3. Future: обіцянка результату
Future — це «обіцянка» повернути результат у майбутньому. Коли ви надсилаєте завдання в ExecutorService, ви отримуєте Future, з якого згодом зможете отримати результат, дізнатися, чи завершилося завдання, або скасувати його.
Основні методи Future
- T get() — отримати результат (чекає, поки завдання завершиться).
- boolean isDone() — чи завершене завдання.
- boolean cancel(boolean mayInterruptIfRunning) — спробувати скасувати завдання.
- boolean isCancelled() — чи було завдання скасоване.
Приклад: запуск Callable і отримання результату
import java.util.concurrent.*;
public class ParallelSumApp {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(2);
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;
return sum;
};
Future<Integer> future = executor.submit(sumTask);
System.out.println("Завдання запущено, можна робити щось іще...");
// Отримуємо результат (метод блокує потік, якщо завдання ще не завершене)
Integer result = future.get();
System.out.println("Результат обчислень: " + result);
executor.shutdown();
}
}
- Завдання відправляється в пул потоків.
- Поки завдання виконується, основний потік може робити щось іще.
- Коли результат потрібен, викликаємо future.get() — потік почекає, якщо завдання ще в роботі.
- Щойно завдання завершиться, результат повернеться.
4. Практика: кілька завдань, очікування завершення
Часто потрібно запустити одразу кілька завдань і дочекатися, коли вони всі завершаться. Наприклад, ви обробляєте масив даних, розбиваєте його на частини та рахуєте суму кожної частини в окремому завданні.
Приклад: сума елементів масиву частинами
import java.util.*;
import java.util.concurrent.*;
public class ParallelArraySum {
public static void main(String[] args) throws Exception {
int[] array = new int[1000];
Arrays.setAll(array, i -> i + 1); // Заповнюємо числами від 1 до 1000
ExecutorService executor = Executors.newFixedThreadPool(4);
int chunkSize = array.length / 4;
List<Future<Integer>> futures = new ArrayList<>();
for (int i = 0; i < 4; i++) {
int from = i * chunkSize;
int to = (i == 3) ? array.length : (i + 1) * chunkSize;
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int j = from; j < to; j++) sum += array[j];
System.out.println("Сума від " + from + " до " + (to - 1) + " = " + sum);
return sum;
};
futures.add(executor.submit(sumTask));
}
int totalSum = 0;
for (Future<Integer> f : futures) {
totalSum += f.get(); // Чекаємо кожне завдання по черзі
}
System.out.println("Загальна сума: " + totalSum);
executor.shutdown();
}
}
Тут масив розбивається на 4 частини. Для кожної частини створюється завдання (Callable), яке рахує суму. Усі завдання надсилаються в ExecutorService, повертаються об’єкти Future. Наприкінці збираємо результати всіх завдань і підсумовуємо.
У реальних завданнях зручно використовувати invokeAll, щоб дочекатися виконання всіх завдань одразу.
5. Обробка помилок під час роботи з Future
Коли ви викликаєте future.get(), якщо завдання завершилося з винятком, його буде викинуто у вигляді ExecutionException. Це важливо: якщо в завданні щось пішло не так, ви дізнаєтеся про це лише під час виклику get().
Приклад: обробка винятків
Callable<Integer> errorTask = () -> {
throw new IllegalArgumentException("Щось пішло не так!");
};
Future<Integer> badFuture = executor.submit(errorTask);
try {
badFuture.get();
} catch (ExecutionException e) {
System.out.println("Завдання завершилося з помилкою: " + e.getCause());
}
- Усередині завдання викидається виняток.
- Під час виклику get() він «загортається» в ExecutionException.
- Справжню причину можна отримати через getCause().
6. Корисні нюанси
Як скасувати завдання
Future<?> f = executor.submit(() -> {
while (true) {
// Нескінченна робота
if (Thread.currentThread().isInterrupted()) {
System.out.println("Мене попросили завершитися!");
break;
}
}
});
Thread.sleep(100); // Почекаємо трохи
f.cancel(true); // Спробуємо скасувати завдання
- cancel(true) намагається перервати завдання, якщо воно ще не завершене.
- Усередині завдання бажано перевіряти Thread.currentThread().isInterrupted() і коректно завершуватися.
shutdown vs shutdownNow
shutdown() — м’яка зупинка: забороняє додавати нові завдання і дає поточним спокійно завершитися. Використовується найчастіше.
shutdownNow() — жорстка зупинка: намагається перервати активні потоки і повертає список завдань, які не встигли стартувати. Застосовуйте обережно.
invokeAll і invokeAny
invokeAll(Collection<Callable<T>> tasks) запускає всі передані завдання і чекає, поки вони всі завершаться. Повертає список Future.
invokeAny(Collection<Callable<T>> tasks) чекає тільки перше успішно виконане завдання, повертає його результат і скасовує решту. Зручно, коли важлива перша успішна відповідь.
7. Типові помилки при роботі з ExecutorService, Callable і Future
Помилка № 1: не закривати ExecutorService. Якщо забути викликати shutdown(), програма може «висіти» після завершення main, тому що потоки пулу чекають на нові завдання.
Помилка № 2: очікування результату одразу після надсилання завдання. Якщо одразу після submit() викликати get(), ви не отримаєте переваг асинхронності — потік усе одно чекатиме. Виконуйте корисну роботу паралельно і запитуйте результат, коли він справді потрібен.
Помилка № 3: ігнорування винятків у завданнях. Якщо не обробляти ExecutionException під час виклику get(), можна пропустити важливі помилки, які сталися в завданні.
Помилка № 4: використання спільних змінюваних змінних без синхронізації. Якщо кілька завдань працюють з одними й тими самими даними — потрібна синхронізація або потокобезпечні колекції.
Помилка № 5: створення надто великої кількості потоків. Не варто робити пул із числом потоків, що значно перевищує кількість ядер процесора — це може навіть уповільнити виконання.
Помилка № 6: забувати про скасування завдань. Якщо завдання більше не потрібне, скасовуйте його через cancel(), щоб не витрачати ресурси.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ