Async, Scheduling & Virtual Threads

Virtual threads

One property, and every request plus every @Async task runs on a virtual thread:

spring.threads.virtual.enabled=true

Blocking code stops being expensive: a thread parked on I/O costs almost nothing, so an MVC app handles thousands of concurrent requests without going reactive.

Two rules

Do not pool virtual threads — create one per task. And avoid synchronized around blocking calls; use ReentrantLock instead.

@Async

@Configuration
@EnableAsync
class AsyncConfig {}
@Service
class ReportService {

    @Async
    public CompletableFuture<Report> build(long id) {
        
        return CompletableFuture.completedFuture(report);
    }
}
  • return CompletableFuture<T> (or void for fire-and-forget)
  • the call must come from another bean — self-invocation bypasses the proxy
  • exceptions in void methods disappear unless you set an AsyncUncaughtExceptionHandler
var f1 = reports.build(1);
var f2 = reports.build(2);
CompletableFuture.allOf(f1, f2).join();

Scheduling

@Configuration
@EnableScheduling
class SchedulingConfig {}
@Component
class Jobs {

    @Scheduled(fixedRate = 60_000)                 // every minute, from start to start
    void poll() {  }

    @Scheduled(fixedDelay = 5_000, initialDelay = 10_000)   // 5 s after the last one ended
    void drainQueue() {  }

    @Scheduled(cron = "0 0 3 * * *", zone = "Europe/Berlin")   // 03:00 daily
    void nightlyCleanup() {  }
}

Cron fields: second minute hour day-of-month month day-of-week.

app.cleanup.cron=0 0 3 * * *
app.cleanup.cron=-              # "-" disables the job
@Scheduled(cron = "${app.cleanup.cron}")

By default all scheduled tasks share one thread — a slow job delays the others:

spring.task.scheduling.pool.size=4
Warning

With several instances running, every instance runs the job. Use a leader election or a database lock (ShedLock) for “exactly once”.

Application events

Decouple side effects from the main flow.

public record BookCreated(long id, String title) {}
@Service
class BookService {
    private final ApplicationEventPublisher events;

    BookService(ApplicationEventPublisher events) { this.events = events; }

    @Transactional
    public Book create(BookRequest r) {
        var saved = repo.save();
        events.publishEvent(new BookCreated(saved.getId(), saved.getTitle()));
        return saved;
    }
}
@Component
class SearchIndexer {

    @EventListener
    void on(BookCreated event) {  }                    // synchronous, same transaction

    @Async
    @TransactionalEventListener                         // only after a successful commit
    void index(BookCreated event) {  }
}

Task executors

spring.task.execution.pool.core-size=8
spring.task.execution.pool.max-size=32
spring.task.execution.pool.queue-capacity=1000

Ignored when virtual threads are enabled — there is no pool to size.

★ Exercises

  1. Turn on virtual threads and log Thread.currentThread() in a controller.
  2. Make a slow service method @Async and call it twice in parallel.
  3. Add a @Scheduled(fixedDelay = …) job and watch the timing in the logs.
  4. Publish an event on create and index it with @TransactionalEventListener.
  5. Why does calling an @Async method from within the same class do nothing?