By Pankaj Kumar and Manikandan Kurup
Multithreading is an important concept in Java that allows a program to do many things at the same time. Instead of running tasks one after another, a multithreaded program can run multiple tasks in parallel, making better use of system resources and improving performance.
In this comprehensive guide to multithreading in Java, we’ll cover everything from basic thread creation to advanced concurrency control. You’ll learn how to work with the Thread
class, Runnable
and Callable
interfaces, and the modern ExecutorService
framework. We’ll explore the thread lifecycle, synchronization using synchronized, wait()
/notify()
, and thread-safe patterns for shared resources. This article also compares multithreading vs. parallelism, and outlines best practices and common mistakes to avoid. Whether you’re new to Java concurrency or looking to deepen your expertise, this guide provides the knowledge and examples you need to write robust multithreaded applications.
Thread
class, Runnable
interface, and the java.util.concurrent
package, enabling concurrent task execution within the same application.Thread
class, implementing Runnable
, or using lambda expressions and are started using the start()
method.sleep()
, join()
, yield()
, and setDaemon()
help control execution timing and coordination between threads.synchronized
, wait()
, notify()
, or concurrent utilities.ExecutorService
framework is preferred over raw thread creation for efficient, scalable, and reusable task execution via thread pools.In simple terms, multithreading means running multiple “threads” of execution within a single program. A thread is like a small, independent path of execution. Think of a thread as one person doing a task: if you have multiple threads, it’s like having several people working together on different parts of a job.
Java supports multithreading through built-in features. Each thread runs separately but shares the same memory space, which allows them to work together efficiently, but also means they need to coordinate carefully to avoid conflicts.
Multithreading offers several key advantages:
Multithreading is widely used in modern Java applications across many domains:
While multithreading and parallel computing are often used interchangeably in casual conversation, they refer to distinct concepts with different goals, characteristics, and use cases. Understanding the difference is crucial when designing performance-critical applications in Java, especially when working with concurrent or compute-intensive workloads.
Parallel computing is the process of dividing a large problem into smaller sub-problems that can be executed simultaneously across multiple processors or cores. The goal is to reduce execution time by making efficient use of hardware resources.
In Java, parallel computing is commonly used for:
Java provides several tools for parallel computing, including:
java.util.concurrent.ForkJoinPool
)Stream.parallel()
)The following table compares multithreading with parallel computing across various technical dimensions:
Feature | Multithreading | Parallel Computing |
---|---|---|
Primary Goal | Improve responsiveness and task coordination | Increase speed through simultaneous computation |
Typical Use Case | I/O-bound or asynchronous tasks | CPU-bound or data-intensive workloads |
Execution Model | Multiple threads, possibly interleaved on one core | Tasks distributed across multiple cores or processors |
Concurrency vs. Parallelism | Primarily concurrency (tasks overlap in time) | True parallelism (tasks run at the same time) |
Thread Communication | Often requires synchronization | Often independent tasks (less inter-thread communication) |
Memory Access | Threads share memory | May share or partition memory |
Java Tools & APIs | Thread , ExecutorService , CompletableFuture |
ForkJoinPool , parallelStream() , and ExecutorService configured for CPU-bound tasks |
Performance Bottlenecks | Thread contention, deadlocks, synchronization latency | Poor task decomposition, load imbalance |
Scalability | Limited by synchronization and resource management | Limited by number of available CPU cores |
Determinism | Often non-deterministic due to timing and order | Can be deterministic with proper design |
Use parallel computing techniques when your application performs heavy, repetitive, or large-scale computations that can be broken into independent subtasks. This includes:
To work with multithreading in Java, it’s important to understand what a thread is, how it works behind the scenes, and how it differs from a process. This section will explain the basics of Java threads and how they differ from processes.
A thread in Java is a lightweight unit of execution. It represents a single path of code that runs independently but shares memory and system resources with other threads in the same program. Threads are used to perform tasks in parallel, allowing your program to do multiple things at once.
Java makes it easy to work with threads using the built-in Thread
class and other concurrency tools in the java.util.concurrent
package. By default, every Java application starts with one main thread, which begins executing the main()
method. From there, you can create additional threads to run tasks concurrently.
For example, you can create a thread to download a file while the main thread continues updating the user interface.
While threads and processes both allow tasks to run independently, it’s important to understand the difference between a thread and a process:
Feature | Thread | Process |
---|---|---|
Definition | A smaller unit of a process | An independent program running in memory |
Memory Sharing | Shares memory with other threads | Has its own separate memory space |
Communication | Easier and faster (uses shared memory) | Slower (requires inter-process communication) |
Overhead | Low | High |
Example | Multiple tasks in a Java program | Running two different programs (e.g., a browser and a text editor) |
In Java, when you run a program, the Java Virtual Machine (JVM) starts a process. Inside that process, the JVM can run multiple threads to perform different tasks.
A Java thread goes through several stages in its lifetime. These stages are managed by the JVM and the operating system.
Here are the main stages in the thread lifecycle:
New: The thread is created but hasn’t started yet. It’s like assigning a worker to a task, but they haven’t begun the work.
Thread thread = new Thread();
Runnable: The thread is ready to run and waiting for CPU time. It’s like the worker is standing by, ready to work when given the chance.
thread.start(); // Moves to Runnable
Running: The thread is actively executing code. Only one thread per CPU core can be in this state at a time. From the JVM’s perspective, a thread that is actively running is still in the RUNNABLE
state. We separate them here conceptually to distinguish between a thread that is ready to run and one that is currently executing on a CPU.
Blocked / Waiting / Timed Waiting: The thread is temporarily inactive:
Blocked: Waiting for a resource (like a lock held by another thread)
Waiting: Waiting indefinitely for another thread’s action (e.g., using wait()
)
Timed Waiting: Waiting for a specified time (e.g., sleep(1000)
)
These states help threads pause without consuming CPU resources.
Terminated (Dead): The thread has finished executing or has been stopped. It cannot be restarted.
Here’s a simple diagram to visualize the lifecycle:
Understanding the lifecycle helps you write better multithreaded programs and avoid common issues like deadlocks and resource contention.
In Java, there are several ways to create and run threads. Each approach has its own use cases, and choosing the right one depends on how your application is structured. Here, we’ll cover the three main ways to create threads:
Thread
classRunnable
interfaceThread
ClassOne of the simplest ways to create a thread in Java is by extending the built-in Thread
class and overriding its run()
method. This method contains the code that should run in the new thread.
Here’s an example:
public class MyThread extends Thread {
public void run() {
System.out.println("Thread is running...");
}
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start(); // Start the thread
}
}
The run()
method defines the task the thread will perform. The start()
method tells the JVM to start a new thread and execute run()
in parallel with the main thread.
Use this approach when you don’t need to extend another class (since Java supports single inheritance).
Runnable
InterfaceA more flexible way to create threads is to implement the Runnable
interface. This separates the task from the thread itself, which is useful when you want your class to extend another class.
Let’s see an example:
public class MyRunnable implements Runnable {
public void run() {
System.out.println("Runnable thread is running...");
}
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.start();
}
}
This method is more flexible than extending Thread
. It encourages better separation of concerns and allows you to reuse the same task with different threads
If you’re using Java 8 or later, you can simplify thread creation using lambda expressions. This is ideal for short, one-off tasks where you don’t want to write a full class.
Here’s an example:
public class LambdaThread {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
System.out.println("Thread running with lambda!");
});
thread.start();
}
}
This method has the following advantages:
Method | Inheritance Used | Reusability | Concise | Best For |
---|---|---|---|---|
Extend Thread |
Yes | No | Moderate | Simple custom thread logic |
Implement Runnable |
No | Yes | Moderate | Reusable tasks, flexible design |
Lambda Expression (Java 8+) | No | Yes | High | Quick and short-lived tasks |
Once you’ve created threads in Java, you need ways to control how and when they execute. Java provides several built-in methods in the Thread
class to manage thread behavior effectively.
We’ll cover the following:
start()
You call the start()
method to execute a thread. This tells the JVM to create a new thread and begin running its run()
method in parallel with the current thread.
Thread thread = new Thread(() -> {
System.out.println("Thread is running.");
});
thread.start(); // Starts the thread
Calling start()
creates a new call stack and runs the thread’s task independently. It’s important not to call run()
directly; doing so would execute the method in the current thread, not on a new one.
sleep()
The Thread.sleep()
method pauses the current thread for a specific number of milliseconds. This is useful in many real-world scenarios. For instance, you might want to simulate a delay when polling a remote server, slow down animations or visual transitions in a game, or introduce a backoff strategy for retrying failed operations.
try {
System.out.println("Sleeping for 2 seconds...");
Thread.sleep(2000); // 2000 ms = 2 seconds
System.out.println("Awake!");
} catch (InterruptedException e) {
System.out.println("Thread interrupted during sleep.");
}
Note: Always handle InterruptedException
, which may occur if another thread interrupts this one during sleep.
join()
The join()
method allows one thread to wait for another to complete before continuing. This is especially useful when coordinating tasks between threads. For example, if you start a background task that performs a calculation, you might use join()
to ensure the main thread waits for the result before moving forward. It also comes in handy when you need to ensure that worker threads finish before your application shuts down.
Thread worker = new Thread(() -> {
System.out.println("Working...");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
// Restore the interrupted status
Thread.currentThread().interrupt();
e.printStackTrace();
}
System.out.println("Work complete.");
});
worker.start();
try {
worker.join(); // Main thread waits for worker to finish
System.out.println("Main thread resumes.");
} catch (InterruptedException e) {
e.printStackTrace();
}
yield()
The Thread.yield()
method suggests to the thread scheduler that the current thread is willing to pause and let other threads execute. However, this is just a hint. There’s no guarantee that it will actually yield control. In practice, this method is rarely used, but it may be helpful in fine-tuning low-level thread behavior, such as in CPU-intensive computations that need cooperative multitasking.
Thread.yield();
Note: This method depends on the JVM and OS scheduler. It’s rarely used in modern Java code.
Java allows you to assign priorities to threads using the setPriority()
method, with values ranging from Thread.MIN_PRIORITY
(1) to Thread.MAX_PRIORITY
(10). The default is Thread.NORM_PRIORITY
(5). Higher-priority threads are more likely to be scheduled first, but again, this depends on the JVM and underlying OS.
Thread priorities can be useful when you want certain tasks to run ahead of others. For example, in a game engine, you might give rendering threads higher priority than background data loading.
Thread thread = new Thread(() -> {});
thread.setPriority(Thread.MAX_PRIORITY); // Sets priority to 10
However, relying too heavily on priorities can lead to unpredictable behavior and should be done with care.
Daemon threads are background threads that don’t prevent the Java Virtual Machine from exiting once all user (non-daemon) threads have completed. These are commonly used for background services that should not block application termination.
For example, a thread that performs periodic logging, monitoring, or cleanup operations is a good candidate for a daemon. If the rest of the application finishes, you usually don’t want these background tasks to keep the program running.
Thread daemon = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) { // Check for interrupt
System.out.println("Background task...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// Exit the loop if interrupted
Thread.currentThread().interrupt(); // Restore status
break;
}
}
System.out.println("Daemon thread stopping.");
});
Java’s Thread.stop()
method is deprecated because it can cause threads to terminate in the middle of critical operations, leaving shared data in an inconsistent state. Instead, the recommended way to stop a thread is to use interruption or a volatile flag.
For instance, in a long-running task, you can check whether the thread has been interrupted and exit gracefully if it has. This is especially important when your thread is working with shared resources or performing critical operations like file writes or database updates.
Thread thread = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
// Perform task
}
System.out.println("Thread interrupted and stopping.");
});
thread.start();
thread.interrupt(); // Gracefully request stop
When multiple threads access shared data or resources, problems can arise if their actions are not coordinated. Without proper control, threads might interfere with each other, leading to incorrect results, crashes, or unpredictable behavior.
In multithreaded programs, threads often share variables or objects in memory. For example, two threads may try to update the same bank account balance at the same time. If both threads read the value before either writes it, the final result could be incorrect. This is known as a race condition.
Without proper control, even basic operations like incrementing a counter (count++
) can lead to inconsistent data when accessed by multiple threads concurrently.
A race condition occurs when two or more threads access shared data at the same time, and the outcome depends on the order of execution. These bugs can be hard to detect and reproduce because they depend on timing.
public class CounterExample {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) count++;
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) count++;
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final count: " + count); // Output may vary!
}
}
Expected output is 20000
, but due to race conditions, you may see a smaller number.
synchronized
KeywordTo prevent race conditions, Java provides the synchronized
keyword. It allows only one thread at a time to execute a block of code or method that accesses shared resources.
Synchronized Method:
public synchronized void increment() {
count++;
}
Synchronized Block:
public void increment() {
synchronized (this) {
count++;
}
}
In both cases, Java places a lock (or monitor) on the object, allowing only one thread to enter the synchronized code at a time.
If the shared data is static (class-level), you need to synchronize on the class itself:
public static synchronized void staticIncrement() {
// synchronized at the class level
}
Or using a block:
public void staticIncrement() {
synchronized (CounterExample.class) {
count++;
}
}
It’s best to synchronize only the smallest possible portion of code that needs protection, called the critical section, to reduce contention and improve performance.
public void updateData() {
// non-critical code
synchronized (this) {
// update shared data
}
// more non-critical code
}
Another way to ensure thread safety is by designing immutable objects, objects whose state cannot change after creation. If threads only read from immutable objects, no synchronization is needed.
For mutable shared state, you can also use thread-safe classes like:
AtomicInteger
, AtomicBoolean
(from java.util.concurrent.atomic
)ConcurrentHashMap
Collections.synchronizedList()
A deadlock occurs when two or more threads are waiting for each other to release locks, and none can proceed. This usually happens when multiple locks are acquired in an inconsistent order.
synchronized (resourceA) {
synchronized (resourceB) {
// do something
}
}
// In another thread:
synchronized (resourceB) {
synchronized (resourceA) {
// do something else
}
}
To prevent deadlock:
tryLock()
with a timeout) via ReentrantLock
.As your applications grow in complexity, simply creating and synchronizing threads isn’t enough. You’ll need advanced tools and patterns for inter-thread communication, memory visibility, and coordinated execution. Java provides several powerful constructs for this, including wait()
, notify()
, volatile
, and more.
wait()
and notify()
Sometimes threads need to coordinate their actions; one thread waits for a signal from another. For example, in a producer-consumer setup, the producer adds items to a queue and the consumer waits until items are available.
Java provides three methods to support this:
wait()
: Causes the current thread to release the lock and wait until another thread calls notify()
or notifyAll()
.notify()
: Wakes up a single waiting thread.notifyAll()
: Wakes up all waiting threads.Example:
class SharedData {
private boolean available = false;
public synchronized void produce() throws InterruptedException {
while (available) {
wait(); // Wait until item is consumed
}
System.out.println("Producing item...");
available = true;
notify(); // Notify waiting consumer
}
public synchronized void consume() throws InterruptedException {
while (!available) {
wait(); // Wait until item is produced
}
System.out.println("Consuming item...");
available = false;
notify(); // Notify waiting producer
}
}
Always call wait()
and notify()
inside a synchronized block or method. Also use a loop to recheck the condition after waking up—this guards against spurious wakeups.
volatile
KeywordBy default, changes made by one thread may not be immediately visible to others due to CPU caching or compiler optimizations. The volatile
keyword tells the JVM to always read the latest value of a variable from main memory.
Example:
class FlagExample {
private volatile boolean running = true;
public void stop() {
running = false;
}
public void run() {
while (running) {
// do work
}
}
}
Use volatile
for flags and simple state indicators. It doesn’t provide atomicity for compound actions (like incrementing), but it does ensure visibility.
ReentrantLock
for Fine-Grained LockingJava’s ReentrantLock
class (from java.util.concurrent.locks
) offers more control than the synchronized
keyword. It supports features like:
Example:
import java.util.concurrent.locks.ReentrantLock;
ReentrantLock lock = new ReentrantLock();
try {
lock.lock();
// critical section
} finally {
lock.unlock(); // Always unlock in a finally block
}
Use ReentrantLock
when you need advanced locking behavior or fine-grained control over access timing and fairness.
Deadlocks occur when multiple threads hold resources and wait for each other to release them. Preventing deadlocks requires a consistent and cautious design strategy.
Tips to avoid deadlocks:
tryLock()
with timeout: Avoid waiting forever by using timed lock attempts.Some object-level patterns help avoid thread-safety issues without the need for explicit locking:
String
, LocalDate
). No need to synchronize.ThreadLocal<T>
): Provides a separate variable copy for each thread.BlockingQueue
.As your applications grow more complex, creating and managing individual threads manually becomes inefficient and error-prone. Java provides a high-level concurrency framework called the Executor Framework that simplifies thread management and improves performance using thread pools.
Creating a new thread for every task in a Java application can quickly become inefficient and problematic, especially in high-concurrency environments. It adds overhead due to the cost of thread creation, increased memory usage, and the complexity of managing the thread lifecycle. Thread pools solve these issues by reusing a fixed number of threads to execute multiple tasks. This approach improves performance, prevents the system from being overwhelmed by too many simultaneous threads, and centralizes thread management, making applications more scalable and easier to maintain.
ExecutorService
to Run TasksJava provides the ExecutorService
interface for managing thread pools. The most common way to get one is through the Executors
utility class.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadPoolExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(3); // 3 threads
Runnable task = () -> {
System.out.println("Running task in thread: " + Thread.currentThread().getName());
};
for (int i = 0; i < 5; i++) {
executor.submit(task); // Submits task to thread pool
}
executor.shutdown(); // Initiates graceful shutdown
}
}
Callable
and Future
for Return ValuesUnlike Runnable
, which cannot return a result, Callable
allows you to return a value and even throw checked exceptions.
import java.util.concurrent.*;
public class CallableExample {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
Callable<String> task = () -> {
Thread.sleep(1000);
return "Task result";
};
Future<String> future = executor.submit(task);
System.out.println("Waiting for result...");
String result = future.get(); // Blocks until result is available
System.out.println("Result: " + result);
executor.shutdown();
}
}
Executors
Java provides several predefined thread pool types for different use cases:
Factory Method | Description |
---|---|
Executors.newFixedThreadPool(n) |
Pool with a fixed number of threads. Good for predictable workloads. |
Executors.newCachedThreadPool() |
Creates threads as needed and reuses idle threads. Best for short-lived asynchronous tasks. |
Executors.newSingleThreadExecutor() |
A single-threaded executor. Tasks are executed sequentially. |
Executors.newScheduledThreadPool(n) |
Allows scheduling tasks to run after a delay or at fixed intervals. |
Always shut down executors after use to free resources. You can do this using:
executor.shutdown(); // Initiates a graceful shutdown
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
executor.shutdownNow(); // Forces shutdown if tasks don't finish in time
}
Never forget to shut down the executor. Otherwise your application may hang due to non-daemon threads.
Java provides two primary approaches to running concurrent tasks: manually managing threads with the Thread
class, or using the more modern and scalable Executor framework. While both can execute code concurrently, they differ significantly in terms of abstraction, control, and suitability for real-world applications.
Thread
Class)Using the Thread
class gives you low-level control over concurrency. When you create a new thread with new Thread()
and call .start()
, you are explicitly requesting the JVM to create a brand-new operating system-level thread.
When to use:
Limitations:
OutOfMemoryError
) or system instability.The Executor framework, introduced in Java 5, provides a high-level abstraction for managing threads. It decouples task submission from thread execution using a thread pool. Instead of creating a new thread for each task, the executor reuses existing threads, improving efficiency and scalability.
When to Use:
newFixedThreadPool()
or newCachedThreadPool()
) to better manage concurrency under varying workloads.Callable
tasks and Future
objects, which allow you to retrieve results or handle exceptions from asynchronous tasks.shutdown()
and awaitTermination()
to gracefully terminate your thread pool.The table below highlights the key differences between using raw threads and the ExecutorService, helping you decide which approach best fits your use case:
Use Case | Raw Thread (new Thread() ) |
ExecutorService (Executors ) |
---|---|---|
Learning and experimentation | Yes | Yes |
One-off, lightweight background task | Sometimes | Recommended |
Real-world, production application | Not recommended | Preferred |
Efficient thread reuse | Manual | Automatic |
Handling return values or exceptions | Requires custom logic | Built-in via Future /Callable |
Graceful shutdown of background work | Hard to coordinate | Easy with shutdown() |
Managing many tasks concurrently | Inefficient and risky | Scalable and safe |
Despite Java’s rich concurrency API, multithreaded programming remains complex and even small errors can lead to serious problems like race conditions, deadlocks, memory leaks, or unpredictable behavior. Below are some of the most common mistakes developers make when working with threads in Java, along with tips on how to avoid them.
run()
Instead of start()
A frequent beginner mistake is calling .run()
directly on a thread instance, which runs the task in the current thread instead of starting a new one.
Thread t = new Thread(() -> doWork());
t.run(); // runs on the main thread
t.start(); // runs in a new thread
InterruptedException
ProperlyThreads that perform blocking operations (e.g., sleep()
, wait()
, or join()
) can be interrupted, but many developers ignore or swallow the InterruptedException
without restoring the thread’s interrupt status.
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// Don't just ignore it
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // Restore interrupt status
}
Spawning a new thread for every task may seem simple, but it quickly becomes unsustainable in real-world applications. This can cause:
OutOfMemoryError
)Use ExecutorService
with a bounded thread pool to control concurrency.
Accessing or modifying shared mutable variables across threads without proper synchronization leads to race conditions.
// Not thread-safe
counter++;
Use synchronized blocks, atomic variables (AtomicInteger
), or thread-safe collections to protect shared data.
Acquiring multiple locks in inconsistent order can cause deadlocks, where threads wait on each other forever.
Always acquire locks in a consistent global order, or use ReentrantLock.tryLock()
with timeouts.
Running long or blocking operations (e.g., network calls, disk I/O) on the main thread can freeze the application, especially in GUI or server contexts.
Offload long-running tasks to worker threads or use CompletableFuture
.
Failing to shut down an ExecutorService
can leave background threads alive and prevent the JVM from exiting.
ExecutorService executor = Executors.newFixedThreadPool(4);
// Forgetting this leads to memory/resource leaks
executor.shutdown();
volatile
or SynchronizationJava’s memory model doesn’t guarantee that a change in one thread is visible to another unless the variable is declared volatile
or accessed within a synchronized block.
// May loop forever if flag update isn’t visible
while (!flag) {}
Use volatile
or synchronized blocks to ensure visibility.
Excessive synchronization, especially around large blocks of code or frequently accessed methods, can lead to poor performance due to thread contention.
Keep synchronized sections as small and brief as possible; only lock the lines of code that are absolutely necessary.
wait()
/notify()
With Incorrect LocksCalling wait()
or notify()
without holding the object’s intrinsic lock (via synchronized
) throws an IllegalMonitorStateException
.
Invalid usage:
obj.wait();
Correct usage:
synchronized (obj) {
obj.wait();
}
Writing multithreaded applications in Java requires more than just creating and starting threads. To build safe, scalable, and maintainable systems, developers need to follow sound engineering practices that address thread lifecycle management, synchronization, resource usage, and error handling.
Here are the most important and widely recommended best practices for multithreading in Java:
Prefer ExecutorService Over Raw Threads: Instead of manually creating threads with new Thread()
, use the ExecutorService
or ForkJoinPool
for better resource management, thread reuse, and task scheduling. Executors decouple task submission from execution logic, improve performance, and simplify shutdown and error handling.
Limit the Number of Active Threads: Avoid creating more threads than the system can handle. Use fixed-size thread pools or bounded queues to keep concurrency within safe limits. Excessive threads increase CPU context switching, memory usage, and can crash the JVM.
Keep Synchronized Blocks Short and Specific: Minimize the scope of synchronized
blocks to only the critical section of code that accesses shared resources. Broad synchronization increases the risk of thread contention and reduces parallelism.
Use Thread-Safe and Atomic Classes: Use thread-safe collections (ConcurrentHashMap
, CopyOnWriteArrayList
) and atomic classes (AtomicInteger
, AtomicBoolean
) instead of manual synchronization when appropriate. These classes are optimized for concurrency and reduce boilerplate synchronization code.
Avoid Deadlocks Through Lock Ordering or Timeouts: Always acquire multiple locks in a consistent global order, or use timed locking mechanisms like tryLock()
from ReentrantLock
. Deadlocks can cause threads to hang indefinitely and are notoriously hard to debug.
Properly Handle InterruptedException
: Always handle interruptions in thread code and propagate the interrupt status using Thread.currentThread().interrupt()
. Ignoring interrupts can prevent threads from shutting down cleanly and make your application unresponsive.
Gracefully Shut Down Executors: Always shut down your thread pools using shutdown()
or shutdownNow()
followed by awaitTermination()
to allow ongoing tasks to complete.
executor.shutdown();
executor.awaitTermination(30, TimeUnit.SECONDS);
Unfinished or orphaned threads can prevent the JVM from exiting and lead to memory leaks.
Name Your Threads for Easier Debugging: Use custom thread factories or Thread.setName()
to give meaningful names to threads, especially in thread pools.
Executors.newFixedThreadPool(4, runnable -> {
Thread t = new Thread(runnable);
t.setName("Worker-" + t.getId());
return t;
});
Named threads help when analyzing logs, stack traces, or thread dumps in debugging tools like JVisualVM.
Minimize Shared Mutable State: Where possible, design threads to operate on independent data. Use immutable objects, thread-local variables, or data partitioning strategies to reduce shared state. Less shared state means fewer synchronization requirements and fewer chances of race conditions.
Use Modern Concurrency Utilities: Leverage the rich utilities in java.util.concurrent
, such as:
CountDownLatch
, CyclicBarrier
, and Semaphore
for thread coordinationBlockingQueue
for producer-consumer patternsCompletableFuture
for asynchronous pipelinesThese abstractions reduce boilerplate and prevent many common concurrency bugs.
Multithreading in Java is the process of running multiple threads (small, independent paths of execution) within a single program. This allows the application to perform several tasks concurrently, making better use of multi-core processors, improving performance, and keeping the application responsive. Threads share the same memory space, which allows them to work together efficiently.
There are three primary ways to create a thread in Java:
Thread
class: You create a new class that inherits from Thread
and override its run()
method.Runnable
interface: This is the preferred method. You create a class that implements Runnable
, and then pass an instance of it to a Thread
’s constructor. This is more flexible as it allows your class to extend other classes.Thread
constructor, making the code more concise.thread.start()
and thread.run()
?This is a critical distinction and a common mistake:
thread.start()
: This creates a new thread of execution and tells the Java Virtual Machine (JVM) to execute the run()
method’s code within that new thread. This is the correct way to achieve concurrency.thread.run()
: This simply executes the code inside the run()
method on the current thread, just like a regular method call. No new thread is created, and no concurrency is achieved.A race condition occurs when two or more threads access shared, mutable data at the same time, and the final outcome depends on the unpredictable order of their execution. This can lead to corrupted data. The primary way to prevent this is by using the synchronized
keyword on a method or a block of code. This ensures that only one thread can execute that “critical section” at a time, protecting the shared data.
ExecutorService
than creating threads manually?Manually creating a new thread for every task (new Thread()
) is inefficient, resource-intensive, and can lead to system instability if too many threads are created. The ExecutorService
framework solves this by managing a thread pool—a collection of reusable worker threads. This improves performance, gives you control over the number of concurrent threads, and simplifies managing the lifecycle of tasks, especially those that return results (using Callable
and Future
).
A deadlock is a situation where two or more threads are blocked forever, each waiting for a resource that the other thread holds. For example, Thread A acquires a lock on Resource 1 and waits for Resource 2, while Thread B holds Resource 2 and waits for Resource 1, resulting in a deadlock. The most common strategy to prevent deadlocks is to ensure that all threads acquire multiple locks in a consistent, predefined order.
thread.join()
method?The thread.join()
method forces the current thread to pause its execution and wait until the thread it is called on has finished its task and terminated. This is a key mechanism for coordinating threads, ensuring that a dependent task (e.g., in the main thread) only proceeds after its prerequisite background task is complete.
In essence, multithreading is a technique you use in your code to achieve parallelism on capable hardware.
A great real-time example is a stock trading application:
In this article, we explored the core concepts of multithreading in Java—from thread lifecycle and creation methods to synchronization, inter-thread communication, and thread coordination. We compared raw threads with the Executor framework, examined the differences between multithreading and parallel computing, and highlighted real-world use cases. Finally, we covered best practices and common pitfalls to help you write safer, more efficient concurrent code.
Mastering these concepts is essential for building responsive, scalable, and robust Java applications. With the right tools and practices, multithreading becomes a powerful capability in any Java developer’s toolkit.
To deepen your understanding of multithreading, explore these hands-on tutorials:
Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.
Java and Python Developer for 20+ years, Open Source Enthusiast, Founder of https://www.askpython.com/, https://www.linuxfordevices.com/, and JournalDev.com (acquired by DigitalOcean). Passionate about writing technical articles and sharing knowledge with others. Love Java, Python, Unix and related technologies. Follow my X @PankajWebDev
With over 6 years of experience in tech publishing, Mani has edited and published more than 75 books covering a wide range of data science topics. Known for his strong attention to detail and technical knowledge, Mani specializes in creating clear, concise, and easy-to-understand content tailored for developers.
Please can you tell me how to print the Future values as in Example 16(Callable task) in example 17(FutureTask )?
- Mohamed
it has given me a use full tips for me and it helped me to understand
- Gitanjali
HI, I have tried to run Java thread example, but its run method is not calling. I just created MyThread and HeavyRunnable class through eclipse editor and add extends and implements Runnable by hand. Any issue. Thanks.
- Sikander Rafiq
Hi Sir, There was a spelling mistake at ( of ) in the below line, please change it sir. We can create Threads by either implementing Runnable interface of by extending Thread Class.
- Suresh Yadam
I like your articles very much. I was going through Multithreading article and I think almost all things are covered except about ForkJoinPool class in java. It will be of very much help to me as well as others as all articles will get consolidated at one place only. It will be of much help if you can write about it. Thanks
- Ankit Neema
Hi i am writing about the Thread above Thread t = new Thread(() -> {System.out.println(“My Runnable”);}); t.run(); do you mean t.run(); or t.start(), ? thanks
- Raed
Hi Pankaj, I have a doubt. How can we manage performance while 500 threads accessing a thread safe object? And each thread takes 1 min to complete its task.
- Aliva
Get paid to write technical tutorials and select a tech-focused charity to receive a matching donation.
Full documentation for every DigitalOcean product.
The Wave has everything you need to know about building a business, from raising funding to marketing your product.
Stay up to date by signing up for DigitalOcean’s Infrastructure as a Newsletter.
New accounts only. By submitting your email you agree to our Privacy Policy
Scale up as you grow — whether you're running one virtual machine or ten thousand.
Sign up and get $200 in credit for your first 60 days with DigitalOcean.*
*This promotional offer applies to new accounts only.