Junior 3 min · March 09, 2026

Java Threads — Why Direct Thread Creation Fails at Scale

Each Java thread allocates ~1MB; at 2000 req/s the JVM exhausts native memory before GC.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Threads are lightweight units of execution within a JVM process
  • Runnable is a functional interface that decouples task logic from thread management
  • Prefer Runnable over extending Thread for flexibility and reusability
  • Creating threads manually per request causes OOM under load — always use a pool
  • Biggest mistake: calling run() instead of start() — that's just a method call on the current thread
Plain-English First

Think of Java Threads and Runnable Explained as a powerful tool in your developer toolkit. Once you understand what it does and when to reach for it, everything clicks into place. Imagine a restaurant kitchen: a Thread is like a physical chef, and a Runnable is the recipe card. You can have a chef who only knows one recipe (extending Thread), but it is much more flexible to have a professional chef who can pick up any recipe card you hand them (implementing Runnable). This allows your 'chefs' to stay busy with different tasks without being restricted to just one job.

Java Threads and Runnable Explained is a fundamental concept in Java development. It is the bedrock of concurrency, allowing your applications to perform multiple tasks simultaneously, such as processing a file in the background while keeping the user interface responsive. In the modern landscape of high-throughput microservices at io.thecodeforge, understanding how to manage these units of execution is the difference between a scalable system and a bottlenecked one.

In this guide, we'll break down exactly what Java Threads and Runnable Explained is, why it was designed to separate the task logic from the execution mechanism, and how to use it correctly in real projects.

By the end, you'll have both the conceptual understanding and practical code examples to use Java Threads and Runnable Explained with confidence.

What Is Java Threads and Runnable Explained and Why Does It Exist?

Java Threads and Runnable Explained is a core feature of Concurrency. It was designed to solve the problem of sequential execution bottlenecks. In a single-threaded environment, a long-running task blocks the entire application. By using the Thread class or the Runnable functional interface, developers can delegate work to independent execution paths. The Runnable interface is generally preferred because it supports the 'Composition over Inheritance' principle, allowing your class to extend another base class (like a Spring service) while still being executable by a thread. At io.thecodeforge, we treat Runnable as the blueprint and Thread as the engine.

io/thecodeforge/concurrency/ForgeTask.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package io.thecodeforge.concurrency;

/**
 * io.thecodeforge: Using the Runnable interface is the production standard.
 * It separates the task logic from the thread management.
 */
public class ForgeTask implements Runnable {
    @Override
    public void run() {
        System.out.println("Task execution started in: " + Thread.currentThread().getName());
        try {
            // Simulate production workload - e.g., processing an order
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            // Restore interrupted status as per best practices
            Thread.currentThread().interrupt();
            System.err.println("ForgeTask was interrupted during execution");
        }
        System.out.println("ForgeTask completed successfully on thread: " + Thread.currentThread().getName());
    }

    public static void main(String[] args) {
        ForgeTask task = new ForgeTask();
        
        // Pass the Runnable 'recipe' to the Thread 'chef'
        Thread worker = new Thread(task, "Forge-Worker-01");
        
        // Moving from NEW to RUNNABLE state
        worker.start();
    }
}
Output
Task execution started in: Forge-Worker-01
ForgeTask completed successfully on thread: Forge-Worker-01
Key Insight:
The most important thing to understand about Java Threads and Runnable Explained is the problem it was designed to solve. Always ask 'why does this exist?' before asking 'how do I use it?' It exists to maximize CPU utilization by enabling asynchronous processing.
Production Insight
Creating a Thread per request is the single most common production failure.
Thread stack memory is 1MB per thread — 1000 threads = 1GB just for stacks.
Rule: always use a shared thread pool.
Key Takeaway
Runnable separates task from executor.
Thread is the heavyweight OS abstraction.
Use Runnable always; extend Thread never.

Common Mistakes and How to Avoid Them

When learning Java Threads and Runnable Explained, most developers hit the same set of gotchas. A classic mistake is calling run() instead of start(). Calling run() simply executes the code in the current thread like a normal method call, whereas start() triggers the JVM to create a new call stack. Another common pitfall is 'Thread Leaks,' where threads are created but never terminated or managed by a pool, eventually exhausting system memory. In production, we almost never create threads manually; we use managed pools to recycle these expensive resources.

io/thecodeforge/concurrency/ThreadPitfalls.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package io.thecodeforge.concurrency;

public class ThreadPitfalls {
    public static void main(String[] args) {
        Runnable task = () -> System.out.println("Active Thread: " + Thread.currentThread().getName());
        Thread t = new Thread(task, "Async-Thread");

        // WRONG: This executes in 'main' thread! It is just a method call.
        // t.run(); 

        // CORRECT: io.thecodeforge standard - starts a new call stack in 'Async-Thread'
        t.start();
        
        System.out.println("Main thread finished: " + Thread.currentThread().getName());
    }
}
Output
Main thread finished: main
Active Thread: Async-Thread
Watch Out:
The most common mistake with Java Threads and Runnable Explained is using it when a simpler alternative would work better. For high-scale production, avoid manual thread management and use the ExecutorService or Spring's @Async instead.
Production Insight
Calling run() instead of start() is a silent no-op in terms of parallelism.
Your code runs but at half the expected throughput.
Rule: always grep for .run() in code reviews — it's almost always a bug.
Key Takeaway
start() creates a new call stack.
run() is just a normal method call.
If you see run() outside of a test, flag it immediately.

Thread Lifecycle and State Transitions

A Java thread goes through six states: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED. Understanding these states is critical for debugging hangs and performance issues. A thread enters NEW when created but not started. After start(), it moves to RUNNABLE (actually ready to run; the OS scheduler decides when it actually runs). When a thread tries to acquire a lock held by another thread, it enters BLOCKED. WAITING occurs when a thread calls wait(), join(), or park(). TIMED_WAITING is similar but with a timeout. Finally, TERMINATED after run() completes. One important detail: you cannot restart a thread once it reaches TERMINATED — that throws IllegalThreadStateException.

io/thecodeforge/concurrency/ThreadLifecycleDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package io.thecodeforge.concurrency;

public class ThreadLifecycleDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + " state: " + Thread.currentThread().getState());
        }, "Demo-Thread");
        System.out.println("Before start: " + t.getState()); // NEW
        t.start();
        System.out.println("After start: " + t.getState()); // RUNNABLE (most likely)
        t.join();
        System.out.println("After completion: " + t.getState()); // TERMINATED
    }
}
Output
Before start: NEW
After start: RUNNABLE
Demo-Thread state: RUNNABLE
After completion: TERMINATED
State Machine Mental Model
  • NEW: Thread object created but not started. No OS thread yet.
  • RUNNABLE: Ready to run or running — depends on OS scheduler.
  • BLOCKED: Waiting for a monitor lock to enter a synchronized block.
  • WAITING: Indefinitely waiting for another thread to notify/park_unpark.
  • TIMED_WAITING: Waiting with a timeout (Thread.sleep, wait(timeout)).
  • TERMINATED: run() completed or exception thrown. Cannot be restarted.
Production Insight
Threads in BLOCKED state for more than a few ms usually indicate lock contention.
Thread dumps with many BLOCKED threads point to single-threaded bottlenecks in synchronized blocks.
Rule: if your thread dump shows >10% threads in BLOCKED, add more granular locks or use concurrent data structures.
Key Takeaway
TERMINATED threads can't restart.
BLOCKED threads waste CPU checking locks.
Monitor thread states in production to catch contention early.

Daemon vs User Threads: When the JVM Shuts Down

Java threads are either user threads or daemon threads. A user thread prevents the JVM from exiting until it completes. A daemon thread does not — the JVM can terminate as soon as all user threads finish. Daemon threads are ideal for background services like statistics collection or garbage collection. By default, a new thread inherits the daemon status of the creating thread; but you can set it explicitly via setDaemon(true) before calling start(). Important: trying to set daemon after start() throws IllegalThreadStateException. Also, daemon threads do not execute finally blocks on JVM exit — they are abruptly terminated.

io/thecodeforge/concurrency/DaemonExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package io.thecodeforge.concurrency;

public class DaemonExample {
    public static void main(String[] args) {
        Thread daemon = new Thread(() -> {
            while (true) {
                // Simulate background stats collection
                try { Thread.sleep(1000); } catch (InterruptedException e) { break; }
                System.out.println("Daemon collecting stats...");
            }
        });
        daemon.setDaemon(true);
        daemon.start();
        
        Thread userThread = new Thread(() -> {
            try { Thread.sleep(2000); } catch (InterruptedException e) { }
            System.out.println("User thread finishing.");
        });
        userThread.start();
        
        // JVM exits after userThread ends, killing daemon
    }
}
Output
Daemon collecting stats...
Daemon collecting stats...
User thread finishing.
(Program exits, daemon prints no more)
Production Trap:
Never use daemon threads for critical I/O (database writes, file sync). On JVM shutdown, daemon threads are killed before finally blocks execute, leading to data loss. Always use user threads for operations that must complete.
Production Insight
Daemon threads are great for metrics collection but dangerous for cleanup.
If a daemon thread holds a file handle, the JVM exit may leave the file in an inconsistent state.
Rule: reserve daemon for optional backround tasks only.
Key Takeaway
Daemon = background service that can be killed.
User = must finish for JVM to exit.
Choose based on whether the task is essential for correctness.

Best Practices: From Manual Threads to ExecutorService

In modern production code, you rarely interact with Thread directly. The java.util.concurrent.ExecutorService provides a higher-level replacement: thread pool management, task submission, and future results. The typical pattern is to create a fixed thread pool sized according to the workload type (CPU-bound: nThreads = number of cores; I/O-bound: nThreads much higher). Always shut down the executor when the application stops to avoid resource leaks. Spring Boot applications can use @Async on methods with a custom AsyncConfigurer. At io.thecodeforge, we never use new Thread(...) in production code — only in tests or quick scripts.

io/thecodeforge/concurrency/ExecutorServiceExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package io.thecodeforge.concurrency;

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

public class ExecutorServiceExample {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(5);
        
        for (int i = 0; i < 10; i++) {
            final int taskId = i;
            executor.submit(() -> {
                System.out.println("Task " + taskId + " on thread: " + Thread.currentThread().getName());
                try { Thread.sleep(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            });
        }
        
        executor.shutdown();
        executor.awaitTermination(10, TimeUnit.SECONDS);
        System.out.println("All tasks completed.");
    }
}
Output
Task 0 on thread: pool-1-thread-1
Task 1 on thread: pool-1-thread-2
...
All tasks completed.
Production Pattern:
Use Executors.newFixedThreadPool(nThreads) with a bounded queue and a RejectedExecutionHandler. For Spring Boot, define a TaskExecutor bean with proper pool configuration.
Production Insight
A mismatched pool size can cause throughput collapse: too few threads -> queue builds up, too many -> context switching overhead.
For I/O-bound tasks, threads can be 2-4x the core count; for CPU-bound, stick close to core count.
Rule: always size pools based on measured blocking factor, not guesswork.
Key Takeaway
Manual threads are wrong for production.
ExecutorService provides reusable, configurable threads.
Size pools based on workload characteristics, not conventions.
● Production incidentPOST-MORTEMseverity: high

Thread Starvation Brings Down Payment Service

Symptom
Payment service gradually slowed to a crawl, then threw OutOfMemoryError: unable to create new native thread. Response times spiked from 50ms to 30 seconds before complete failure.
Assumption
The team assumed creating a new thread per request was acceptable because 'Java can handle thousands of threads' from local testing.
Root cause
Each new thread allocates ~1MB of stack memory. At 2000 requests/sec, the JVM exhausts native memory before the garbage collector ever runs. Additionally, thread creation overhead adds latency that compounds under load.
Fix
Replace manual new Thread(runnable).start() with an ExecutorService using a bounded pool (e.g., 50 threads) and a CallerRunsPolicy rejection handler. This prevents thread explosion and provides backpressure to the caller.
Key lesson
  • Never create threads directly in production code — always use a managed executor.
  • Size your thread pool around CPU cores and I/O latency, not request volume.
  • Always configure rejection policies to handle overload gracefully instead of crashing.
  • Monitor thread count and queue depth as part of your production observability.
Production debug guideCommon symptoms and the exact actions to diagnose thread-related failures4 entries
Symptom · 01
Application hangs with high CPU but no progress
Fix
Take a thread dump (jstack <pid> or kill -3 <pid>). Look for threads in RUNNABLE stuck in tight loops or threads in BLOCKED/WAITING that never leave. Identify deadlock cycles.
Symptom · 02
OutOfMemoryError: unable to create new native thread
Fix
Immediately check thread count via jcmd <pid> Thread.print or top -H -p <pid>. Then inspect code for ad-hoc thread creation. Replace with bounded executor and review thread stack size (use -Xss to reduce if needed).
Symptom · 03
Intermittent data corruption or wrong result order
Fix
Check for missing synchronization on shared mutable state. Look for fields accessed from multiple threads without volatile, synchronized, or atomic variables. Audit all run() methods for shared state access.
Symptom · 04
Method calls seem to execute in wrong order across threads
Fix
Verify use of happens-before relationships: volatile, synchronized, or concurrent collections. Ensure memory visibility by using proper synchronization, not just timing assumptions.
★ Quick Thread Debugging Cheat SheetCommands and actions to diagnose thread-related problems fast
Application hangs or deadlock suspected
Immediate action
Capture thread dump
Commands
jstack -l <pid> | tee threaddump.txt
grep -E 'BLOCKED|DEADLOCK|WAITING' threaddump.txt
Fix now
Identify blocked threads and resolve missing synchronization or lock ordering issues.
OutOfMemoryError: native thread limit+
Immediate action
Count live threads
Commands
jcmd <pid> Thread.print | grep 'tid=' | wc -l
ps -T -p <pid> | wc -l
Fix now
Reduce pool sizes, use bounded executors, and consider -Xss to reduce per-thread stack (e.g., -Xss256k).
CPU 100% but threads appear idle in dump+
Immediate action
Sample threads with top -H
Commands
top -H -b -n1 -p <pid> | grep java
jcmd <pid> Thread.print | grep -A5 'nid=0x' | grep -v 'state._at'
Fix now
Check for infinite loops inside run() blocks or inefficient synchronized sections causing spin-wait.
Thread vs Runnable Comparison
AspectExtending ThreadImplementing Runnable
InheritanceUses up the single class inheritance (Rigid)Allows class to extend another class (Flexible)
DesignCouples task and execution (Anti-pattern)Separates task from execution (Clean Architecture)
FlexibilityLow (Hard to share tasks across threads)High (Easy to pass to Thread Pools/Executors)
Use CaseLegacy / Simple one-off scriptsModern Production / Scalable applications
Learning curveModerateModerate

Key takeaways

1
Java Threads and Runnable Explained is a core concept in Concurrency that every Java developer should understand to build responsive software.
2
Always use 'start()' to begin a new execution path, never call 'run()' directly unless you want to execute sequentially.
3
Favor the Runnable interface to keep your code loosely coupled and compatible with Java's Executor framework.
4
A Thread is the worker; a Runnable is the task. Keeping them separate is fundamental to 'Clean Code' in Java.
5
Learn the thread lifecycle (NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED) to diagnose stuck threads.
6
Daemon threads are for background tasks that can be killed
never use for critical operations.
7
Always use ExecutorService in production; never create threads manually.

Common mistakes to avoid

5 patterns
×

Calling run() instead of start()

Symptom
Task executes on the calling thread instead of a new thread. No parallelism gain. Often leads to performance issues and unexpected execution order.
Fix
Always call start() to create a new call stack. Use code reviews and static analysis rules to flag direct run() calls outside tests.
×

Overusing manual thread creation when a simpler approach like an ExecutorService would work

Symptom
JVM crashes with OutOfMemoryError: unable to create new native thread under load. Each manual thread consumes ~1MB of stack memory; unbounded creation exhausts OS resources.
Fix
Replace new Thread().start() with Executors.newFixedThreadPool(n) and submit tasks. Set bounds on pool size and queue capacity.
×

Not understanding the thread lifecycle (states) leads to deadlocks or zombie threads

Symptom
Application appears hung or makes no progress. Thread dumps show threads in BLOCKED or WAITING indefinitely without making progress.
Fix
Learn the six thread states and how to interpret thread dumps. Use tools like jconsole, VisualVM, or jstack to diagnose. Ensure proper synchronization and timeout usage.
×

Ignoring error handling inside run()

Symptom
Exceptions thrown inside run() go uncaught and kill the thread silently. No error logged, no recovery. The application continues with fewer threads than expected.
Fix
Wrap run() logic in try-catch block that logs the exception. Set a default UncaughtExceptionHandler for the thread pool or thread group to handle unexpected errors.
×

Calling start() twice on the same Thread object

Symptom
IllegalThreadStateException at runtime. The second start() call fails because the thread is already in RUNNABLE or TERMINATED state.
Fix
Never reuse a Thread object. Create a new Thread instance for each execution. Use ExecutorService which handles thread reuse internally.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Explain the difference between start() and run() in the Thread class. Wh...
Q02SENIOR
Why is implementing the Runnable interface preferred over extending the ...
Q03JUNIOR
What happens when a thread reaches the TERMINATED state? Can you call st...
Q04SENIOR
How do you handle checked exceptions like IOException inside a Runnable'...
Q05SENIOR
What is a Daemon thread in Java, and how does it differ from a User thre...
Q06JUNIOR
How would you wait for a thread to complete its execution before proceed...
Q01 of 06JUNIOR

Explain the difference between start() and run() in the Thread class. Which one creates a new call stack?

ANSWER
start() creates a new call stack and executes the run() method in a separate thread. run() simply invokes the run() method in the current thread — it's just a regular method call. Only start() triggers thread creation. Calling run() directly means no new thread is spawned.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
How many threads can a Java application create?
02
Can a Runnable return a value?
03
Is Runnable a functional interface?
04
How do I set the name of a thread for debugging purposes?
05
What is the default stack size for a thread and how can I change it?
🔥

That's Concurrency. Mark it forged?

3 min read · try the examples if you haven't

Previous
Gradle Build Script Basics
1 / 6 · Concurrency
Next
Java Executor Service and Thread Pools