1. Введение
В этом руководстве мы собираемся изучить различные способы запуска потока и выполнения параллельных задач.
Это очень полезно, особенно при работе с длительными или повторяющимися операциями, которые не могут выполняться в основном потоке , или когда взаимодействие с пользовательским интерфейсом нельзя приостановить в ожидании результатов операции.
Чтобы узнать больше о деталях потоков, обязательно прочитайте наш учебник о жизненном цикле потока в Java.
2. Основы запуска потока
Мы можем легко написать некоторую логику, которая выполняется в параллельном потоке, используя структуру Thread
.
Давайте попробуем базовый пример, расширив класс Thread :
public class NewThread extends Thread {
public void run() {
long startTime = System.currentTimeMillis();
int i = 0;
while (true) {
System.out.println(this.getName() + ": New Thread is running..." + i++);
try {
//Wait for one sec so it doesn't print too fast
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
...
}
}
}
И теперь мы пишем второй класс для инициализации и запуска нашего потока:
public class SingleThreadExample {
public static void main(String[] args) {
NewThread t = new NewThread();
t.start();
}
}
Мы должны вызывать метод start()
для потоков в состоянии NEW
(эквивалент не запущенного). В противном случае Java выдаст экземпляр исключения IllegalThreadStateException
.
Теперь предположим, что нам нужно запустить несколько потоков:
public class MultipleThreadsExample {
public static void main(String[] args) {
NewThread t1 = new NewThread();
t1.setName("MyThread-1");
NewThread t2 = new NewThread();
t2.setName("MyThread-2");
t1.start();
t2.start();
}
}
Наш код по-прежнему выглядит довольно просто и очень похож на примеры, которые мы можем найти в Интернете.
Конечно, это далеко не готовый к производству код, где крайне важно правильно управлять ресурсами, чтобы избежать слишком большого переключения контекста или слишком большого использования памяти.
Итак, чтобы подготовиться к работе, нам теперь нужно написать дополнительный шаблон для работы с:
- последовательное создание новых потоков
- количество одновременных живых потоков
- освобождение потоков: очень важно для потоков демона, чтобы избежать утечек
Если мы захотим, мы можем написать свой собственный код для всех этих сценариев и даже для некоторых других, но зачем нам изобретать велосипед?
3. Платформа ExecutorService
Framework
ExecutorService реализует шаблон проектирования пула
потоков (также называемый моделью реплицированного рабочего или рабочей группы) и заботится об управлении потоками, о котором мы упоминали выше, а также добавляет некоторые очень полезные функции, такие как повторное использование потоков и очереди задач.
В частности, очень важна возможность повторного использования потоков: в крупномасштабном приложении выделение и освобождение множества объектов потока создает значительные накладные расходы на управление памятью.
С рабочими потоками мы минимизируем накладные расходы, вызванные созданием потока.
Чтобы упростить настройку пула, ExecutorService
поставляется с простым конструктором и некоторыми параметрами настройки, такими как тип очереди, минимальное и максимальное количество потоков и соглашение об их именах.
Дополнительные сведения о ExecutorService
см. в нашем Руководстве по Java ExecutorService .
4. Запуск задачи с исполнителями
Благодаря этой мощной структуре мы можем переключить свое мышление с запуска потоков на отправку задач.
Давайте посмотрим, как мы можем отправить асинхронную задачу нашему исполнителю:
ExecutorService executor = Executors.newFixedThreadPool(10);
...
executor.submit(() -> {
new Task();
});
Мы можем использовать два метода: execute
, который ничего не возвращает, и submit
, который возвращает Future
, инкапсулирующий результат вычисления.
Дополнительные сведения о фьючерсах
см. в нашем руководстве по java.util.concurrent.Future .
5. Запуск задачи с помощью CompletableFutures
Чтобы получить окончательный результат из объекта Future
, мы можем использовать метод get
, доступный в объекте, но это заблокирует родительский поток до конца вычислений.
В качестве альтернативы мы могли бы избежать блокировки, добавив больше логики в нашу задачу, но мы должны увеличить сложность нашего кода.
В Java 1.8 появилась новая структура поверх конструкции Future
для лучшей работы с результатом вычислений: CompletableFuture
.
CompletableFuture
реализует CompletableStage
, который добавляет широкий выбор методов для присоединения обратных вызовов и позволяет избежать всех операций, необходимых для выполнения операций над результатом после того, как он будет готов.
Реализация отправки задачи намного проще:
CompletableFuture.supplyAsync(() -> "Hello");
SupplyAsync
принимает Supplier
, содержащий код, который мы хотим выполнить асинхронно — в нашем случае параметр lambda.
Теперь задача неявно отправляется в ForkJoinPool.commonPool()
, или мы можем указать предпочитаемого исполнителя
в качестве второго параметра.
Чтобы узнать больше о CompletableFuture,
ознакомьтесь с нашим руководством по CompletableFuture .
6. Запуск отложенных или периодических задач
При работе со сложными веб-приложениями нам может потребоваться запускать задачи в определенное время, возможно, регулярно.
В Java есть несколько инструментов, которые могут помочь нам выполнять отложенные или повторяющиеся операции:
java.util.Таймер
java.util.concurrent.ScheduledThreadPoolExecutor
6.1. Таймер
Таймер
— это средство для планирования задач для будущего выполнения в фоновом потоке.
Задачи могут быть запланированы для однократного выполнения или для повторного выполнения через равные промежутки времени.
Давайте посмотрим, как выглядит код, если мы хотим запустить задачу после одной секунды задержки:
TimerTask task = new TimerTask() {
public void run() {
System.out.println("Task performed on: " + new Date() + "n"
+ "Thread's name: " + Thread.currentThread().getName());
}
};
Timer timer = new Timer("Timer");
long delay = 1000L;
timer.schedule(task, delay);
Теперь давайте добавим повторяющееся расписание:
timer.scheduleAtFixedRate(repeatedTask, delay, period);
На этот раз задача будет запущена после указанной задержки и будет повторяться по прошествии определенного периода времени.
Для получения дополнительной информации, пожалуйста, прочитайте наше руководство по Java Timer .
6.2. Запланированный поток пула исполнителей
ScheduledThreadPoolExecutor
имеет методы, аналогичные классу Timer :
ScheduledExecutorService executorService = Executors.newScheduledThreadPool(2);
ScheduledFuture<Object> resultFuture
= executorService.schedule(callableTask, 1, TimeUnit.SECONDS);
Чтобы закончить наш пример, мы используем scheduleAtFixedRate()
для повторяющихся задач:
ScheduledFuture<Object> resultFuture
= executorService.scheduleAtFixedRate(runnableTask, 100, 450, TimeUnit.MILLISECONDS);
Приведенный выше код выполнит задачу после первоначальной задержки в 100 миллисекунд, а затем одну и ту же задачу будет выполнять каждые 450 миллисекунд.
Если процессор не может вовремя завершить обработку задачи до следующего возникновения, ScheduledExecutorService
будет ждать завершения текущей задачи, прежде чем запускать следующую.
Чтобы избежать этого времени ожидания, мы можем использовать scheduleWithFixedDelay()
, который, как следует из его названия, гарантирует фиксированную задержку между итерациями задачи.
Дополнительные сведения о ScheduledExecutorService
см. в нашем Руководстве по Java ExecutorService .
6.3. Какой инструмент лучше?
Если мы запустим приведенные выше примеры, результат вычислений будет таким же.
Итак, как выбрать правильный инструмент ?
Когда фреймворк предлагает несколько вариантов, важно понимать лежащую в основе технологию, чтобы принять обоснованное решение.
Давайте попробуем погрузиться немного глубже под капот.
Таймер
:
- не дает гарантий реального времени: он планирует задачи, используя метод Object.wait
(long)
- есть один фоновый поток, поэтому задачи выполняются последовательно, и длительная задача может задерживать другие
- Исключения времени выполнения, созданные в
TimerTask
, убьют единственный доступный поток, тем самым убивTimer
РасписаниеТреадпулЭкзекутор
:
- можно настроить с любым количеством потоков
- может использовать все доступные ядра ЦП
- перехватывает исключения во время выполнения и позволяет нам обрабатывать их, если мы хотим (путем переопределения метода
afterExecute
изThreadPoolExecutor
) - отменяет задачу, вызвавшую исключение, позволяя другим продолжать работу
- полагается на систему планирования ОС для отслеживания часовых поясов, задержек, солнечного времени и т. д.
- предоставляет совместный API, если нам нужна координация между несколькими задачами, например, ожидание завершения всех отправленных задач
- предоставляет лучший API для управления жизненным циклом потока
Теперь выбор очевиден, верно?
7. Разница между Future
и ScheduledFuture
В наших примерах кода мы можем наблюдать, что ScheduledThreadPoolExecutor
возвращает определенный тип Future
: ScheduledFuture
.
ScheduledFuture
расширяет интерфейсы Future
и Delayed
, наследуя, таким образом, дополнительный метод getDelay
, возвращающий оставшуюся задержку, связанную с текущей задачей. Он расширен с помощью RunnableScheduledFuture
, который добавляет метод для проверки того, является ли задача периодической.
ScheduledThreadPoolExecutor
реализует все эти конструкции через внутренний класс ScheduledFutureTask
и использует их для управления жизненным циклом задачи.
8. Выводы
В этом руководстве мы экспериментировали с различными платформами, доступными для запуска потоков и параллельного выполнения задач.
Затем мы углубились в различия между Timer
и ScheduledThreadPoolExecutor.
Исходный код статьи доступен на GitHub .