Що робить ExecutorService
Він вирішує три ключові завдання:
🟢 Junior Level
Коротка відповідь для співбесіди (30 секунд)
ExecutorService — це центральний інтерфейс фреймворку java.util.concurrent, що надає високорівневий механізм керування асинхронним виконанням задач та абстрагує розробника від ручного створення й керування потоками ОС (Thread).
Він вирішує три ключові завдання:
- Розділення відповідальності: повністю відокремлює визначення задачі («що робити» — інтерфейси
RunnableтаCallable) від механіки виконання («як, коли і на якому ресурсі робити» — пули потоків, черги, планувальники). - Керування результатами: повертає об’єкти
Future<T>, що дозволяють перевіряти статус завершення задачі, скасовувати її або блокуюче очікувати на результат із підтримкою таймаутів. - Керування життєвим циклом: надає стандартизований протокол коректної зупинки (Graceful Shutdown) для запобігання витокам системних ресурсів.
Починаючи з Java 19, ExecutorService розширює інтерфейс AutoCloseable, що дозволяє безпечно використовувати його в блоках try-with-resources.
Основні методи відправки задач
| Метод | Вхідний параметр | Значення, що повертається | Обробка винятків всередині задачі |
|---|---|---|---|
execute(task) |
Runnable |
void |
Вилітає в UncaughtExceptionHandler потоку |
submit(task) |
Callable<T> |
Future<T> |
Перехоплюється та огортається в ExecutionException |
submit(task) |
Runnable |
Future<?> |
Повертає null при успіху або ExecutionException |
invokeAll(tasks) |
Collection<Callable<T>> |
List<Future<T>> |
Блокує до завершення всіх задач |
invokeAny(tasks) |
Collection<Callable<T>> |
T |
Повертає результат першої успішної, скасовуючи решту |
Ідіоматичний приклад на Java 21 (try-with-resources)
import java.util.concurrent.*;
public class ExecutorServiceDemo {
public static void main(String[] args) throws Exception {
// У Java 21 ExecutorService реалізує AutoCloseable!
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
// Відправка задачі Callable з отриманням Future
Future<Integer> future = executor.submit(() -> {
TimeUnit.MILLISECONDS.sleep(200);
return 42;
});
// Отримання результату з таймаутом
Integer result = future.get(1, TimeUnit.SECONDS);
System.out.println("Результат виконання: " + result);
} // Блок try-with-resources автоматично викликає close(),
// який виконує shutdown() і чекає завершення задач!
}
}
🟡 Middle Level
Різниця між execute() та submit(): Пастка прихованих винятків
Фундаментальна відмінність криється у способі доставки винятків:
1. Метод execute(Runnable) («Fire and Forget»):
Задача виконується безпосередньо робочим потоком. Якщо в коді задачі виникає необроблений RuntimeException:
- Виняток викидається в стек поточного потоку пулу.
- Потік аварійно завершується, про що сповіщається
Thread.UncaughtExceptionHandler. - Пул потоків змушений знищити загиблий потік і створити новий робочий потік замість нього.
2. Метод submit(Callable<T>) / submit(Runnable):
Задача негайно огортається у внутрішній клас FutureTask<T>:
// Спрощено всередині FutureTask.run():
try {
result = callable.call();
set(result); // Запам'ятовує результат у полі outcome
} catch (Throwable ex) {
setException(ex); // ПЕРЕХОПЛЮЄ БУДЬ-ЯКИЙ THROWABLE і зберігає в полі outcome!
}
- Виняток не потрапляє в потік і не відображається в консолі/логах!
- Робочий потік пулу не вмирає та повертається в чергу.
- Помилка стане видимою тільки тоді, коли клієнтський код явно викличе
future.get(). У цей момент буде викинуто перевірюваний винятокExecutionException, що містить вихідну помилку вe.getCause(). - Небезпека: якщо відправити задачу через
submit()і проігнорувати повернутийFuture, помилки виконання будуть проковтнуті назавжди.
Контракт invokeAll() проти invokeAny()
invokeAll(Collection<Callable<T>> tasks):
- Відправляє пачку задач і блокує викликаючий потік доти, доки абсолютно всі задачі не завершаться (успішно або з винятком).
- Повертає список
List<Future<T>>у тому ж порядку, в якому передавалися задачі. - При виклику з таймаутом
invokeAll(tasks, timeout, unit)усі задачі, що не встигли завершитися до моменту вичерпання часу, автоматично скасовуються (future.cancel(true)).
invokeAny(Collection<Callable<T>> tasks):
- Реалізує патерн «конкурентного запиту» (Hedged Requests): запускає паралельні задачі та повертає результат першої задачі, що завершилася успішно (без винятків).
- Щойно перший результат отримано, всі інші задачі колекції негайно скасовуються.
- Якщо всі задачі завершилися аварійно, викидає
ExecutionException.
Життєвий цикл та патерн Graceful Shutdown
Керування зупинкою пулу складається зі строгої послідовності станів:
[ RUNNING ] ─── shutdown() ───> [ SHUTDOWN ] ─── всі задачі завершено ───> [ TIDYING ] ──> [ TERMINATED ]
│ │
│ shutdownNow() │ shutdownNow()
v v
[ STOP ] ───────────────────────────┘
Стандартний двофазний протокол зупинки:
public static void shutdownAndAwaitTermination(ExecutorService pool) {
// 1. Забороняємо прийом нових задач (задачі з черги доробляються)
pool.shutdown();
try {
// 2. Чекаємо завершення існуючих задач до 60 секунд
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
// 3. Таймаут минув: примусово перериваємо активні потоки
List<Runnable> droppedTasks = pool.shutdownNow();
System.err.printf("Скасовано %d не розпочатих задач%n", droppedTasks.size());
// 4. Чекаємо ще 10 секунд реакції потоків на переривання
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
System.err.println("Пул не зупинився остаточно!");
}
}
} catch (InterruptedException ie) {
// Повторний виклик shutdownNow при перериванні самого очікуючого потоку
pool.shutdownNow();
Thread.currentThread().interrupt();
}
}
🔴 Senior Level
AutoCloseable в Java 19+ та специфікація close()
Включення ExecutorService до інтерфейсу AutoCloseable (JEP 425 / Java 19+) уніфікувало роботу з пулами:
// Реалізація методу close() в java.util.concurrent.ExecutorService:
default void close() {
boolean terminated = isTerminated();
if (!terminated) {
shutdown();
boolean interrupted = false;
while (!terminated) {
try {
terminated = awaitTermination(1L, TimeUnit.DAYS);
} catch (InterruptedException e) {
if (!interrupted) {
shutdownNow();
interrupted = true;
}
}
}
if (interrupted) {
Thread.currentThread().interrupt();
}
}
}
[!IMPORTANT] Метод
close()спочатку викликаєshutdown(), а потім блокує поточний потік, очікуючи на завершення всіх задач (аж до доби!). Якщо очікуючий потік переривається, викликаєтьсяshutdownNow(). Це виключає «витік» працюючих фонових потоків при виході зі скоупуtry-with-resources.
Прокидання контексту (ThreadLocal, MDC, SecurityContext)
У розподілених мікросервісних системах контекст запиту (Trace ID, Span ID, авторизаційні токени) зберігається в ThreadLocal. При передачі задачі в ExecutorService контекст втрачається, оскільки робочий потік пулу має власне сховище ThreadLocal.
Архітектурне рішення: Декорування задач
public class MdcDecoratingExecutor implements Executor {
private final Executor delegate;
public MdcDecoratingExecutor(Executor delegate) {
this.delegate = delegate;
}
@Override
public void execute(Runnable command) {
// 1. Захоплюємо контекст із викликаючого потоку
Map<String, String> contextMap = org.slf4j.MDC.getCopyOfContextMap();
delegate.execute(() -> {
// 2. Встановлюємо контекст у робочому потоці пулу
if (contextMap != null) {
org.slf4j.MDC.setContextMap(contextMap);
} else {
org.slf4j.MDC.clear();
}
try {
command.run();
} finally {
// 3. КРИТИЧНО ВАЖЛИВО: очищаємо контекст перед поверненням потоку в пул!
org.slf4j.MDC.clear();
}
});
}
}
Семантика скасування: Future.cancel(boolean mayInterruptIfRunning)
Виклик future.cancel(true) не вбиває потік безумовно:
- Якщо задача ще знаходиться в черзі очікування, вона просто видаляється з черги і ніколи не запуститься.
- Якщо задача вже виконується, параметр
mayInterruptIfRunning = trueвиставляє прапорець переривання потоку черезThread.interrupt(). - Обмеження: якщо всередині задачі виконується обчислювальний цикл без перевірки прапорця
Thread.currentThread().isInterrupted()або безперервне введення-виведення (старийInputStream.read()), задача продовжить виконуватися, незважаючи на викликcancel(true)!
🎯 Шпаргалка для інтерв’ю
1. 30-секундний есенс (Elevator Pitch)
ExecutorService — базовий контракт асинхронного виконання задач у Java, що відокремлює бізнес-логіку задач від фізичних потоків. Надає методи submit() для отримання результатів через Future<T>, пакетні методи invokeAll()/invokeAny() та стандартизований протокол завершення (shutdown() доробляє чергу, shutdownNow() розсилає переривання та повертає не розпочаті задачі). З Java 19 реалізує AutoCloseable, блокуючи викликаючий потік до завершення задач при виході з try-with-resources.
2. 4 каверзних питання з глибокими відповідями
Питання 1: Що повертає метод shutdownNow(), і чи гарантує він негайну зупинку всіх активних потоків?
Відповідь:
Метод shutdownNow() повертає список List<Runnable> — це задачі, які перебували в черзі очікування (workQueue) і так і не почали виконуватися.
Метод не гарантує зупинку активних потоків! Він лише розсилає виклики Thread.interrupt() усім робочим потокам. Якщо задача не опитує прапорець переривання (Thread.interrupted()), не викликає перериваних методів (sleep, wait, канали NIO) або не перехоплює InterruptedException, вона продовжить виконуватися нескінченно, і пул не зможе перейти у стан TERMINATED.
Питання 2: Як поводиться ExecutorService всередині блоку try-with-resources у Java 19+?
Відповідь:
У методі close() інтерфейсу ExecutorService реалізовано наступний алгоритм:
- Викликається
shutdown(), блокуючи надходження нових задач. - Поточний потік блокується в циклі
awaitTermination(), очікуючи фізичного завершення виконання всіх запланованих задач. - Якщо поточний потік буде перервано під час очікування, метод викликає
shutdownNow(), форсуючи зупинку, та відновлює прапорець переривання.
Таким чином, вихід із блокуtry-with-resourcesне завершиться доти, доки всі задачі пулу не закінчать свою роботу.
Питання 3: Що станеться, якщо задача, запущена через submit(), викине необроблений OutOfMemoryError або RuntimeException, але викликаючий код ніколи не викличе future.get()?
Відповідь:
Виняток буде повністю проковтнуто. Всередині FutureTask.run() будь-який Throwable перехоплюється блоком catch (Throwable ex) і записується в приватне поле outcome. Потік пулу не завершується аварійно, системний обробник UncaughtExceptionHandler не викликається, і в системний журнал (лог-файл) не потрапляє жодного запису. Помилка залишиться прихованою доти, доки хтось явно не викличе future.get(), який викине її огорнутою в ExecutionException.
Питання 4: Як коректно передати контекст MDC (Trace ID) в асинхронний потік ExecutorService?
Відповідь:
Оскільки MDC використовує ThreadLocal, його дані не копіюються автоматично в потік пулу. Для безпечної передачі:
- Перед відправкою задачі в пул викликати
MDC.getCopyOfContextMap(). - Огорнути
Runnable/Callableв декоратор, який всередині методуrun()першим рядком виконуєMDC.setContextMap(capturedContext). - У блоці
finallyтіла задачі обов’язково виконатиMDC.clear(), щоб чужий контекст не залишився в перевживаному потоці пулу.
3. Типові помилки та Red Flags
❌ “Використання submit() без збереження посилання на Future та без виклику get().”
Правильно: Якщо результат не потрібен, використовуйте execute() (щоб винятки потрапляли в лог через UncaughtExceptionHandler) або обов’язково логуйте завершення Future.
❌ “Очікування, що future.cancel(true) жорстко зніме потік із виконання (як Thread.stop()).”
Правильно: Метод cancel(true) лише виставляє статус переривання. Якщо задача зайнята важкими обчисленнями і не перевіряє isInterrupted(), вона не зупиниться.
❌ “Використання shutdown() без awaitTermination().”
Правильно: Виклик shutdown() повертає керування негайно. Якщо застосунок одразу після цього завершить процес main(), JVM може завершитися до виконання задач із черги (якщо потоки були позначені як демони).