Thread Safety in Web Servers
Know which objects in a web service are shared across requests and keep them stateless, immutable or properly synchronised.
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
private final AtomicLong requests = new AtomicLong();
private final ConcurrentHashMap<String, LongAdder> perUser = new ConcurrentHashMap<>();A web server handles many requests at once, each on its own thread. The handler objects, services, repositories and caches you create at startup are usually single instances shared by every one of those threads. In Spring, beans are singletons by default; with com.sun.net.httpserver, the handler you register is one object. Any mutable field on them is shared mutable state.
Classify every field
| Kind of state | Where it lives | Safe? |
|---|---|---|
| Request data: body, user, parameters | local variables and parameters | Yes, each thread has its own stack |
| Configuration, formatters, clients | final fields holding immutable or thread-safe objects | Yes |
| Counters, caches, rate limits | atomics and concurrent collections | Yes, if updates are single atomic calls |
| A mutable field written per request | a handler or service field | No |
The classic bug is storing request data in a field, such as this.currentUser = user, and reading it a few lines later. Under load, another request overwrites it in between, and one customer sees another customer's data. It never shows up in a single-user test.
Thread-safe and unsafe library classes
- Safe to share:
DateTimeFormatter,String, records of immutable values,HttpClient,ConcurrentHashMap, aDataSource. - Not safe to share:
SimpleDateFormat,HashMapandArrayListthat are modified,StringBuilder, a JDBCConnection,MessageDigest.
Check the Javadoc of anything you store in a field. If it is not documented as thread-safe, create one per request or protect it.
Server threads
HttpServer runs handlers on whatever executor you give it, and on its single dispatcher thread if you give none. In practice you call server.setExecutor(...); with Executors.newVirtualThreadPerTaskExecutor() each exchange gets its own virtual thread. Switching to virtual threads does not change thread safety: more concurrency makes existing races easier to hit, not rarer.
Practical rules
- Make handlers and services stateless, with only
finalfields set in the constructor. - Model data as immutable records; a value that cannot change can be shared freely.
- Use
mergeandcomputeIfAbsentrather than read-then-write sequences. - Remember that every JVM lock and counter is per instance. With three replicas, shared limits and totals belong in the database or a shared store.
Example
When to use it
- Review a service class before enabling virtual threads and turn per-request fields into local variables.
- Count requests per user for a /stats endpoint with ConcurrentHashMap and LongAdder shared by all handlers.
- Share one DateTimeFormatter and one HttpClient across every request instead of creating them per call.
More examples
Request data in a shared field leaks between users
The latches force the unlucky timing that happens naturally under load: Alice is greeted as Bob. Making currentUser a local variable in handle fixes it.
class GreetingHandler {
private String currentUser; // BUG: one handler instance serves every request
String handle(String user, CountDownLatch started, CountDownLatch resume) throws InterruptedException {
currentUser = user;
started.countDown();
resume.await(); // another request arrives while this one is mid-flight
return "Hello, " + currentUser;
}
}
void main() throws Exception {
GreetingHandler handler = new GreetingHandler();
CountDownLatch aliceStarted = new CountDownLatch(1);
CountDownLatch aliceResume = new CountDownLatch(1);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> alice = executor.submit(() -> handler.handle("alice", aliceStarted, aliceResume));
aliceStarted.await();
Future<String> bob = executor.submit(() -> handler.handle("bob", new CountDownLatch(1), new CountDownLatch(0)));
IO.println("bob's response: " + bob.get());
aliceResume.countDown();
IO.println("alice's response: " + alice.get());
}
}
// Output:
// bob's response: Hello, bob
// alice's response: Hello, bobSharing an immutable DateTimeFormatter
DateTimeFormatter is immutable, so a single static instance is safe for every request. The older SimpleDateFormat keeps parsing state in fields and must not be shared.
static final DateTimeFormatter LOG_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
void main() throws Exception {
List<LocalDateTime> stamps = List.of(
LocalDateTime.of(2026, 3, 1, 9, 30),
LocalDateTime.of(2026, 3, 1, 14, 5, 12),
LocalDateTime.of(2026, 12, 31, 23, 59, 59));
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> formatted = new ArrayList<>();
for (LocalDateTime t : stamps) {
formatted.add(executor.submit(() -> LOG_TIME.format(t))); // one formatter, many threads
}
for (Future<String> f : formatted) IO.println(f.get());
}
}
// Output:
// 2026-03-01 09:30:00
// 2026-03-01 14:05:12
// 2026-12-31 23:59:59A per-client rate limit with ConcurrentHashMap
computeIfAbsent creates exactly one counter per client and incrementAndGet hands out each number once, so exactly five requests pass however the threads interleave. Behind several replicas the counter would need to live in a shared store.
record Attempt(String client, boolean allowed) {}
final int limit = 5;
final ConcurrentHashMap<String, AtomicInteger> counts = new ConcurrentHashMap<>();
boolean allow(String client) {
return counts.computeIfAbsent(client, k -> new AtomicInteger()).incrementAndGet() <= limit;
}
void main() throws Exception {
List<String> clients = new ArrayList<>();
for (int i = 0; i < 8; i++) clients.add("ip-1");
for (int i = 0; i < 3; i++) clients.add("ip-2");
List<Attempt> attempts = new ArrayList<>();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Attempt>> futures = new ArrayList<>();
for (String c : clients) futures.add(executor.submit(() -> new Attempt(c, allow(c))));
for (Future<Attempt> f : futures) attempts.add(f.get());
}
Map<String, int[]> summary = new TreeMap<>();
for (Attempt a : attempts) {
summary.computeIfAbsent(a.client(), k -> new int[2])[a.allowed() ? 0 : 1]++;
}
summary.forEach((client, n) -> IO.println(client + ": " + n[0] + " allowed, " + n[1] + " got 429"));
}
// Output:
// ip-1: 5 allowed, 3 got 429
// ip-2: 3 allowed, 0 got 429
Discussion