Thread Safety in Web Servers

Know which objects in a web service are shared across requests and keep them stateless, immutable or properly synchronised.

Syntaxserver.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 stateWhere it livesSafe?
Request data: body, user, parameterslocal variables and parametersYes, each thread has its own stack
Configuration, formatters, clientsfinal fields holding immutable or thread-safe objectsYes
Counters, caches, rate limitsatomics and concurrent collectionsYes, if updates are single atomic calls
A mutable field written per requesta handler or service fieldNo

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, a DataSource.
  • Not safe to share: SimpleDateFormat, HashMap and ArrayList that are modified, StringBuilder, a JDBC Connection, 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 final fields set in the constructor.
  • Model data as immutable records; a value that cannot change can be shared freely.
  • Use merge and computeIfAbsent rather 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · java
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, bob

Sharing 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.

Example · java
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:59

A 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.

Example · java
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

  • Be the first to comment on this lesson.