Что такое ExecutorService в Java и как им правильно пользоваться?

«Что такое ExecutorService в Java и как им правильно пользоваться?» — вопрос из категории Java Core, который задают на 10% собеседований Java Разработчик. Ниже — развёрнутый ответ с разбором ключевых моментов.

Ответ

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

Ключевые методы:

  • execute(Runnable task): Запускает задачу без возможности получить результат.
  • submit(Callable<T> task) / submit(Runnable task, T result): Отправляет задачу на выполнение и возвращает Future<T> для отслеживания статуса и получения результата.
  • invokeAll(Collection<Callable<T>> tasks): Запускает все задачи и возвращает список Future объектов, когда все завершатся.
  • invokeAny(Collection<Callable<T>> tasks): Запускает все задачи и возвращает результат первой успешно завершившейся.
  • shutdown(): Инициирует упорядоченное завершение, при котором выполняются ранее отправленные задачи, но новые задачи не принимаются.
  • shutdownNow(): Пытается остановить все активно выполняемые задачи, останавливает обработку ожидающих задач и возвращает список задач, которые не были выполнены.
  • awaitTermination(long timeout, TimeUnit unit): Блокирует поток до завершения всех задач после вызова shutdown() или истечения таймаута.

Создание пулов через Executors (фабричные методы):

// 1. Фиксированный пул потоков
ExecutorService fixedPool = Executors.newFixedThreadPool(10); // 10 рабочих потоков

// 2. Пул с кэшированием потоков (создает новые при необходимости)
ExecutorService cachedPool = Executors.newCachedThreadPool();

// 3. Один поток (последовательное выполнение)
ExecutorService singleThread = Executors.newSingleThreadExecutor();

// 4. Пул для планирования задач
ScheduledExecutorService scheduledPool = Executors.newScheduledThreadPool(4);
scheduledPool.schedule(() -> System.out.println("Запуск через 5 сек"), 5, TimeUnit.SECONDS);
scheduledPool.scheduleAtFixedRate(() -> System.out.println("Повтор каждые 2 сек"), 1, 2, TimeUnit.SECONDS);

Правильное использование и завершение (шаблон try-with-resources с Java 19+):

// Рекомендуемый способ с Java 19+ (AutoCloseable)
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { // или любой другой пул
    Future<Integer> future = executor.submit(() -> {
        // Длительная задача
        Thread.sleep(1000);
        return 42;
    });

    // Блокирующее получение результата с таймаутом
    Integer result = future.get(2, TimeUnit.SECONDS);
    System.out.println("Результат: " + result);
} // executor.shutdown() вызывается автоматически
catch (TimeoutException e) {
    System.err.println("Задача не завершилась вовремя");
}
catch (Exception e) {
    e.printStackTrace();
}

// Классический способ (до Java 19)
ExecutorService executor = Executors.newFixedThreadPool(4);
try {
    // Отправка задач...
    List<Future<String>> futures = executor.invokeAll(listOfCallables);
    for (Future<String> f : futures) {
        System.out.println(f.get());
    }
} finally {
    executor.shutdown(); // Начинаем завершение
    try {
        // Ждем завершения всех задач, но не более 1 часа
        if (!executor.awaitTermination(1, TimeUnit.HOURS)) {
            executor.shutdownNow(); // Принудительная остановка
        }
    } catch (InterruptedException e) {
        executor.shutdownNow();
        Thread.currentThread().interrupt(); // Восстанавливаем флаг прерывания
    }
}

Тонкая настройка через ThreadPoolExecutor:

// Позволяет точно контролировать параметры пула
int corePoolSize = 5;    // Минимальное количество потоков
int maxPoolSize = 20;    // Максимальное количество потоков
long keepAliveTime = 60L; // Время простоя лишних потоков
TimeUnit unit = TimeUnit.SECONDS;
BlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<>(100); // Очередь задач
ThreadFactory threadFactory = Executors.defaultThreadFactory();
RejectedExecutionHandler handler = new ThreadPoolExecutor.CallerRunsPolicy(); // Политика при переполнении

ExecutorService customPool = new ThreadPoolExecutor(
    corePoolSize, maxPoolSize, keepAliveTime, unit, workQueue, threadFactory, handler
);

Политики обработки переполнения очереди (RejectedExecutionHandler):

  • AbortPolicy (по умолчанию): Выбрасывает RejectedExecutionException.
  • CallerRunsPolicy: Выполняет задачу в потоке, который вызвал execute/submit.
  • DiscardPolicy: Молча отбрасывает задачу.
  • DiscardOldestPolicy: Удаляет самую старую задачу из очереди и пытается добавить новую.

Лучшие практики:

  1. Всегда завершайте ExecutorService с помощью shutdown()/awaitTermination().
  2. Используйте Callable и Future для получения результатов и обработки исключений.
  3. Настройте размер пула в зависимости от типа задач (CPU-bound vs I/O-bound).
  4. Обрабатывайте RejectedExecutionException при высокой нагрузке.
  5. Рассмотрите CompletableFuture (Java 8+) для более сложных асинхронных цепочек и комбинаций.