← Back to the section

A common objection: "services are stateless now, concurrency is theory, at most slap on an @Async." That's a misconception. Stateless is about horizontal scaling (no session between requests, you can run 10 copies) and it does nothing to remove concurrency inside the process. Below — where concurrency bites in an ordinary Spring app, and why the memory model and races are not theory here.

http-1 http-2 http-3 a thread per request, one bean for all of them@Servicefield is shared count = 0 count = 1 count = 2 three ++, count = 2one increment is lost @Async: pool of 2 threads, queue of 2task pool report-1: waitsreport-2: waitsblocking call task 3task 4queue task 5✗ new ones rejectedthe app "hangs",the CPU stays idle

Top scene: Tomcat request threads call the same bean, and its field is shared — two simultaneous increments write the same value, and the third one brings the counter to two instead of three. Bottom scene: the same tasks in a pool of two threads, both waiting on another service, the queue is full, and new tasks are rejected while the CPU has nothing to do.

Beans are singletons, and their fields are shared

Spring's main trap: beans are singletons by default. One @Service instance serves all requests, and Tomcat runs them on a thread pool — so a mutable field is seen by dozens of threads at once.

live example

import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;

public class SharedBean {                  // a singleton @Service in Spring
    private int count;                                   // wrong
    private final AtomicLong safe = new AtomicLong();    // right

    void onOrder() { count++; safe.incrementAndGet(); }

    public static void main(String[] args) {
        var bean = new SharedBean();
        try (var pool = Executors.newFixedThreadPool(8)) {
            for (int t = 0; t < 8; t++) pool.submit(() -> { for (int i = 0; i < 100_000; i++) bean.onOrder(); });
        }
        System.out.println("added 800000: count = " + bean.count + ", safe = " + bean.safe.get());
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

800000 increments: safe shows exactly that, count shows less, and a different number every run. The fix is not a lock but the right primitive — an atomic variable.

Rule: a singleton bean should have no mutable state other than thread-safe state (atomics, thread-safe collections). State here is the first candidate for a bug under load.

Caches and counters in memory

Even a "stateless" service is full of shared mutable state: a reference cache, a rate limiter, idempotency dedup, metrics. All of it lives in a singleton, read and written from many threads. A plain HashMap is not acceptable here — it breaks in concurrent code (structure corruption, and in old versions an infinite loop on resize):

private final Map<String, Rate> limits = new ConcurrentHashMap<>();  // not HashMap

@Async: your own pool, not the default

@Async moves a method to another thread. The Spring Boot default is a pool of eight threads with an unbounded queue. Threads don't multiply, but under load the queue grows: tasks pile up in memory, latency creeps up, and a restart drops the queue together with the undone work. Always define your own pool with clear bounds:

@Bean("reportExecutor")
public Executor reportExecutor() {
    var ex = new ThreadPoolTaskExecutor();
    ex.setCorePoolSize(4);
    ex.setMaxPoolSize(8);
    ex.setQueueCapacity(100);          // a bounded queue
    ex.setThreadNamePrefix("report-");
    ex.initialize();
    return ex;
}

@Async("reportExecutor")
public CompletableFuture<Report> build(long id) { ... }

The thread-name prefix isn't cosmetics: when the service hangs, you'll be reading a thread dump, and report-3 tells you at once where the threads got stuck.

Pool exhaustion — the most common production incident

The scenario that takes services down most often: a pool of 8 threads, and inside a task a blocking call (HTTP to another service that stalled). All 8 threads are stuck waiting, the queue fills up, new tasks are rejected — the app "hangs" while the CPU is idle.

Same on a bare pool: two threads, a queue of two, an "external call" of 300 ms.

live example

import java.util.concurrent.*;

public class PoolExhaustion {
    public static void main(String[] args) {
        var pool = new ThreadPoolExecutor(2, 2, 0, TimeUnit.SECONDS, new ArrayBlockingQueue<>(2));
        int rejected = 0;
        for (int i = 0; i < 8; i++) {
            try { pool.submit(() -> { Thread.sleep(300); return null; }); }   // the "external call"
            catch (RejectedExecutionException e) { rejected++; }
        }
        System.out.println("submitted 8 tasks, rejected " + rejected);
        pool.shutdown();
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Two run, two wait in the queue, four bounce with RejectedExecutionException — with the CPU idle.

You don't fix this by "adding threads" but by understanding that blocking inside a bounded pool is a loan against all the other tasks. Hence timeouts on external calls, separate pools for different kinds of load, and — as the more fundamental fix — virtual threads, where blocking no longer occupies an expensive resource.

@Transactional doesn't cross into another thread

A subtle but painful trap. A Spring transaction is bound to the thread (via a ThreadLocal). The moment you go into @Async or your own pool — you're in another thread, and there's no transaction there anymore: executor.submit(() -> repo.save(entity)) inside a @Transactional method saves the row outside the transaction. The mechanism is simple: the context sits in a ThreadLocal, and every thread has its own.

live example

public class TxContext {
    static final ThreadLocal<String> TX = new ThreadLocal<>();

    public static void main(String[] args) throws Exception {
        TX.set("tx-42");                   // the "transaction" starts here
        var other = new Thread(() -> System.out.println("other thread: " + TX.get()));
        other.start();
        other.join();
        System.out.println("own thread:   " + TX.get());
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Your own thread has the value, the other has null — and no transaction either.

A trap with the same symptoms but a different nature sits next to it: @Transactional called from within the same bean (self-invocation) does nothing, because the call goes past the proxy, not because of threads. Hibernate lazy loading in an async thread (LazyInitializationException) does break because of threads: the session stayed in the original thread. Rule: a transaction lives in a single thread; work that moves to another thread needs its own. More on transactions — in the @Transactional article.

Virtual threads in Spring Boot

With Spring Boot 3.2+ and Java 21, virtual threads turn on with a single flag:

spring.threads.virtual.enabled=true

Now each HTTP request is served by its own virtual thread, and a blocking call no longer occupies an expensive platform thread — throughput on IO-bound load grows without going reactive. But virtual threads do not cancel everything above. Singletons are just as shared, races are just as possible, @Transactional is just as thread-bound. More concurrency also means more contention on shared state, and on Java 21–23 synchronized around a long operation "pins" a virtual thread to its carrier; Java 24 removed that (JEP 491). Virtual threads remove expensive blocking — not shared data.

In short

  • Spring beans are singletons: a mutable field is shared across all request threads, hence races. Keep state thread-safe (atomics, ConcurrentHashMap) or keep none.
  • @Async — always with your own pool with bounds and a thread name; the default executor is dangerous.
  • Pool exhaustion by blocking calls is the most common production hang — timeouts, separate pools, virtual threads.
  • @Transactional is thread-bound: moving into @Async/a pool takes the work out of the transaction, and LazyInitializationException too. Self-invocation is a separate story — that one bypasses the proxy.
  • Virtual threads (Spring Boot 3.2+) remove expensive blocking but not shared state, races, or the transaction's thread binding.