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.
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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
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.
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.
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(); } }
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.
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()); } }
run() instead of start() is a silent no-op in terms of parallelism.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 , 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 start(), wait(), or join(). TIMED_WAITING is similar but with a timeout. Finally, TERMINATED after park() completes. One important detail: you cannot restart a thread once it reaches TERMINATED — that throws IllegalThreadStateException.run()
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 } }
- 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.
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.
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 } }
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.
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."); } }
Executors.newFixedThreadPool(nThreads) with a bounded queue and a RejectedExecutionHandler. For Spring Boot, define a TaskExecutor bean with proper pool configuration.Why Runnable Beats Thread Every Time (And Nobody Tells You)
You've probably seen code where someone extends Thread directly. That's cargo-cult inheritance. The real reason Runnable exists is simple: Java only gives you one extends slot. Burn it on Thread and you can never extend anything else. Ever.
Runnable is an interface. It decouples the work from the execution context. That means you can pass the same Runnable to a Thread, an ExecutorService, a ForkJoinPool, or even run it inline for testing. No refactoring needed.
Think about what happens when your app grows. You start with new Thread(runnable).start(). Then your boss wants a thread pool. If you extended Thread, you're rewriting everything. If you used Runnable, it's one constructor parameter swap.
There's a subtler win too: Runnable has a single method, run(). No cleanup hooks, no native thread state to manage. You're forced to keep your task self-contained. That discipline alone prevents half the race conditions I see in production post-mortems.
// io.thecodeforge — java tutorial // DON'T do this class DataPoller extends Thread { public void run() { System.out.println("Polling data..."); } } // DO this class PollingTask implements Runnable { public void run() { System.out.println("Polling data..."); } } public class RunnableVsThread { public static void main(String[] args) { // With Thread subclass - stuck in manual thread land Thread manual = new DataPoller(); manual.start(); // With Runnable - swap executor freely Runnable task = new PollingTask(); new Thread(task).start(); // quick test // Executors.newFixedThreadPool(4).submit(task); // production // ForkJoinPool.commonPool().execute(task); // parallel } }
The Interrupt Contract: Your Runnable Will Lie To You
Here's the ugly truth: Thread.interrupt() does NOT stop your Runnable. It sets a flag and hopes your code cooperates. Most Runnable implementations ignore it entirely. That's how you get zombie threads that never die.
Your Runnable has exactly one responsibility when interrupted: check the flag and bail out. But checking is not automatic. You have to poll Thread.currentThread().isInterrupted() in your loops. Or catch InterruptedException and reset the flag.
Notice the clever footgun: catching InterruptedException clears the interrupted flag. If you swallow that exception without restoring the flag, your Runnable acts immortal. Thread pools rely on this flag to know your task gave up. Break that contract and your executor leaks threads until the JVM chokes.
Here's the pattern I enforce on every code review: catch InterruptedException, restore the flag with Thread.currentThread().interrupt(), then return. No logging, no recovery attempts, just clean shutdown. Your Runnable needs to be a good citizen, not a hero.
// io.thecodeforge — java tutorial import java.util.concurrent.TimeUnit; public class InterruptContract { public static void main(String[] args) throws InterruptedException { Runnable poller = () -> { while (!Thread.currentThread().isInterrupted()) { try { System.out.println("Polling..."); TimeUnit.SECONDS.sleep(1); // blocks, catches interrupt } catch (InterruptedException e) { // ALWAYS restore the flag Thread.currentThread().interrupt(); System.out.println("Task interrupted, exiting cleanly"); } } }; Thread worker = new Thread(poller); worker.start(); Thread.sleep(2500); // let it run a couple cycles worker.interrupt(); // request shutdown worker.join(); System.out.println("Main done"); } }
Defining and Starting a Thread: That's Not a Constructor Call, That's a Promise
Developers new to Java think new Thread(runnable).start() is a method call. It's not — it's a contract with the OS scheduler. You're not telling Java "run this now." You're saying "queue this work — I have no idea when it'll execute." That's the whole point of preemptive threading. You lose control the moment you call start().
Thread t = new Thread(() -> heavyWork()); t.start(); — two lines that hand off your execution timeline to the JVM and underlying OS. The constructor defines the task. Start() creates a new call stack and begins the thread lifecycle. Never call run() directly. That's a method invocation on the current thread — no new execution path. I've seen production systems deadlock because someone read the docs wrong.
Blocking I/O, file writes, database calls — those belong in the Runnable. The thread is just the vehicle. Define the work first. Then hand it off. That's the entire pattern.
// io.thecodeforge — java tutorial public class DefineStartExample { public static void main(String[] args) { Runnable task = () -> { String name = Thread.currentThread().getName(); System.out.println(name + " processing payment"); }; Thread worker = new Thread(task, "payment-worker"); System.out.println("Submitting work..."); worker.start(); System.out.println("Main continues immediately"); } }
thread.run() instead of start() executes the Runnable synchronously on the calling thread. No parallelism. Your 'background task' blocks the main thread. Track this down in a 3AM production issue — you'll never make that mistake again.Thread.start() hands execution to the OS scheduler. Thread.run() is synchronous deception. Never confuse the two.Defining and Starting a Thread: That's Not a Constructor Call, That's a Promise
Developers new to Java think new Thread(runnable).start() is a method call. It's not — it's a contract with the OS scheduler. You're not telling Java "run this now." You're saying "queue this work — I have no idea when it'll execute." That's the whole point of preemptive threading. You lose control the moment you call start().
Thread t = new Thread(() -> heavyWork()); t.start(); — two lines that hand off your execution timeline to the JVM and underlying OS. The constructor defines the task. Start() creates a new call stack and begins the thread lifecycle. Never call run() directly. That's a method invocation on the current thread — no new execution path. I've seen production systems deadlock because someone read the docs wrong.
Blocking I/O, file writes, database calls — those belong in the Runnable. The thread is just the vehicle. Define the work first. Then hand it off. That's the entire pattern.
// io.thecodeforge — java tutorial public class DefineStartExample { public static void main(String[] args) { Runnable task = () -> { String name = Thread.currentThread().getName(); System.out.println(name + " processing payment"); }; Thread worker = new Thread(task, "payment-worker"); System.out.println("Submitting work..."); worker.start(); System.out.println("Main continues immediately"); } }
thread.run() instead of start() executes the Runnable synchronously on the calling thread. No parallelism. Your 'background task' blocks the main thread. Track this down in a 3AM production issue — you'll never make that mistake again.Thread.start() hands execution to the OS scheduler. Thread.run() is synchronous deception. Never confuse the two.Thread Starvation Brings Down Payment Service
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.- 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.
run() methods for shared state access.jstack -l <pid> | tee threaddump.txtgrep -E 'BLOCKED|DEADLOCK|WAITING' threaddump.txtjcmd <pid> Thread.print | grep 'tid=' | wc -lps -T -p <pid> | wc -ltop -H -b -n1 -p <pid> | grep javajcmd <pid> Thread.print | grep -A5 'nid=0x' | grep -v 'state._at'run() blocks or inefficient synchronized sections causing spin-wait.| Aspect | Extending Thread | Implementing Runnable |
|---|---|---|
| Inheritance | Uses up the single class inheritance (Rigid) | Allows class to extend another class (Flexible) |
| Design | Couples task and execution (Anti-pattern) | Separates task from execution (Clean Architecture) |
| Flexibility | Low (Hard to share tasks across threads) | High (Easy to pass to Thread Pools/Executors) |
| Use Case | Legacy / Simple one-off scripts | Modern Production / Scalable applications |
| Learning curve | Moderate | Moderate |
| File | Command / Code | Purpose |
|---|---|---|
| io | /** | What Is Java Threads and Runnable Explained and Why Does It |
| io | public class ThreadPitfalls { | Common Mistakes and How to Avoid Them |
| io | public class ThreadLifecycleDemo { | Thread Lifecycle and State Transitions |
| io | public class DaemonExample { | Daemon vs User Threads |
| io | public class ExecutorServiceExample { | Best Practices |
| RunnableVsThread.java | class DataPoller extends Thread { | Why Runnable Beats Thread Every Time (And Nobody Tells You) |
| InterruptContract.java | public class InterruptContract { | The Interrupt Contract |
| DefineStartExample.java | public class DefineStartExample { | Defining and Starting a Thread |
Key takeaways
Interview Questions on This Topic
Explain the difference between start() and run() in the Thread class. Which one creates a new call stack?
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.Why is implementing the Runnable interface preferred over extending the Thread class in a Spring Boot environment?
What happens when a thread reaches the TERMINATED state? Can you call start() on it again?
start() again throws IllegalThreadStateException. A thread is a one-shot object. To run the same task again, create a new Thread instance with the same Runnable, or better, use an ExecutorService which reuses threads from a pool.How do you handle checked exceptions like IOException inside a Runnable's run() method since the method signature doesn't allow 'throws'?
run() method cannot throw checked exceptions. You must catch them inside the run() method and handle them appropriately. Common strategies: wrap in an unchecked exception like RuntimeException, log the error and allow the thread to terminate, or store the exception for later retrieval (e.g., via a Future). For ExecutorService, use Callable instead of Runnable, which allows throwing checked exceptions.What is a Daemon thread in Java, and how does it differ from a User thread in terms of JVM shutdown behavior?
How would you wait for a thread to complete its execution before proceeding in the main thread?
join() method on the thread object: thread.join(). This blocks the calling thread until the target thread finishes execution. You can also provide a timeout: join(1000). Alternatively, use CountDownLatch or Future.get() when using ExecutorService.Frequently Asked Questions
There is no fixed limit in Java, but it is constrained by the underlying Operating System and the available RAM. Each thread has its own stack (usually 1MB). Creating thousands of manual threads will eventually lead to an OutOfMemoryError: 'unable to create new native thread'.
No, the run() method has a void return type. If you need a thread to return a result or throw a checked exception, you should use the 'Callable' interface combined with a 'Future' or 'CompletableFuture'.
Yes, since Java 8, Runnable is annotated with @FunctionalInterface. This means you can implement it using a lambda expression like: () -> { / logic / }.
You can set the thread name via the constructor: new Thread(runnable, "my-thread-name") or via setter: thread.setName("worker-1"). Name threads meaningfully to make thread dumps readable.
The default stack size varies by JVM and platform, typically 1MB for Java 8+ on 64-bit systems. You can change it with the JVM flag -Xss (e.g., -Xss256k). Reducing stack size allows more threads but increases risk of StackOverflowError for deep recursion.
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
That's Concurrency. Mark it forged?
4 min read · try the examples if you haven't