Три близкие темы про то, как заставить код работать не «прямо сейчас в ответ на запрос», а по расписанию или в фоне. Разберём с нуля: как запускать задачи по таймеру (@Scheduled), как выносить долгую работу в отдельный поток (@Async) и что меняют виртуальные потоки из Java 21.
Одна и та же задача на трёх дорожках. Сверху fixedDelay: пауза отсчитывается от конца предыдущего запуска, поэтому старты уезжают вправо. В середине fixedRate: метки старта стоят на сетке, ритм ровный. Снизу тот же fixedRate, но первый запуск затянулся: пропущенные старты идут подряд, догоняя расписание, — задача не запускается параллельно сама с собой.
Зачем запускать код по расписанию
Часто приложению нужно что-то делать само, без запроса от пользователя: раз в сутки чистить старые заказы, раз в минуту публиковать показатели, раз в 15 минут опрашивать внешнюю систему.
Раньше для этого заводили отдельную программу и вешали её на системный планировщик (cron в Linux) — это второй кусок кода, который надо отдельно собирать, выкладывать и обслуживать.
Spring умеет делать это внутри самого приложения. Достаточно включить планировщик и пометить метод аннотацией @Scheduled — Spring сам будет вызывать его по таймеру.
@Configuration
@EnableScheduling // включаем планировщик
public class AppConfig { }
@Component
public class OrderCleanupJob {
private final OrderRepository orderRepo;
OrderCleanupJob(OrderRepository orderRepo) {
this.orderRepo = orderRepo;
}
@Scheduled(cron = "0 0 3 * * *", zone = "Europe/Moscow") // каждый день в 3 ночи
public void cleanupAbandoned() {
orderRepo.deleteAbandonedBefore(Instant.now().minus(30, ChronoUnit.DAYS));
}
}
Метод с @Scheduled не принимает параметров и обычно ничего не возвращает.
fixedDelay, fixedRate и cron — три способа задать расписание
Расписание задают одним из трёх атрибутов, и разница между ними — частая причина ошибок.
fixedDelay — пауза между окончанием одного запуска и началом следующего. Задача длится 50 секунд, fixedDelay = 60_000 — следующий старт через 110 секунд после предыдущего. Запуски не накладываются, поэтому это самый безопасный вариант для чистки данных.
fixedRate — пауза между началом одного запуска и началом следующего: при fixedRate = 60_000 метод стартует раз в минуту. Если задача однажды затянется дольше минуты, параллельно сама с собой она не запустится — планировщик дождётся конца и сразу начнёт следующий проход, догоняя расписание. Нужен, когда важна ровная периодичность.
@Scheduled(fixedDelay = 60_000) // 60 секунд после завершения предыдущего
public void publishMetrics() { }
@Scheduled(fixedRate = 30_000) // каждые 30 секунд от старта к старту
public void sampleQueueSize() { }
Разницу видно и без Spring — на том же ScheduledExecutorService, поверх которого работает планировщик:
живой пример
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class ScheduleDemo {
static final long TICK = 120; // один «тик» — 120 мс, чтобы прогон занял пару секунд
static long start;
static int run;
static Runnable task(long... durations) {
return () -> {
long tick = Math.round((System.currentTimeMillis() - start) / (double) TICK);
System.out.println(" запуск на тике " + tick);
try {
Thread.sleep(durations[Math.min(run++, durations.length - 1)] * TICK);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
}
static void demo(String title, boolean rate, long... durations) throws Exception {
System.out.println(title);
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
start = System.currentTimeMillis();
run = 0;
Runnable job = task(durations);
if (rate) {
scheduler.scheduleAtFixedRate(job, 0, 3 * TICK, TimeUnit.MILLISECONDS);
} else {
scheduler.scheduleWithFixedDelay(job, 0, 3 * TICK, TimeUnit.MILLISECONDS);
}
Thread.sleep(11 * TICK);
scheduler.shutdownNow();
}
public static void main(String[] args) throws Exception {
demo("fixedDelay = 3 тика, задача 2 тика:", false, 2);
demo("fixedRate = 3 тика, задача 2 тика:", true, 2);
demo("fixedRate = 3 тика, первый запуск затянулся на 5 тиков:", true, 5, 1);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Неделя бесплатно →
Старты fixedDelay — 0, 5, 10: пауза от конца. fixedRate — 0, 3, 6, 9. В третьем прогоне первый запуск затянулся, старты 5 и 6 идут подряд — планировщик догоняет пропущенное.
cron — для расписаний посложнее, чем «каждые N секунд»: «каждый день в 3 ночи», «по будням в 9 утра». Spring использует cron из шести полей: секунда минута час день_месяца месяц день_недели.
@Scheduled(cron = "0 0 3 * * *") // каждый день в 3:00:00
@Scheduled(cron = "0 */15 * * * *") // каждые 15 минут
@Scheduled(cron = "0 0 9 * * MON-FRI") // в 9:00 по будням
У cron всегда указывайте zone — иначе расписание считается в часовом поясе сервера, а он может оказаться не тем. И само расписание лучше держать в настройках, чтобы менять его без пересборки:
@Scheduled(cron = "${app.cleanup.cron}")
public void cleanup() { }
По умолчанию все задачи делят один поток
Неочевидная ловушка: если навесить @Scheduled на несколько методов, Spring выполняет их все в одном потоке. Пока одна задача работает, остальные ждут в очереди: одна медленная задерживает всю очередь.
Чтобы задачи могли идти параллельно, заводят пул потоков для планировщика — бин TaskScheduler:
@Configuration
@EnableScheduling
public class SchedulingConfig {
@Bean
public TaskScheduler taskScheduler() {
var scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5); // до 5 задач параллельно
scheduler.setThreadNamePrefix("scheduled-"); // понятные имена в логах
return scheduler;
}
}
Размер пула — сколько задач идёт одновременно; понятный префикс в имени потока помогает искать в логах, кто тормозит.
Одно приложение в нескольких копиях запустит задачу несколько раз
В реальной эксплуатации приложение обычно запускают в нескольких копиях для надёжности, и тут вылезает проблема: @Scheduled-задача сработает на каждой копии. Три копии — значит, базу почистили три раза, письмо отправили трижды.
Самое простое решение — общий замок (lock) через базу данных. Библиотека ShedLock: перед запуском копия пытается взять замок в общей таблице. Кто успел первым — выполняет задачу, остальные видят занятый замок и пропускают этот запуск.
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class SchedulingConfig {
@Bean
public LockProvider lockProvider(DataSource ds) {
return new JdbcTemplateLockProvider(ds);
}
}
@Component
public class OrderCleanupJob {
@Scheduled(cron = "0 0 3 * * *")
@SchedulerLock(name = "orderCleanup") // имя замка должно быть уникальным
public void cleanup() { }
}
ShedLock не требует отдельной инфраструктуры — хватает уже имеющейся базы. Есть и другие подходы (выбор «главной» копии средствами Kubernetes, отдельный CronJob для редких тяжёлых работ), но общего замка хватает почти всегда.
Зачем выполнять код в фоне через @Async
Представьте обработчик, который оформляет заказ и в конце отправляет письмо-подтверждение. Отправка идёт через внешний сервис и занимает секунду-две, и всё это время пользователь ждёт ответ — хотя письмо к самому заказу отношения не имеет.
Логично оформить заказ, сразу ответить пользователю, а письмо отправить в фоне. Это и делает аннотация @Async: помеченный метод выполняется в отдельном потоке, а вызывающий код не ждёт его завершения.
@Configuration
@EnableAsync // включаем поддержку @Async
public class AsyncConfig { }
@Component
public class EmailService {
@Async
public void sendConfirmation(String to, String body) {
// выполнится в другом потоке, вызывающий код не ждёт
}
}
Что может вернуть @Async-метод
void— «запустил и забыл». Вызывающий код не узнаёт, чем кончилось дело, и не увидит ошибку.CompletableFuture<T>— когда результат всё-таки нужен. Вызывающий код может дождаться его или обработать ошибку.
@Async
public CompletableFuture<Report> buildReport() {
return CompletableFuture.completedFuture(new Report(...));
}
Типичные ловушки @Async
Аннотация работает не магией, а через прокси-обёртку вокруг бина (тот же механизм, что у @Transactional). Из этого следуют две частые ошибки.
Вызов метода из того же класса не сработает. Если метод с @Async вызывают через this, обёртка обходится стороной, и код выполнится в обычном потоке. Механику видно на чистой Java: обёртка здесь — java.lang.reflect.Proxy, «в фоне» значит «в потоке async-1».
живой пример
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.reflect.Proxy;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class AsyncProxyDemo {
@Retention(RetentionPolicy.RUNTIME)
@interface Async { }
interface Mailer {
void checkout(String to);
@Async void sendReceipt(String to);
}
static class MailerBean implements Mailer {
public void checkout(String to) {
System.out.println("оформляю заказ — поток " + Thread.currentThread().getName());
this.sendReceipt(to); // вызов через this: обёртка в стороне
}
public void sendReceipt(String to) {
System.out.println(" письмо для " + to + " — поток " + Thread.currentThread().getName());
}
}
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newSingleThreadExecutor(r -> new Thread(r, "async-1"));
Mailer target = new MailerBean();
Mailer bean = (Mailer) Proxy.newProxyInstance( // так Spring и оборачивает бин
Mailer.class.getClassLoader(), new Class<?>[]{Mailer.class},
(proxy, method, params) -> {
if (method.isAnnotationPresent(Async.class)) {
pool.submit(() -> method.invoke(target, params));
} else {
method.invoke(target, params);
}
return null;
});
bean.checkout("anna@example.com"); // письмо изнутри — мимо обёртки
Thread.sleep(150);
bean.sendReceipt("ivan@example.com"); // письмо снаружи — через обёртку
Thread.sleep(150);
pool.shutdown();
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Неделя бесплатно →
Первое письмо ушло в потоке main: вызов через this прошёл мимо обёртки. Второе, вызванное снаружи, попало в async-1. Решение — вынести @Async-метод в отдельный бин и вызывать его как зависимость.
Ошибка из void-метода теряется. У обычного метода исключение «всплывает» к вызывающему коду. У @Async void его некому ловить — вызывающий код уже ушёл дальше. Чтобы ошибки не пропадали тихо, задают общий обработчик:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
log.error("Async-метод {} упал", method.getName(), ex);
}
}
Если метод возвращает CompletableFuture, проблемы нет: ошибка попадёт в результат, и вызывающий код увидит её.
Свой пул потоков для @Async
Spring Boot даёт готовый пул, но у него восемь потоков и очередь без ограничения: под нагрузкой потоки не плодятся, зато задачи копятся в памяти. Внешне всё здорово, а задержка растёт, и при перезапуске очередь исчезает вместе с несделанной работой.
В большинстве случаев заводят ограниченный пул — бин с типом Executor и именем taskExecutor:
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8); // постоянно живущих потоков
executor.setMaxPoolSize(32); // максимум под пиковой нагрузкой
executor.setQueueCapacity(100); // очередь, когда все потоки заняты
executor.setThreadNamePrefix("async-");
executor.initialize();
return executor;
}
Так нагрузка ограничена: больше maxPoolSize потоков не появится, лишние задачи подождут в очереди.
Виртуальные потоки (Java 21)
Обычный поток в Java «весит» много: каждый держит ресурсы операционной системы, поэтому их нельзя завести тысячами. Из-за этого и нужны пулы — они переиспользуют ограниченное число дорогих потоков.
Виртуальные потоки из Java 21 переворачивают ситуацию: они стоят почти ничего, их создают тысячами. Когда виртуальный поток упирается в ожидание (запрос к базе, вызов по сети), он не держит «настоящий» поток операционной системы — тот в это время обслуживает другую работу.
Для веб-сервиса, который в основном ждёт ввод-вывод, это значит: можно писать обычный понятный код «сверху вниз» и при этом держать тысячи параллельных запросов.
В Spring Boot включается одной настройкой:
spring.threads.virtual.enabled=true
После этого Spring обслуживает каждый веб-запрос в виртуальном потоке, а @Async и @Scheduled тоже начинают работать на виртуальных потоках — отдельный пул им больше не нужен, потому что создавать виртуальный поток на каждую задачу дёшево.
Чего виртуальные потоки не делают
- Не ускоряют вычисления. Они помогают там, где код ждёт ввод-вывод. Если задача нагружает процессор расчётами — виртуальные потоки ничего не дадут, упрётесь в число ядер.
- Не отменяют осторожность с общими данными. Параллельность никуда не делась: общее изменяемое состояние по-прежнему нужно защищать.
Коротко
@Scheduledзапускает метод по таймеру; включается аннотацией@EnableScheduling.fixedDelay— пауза от конца до начала (запуски не накладываются),fixedRate— от начала до начала (важна ровная периодичность),cron— расписание из шести полей; у cron всегда указывайтеzone.- По умолчанию все
@Scheduled-задачи делят один поток — для параллельности заведитеTaskSchedulerс пулом. - В нескольких копиях приложения задача сработает на каждой — общий замок через ShedLock оставляет один запуск.
@Asyncвыполняет метод в фоне; включается аннотацией@EnableAsync. Возврат —void(без результата) илиCompletableFuture<T>(с результатом и ошибкой).- Ловушки
@Async: вызов черезthisне срабатывает (нужен отдельный бин); ошибка изvoid-метода теряется (задайтеAsyncUncaughtExceptionHandler). - Для
@Asyncпод нагрузкой заведите ограниченный пулThreadPoolTaskExecutor(taskExecutor). - Виртуальные потоки (Java 21,
spring.threads.virtual.enabled=true) делают параллельность дешёвой для задач с ожиданием ввод-вывода; вычисления они не ускоряют.
Что почитать дальше
- Spring Events —
@Asyncвместе с@EventListener. - Виртуальные потоки в Java 21 — что у них внутри и где они не помогают.
- Spring WebFlux — другой подход к параллельной работе.
@Transactionalглубоко — тот же механизм прокси и те же ловушки с вызовом черезthis.