What is Multithreading in Java?
Multithreading in Java means running two or more parts of a program, called threads, at the same time. Each thread can handle a different task, so your program can do multiple things simultaneously instead of one after another.
For example:
Imagine a chef in a kitchen.
- In a single-threaded kitchen, the chef cooks one dish, finishes it, and then starts the next.
- In a multithreaded kitchen, the chef works on several dishes at once—boiling water for pasta while chopping vegetables and baking a cake.
Why use multithreading?
- It makes programs faster by using the CPU more efficiently.
- It saves time by handling many tasks together.
- It keeps applications responsive, so users don’t have to wait for one task to finish before starting another.
In short, multithreading lets Java programs handle many tasks at the same time, leading to better performance and a smoother user experience.
Thread Creation Methods
Java gives you several flexible ways to create and manage threads. Understanding these approaches is fundamental to mastering multithreading in Java. Here are the main methods:
1. Extending the Thread Class
You can create a new thread by extending the built-in Thread class and overriding its run() method. This approach is straightforward and gives you direct access to thread methods, such as setPriority(), setDaemon(), and getName().
Example:
class MyThread extends Thread {
public void run() {
System.out.println("Thread running: " + Thread.currentThread().getName());
}
}
public class ThreadDemo {
public static void main(String[] args) {
MyThread t1 = new MyThread();
t1.start(); // Calls run() in a new thread
}
}
- start() method: Launches the thread and calls run() on a new call stack.
- main thread: The main program thread from which new threads are spawned.
When to use:
If your class does not need to extend any other class, extending Thread is simple and effective.
2. Implementing the Runnable Interface
A more flexible and widely recommended way is to implement the Runnable interface. You define the run() method and pass an instance of your class to a Thread constructor.
Example:
class MyRunnable implements Runnable {
public void run() {
System.out.println("Runnable running: " + Thread.currentThread().getName());
}
}
public class RunnableDemo {
public static void main(String[] args) {
Thread t = new Thread(new MyRunnable());
t.start();
}
}
- Runnable interface: Allows your class to extend other classes, since Java doesn’t support multiple inheritance.
- Thread constructor: Accepts a Runnable object.
- join(): Used to make one thread wait for another to finish.
When to use:
Preferred when your class already extends another class or when you want to separate the task from the thread logic.
3. Using Lambda Expressions (Java 8+)
For short, one-off tasks, you can use lambda expressions to create threads concisely, without defining a separate class.
Example:
public class LambdaThreadDemo {
public static void main(String[] args) {
Thread t = new Thread(() -> {
System.out.println("Thread with lambda: " + Thread.currentThread().getName());
});
t.start();
}
}
- lambda expressions: Provide a clean, functional way to implement Runnable for simple tasks.
4. Using the java.util.concurrent Package
For advanced thread management, Java offers the java.util.concurrent package, which includes thread pools, executors, and more. This is ideal for managing many threads efficiently.
Example:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ExecutorDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.execute(() -> System.out.println("Task in thread pool"));
executor.shutdown();
}
}
- Thread group: You can organize threads into groups for collective management.
- setDaemon(): Marks a thread as a daemon (background) thread.
- setPriority(): Sets the thread’s priority for scheduling.
Summary Table
| Method |
Key Features |
When to Use |
| Extending Thread class |
Provides direct access to thread lifecycle methods |
Use for simple scenarios where you don’t need to extend another class |
| Implementing Runnable |
More flexible and supports multiple inheritance (since Java allows extending only one class) |
Preferred in most real-world applications |
| Lambda Expressions |
Concise and readable syntax for defining tasks |
Ideal for short, simple background tasks |
| java.util.concurrent package |
Offers thread pools, executors, and advanced concurrency control |
Best for handling multiple tasks efficiently and building scalable applications |
Thread Lifecycle and States
Understanding the lifecycle of a thread in Java is essential for writing reliable and efficient multithreaded programs. Each thread goes through several well-defined states from creation to termination. Let’s break down these states and the key methods that influence them.
Main Thread States
- Newborn (New)
- The thread is created but not yet started.
- Example: Thread t = new Thread();
- At this point, the thread isn’t eligible to run.
- Runnable
- The thread is ready to run but waiting for CPU time.
- It enters this state after calling start().
- The thread scheduler decides when it actually runs.
- Running
- The thread is actively executing its run() method.
- Only one thread per CPU core can be truly running at any moment.
- Blocked
- The thread is waiting to acquire a monitor lock (for example, waiting to enter a synchronized block that’s currently held by another thread).
- It cannot proceed until the lock is available.
- Waiting
- The thread is waiting indefinitely for another thread’s action (like calling wait() without a timeout).
- It will stay in this state until notified by another thread.
- Timed Waiting
- The thread is waiting for a specific period (for example, sleep(1000) or wait(500)).
- After the time elapses, it becomes runnable again.
- Terminated (Dead)
- The thread has finished executing (either normally or due to an uncaught exception).
- Once terminated, a thread cannot be restarted.
Key Methods Affecting Thread States
- start()
- Moves a thread from Newborn to Runnable.
Example:
t.start();
- sleep()
- Puts a running thread into Timed Waiting for a specified duration.
Example:
Thread.sleep(1000);
- wait()
- Moves a thread into the Waiting state until it is notified.
- Must be called inside a synchronized block.
- resume() (Deprecated)
- Used to wake up a suspended thread. Modern code should use notify()/notifyAll() instead.
- currentThread()
- Returns a reference to the currently executing thread.
Example:
Thread t = Thread.currentThread();
- isAlive()
- Checks if a thread has been started and has not yet terminated.
Example:
t.isAlive(); // true if thread is running or waiting
State Transitions Overview
- Newborn → Runnable: Call start()
- Runnable → Running: Selected by the thread scheduler
- Running → Blocked/Waiting/Timed Waiting: Calls wait(), sleep(), or tries to acquire a locked resource
- Blocked/Waiting/Timed Waiting → Runnable: Acquires resource, is notified, or timeout expires
- Any State → Terminated: Thread completes its run() method or is interrupted by an uncaught exception
Example: Observing Thread States
public class ThreadStateDemo {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
try {
Thread.sleep(500); // Timed Waiting
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
System.out.println(t.isAlive()); // false (Newborn)
t.start(); // Runnable
System.out.println(t.isAlive()); // true (Runnable/Running)
t.join(); // Waits for thread to die
System.out.println(t.isAlive()); // false (Terminated)
}
}
Quick Recap
| State |
Description |
Trigger / Methods |
| Newborn |
Thread is created but not yet started |
new Thread() |
| Runnable |
Thread is ready to run and waiting for CPU allocation |
start(), notify(), notifyAll() |
| Running |
Thread is actively executing instructions |
Selected by thread scheduler |
| Blocked |
Thread is waiting to acquire a lock to enter a synchronized block |
Entering synchronized block |
| Waiting |
Thread is waiting indefinitely for another thread’s action |
wait() |
| Timed Waiting |
Thread is waiting for a specified duration before resuming |
sleep(), wait(timeout) |
| Terminated |
Thread execution has completed or stopped due to an exception |
run() completes, exception occurs |
Bottom Line:
Knowing the thread lifecycle helps you write safer, more predictable multithreaded code. Always use the right methods to control thread state transitions, and avoid deprecated methods like resume() in new projects.
Thread Management and Control
Once you’ve learned how to create threads in Java, the next step is understanding how to manage and control them effectively. Proper thread management ensures your programs run smoothly, efficiently, and safely even as they grow more complex.
1. Starting and Pausing Threads
- start(): Launches a thread, moving it from the "new" state to "runnable".
- sleep(): Temporarily pauses the current thread for a specified number of milliseconds. Useful for simulating delays or waiting for resources.
try {
Thread.sleep(1000); // Pauses for 1 second
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
2. Joining Threads
- join(): Makes one thread wait for another to finish before continuing. This is useful for sequencing tasks or ensuring certain threads complete before the program ends.
Thread t = new Thread(() -> {
/* task */
});
t.start();
t.join(); // Main thread waits for t to finish
3. Yielding Execution
- yield(): Suggests to the thread scheduler that the current thread is willing to pause and allow other threads of the same priority to execute. This is a hint, not a guarantee, and is rarely needed in most applications. java Thread.yield();
4. Setting Thread Priorities
- setPriority(): Assigns a priority (from Thread.MIN_PRIORITY to Thread.MAX_PRIORITY) to a thread. Higher-priority threads are generally scheduled before lower-priority ones, but actual behavior depends on the JVM and OS.
Thread t = new Thread();
t.setPriority(Thread.MAX_PRIORITY);
- thread priorities: Help manage which threads are more important, but overusing priorities can lead to thread starvation, where low-priority threads never get CPU time.
5. Daemon Threads
- Daemon threads are background threads that do not prevent the JVM from exiting when all user threads have completed. They’re ideal for background services like monitoring or garbage collection.
Thread t = new Thread(() -> {
/* background task */
});
t.setDaemon(true); // Must be set before starting the thread
t.start();
- setDaemon(): Marks a thread as a daemon.
6. Thread Groups
- ThreadGroup: Allows you to group multiple threads together for management as a unit. You can interrupt, check status, or set priorities for all threads in a group at once.
ThreadGroup group = new ThreadGroup("MyGroup");
Thread t = new Thread(group, () -> {
/* task */
});
7. Handling Deadlock and Starvation
- Deadlock: Occurs when two or more threads block each other forever, each waiting for the other to release a resource. Prevent this by acquiring locks in a consistent order and minimizing nested locks.
- Thread starvation: Happens when lower-priority threads never get a chance to run. Avoid by using priorities sparingly and designing fair scheduling.
8. The volatile Keyword
- volatile: Use for simple flags that may be updated by multiple threads, ensuring that changes are visible to all threads. For more complex synchronization, use locks or synchronized blocks.
Summary Table
| Feature / Method |
Purpose |
| start() |
Launches a new thread and invokes the run() method |
| sleep() |
Temporarily pauses thread execution for a specified duration |
| join() |
Causes one thread to wait until another thread completes execution |
| yield() |
Hints the thread scheduler to pause the current thread and allow others to execute |
| setPriority() |
Sets the priority level of a thread for scheduling |
| setDaemon() |
Marks a thread as a background (daemon) thread that runs in the background |
| ThreadGroup |
Organizes multiple threads into a single group for easier management |
| volatile |
Ensures visibility of variable updates across multiple threads |
| Deadlock / Starvation |
Concurrency issues that must be avoided through proper synchronization and design |
Synchronization and Concurrency Control
When multiple threads access shared resources in Java, careful coordination is needed to prevent data corruption and unpredictable behavior. This is the core of synchronization and concurrency control, ensuring that only one thread can access critical sections of code at a time, so your program remains correct and reliable.
Why Synchronization Is Needed
- Race Condition: Occurs when two or more threads access and modify shared data simultaneously, leading to unpredictable or incorrect results.
- Critical Section: A part of the code where shared resources are accessed or modified. Only one thread should execute a critical section at any moment.
- Thread Safety: Ensures that shared data remains consistent and correct, no matter how many threads access it.
The Java Memory Model
The Java Memory Model defines how threads interact through memory and what behaviors are allowed in concurrent programs. It ensures that changes made by one thread to shared variables are visible to others only under certain conditions usually when synchronization or the volatile keyword is used.
The synchronized Keyword
Java provides the synchronized keyword to restrict access to critical sections:
- Synchronized Method: Locks the entire method for a single thread at a time. java public synchronized void increment() { count++; }
- Synchronized Block: Locks a specific block of code, allowing more fine-grained control. java public void increment() { synchronized(this) { count++; } }
- Monitor: Every Java object has an intrinsic lock (monitor). Threads must acquire this lock before entering a synchronized method or block.
Static Synchronization
If you need to synchronize access to static (class-level) data, use static synchronized methods or blocks:
public static synchronized void staticIncrement() {
// critical section for class-level data
}
Or:
public void staticIncrement()
{
synchronized(ClassName.class)
{ // critical section
}
}
Using volatile and Atomic Variables
- volatile keyword: Ensures changes to a variable are immediately visible to all threads. Use it for simple flags or state variables, but not for compound actions. java private volatile boolean running = true;
- Atomic Variables: Classes like AtomicInteger (from java.util.concurrent.atomic) provide thread-safe operations for simple data types without explicit synchronization. java AtomicInteger counter = new AtomicInteger(0); counter.incrementAndGet();
Thread-Safe Collections and java.util.concurrent
- Thread-safe collections: Use classes like ConcurrentHashMap, CopyOnWriteArrayList, or Collections.synchronizedList() to safely share collections between threads.
- java.util.concurrent: This package offers advanced concurrency utilities, such as locks, semaphores, concurrent collections, and thread pools, making synchronization easier and more scalable.
Thread Contention and Performance
- Thread contention: Occurs when multiple threads compete for the same lock, causing delays.
- Keep synchronized sections as short as possible to reduce contention and improve performance.
wait() and notify()
- wait() and notify(): Used for inter-thread communication, allowing threads to wait for certain conditions and notify others when the condition changes. These methods must be called within a synchronized context.
Example: Preventing a Race Condition
class Counter {
private int count = 0;
public synchronized void increment() {
count++; // Critical section
}
public int getCount() {
return count;
}
}
Types of Multithreading in Java
When discussing multithreading in Java, it’s important to understand the different ways threads can be organized and used within a program. Broadly, there are two main types:
1. Process-Based Multithreading
Multiple independent processes run at the same time, each with its own memory and resources.
Characteristics:
- Heavyweight: Each process has its own address space and memory.
- Higher overhead: Context switching between processes is expensive.
- Less common in Java applications, but used in operating systems or when running multiple Java programs.
2. Thread-Based Multithreading (Java Focus)
Multiple threads run within a single process, sharing the same memory and resources.
Characteristics:
- Lightweight: Threads share the same address space, so creating and switching between them is fast.
- Efficient use of resources: Threads can easily communicate and share data.
- This is the standard approach in Java for parallelism and concurrency.
Based on Execution
- User Threads:
These are threads created and managed by your program. Most application logic runs in user threads. - Daemon Threads:
These are background threads that provide services to user threads (like garbage collection). The JVM will exit when only daemon threads remain.
Bottom Line
Java’s multithreading model is designed for lightweight, efficient thread-level multitasking—not heavy, process-level multitasking. Most of the time, you’ll be working with multiple threads inside a single Java process.
How to Create Multiple Threads in Java
There are two primary ways:
1. By Extending Thread Class
class MyThread extends Thread {
public void run() {
System.out.println("Thread is running...");
}
}
public class Test {
public static void main(String[] args) {
MyThread t1 = new MyThread();
t1.start();
}
}
2. By Implementing Runnable Interface (Preferred)
class MyRunnable implements Runnable {
public void run() {
System.out.println("Thread is running...");
}
}
public class Test {
public static void main(String[] args) {
Thread t1 = new Thread(new MyRunnable());
t1.start();
}
}
Why Runnable is Better?
- Supports multiple inheritance
- Cleaner design
- More flexible
Note
Always prefer Runnable unless you need to extend Thread.
Basic Multithreading Program in Java
Here’s a basic multithreading program in Java:
class ThreadExample extends Thread {
public void run() {
for (int i = 1; i <= 5; i++) {
System.out.println("Thread: " + i);
}
}
public static void main(String[] args) {
ThreadExample t1 = new ThreadExample();
ThreadExample t2 = new ThreadExample();
t1.start();
t2.start();
}
}
What Happens Here?
- Two threads run simultaneously
- Output may interleave unpredictably
Bottom Line
Threads run independently, and execution order is not guaranteed.
Example of Multithreading in Java (Realistic)
class Task1 extends Thread {
public void run() {
System.out.println("Playing music...");
}
}
class Task2 extends Thread {
public void run() {
System.out.println("Downloading file...");
}
}
public class Demo {
public static void main(String[] args) {
Task1 t1 = new Task1();
Task2 t2 = new Task2();
t1.start();
t2.start();
}
}
Real-Life Meaning
- One thread handles music
- Another handles downloads
Bottom Line
This is a simple example of multithreading in Java used in everyday apps.
Example: Using Runnable
class Task implements Runnable {
public void run() {
System.out.println(Thread.currentThread().getName() + " is running");
}
}
public class Main {
public static void main(String[] args) {
Thread t1 = new Thread(new Task());
Thread t2 = new Thread(new Task());
t1.start();
t2.start();
}
}
Output Insight
Each thread gets a unique name and runs independently.
Advanced Multithreading Concepts in Java
As you progress beyond the basics of multithreading in Java, you’ll encounter more complex challenges and solutions. These advanced concepts help you write safer, faster, and more reliable concurrent programs. Let’s explore some of the key ideas every Java developer should know.
1. The volatile Keyword
In multithreaded programming, threads may cache variables locally, leading to situations where one thread’s changes aren’t visible to others. The volatile keyword in Java tells the JVM that a variable’s value will be modified by different threads. When a variable is declared as volatile, every read and write goes directly to main memory, ensuring visibility across threads.
Example:
private volatile boolean running = true;
Use volatile for simple flags or state variables, but not for compound actions like incrementing a counter (which are not atomic).
2. ReentrantLock
While the synchronized keyword is the traditional way to control access to shared resources, Java also provides the ReentrantLock class (from java.util.concurrent.locks). This lock offers more flexibility than synchronized, including:
- The ability to try acquiring the lock without blocking (tryLock())
- The ability to interrupt threads waiting for the lock
- Support for fair locking (first-come, first-served)
Example:
import java.util.concurrent.locks.ReentrantLock;
public class Main {
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// critical section
} finally {
lock.unlock();
}
}
}
3. Deadlocks and Deadlock Prevention
A deadlock occurs when two or more threads are each waiting for the other to release a resource, causing all to block forever.
Classic Deadlock Example:
- Thread A locks Resource 1, waits for Resource 2
- Thread B locks Resource 2, waits for Resource 1
Deadlock Prevention Techniques:
- Lock Ordering: Always acquire locks in a consistent global order across all threads.
- TryLock(): Use tryLock() with a timeout to avoid waiting forever.
- Limit Nested Locks: Avoid acquiring multiple locks at once, if possible.
Example using tryLock():
if (lock1.tryLock()) {
try {
if (lock2.tryLock()) {
try {
// critical section
} finally {
lock2.unlock();
}
}
} finally {
lock1.unlock();
}
}
4. Deadlock Detection Algorithms
In large systems, it may be necessary to detect deadlocks automatically. Deadlock detection algorithms analyze thread and lock states to identify cycles where threads are waiting on each other. While Java doesn’t provide built-in detection, tools like thread dumps and profilers (e.g., VisualVM) help diagnose deadlocks.
5. Thread Contention, Starvation, and Fairness
- Thread Contention: Occurs when multiple threads compete for the same resources (locks), leading to delays.
- Starvation: A thread never gets CPU time or access to resources because others always take precedence.
- Fairness: Ensuring all threads get a chance to execute. Some locks (like ReentrantLock(true)) can be set to fair mode, granting access in the order requested.
Example:
import java.util.concurrent.locks.ReentrantLock;
public class Main {
public static void main(String[] args) {
ReentrantLock fairLock = new ReentrantLock(true); // Fair lock
}
}
6. Synchronization Latency and Load Imbalance
- Synchronization Latency: The delay caused when threads wait to acquire locks. Excessive synchronization can slow down your application.
- Load Imbalance: When some threads do much more work than others, leading to inefficient CPU usage. Proper task division and thread pool sizing can help balance the load.
7. Nested Monitor Lockout
This occurs when a thread holds one lock and waits for another, but another thread holds the second lock and waits for the first. This is a form of deadlock. Avoid nested synchronized blocks or always acquire locks in the same order.
8. Thread-Safe Design Patterns
Certain design patterns help make your code thread-safe:
- Immutable Objects: Objects whose state cannot change after construction (e.g., String, custom classes with final fields).
- Thread-Local Storage: Each thread gets its own instance of a variable using ThreadLocal.
- Producer-Consumer Pattern: Use BlockingQueue to safely pass data between threads.
- Worker Thread Pattern: A pool of threads processes tasks from a queue.
Summary Table
| Concept |
Purpose / Benefit |
| volatile keyword |
Ensures visibility of variable changes across threads |
| ReentrantLock / tryLock() |
Provides flexible, interruptible, and fair locking mechanisms |
| Deadlock Prevention |
Avoids program freezes caused by circular waiting between threads |
| Deadlock Detection |
Helps identify deadlocks using tools, monitoring, or algorithms |
| Thread Contention |
Reduces competition among threads for shared resources |
| Starvation and Fairness |
Ensures all threads get a fair chance to execute |
| Synchronization Latency |
Minimizes delays caused by excessive locking and synchronization |
| Load Imbalance |
Distributes tasks evenly across threads to improve performance |
| Nested Monitor Lockout |
Prevents deadlocks caused by improper nested locking |
| Thread-safe Patterns |
Ensures safe data sharing and isolation between threads |
Understanding these advanced concepts will help you write robust, high-performance, and safe multithreaded applications in Java.
Exception Handling in Threads
When working with multithreading in Java, handling exceptions properly is crucial for building robust and stable applications. Unlike single-threaded programs, exceptions thrown in one thread do not automatically affect other threads. If not managed, some exceptions may go unnoticed, leading to unpredictable behavior or silent failures.
1. Using try-catch Blocks in Threads
The most straightforward way to handle exceptions within a thread is to use a try-catch block inside the thread’s run() method (or the code executed by the thread). For example:
class CalculationTask implements Runnable {
public void run() {
try {
int result = 10 / 0; // This will throw ArithmeticException
} catch (ArithmeticException e) {
System.out.println("Caught exception: " + e.getMessage());
}
}
}
This ensures that exceptions like ArithmeticException are caught and handled within the thread, preventing the thread from crashing silently.
2. Handling InterruptedException and Blocking Operations
Many thread operations, such as Thread.sleep(), wait(), or acquiring locks (ReentrantLock), can throw InterruptedException. Always handle this exception properly and restore the thread’s interrupted status if needed:
Here’s your code properly formatted:
```java id="x7p2kd"
try {
Thread.sleep(1000); // Blocking operation
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // Restore interrupt status
System.out.println("Thread was interrupted during sleep.");
}
```
Restoring the interrupt status allows higher-level code to detect the interruption and take appropriate action, such as shutting down gracefully.
3. Using UncaughtExceptionHandler
Sometimes, exceptions may escape the thread’s run method without being caught. Java provides the UncaughtExceptionHandler interface to handle such uncaught exceptions globally for a thread:
Thread thread = new Thread(() -> {
throw new RuntimeException("Unexpected error!");
});
thread.setUncaughtExceptionHandler((t, e) -> {
System.out.println("Exception in thread " + t.getName() + ": " + e.getMessage());
});
thread.start();
This is especially useful for logging, debugging, or performing cleanup when a thread fails unexpectedly.
4. Dealing with Deadlock and Mutual Exclusion
While not exceptions in the traditional sense, deadlocks (where threads block each other indefinitely) and mutual exclusion failures are common pitfalls in multithreaded programs. Detecting deadlocks often involves analyzing a thread dump, a snapshot of all threads and their states, to identify cycles in resource locking.
Use ReentrantLock with tryLock() and timeouts to help prevent deadlock conditions:
import java.util.concurrent.TimeUnit;
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
// Critical section
} finally {
lock.unlock();
}
} else {
System.out.println("Could not acquire lock, avoiding deadlock.");
}
5. Best Practices
- Always use try-catch blocks for code that may throw exceptions inside threads.
- Handle InterruptedException by restoring the interrupt status.
- Use UncaughtExceptionHandler for centralized exception handling and logging.
- For blocking operations and locks, prefer mechanisms that allow interruption or timeout.
- Regularly analyze thread dumps to diagnose deadlocks or unhandled exceptions.
Inter-Thread Communication
In multithreaded Java programs, threads often need to coordinate their actions for example, one thread might produce data while another consumes it. This coordination is called inter-thread communication, and Java provides several built-in mechanisms to help threads communicate and work together safely.
Why Is Inter-Thread Communication Needed?
Without coordination, threads may run into problems like:
- Race conditions: Where two threads update shared data at the same time, causing unpredictable results.
- Deadlocks: Where threads wait for each other forever, blocking progress.
- Wasted resources: When a thread keeps checking for a condition instead of waiting efficiently.
Key Methods for Inter-Thread Communication
1. wait(), notify(), and notifyAll()
These methods are used to coordinate threads that share the same object monitor (lock). They must be called from within a synchronized block or method.
- wait(): Causes the current thread to release the lock and enter a waiting state until another thread calls notify() or notifyAll() on the same object.
- notify(): Wakes up one waiting thread.
- notifyAll(): Wakes up all waiting threads.
Example: Producer & Consumer Problem
class SharedResource {
private int data;
private boolean available = false;
public synchronized void produce(int value) throws InterruptedException {
while (available) {
wait(); // Wait until data is consumed
}
data = value;
available = true;
notify(); // Notify consumer
}
public synchronized int consume() throws InterruptedException {
while (!available) {
wait(); // Wait until data is produced
}
available = false;
notify(); // Notify producer
return data;
}
}
In this pattern, the producer waits if the resource is full, and the consumer waits if it’s empty, using wait() and notify() to coordinate.
2. join() Method
The join() method allows one thread to wait for another to finish before continuing. It’s useful for sequencing actions between threads.
Thread t = new Thread(() -> {
/* task */
});
t.start();
t.join(); // Main thread waits for t to finish
3. suspend() and resume() (Deprecated)
Older Java versions provided suspend() and resume() for pausing and resuming threads, but these methods are deprecated because they can lead to deadlocks and unpredictable behavior. Instead, use wait()/notify() or higher-level concurrency tools.
4. The volatile Keyword
Sometimes, for simple flags, you can use the volatile keyword to ensure that changes made by one thread are visible to others. However, for complex coordination, use proper synchronization and communication methods.
5. Handling Errors
Calling wait(), notify(), or notifyAll() outside a synchronized block throws an IllegalMonitorStateException. Always use these methods inside a synchronized context to avoid this error.
synchronized (sharedObject)
{
sharedObject.wait();
}
Best Practices
- Use wait()/notify() for efficient, low-level thread communication.
- Always call them within a synchronized block.
- Prefer higher-level constructs (like BlockingQueue) for complex coordination.
- Avoid using deprecated methods like suspend() and resume().
Thread Pools and the Executor Framework
Creating a new thread for every task can be inefficient and resource-intensive, especially in applications that handle many short-lived tasks. Java solves this with thread pools and the Executor framework, which efficiently manage and reuse threads for better performance and scalability.
What is a Thread Pool?
A thread pool is a collection of pre-instantiated worker threads that stand ready to execute tasks. Instead of creating a new thread for each job, you submit tasks to the pool, and an available thread picks up the work. This reduces overhead and helps prevent resource exhaustion.
- Worker Thread Pattern: Threads in the pool repeatedly fetch and execute tasks, then return to the pool for reuse.
The Executor Framework
Java’s Executor framework (in java.util.concurrent) provides powerful abstractions for managing thread pools and asynchronous task execution.
1. ExecutorService
- ExecutorService is the main interface for managing thread pools. It lets you submit tasks, shut down the pool, and manage task completion.
- Use the Executors utility class to create different types of thread pools:
- Executors.newFixedThreadPool(int n): Fixed number of threads.
- Executors.newCachedThreadPool(): Dynamically sized pool.
- Executors.newSingleThreadExecutor(): Single worker thread.
- Executors.newScheduledThreadPool(int n): For scheduled tasks.
- ForkJoinPool: Optimized for parallelism and divide-and-conquer tasks.
Example:
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(3);
executor.submit(() ->
System.out.println("Task running in thread pool")
);
executor.shutdown(); // Initiates graceful shutdown
}
}
2. Submitting Tasks: Runnable, Callable, and Future
- Runnable: Represents a task with no return value.
- Callable: Represents a task that returns a result (or throws an exception).
- Future: Represents the result of an asynchronous computation. You can use it to check if a task is done, wait for its completion, or retrieve its result.
Example with Callable and Future:
import java.util.concurrent.Callable;
import java.util.concurrent.Future;
Callable<Integer> task = () -> 42;
Future<Integer> future = executor.submit(task);
Integer result = future.get(); // Blocks until result is available
3. BlockingQueue
Thread pools often use a BlockingQueue to hold tasks before they are executed. This ensures safe, thread-friendly task submission and retrieval.
4. ForkJoinPool and Parallel Streams
- ForkJoinPool: Designed for tasks that can be broken down into smaller subtasks (divide-and-conquer). Used internally by Java’s parallel streams.
import java.util.concurrent.ForkJoinPool;
ForkJoinPool pool = new ForkJoinPool();
pool.submit(() -> {
/* parallel task */
});
- parallelStream(): Allows easy parallel processing of collections.
import java.util.Arrays;
import java.util.List;
List<Integer> list = Arrays.asList(1, 2, 3, 4);
list.parallelStream().forEach(System.out::println);
5. ThreadLocal and Custom Thread Factories
- ThreadLocal: Provides each thread with its own instance of a variable, useful for thread-specific data.
- Custom thread factories: Used to create threads with custom names, priorities, or configurations when building a thread pool.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
ExecutorService customPool = Executors.newFixedThreadPool(
2,
runnable -> {
Thread t = new Thread(runnable);
t.setName("CustomWorker");
return t;
}
);
6. Proper Shutdown
Always shut down your ExecutorService using shutdown() (or shutdownNow() for immediate termination) to free resources and allow your application to exit cleanly.
Summary Table
| Concept / Tool |
Purpose |
| Thread Pool |
Efficiently manages and reuses a pool of threads to reduce overhead |
| ExecutorService |
Main interface for creating, managing, and controlling thread pools |
| Callable / Future |
Executes tasks that return results and allows tracking of task completion |
| BlockingQueue |
Stores tasks before execution and safely handles producer-consumer scenarios |
| ForkJoinPool |
Efficiently handles parallel, divide-and-conquer tasks |
| parallelStream() |
Enables simple parallel processing of collections |
| ThreadLocal |
Provides thread-specific variables to avoid shared data conflicts |
| Custom Thread Factories |
Allows customization of thread creation within thread pools |
| shutdown() |
Gracefully terminates a thread pool after completing submitted tasks |
Applications of Multithreading in Java
Multithreading is at the heart of many modern software systems. Here are some real-world applications of multithreading in Java:
- Web Servers
Multi threaded programming in Java allows web servers to handle thousands of user requests at the same time. Each incoming request can be processed by a separate thread, ensuring fast and responsive service. - Gaming
Games often use multiple threads for rendering graphics, processing user input, and running background calculations. This ensures smooth gameplay and responsive controls. - Banking Systems
In online banking, multiple transactions can occur simultaneously. A Java program using multithreading ensures that deposits, withdrawals, and transfers are processed concurrently and efficiently, without data corruption. - Multimedia Applications
Video streaming and audio playback use multithreading to buffer data, decode streams, and update the user interface all at the same time. This prevents stuttering and keeps the experience seamless. - Data Processing and Analysis
Large-scale data analysis often requires splitting tasks across threads. For example, in a Java multithreading example program, different threads might process different parts of a dataset in parallel, greatly speeding up computation. - Mobile and Desktop Applications
Multithreading in Java keeps user interfaces responsive by running background tasks (like file downloads or database updates) on separate threads.
Common Pitfalls and Best Practices
Multithreading in Java unlocks powerful capabilities, but it also introduces new challenges. Many issues stem from improper handling of concurrency, resulting in bugs that are hard to detect and fix. Here are some common pitfalls and best practices to help you write safe, efficient, and responsive multi-threaded programs.
Common Pitfalls
- Calling run() Instead of start()
A frequent beginner mistake is calling the run() method directly on a thread object instead of start(). This runs the code on the current thread, not a new one, so no concurrency is achieved.
Thread t = new Thread(() -> {
/* task */
});
t.run(); // Wrong: runs on main thread
t.start(); // Correct: runs on a new thread
- Race Conditions
When multiple threads access and modify shared state without proper synchronization, the program’s outcome becomes unpredictable. This can lead to data corruption or inconsistent results. - Deadlocks
Deadlocks occur when two or more threads wait forever for each other to release resources. This usually happens due to inconsistent lock ordering or holding multiple locks at once. - Memory Leaks
Threads that never finish or executor services that are never shut down can cause memory leaks, preventing the JVM from freeing resources. - Unpredictable Behavior
Without proper synchronization, your program may work on one run and fail on another, especially as the number of threads increases. This makes bugs hard to reproduce and fix. - Poor Responsiveness
Running long or blocking tasks on the main thread can freeze user interfaces or slow down server responses.
Best Practices
- Prefer High-Level Concurrency Tools
Use Java’s concurrency API in java.util.concurrent, such as Executors, ExecutorService, and thread-safe collections, to manage threads and tasks instead of low-level thread management. - Limit Shared State
Minimize the amount of shared mutable data between threads. Use immutable objects or thread-local storage when possible. - Always Shut Down Executors
After using an executor service, always call shutdown() or shutdownNow() to release resources and avoid memory leaks. - Consistent Lock Ordering
To avoid deadlocks, always acquire locks in the same order in all threads. - Keep Synchronized Sections Short
Only synchronize the minimum necessary code to reduce thread contention and improve performance. - Check for Responsiveness
Offloading long-running or blocking operations to background threads to keep applications responsive is especially important in GUIs and distributed systems. - Use Race Condition Detection Tools
Leverage static analysis tools or runtime detectors to find and fix race conditions early. - Understand Multitasking vs. Multithreading
Remember that multitasking (multiple processes) and multithreading (multiple threads in one process) have different challenges; always choose the right approach for your application.
Summary Table
| Pitfall / Best Practice |
Description / Solution |
| run() instead of start() |
Always use start() to launch a new thread; calling run() directly executes in the same thread |
| Race Conditions |
Synchronize access to shared resources using synchronized blocks, locks, or atomic classes |
| Deadlocks |
Prevent by using consistent lock ordering and minimizing nested locks |
| Memory Leaks |
Always shut down executors and properly terminate threads to release resources |
| Unpredictable Behavior |
Use high-level concurrency utilities and proper synchronization mechanisms |
| Poor Responsiveness |
Offload long-running or blocking tasks to background threads |
| High-level Concurrency Tools |
Prefer Executors, thread-safe collections, and modern concurrency APIs for safer implementation |
Conclusion
Multithreading is a cornerstone of modern Java programming, enabling your applications to run faster, stay responsive, and handle multiple tasks at once. By learning how to create, manage, and synchronize threads, you unlock the ability to build everything from smooth user interfaces to high-performance servers and data processing tools.
The journey from understanding basic thread creation to navigating advanced topics like synchronization, deadlock prevention, and thread pools is essential for moving from beginner to true software engineer. Along the way, you’ll discover that writing robust multithreaded code requires both technical knowledge and careful design.
Key takeaways:
- Use the right thread creation method for your needs; Runnable and ExecutorService are the modern best practices.
- Always synchronize access to shared resources to avoid race conditions and unpredictable bugs.
- Manage your threads with care: shut down executors, minimise shared state, and keep your code responsive.
- Don’t be afraid to experiment. Real learning happens when you try out these concepts in your own programs. As you continue practising and building real-world projects, you’ll gain the confidence and skills to write safe, efficient, and scalable multithreaded Java applications. Multithreading isn’t just a feature; it’s a superpower for any serious Java developer.
Frequently Asked Questions
1. What is multithreading in Java?
Multithreading allows multiple threads to execute simultaneously within a single Java program, enabling parallel task execution and improved performance.
2. How do you create multiple threads in Java?
You can create threads by either extending the Thread class or by implementing the Runnable interface.
3. What is the best way to implement multithreading?
The recommended approach is to use the Runnable interface along with ExecutorService for flexibility and efficient thread management.
4. What are the types of multithreading in Java?
Java supports process-based multithreading (multiple processes) and thread-based multithreading (multiple threads within a single process). Thread-based is the standard in Java.
5. What is a basic multithreading program in Java?
A basic program creates and starts multiple threads, allowing them to execute tasks concurrently using the start() method.