Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Spring MVC Controllers, Bean Scopes, and Thread Safety Under Concurrency

Tech 1
  • 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 int counter (not thread-safe)
  • An AtomicInteger counter (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 plain collide.
  • 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.

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.