Multithreading in Java can feel abstract until you face real-world issues like race conditions and deadlocks. Many tutorials oversimplify threading by printing "Hello World" in new threads, but production code demands more robust patterns. Understanding how threads interact with memory and each other is critical to writing safe, high-performance applications.
How Java Threads Share Resources
A Java thread is a lightweight unit of execution within a process. When your application starts, the JVM creates the main thread automatically. Every additional thread spawns from this root thread, inheriting access to the same Java Heap memory. This shared workspace enables fast communication but introduces risks: if one thread reads a value while another modifies it mid-update, data corruption occurs—even if threads don’t run simultaneously.
Shared vs. Private Memory
Threads in the same process share several components:
- Heap memory — where all
new Object()instances reside - Static variables — single copies visible to every thread
- Class bytecode — compiled
.classfiles loaded by the JVM
Each thread, however, maintains its own isolated stack holding:
- Local variables and method parameters
- Return addresses for method calls
- The program counter, tracking the current execution line
This duality explains why multithreading bugs often surface as unpredictable behavior rather than outright crashes.
Launching Threads: Two Approaches
Java offers two primary ways to create threads, each with distinct trade-offs.
Extending the Thread Class
class WorkerThread extends Thread {
@Override
public void run() {
System.out.println("Running in thread: " + Thread.currentThread().getName());
}
}
new WorkerThread().start();This approach is straightforward but limits flexibility, as Java supports single inheritance. It’s rarely used in modern codebases.
Implementing the Runnable Interface
Runnable task = () -> System.out.println("Running in thread: " + Thread.currentThread().getName());
new Thread(task).start();Runnable is a functional interface with a single run() method. Implementing it avoids inheritance constraints and aligns with Java’s functional programming capabilities. Always prefer this method in production code.
The run() vs. start() Misconception
A subtle but critical error occurs when developers confuse run() with start().
// Incorrect: run() executes in the current thread
new Thread(task).run();
// Correct: start() spawns a new thread
new Thread(task).start();Calling run() directly invokes the method synchronously within the calling thread—no new thread is created. The output shows main as the executing thread, defeating the purpose of concurrency. In contrast, start() triggers JVM-level thread creation, assigning a unique stack and allowing parallel execution.
Synchronizing Access to Shared Data
Static variables often serve as ad-hoc return channels in multithreaded code, but this pattern is inherently risky. Consider this flawed example:
static int sharedResult = 0;
Runnable computation = () -> sharedResult = 5 + 5;
new Thread(computation).start();
System.out.println(sharedResult); // Likely prints 0The main thread frequently races ahead, printing sharedResult before the background thread completes its update. To enforce order, use Thread.join():
Thread worker = new Thread(computation);
worker.start();
worker.join(); // Pausing main until worker finishes
System.out.println(sharedResult); // Now reliably prints 10However, this solution fails under concurrent writes. If two threads attempt to modify sharedResult simultaneously, their updates may overwrite each other unpredictably, producing inconsistent results across runs.
Structured Concurrency with Callable and Future
Java introduced Callable to address Runnable’s limitations. Unlike Runnable, a Callable can return a value and throw checked exceptions.
Callable<Integer> task = () -> {
Thread.sleep(1000);
return 42;
};Since Thread cannot accept Callable directly, Java provides FutureTask as a bridge:
FutureTask<Integer> futureTask = new FutureTask<>(task);
new Thread(futureTask).start();
// Blocks until the result is ready
int result = futureTask.get();
System.out.println("Result: " + result);While functional, this pattern still requires manual thread management. For production systems, a more scalable approach is needed.
Scaling with ExecutorService
Managing thousands of threads manually risks OutOfMemoryError. Enter ExecutorService, a thread pool manager that handles task submission and lifecycle.
// Create a pool with 10 worker threads
ExecutorService executor = Executors.newFixedThreadPool(10);
// Submit a task and receive a Future
Future<Integer> future = executor.submit(task);
// Retrieve the result when needed (non-blocking until then)
int value = future.get();
// Clean up resources
executor.shutdown();The Future acts as a promise: you receive it immediately but delay retrieval until the value is available. This decouples task submission from result consumption, enabling efficient resource utilization without blocking the main thread.
Key Takeaways for Production Code
- Always use
Runnableover extendingThreadto preserve class flexibility. - Never call
run()when you intend to spawn a new thread—usestart()instead. - Avoid shared mutable state; prefer thread-safe constructs like
CallableandFuture. - Use
ExecutorServiceto manage thread pools and prevent resource exhaustion. - Replace ad-hoc synchronization with structured concurrency patterns to eliminate race conditions.
The journey from basic threading to robust concurrency requires shifting from "Hello World" examples to real-world patterns. Mastering these principles ensures your Java applications scale safely under load while avoiding the pitfalls of shared-memory corruption and thread starvation.
AI summary
Learn how Java threads share memory, the difference between run() and start(), and why race conditions occur. Discover production-ready concurrency patterns with Callable and ExecutorService.