Back to Scenarios & Design Questions
Q48Question 48 of 67

Thousands of threads are suddenly created and CPU spikes: why is "add more servers" wrong, and how do you investigate

Key Concepts
  • Adding servers multiplies a thread defect across more machines instead of fixing it, raising cost without solving the root cause.
  • Thread dumps reveal what thousands of threads are actually doing: WAITING on a slow dependency or RUNNABLE in hot code.
  • Thread names are a decisive clue: ever-incrementing pool names or raw Thread-N names reveal unbounded creation.
  • The two root causes are unbounded thread spawning per request and a badly sized pool blocked on a slow dependency.
  • CPU spikes from thread explosions are mostly context-switching overhead, not useful computation.
  • The fix is a single bounded thread pool sized to the downstream dependency, with a bounded queue and fail-fast rejection.
  • For IO-bound workloads, consider virtual threads or reactive non-blocking IO, but always bound the real resource: the dependency.

Definition

Adding servers multiplies a thread defect instead of fixing it, so you take thread dumps to see what the threads are doing and decide whether the app is spawning unbounded threads per request or a badly sized pool has all its threads blocked on a slow dependency.
  • Why not scale – A thread explosion is a defect, not legitimate load, and every new instance carries the same code, so it reproduces the same explosion and multiplies cost.
  • Thread dump – The single most valuable tool: it shows each thread's state (RUNNABLE, WAITING, BLOCKED) and stack, revealing what thousands of threads are actually doing.
  • Thread names – A healthy bounded pool has a fixed set of names; ever-incrementing names like pool-137-thread-1 or raw Thread-4821 reveal uncontrolled creation.
  • Two root causes – Either the app spawns a new thread or unbounded executor per request, or a bounded pool's threads are all blocked on a slow dependency and someone raised the max to thousands.
  • Context switching – Thousands of threads burn CPU on scheduling overhead, not useful work, which explains the CPU spike.

Imagine National Bank of India's payment branch suddenly has thousands of temporary clerks crammed into one room, all bumping into each other, and the branch grinds to a halt. A panicking manager shouts: open ten more branches. But if the real problem is that the branch keeps hiring a new clerk for every single customer and never sends them home, every new branch will do the same thing and the chaos multiplies at ten times the cost. The right move is to walk the floor and see what all these clerks are doing: are they idle because someone keeps hiring more than needed, or are they all stuck on the phone waiting for one overloaded back office?

Understanding the Concepts

What happens when thousands of threads are suddenly created?

In a Java microservice at National Bank of India, the payment service's thread count suddenly climbs into the thousands during a festival spike. The JVM's memory usage grows because each thread allocates a stack (typically 512 KB to 1 MB), and the operating system spends more time switching between threads than doing useful work. The CPU spike is largely context-switching overhead, not productive computation.

JAVA
package com.bank.monitoring;

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;

public class ThreadCountMonitor {

    public static void main(String[] args) {
        ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
        System.out.println("Live threads: " + threadBean.getThreadCount());
        System.out.println("Peak threads: " + threadBean.getPeakThreadCount());
        // Healthy: 50-200 threads. Alarm: 2000+ threads.
    }
}
TEXT
Live threads: 3847
Peak threads: 4201

Why is "add more servers" the wrong first move?

Horizontal scaling helps when each instance is healthy and you simply need more capacity for genuine load. A thread explosion is a defect: unbounded thread creation or a pool starved by blocking. A new instance carries the same code and reproduces the same explosion. You multiply threads, context switching, and memory pressure across more machines, spend more money, and often make things worse because the shared dependency that the threads are blocked on now receives even more concurrent load.

JAVA
package com.bank.antipattern;

// WRONG: scaling out a defect
// Instance 1: 4000 threads, all blocked on partner-bank API
// Instance 2: 4000 threads, all blocked on partner-bank API
// Instance 3: 4000 threads, all blocked on partner-bank API
// Result: 12,000 connections to a partner that can handle 100.
// Cost: 3x. Problem: 3x worse.

How do you use thread dumps to investigate?

Capture two or three thread dumps a few seconds apart. Look at the states and stacks. If most threads are WAITING or TIMED_WAITING on the same operation (a database connection borrow or an external HTTP call), they are blocked on a slow dependency. If threads are RUNNABLE deep in your code, they are doing real work.

JAVA
package com.bank.debug;

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.Arrays;

public class ThreadStateAnalyzer {

    public static void analyze() {
        ThreadMXBean bean = ManagementFactory.getThreadMXBean();
        ThreadInfo[] infos = bean.dumpAllThreads(false, false);

        Map<Thread.State, Long> stateCounts = Arrays.stream(infos)
            .collect(Collectors.groupingBy(ThreadInfo::getThreadState,
                                           Collectors.counting()));

        stateCounts.forEach((state, count) ->
            System.out.printf("%s: %d threads%n", state, count));
    }

    public static void main(String[] args) {
        analyze();
    }
}
TEXT
WAITING: 3102 threads
TIMED_WAITING: 580 threads
RUNNABLE: 42 threads
BLOCKED: 123 threads

Here 3,102 threads are WAITING, which means they are blocked on a dependency, not doing useful work.

How do thread names reveal the root cause?

A healthy bounded pool has names like http-nio-exec-1 through a fixed maximum. Ever-incrementing names like pool-137-thread-1 climbing without bound reveal that Executors.newCachedThreadPool() or a new executor per request is creating unbounded threads.

JAVA
package com.bank.antipattern;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class UnboundedPoolAntipattern {

    // WRONG: newCachedThreadPool creates a new thread for every task
    // when none is free, with no upper bound
    private final ExecutorService executor = Executors.newCachedThreadPool();

    public void processPayment(String txnId) {
        executor.submit(() -> {
            // slow partner-bank call, 5 seconds
            callPartnerBank(txnId);
        });
    }

    private void callPartnerBank(String txnId) {
        try { Thread.sleep(5000); } catch (InterruptedException e) { }
    }
}
JAVA
package com.bank.service;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class BoundedPoolCorrect {

    // CORRECT: bounded pool sized to the partner's safe concurrency
    private final ThreadPoolExecutor executor = new ThreadPoolExecutor(
        10,                                    // core pool size
        50,                                    // max pool size
        60, TimeUnit.SECONDS,                  // idle thread keep-alive
        new ArrayBlockingQueue<>(100),         // bounded queue
        new ThreadPoolExecutor.AbortPolicy()   // fail-fast rejection
    );

    public void processPayment(String txnId) {
        try {
            executor.submit(() -> callPartnerBank(txnId));
        } catch (RejectedExecutionException e) {
            // Backpressure: reject and let the caller retry or fail fast
            System.err.println("Pool saturated, rejecting: " + txnId);
        }
    }

    private void callPartnerBank(String txnId) {
        try { Thread.sleep(5000); } catch (InterruptedException e) { }
    }
}

What are the two root causes you are deciding between?

The first root cause is the application spawning unnecessary threads. This happens when code creates a new Thread per request, uses Executors.newCachedThreadPool() which grows without limit, or creates a fresh executor per request and never shuts it down. The second root cause is a badly sized bounded pool whose threads are all blocked on a slow downstream call. Someone sets an enormous max pool size to "fix" saturation, and thousands of threads pile onto the slow dependency.

| Root Cause | Thread Names | Thread State | Fix |
|---|---|---|---|
| Unbounded creation | Ever-incrementing pool-N-thread-M | RUNNABLE or WAITING | Single shared bounded pool |
| Oversized blocked pool | Fixed name pattern but huge count | WAITING on same call | Reduce max, add timeout, fix dependency |

How does context switching cause the CPU spike?

Threads are not free. The OS must save and restore register state on every switch. With thousands of runnable or frequently-waking threads, the CPU spends a large fraction on switching instead of doing useful work. You can see this as high system CPU and high context-switch counts.

BASH
# Check context switches per second on Linux
vmstat 1 5
TEXT
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in     cs  us sy id wa
45  0      0 123456  78900 456789    0    0     0     0 8200 890000  22 68 10  0

890,000 context switches per second and 68% system CPU: the CPU is mostly switching, not computing.

What is the correct executor configuration?

A healthy design uses a bounded thread pool with a sensible maximum sized to the downstream capacity, a bounded queue so backpressure is visible, a clear rejection policy, and timeouts so blocked threads are freed.

JAVA
package com.bank.config;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class PaymentExecutorConfig {

    public static ThreadPoolExecutor createPartnerBankPool() {
        ThreadFactory factory = r -> {
            Thread t = new Thread(r);
            t.setName("partner-bank-" + new AtomicInteger().incrementAndGet());
            t.setDaemon(true);
            return t;
        };

        return new ThreadPoolExecutor(
            10,                                   // core: baseline concurrency
            50,                                   // max: partner's safe limit
            60, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(200),         // bounded queue
            factory,
            new ThreadPoolExecutor.AbortPolicy()   // fail fast on overload
        );
    }
}

What about virtual threads and reactive IO?

For heavily IO-bound workloads, Java 21 virtual threads change the economics of blocking. Virtual threads are cheap (a few KB each) and do not consume OS threads while parked, so thousands of virtual threads waiting on a partner bank do not cause context-switching storms. However, you still must bound the real resource: the dependency.

JAVA
package com.bank.service;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;

public class VirtualThreadPayment {

    // Virtual threads are cheap, but still bound the downstream
    private static final Semaphore PARTNER_LIMIT = new Semaphore(50);
    private static final ExecutorService EXECUTOR =
        Executors.newVirtualThreadPerTaskExecutor();

    public void processPayment(String txnId) {
        EXECUTOR.submit(() -> {
            try {
                PARTNER_LIMIT.acquire();
                callPartnerBank(txnId);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                PARTNER_LIMIT.release();
            }
        });
    }

    private void callPartnerBank(String txnId) {
        try { Thread.sleep(5000); } catch (InterruptedException e) { }
    }
}

What are common interview follow-up questions?

Interviewers often ask: How would you set the pool size for a CPU-bound vs IO-bound workload? What happens if you use CallerRunsPolicy instead of AbortPolicy? How do you monitor thread pool saturation in production? What is the difference between a CachedThreadPool and a FixedThreadPool? How would you diagnose this in a Kubernetes pod where you cannot easily attach a profiler?

Visual Overview

DIAGRAM
┌───────────────────────────────────────────────────────┐
│         THREAD EXPLOSION INVESTIGATION FLOW            │
├───────────────────────────────────────────────────────┤
│                                                       │
│  Alert: Thread count > 2000, CPU spike                │
│         │                                             │
│         ▼                                             │
│  ┌─────────────────────────────┐                      │
│  │ Capture 3 thread dumps      │                      │
│  │ (2 seconds apart)           │                      │
│  └──────────────┬──────────────┘                      │
│                 │                                     │
│         ┌───────▼───────┐                             │
│         │ Check states  │                             │
│         └───┬───────┬───┘                             │
│             │       │                                 │
│      WAITING on   RUNNABLE                            │
│      same call    in code                             │
│         │           │                                 │
│         ▼           ▼                                 │
│  ┌──────────┐ ┌──────────────┐                        │
│  │ Blocked  │ │ CPU-bound    │                        │
│  │ pool     │ │ hot loop     │                        │
│  └────┬─────┘ └──────┬───────┘                        │
│       │              │                                │
│  Check thread names  │                                │
│       │              │                                │
│  ┌────▼──────────┐   │                                │
│  │ Incrementing? │   │                                │
│  │ YES: unbounded│   │                                │
│  │ NO: oversized │   │                                │
│  └────┬──────────┘   │                                │
│       │              │                                │
│       ▼              ▼                                │
│  ┌─────────────────────────────────────┐              │
│  │ FIX: single bounded pool, bounded  │              │
│  │ queue, timeout, fail-fast rejection │              │
│  └─────────────────────────────────────┘              │
└───────────────────────────────────────────────────────┘

Key Takeaway

When thread count suddenly explodes into the thousands and CPU spikes, the instinct to add servers is wrong because a thread explosion is a defect, not legitimate load. Every new instance carries the same code, reproduces the same explosion, and multiplies threads, context switching, and cost across more machines. At National Bank of India, the payments service saw thread count climb to 4,000 during a festival spike, and the on-call lead wanted to triple instances. Pankaj captured thread dumps first and found thousands of threads with ever-incrementing pool names, nearly all WAITING on a slow partner-bank API call.

The investigation identified both root causes at once: the code used Executors.newCachedThreadPool() which created unbounded threads for each blocked request, and the partner bank was responding slowly, so thousands of threads piled onto it. The CPU was burning almost entirely on context switching, not useful work. The thread names climbing from pool-1-thread-1 to pool-1-thread-3847 made the unbounded creation obvious.

The fix was replacing the cached pool with one shared ThreadPoolExecutor bounded to 50 threads (the partner's safe concurrency), a bounded queue of 200, an AbortPolicy for fail-fast backpressure, and a 5-second timeout on the partner call so blocked threads were freed. After that, thread count stayed flat at 50, overload showed up as controlled rejections that Rajesh's retry logic handled, and the partner stopped being overwhelmed by thousands of concurrent connections.

Remember one sentence: Adding servers multiplies a thread defect, so take thread dumps to see what the threads are doing, inspect their names and states, and fix it with one bounded pool sized to the dependency, a bounded queue, timeouts, and fail-fast backpressure.

---

Share
Use ← → arrow keys to navigate