← назад к разделу

Три темы про многопоточность в 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/zipWebFlux лаконичнее
Backpressure от downstreamWebFlux
Команда не знает ReactorVirtual threads

Подробнее — в статье про WebFlux.

Что почитать дальше

  • Spring Events — @Async + @EventListener.
  • Spring WebFlux — альтернатива на потоках событий.
  • @Transactional глубоко — взаимодействие транзакций и @Async.
  • Resilience-паттерны — circuit breaker, retry, bulkhead для асинхронных операций.