Три темы про многопоточность в Spring: периодические задачи (@Scheduled), асинхронные вызовы (@Async), и виртуальные потоки (Java 21+), которые меняют игру для blocking I/O.
@Scheduled — периодические задачи
Активация:
@Configuration
@EnableScheduling
public class AppConfig { }
@Component
@RequiredArgsConstructor
@Slf4j
public class OrderCleanupJob {
private final OrderRepository orderRepo;
@Scheduled(cron = "0 0 3 * * *", zone = "Europe/Moscow")
public void cleanupAbandoned() {
var count = orderRepo.deleteAbandonedBefore(Instant.now().minus(30, ChronoUnit.DAYS));
log.info("Cleaned up {} abandoned orders", count);
}
@Scheduled(fixedDelay = 60_000) // 60s от завершения предыдущего вызова
public void publishMetrics() { ... }
@Scheduled(fixedRate = 30_000) // 30s от старта предыдущего (могут перекрываться)
public void sampleQueueSize() { ... }
}
Разница fixedDelay vs fixedRate
fixedDelay = 60_000— пауза между завершением предыдущего вызова и стартом следующего. Если задача длится 50 секунд, следующая запустится через 50 + 60 = 110 секунд от старта.fixedRate = 60_000— пауза между стартом одного вызова и стартом следующего. Если задача длится 70 секунд приfixedRate=60_000— следующая стартует параллельно (если есть свободный поток).
Правило: для большинства sweeper/cleanup-задач — fixedDelay. fixedRate нужен, если важна точная периодичность (например, регулярный публикации метрик).
Cron expressions
Spring использует Quartz-формат с 6 полями: секунда минута час день_месяца месяц день_недели.
@Scheduled(cron = "0 0 3 * * *") // каждый день в 3:00:00
@Scheduled(cron = "0 */15 * * * *") // каждые 15 минут
@Scheduled(cron = "0 0 9 * * MON-FRI") // в 9:00 в будни
@Scheduled(cron = "${app.cleanup.cron}") // из properties
zone обязателен в production — иначе используется системная зона JVM, которая может отличаться от ожиданий.
Параметризация через свойства
@Scheduled(fixedDelayString = "${app.metrics.publish-interval-ms}")
public void publishMetrics() { ... }
Threadpool для @Scheduled
По умолчанию один поток на все @Scheduled-методы. Если один из них блокирует — остальные ждут. Конфигурация:
@Configuration
@EnableScheduling
public class SchedulingConfig {
@Bean
public TaskScheduler taskScheduler() {
var scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5);
scheduler.setThreadNamePrefix("scheduled-");
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setAwaitTerminationSeconds(30);
return scheduler;
}
}
5 потоков = 5 задач параллельно. Имя префикс помогает в логах и в thread dump.
Distributed scheduling
В кластере из N инстансов сервиса @Scheduled запустится на каждом. Часто это нежелательно (3 инстанса = 3 раза почистили БД).
Решения:
Лидер-выбор через Kubernetes / Consul
Один pod помечен как leader, только он запускает задачи. Spring Cloud / LeaderInitiator.
ShedLock — распределённый лок через БД/Redis
implementation("net.javacrumbs.shedlock:shedlock-spring:5.x.x")
implementation("net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.x.x")
@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", lockAtLeastFor = "PT1M")
public void cleanup() { ... }
}
Перед запуском Spring проверяет lock в БД. Если занят — пропускает. Распределённый лок без отдельной инфраструктуры.
Cron job на уровне Kubernetes
CronJob в k8s запускает отдельный pod по расписанию. Идеально для тяжёлых редких задач, которые не должны жить в основном сервисе.
@Async — асинхронные вызовы
Активация:
@Configuration
@EnableAsync
public class AsyncConfig { }
@Component
public class EmailService {
@Async
public void sendConfirmation(String to, String subject, String body) {
// выполнится в другом потоке
}
@Async
public CompletableFuture<Result> doSomethingHeavy() {
return CompletableFuture.completedFuture(new Result(...));
}
}
@Async возвращает:
void— fire-and-forget, exception теряется.CompletableFuture<T>— caller может ждать или композировать.Future<T>(legacy) — не использовать, нет композиции.
Ловушки @Async
Self-invocation — та же история, что и с @Transactional. @Async срабатывает только через прокси:
@Service
public class MyService {
public void process() {
this.heavyTask(); // НЕ async, через this
}
@Async
public void heavyTask() { ... }
}
Exception теряется. @Async void метод бросает exception → попадает в AsyncUncaughtExceptionHandler, не в caller:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> {
log.error("Async method {} failed", method.getName(), ex);
// alert, metric
};
}
}
Для CompletableFuture — exception попадёт в future, caller увидит через .exceptionally(...) или .get().
MDC и SecurityContext не переносятся автоматически в новый поток. Нужен TaskDecorator:
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(32);
executor.setQueueCapacity(100);
executor.setTaskDecorator(runnable -> {
var mdc = MDC.getCopyOfContextMap();
var auth = SecurityContextHolder.getContext().getAuthentication();
return () -> {
try {
if (mdc != null) MDC.setContextMap(mdc);
SecurityContextHolder.getContext().setAuthentication(auth);
runnable.run();
} finally {
MDC.clear();
SecurityContextHolder.clearContext();
}
};
});
executor.initialize();
return executor;
}
С Spring Boot 3.2+ и micrometer-context-propagation это работает автоматически (для tracing).
Виртуальные потоки (Java 21+)
Виртуальные потоки — лёгкие потоки JVM, которые не привязаны к OS-потоку. Тысячи виртуальных потоков map'ятся на десяток OS-потоков; при blocking I/O виртуальный «паркуется», OS-поток обслуживает другой.
Эффекты для Spring:
- Tomcat обслуживает каждый запрос в виртуальном потоке — тысячи параллельных запросов без switching на reactive.
JdbcTemplate,RestTemplate.exchange(),WebClient.block()не блокируют OS-поток — JVM делает park.@Asyncтеперь запускает задачу в виртуальном потоке, нет накладных расходов на пул.
Активация в Spring Boot 3.2+:
spring.threads.virtual.enabled=true
После этого:
- Tomcat — виртуальные потоки.
@Async,@Scheduled— виртуальные потоки.- Kafka/AMQP listeners — виртуальные потоки.
Что не меняется
- Cgrouroup-лимит CPU остаётся. Виртуальные потоки не помогают CPU-bound задачам — только I/O-bound.
- Synchronized блок «пинит» виртуальный поток к OS-потоку, теряя выгоду. С Java 21 — да, с Java 24+ — починили. Если используете много
synchronized— лучше переходить наReentrantLock. ThreadLocalработает, но каждый виртуальный поток имеет свой. ThreadLocal на пуле традиционного стиля переиспользовался, на VT — нет. Не критично, но может удивить (выделение чаще).
Когда виртуальные потоки vs reactive
| Сценарий | Решение |
|---|---|
| Много I/O-bound запросов, простой код | Virtual threads, MVC |
| Streaming-ответ (SSE, infinite stream) | WebFlux |
| 5-10 параллельных downstream-вызовов с retry/timeout/zip | WebFlux лаконичнее |
| Backpressure от downstream | WebFlux |
| Команда не знает Reactor | Virtual threads |
Подробнее — в статье про WebFlux.
Что почитать дальше
- Spring Events —
@Async+@EventListener. - Spring WebFlux — альтернатива на потоках событий.
@Transactionalглубоко — взаимодействие транзакций и@Async.- Resilience-паттерны — circuit breaker, retry, bulkhead для асинхронных операций.