Spring MVC Controllers, Bean Scopes, and Thread Safety Under Concurrency
- Default singleton scope for controllers
- Switching controllers to prototype scope
- How HTTP requests hit controllers: paralel vs. serial execution
- Building a singleton and stress-testing it for thread safety
- Appendix: Spring bean scopes
Default behavior: controller are singletons
In Spring MVC, classes annotated with @RestController or @Controller are singletons by default. A single instance of the controller is created per application context and shared across all incoming requests.
Example controller and DTO:
package com.example.web;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class WelcomeController {
private final AtomicLong sequence = new AtomicLong();
private static final String TEMPLATE = "Hello, %s!";
@GetMapping("/hello")
public Hello message(@RequestParam(defaultValue = "World") String name) {
long id = sequence.incrementAndGet();
Hello body = new Hello(id, String.format(TEMPLATE, name));
System.out.printf("seq=%d, beanId=%s%n", id, System.identityHashCode(this));
return body;
}
}
package com.example.web;
public class Hello {
private final long id;
private final String text;
public Hello(long id, String text) {
this.id = id;
this.text = text;
}
public long getId() { return id; }
public String getText() { return text; }
}
Drive load with wrk:
wrk -t12 -c400 -d10s http://127.0.0.1:8080/hello
Typical server logs show a stable beanId across requests while the counter grows:
seq=162438, beanId=91234567
seq=162439, beanId=91234567
seq=162440, beanId=91234567
seq=162441, beanId=91234567
The identical beanId confirms all requests are served by the same controller instance, and the AtomicLong increments consistently.
Switching to prototype scope
If you declare a controller as prototype-scoped, a fresh instance is created per injection/lookup. Apply @Scope("prototype") to the controller:
package com.example.web;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.context.annotation.Scope;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import static org.springframework.beans.factory.config.ConfigurableBeanFactory.SCOPE_PROTOTYPE;
@Scope(SCOPE_PROTOTYPE)
@RestController
public class WelcomeController {
private final AtomicLong sequence = new AtomicLong();
@GetMapping("/hello")
public Hello message(@RequestParam(defaultValue = "World") String name) {
long id = sequence.incrementAndGet();
System.out.printf("seq=%d, beanId=%s%n", id, System.identityHashCode(this));
return new Hello(id, "Hello, " + name + "!");
}
}
Now the logs typically show a diffreent beanId for each request and the seq often starting at 1:
seq=1, beanId=14567321
seq=1, beanId=87345002
seq=1, beanId=56781234
Are controller methods invoked in parallel?
Controllers are invoked concurrently by the web container’s request-processing thread pool. Too see this, simulate a slow handler and observe throughput:
package com.example.web;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class SlowController {
private final AtomicLong seq = new AtomicLong();
@GetMapping("/slow-hello")
public Hello slow(@RequestParam(defaultValue = "World") String who) throws InterruptedException {
Thread.sleep(1000); // simulate 1s of work
long id = seq.incrementAndGet();
System.out.printf("seq=%d, beanId=%s%n", id, System.identityHashCode(this));
return new Hello(id, "Hello, " + who + "!");
}
}
Run load again:
wrk -t12 -c400 -d10s http://127.0.0.1:8080/slow-hello
Sample output (numbers will vary):
Running 10s test @ http://127.0.0.1:8080/slow-hello
12 threads and 400 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.16s 290.11ms 1.92s 84.91%
Req/Sec 38.50 37.90 158.00 79.00%
1710 requests in 10.02s, 269.00KB read
Requests/sec: 170.63
Even with a 1-second sleep per request, the server handles many requests per second, demonstrating parallel execution across multiple threads rather than serial invocation.
Building a singleton and validating thread safety with load
The following example shows a classic double-checked locking singleton that exposes two counters:
- A plain
intcounter (not thread-safe) - An
AtomicIntegercounter (thread-safe)
package com.demo.concurrent;
import java.util.concurrent.atomic.AtomicInteger;
public final class CounterBox {
private static volatile CounterBox INSTANCE;
private int plain; // not thread-safe
private final AtomicInteger atomic = new AtomicInteger();
private CounterBox() {}
public static CounterBox instance() {
CounterBox local = INSTANCE;
if (local == null) {
synchronized (CounterBox.class) {
if (INSTANCE == null) {
INSTANCE = new CounterBox();
}
local = INSTANCE;
}
}
return local;
}
public int nextPlain() {
return ++plain; // race-prone
}
public int plainValue() {
return plain;
}
public int nextAtomic() {
return atomic.incrementAndGet();
}
public int atomicValue() {
return atomic.get();
}
}
Test harness that submits a large number of concurrent calls and examines the results:
package com.demo.concurrent;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.IntSupplier;
public class CounterBoxLoadTest {
public static void main(String[] args) throws Exception {
// Unsafe path: increments on a plain int
runBatch(CounterBox.instance()::nextPlain, "plain");
System.out.println("plain total = " + CounterBox.instance().plainValue());
// Safe path: increments via AtomicInteger
runBatch(CounterBox.instance()::nextAtomic, "atomic");
System.out.println("atomic total = " + CounterBox.instance().atomicValue());
}
private static void runBatch(IntSupplier op, String label) throws Exception {
final int calls = 1000;
final int poolSize = Math.max(2, Runtime.getRuntime().availableProcessors());
ExecutorService pool = Executors.newFixedThreadPool(poolSize);
CountDownLatch start = new CountDownLatch(1);
List<Future<Integer>> submitted = new ArrayList<>(calls);
for (int i = 0; i < calls; i++) {
submitted.add(pool.submit(() -> { start.await(); return op.getAsInt(); }));
}
// Release all tasks at once to maximize contention
start.countDown();
Map<Integer, Integer> histogram = new HashMap<>();
for (Future<Integer> f : submitted) {
int value = f.get();
histogram.merge(value, 1, Integer::sum);
}
pool.shutdown();
int unique = histogram.size();
int min = histogram.keySet().stream().min(Integer::compareTo).orElse(0);
int max = histogram.keySet().stream().max(Integer::compareTo).orElse(0);
System.out.printf("%s: unique=%d, min=%d, max=%d%n", label, unique, min, max);
}
}
Typical observations:
- The unsafe counter often ends up less than 1000 because simultaneous increments on
plaincollide. - The atomic counter reaches 1000 reliably as
incrementAndGet()is atomic.
Appendix: Spring bean scopes
- singleton: The default scope. A single instance per application context. Subsequent lookups of the bean return the same cached instance.
- prototype: A new instance is created on each lookup or injection. Prefer for stateful components that must not be shared.
- request: One instance per HTTP request. Available only in web-aware application contexts.
- session: One instance per HTTP session. Available only in web-aware application contexts.
- application: One instance per
ServletContext. Available only in web-aware application contexts. - websocket: One instance per WebSocket session. Available only in web-aware application contexts.