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. Let's look at where concurrency bites in an ordinary Spring app every day — and why you need to understand the memory model and races, not "just add a future."
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 of the bean is seen by dozens of threads at once.
@Service
public class OrderCounter {
private int count = 0; // ONE field for all request threads
public void onOrder() {
count++; // race: ++ is not atomic
}
}
Under load count++ loses increments — a classic race that passes review unnoticed. The fix is not a lock on everything but the right primitive — an atomic variable:
private final AtomicLong count = new AtomicLong();
public void onOrder() { count.incrementAndGet(); }
Rule: a singleton bean should have no mutable state other than thread-safe state (atomics, thread-safe collections). If it has state, that's 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 and is 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's execution to another thread. But by default Spring uses a simple executor that spawns threads without a limit under load. 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 queue so it doesn't grow forever
ex.setThreadNamePrefix("report-"); // the name in a thread dump is a lifesaver
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 immediately tells you where the threads got stuck.
Pool exhaustion — the most common production incident
Here's the scenario that takes services down most often. You have a pool of 8 threads. Inside a task is 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," even though the CPU is idle.
@Async("reportExecutor")
public void build(long id) {
externalApi.call(); // hangs 30 seconds → the thread is busy 30 seconds
}
You don't fix this by "adding threads" — you fix it 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:
@Transactional
public void process(long id) {
executor.submit(() -> repo.save(entity)); // ← this is already OUTSIDE the transaction
}
The same mechanism breaks @Transactional called from within the same bean (self-invocation) and Hibernate lazy loading in an async thread (LazyInitializationException). Rule: a transaction lives in a single thread; if work moves to another thread, start a transaction there explicitly. More on transactions themselves — in the article on @Transactional.
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 doesn't occupy an expensive platform thread — throughput on IO-bound load grows without going reactive. But importantly, virtual threads do not cancel everything above. Singletons are just as shared, races are just as possible, @Transactional is just as thread-bound. Moreover, more concurrency means more contention on shared state, and synchronized around a long operation "pins" a virtual thread to its carrier. Virtual threads remove the problem of expensive blocking — but not the problem of shared data.
Where this applies
Everything above isn't exotic but the daily life of a backend service: a metrics counter from many requests, a shared cache, an @Async email send, a background task that writes data an HTTP thread reads. Understanding the memory model, races, and pools is the difference between a service that holds up under load and one that loses increments, hangs on an exhausted pool, or silently writes outside a transaction.
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 at all.
@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.
@Transactionalis thread-bound: moving into@Async/a pool takes the work out of the transaction; self-invocation andLazyInitializationExceptionshare the same nature.- Virtual threads (Spring Boot 3.2+) remove expensive blocking but not shared state, races, or the transaction's thread binding.
What to read next
- Races and the memory model — why
count++in a singleton loses data. - Thread pools and typical bugs — how a pool works and how it gets exhausted.
- Virtual threads — the fundamental fix for the blocking problem.