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(), чтобы не тратить ресурсы.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ