🧵 Раздел 9 · Вопрос #14

Что делает ExecutorService

Он решает три ключевые задачи:


🟢 Junior Level

Краткий ответ для собеседования (30 секунд)

ExecutorService — это центральный интерфейс фреймворка java.util.concurrent, предоставляющий высокоуровневый механизм управления асинхронным выполнением задач и абстрагирующий разработчика от ручного создания и управления потоками ОС (Thread).

Он решает три ключевые задачи:

  1. Разделение ответственности: полностью отделяет определение задачи («что делать» — интерфейсы Runnable и Callable) от механики исполнения («как, когда и на каком ресурсе делать» — пулы потоков, очереди, планировщики).
  2. Управление результатами: возвращает объекты Future<T>, позволяющие проверять статус завершения задачи, отменять её или блокирующе ожидать результат с поддержкой таймаутов.
  3. Управление жизненным циклом: предоставляет стандартизированный протокол корректной остановки (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) не убивает поток безусловно:

  1. Если задача ещё находится в очереди ожидания, она просто удаляется из очереди и никогда не запустится.
  2. Если задача уже выполняется, параметр mayInterruptIfRunning = true выставляет флаг прерывания потока через Thread.interrupt().
  3. Ограничение: если внутри задачи выполняется вычислительный цикл без проверки флага 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 реализован следующий алгоритм:

  1. Вызывается shutdown(), блокируя поступление новых задач.
  2. Текущий поток блокируется в цикле awaitTermination(), ожидая физического завершения выполнения всех запланированных задач.
  3. Если текущий поток будет прерван во время ожидания, метод вызывает 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, его данные не копируются автоматически в поток пула. Для безопасной передачи:

  1. Перед отправкой задачи в пул вызвать MDC.getCopyOfContextMap().
  2. Обернуть Runnable/Callable в декоратор, который внутри метода run() первой строкой выполняет MDC.setContextMap(capturedContext).
  3. В блоке 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 может завершиться до выполнения задач из очереди (если потоки были помечены как демоны).


4. Связанные вопросы