How Many QPS Are Needed to Reveal Thread‑Unsafe Bugs? A Hands‑On SpringBoot Test

This article explores the QPS threshold required to expose thread‑unsafe operations such as i++ by designing a SpringBoot service, running controlled load tests with both fixed‑thread and precise‑QPS models, and presenting detailed results that show how error rates increase with higher request rates.

FunTester
FunTester
FunTester
How Many QPS Are Needed to Reveal Thread‑Unsafe Bugs? A Hands‑On SpringBoot Test

When testing concurrent systems, a common question is how much load (QPS) is needed to surface thread‑safety bugs. The author investigates this by focusing on the classic unsafe operation i++ and measuring at what request rates the bug becomes observable.

Test Setup

A simple SpringBoot application was created with three endpoints:

int i;

@GetMapping("/funtest")
public Result test1() {
    Thread.sleep(SourceCode.getRandomInt(20));
    return Result.success(i++);
}

@GetMapping("/geti")
public Result test() {
    return Result.success(i);
}

@GetMapping("/zero")
public Result te2st() {
    i = 0;
    return Result.success(i);
}

The service simulates a 10 ms average response time and performs the non‑thread‑safe increment on a shared variable.

Load‑Testing Approach

Instead of using a formal testing framework, the author built a custom load generator that controls QPS via sleep and an asynchronous thread pool. The test procedure follows four steps:

Run a fixed QPS load for 20 seconds.

Reset the counter, then execute the load.

Suppress all log output to avoid measurement noise.

Collect the number of errors and compute the error ratio.

Thread‑Based Model

This model keeps a constant number of threads that continuously call the /funtest endpoint. The code looks like:

public static void main(String[] args) {
    def test = { getHttpResponse(getHttpGet("http://localhost:8080/user/funtest")) };
    def get  = { getHttpResponse(getHttpGet("http://localhost:8080/user/geti")) };
    def init = { getHttpResponse(getHttpGet("http://localhost:8080/user/zero")) };
    AtomicInteger index = new AtomicInteger();
    FunHttp.LOG_KEY = false;
    int t = 1000; // iterations per thread
    int size = 1; // number of threads
    setPoolMax(500);
    init();
    sleep(1.0);
    long start = Time.getTimeStamp();
    size.times {
        fun {
            t.times {
                test();
                index.getAndIncrement();
            }
        }
    }
    ThreadPoolUtil.waitFunIdle();
    int value = get().getIntValue("data");
    long end = Time.getTimeStamp();
    output("Current QPS: ${index / (end - start) * 1000}");
    output(index.get(), value);
    output(getPercent(index.get(), index.get() - value));
}

Precise‑QPS Model

This model drives the request rate by sleeping for a calculated nanosecond interval between calls, allowing a target QPS to be enforced.

public static void main(String[] args) {
    def test = { getHttpResponse(getHttpGet("http://localhost:8080/user/funtest")) };
    def get  = { getHttpResponse(getHttpGet("http://localhost:8080/user/geti")) };
    def init = { getHttpResponse(getHttpGet("http://localhost:8080/user/zero")) };
    AtomicInteger index = new AtomicInteger();
    FunHttp.LOG_KEY = false;
    int qps = 100;
    int t = qps * 10; // total requests
    setPoolMax(1000);
    init();
    long decimal = 1_000_000_000L / qps; // nanoseconds per request
    fun { output(decimal); }
    sleep(1.0);
    long start = Time.getTimeStamp();
    t.times {
        sleepNano(decimal as long);
        fun { test(); index.getAndIncrement(); }
    }
    ThreadPoolUtil.waitFunIdle();
    int value = get().getIntValue("data");
    long end = Time.getTimeStamp();
    output("Current QPS: ${index / (end - start) * 1000}");
    output(index.get(), value);
    output(getPercent(index.get(), index.get() - value));
}

Results – Thread Model

With a simulated 10 ms response time, each thread performed 2 000 calls (≈20 s total). The observed actual QPS and error statistics were:

Design QPS 10 → Actual 9.6, Errors 0

Design QPS 20 → Actual 18.7, Errors 0

Design QPS 50 → Actual 42, Errors 0

Design QPS 100 → Actual 87, Errors 2 (0.1 %)

Design QPS 200 → Actual 174, Errors 9 (0.22 %)

Design QPS 300 → Actual 280, Errors 18 (0.30 %)

Design QPS 400 → Actual 285, Errors 23 (0.28 %)

Design QPS 500 → Actual 417, Errors 2 (0.02 %)

Results – QPS Model

Running the precise‑QPS driver produced the following outcomes for different thread counts (each thread issuing requests at the target QPS):

1 thread → Actual QPS 76, Errors 0

2 threads → Actual QPS 144, Errors 1 (0.05 %)

4 threads → Actual QPS 305, Errors 33 (0.41 %)

8 threads → Actual QPS 617, Errors 111 (0.69 %)

12 threads → Actual QPS 927, Errors 224 (0.93 %)

Conclusion

The experiments demonstrate that simple, non‑atomic operations like i++ can remain hidden under low load but start producing observable inconsistencies once the system approaches a few hundred QPS, especially when many concurrent threads are involved. These numbers provide a practical reference for engineers designing stress‑tests to uncover thread‑safety defects.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaconcurrencyPerformance Testingthread safetySpringBootQPS
FunTester
Written by

FunTester

10k followers, 1k articles | completely useless

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.