In modern computing, performance is not just about executing one task faster but about doing more tasks simultaneously. Multithreading allows a program to execute multiple threads concurrently, effectively enabling multitasking within a single process.
A thread is the smallest unit of execution in a process. In Java, threads are part of the language’s core design, providing a built-in mechanism to implement concurrent behavior. For example, in a video streaming application, one thread can decode video frames, another can process audio, and yet another can synchronize both streams for smooth playback. This seamless experience is made possible by multithreading.
Java’s multithreading model leverages the capabilities of the underlying operating system while providing a developer-friendly abstraction through its java.lang.Thread class and the java.util.concurrent package.
Why Use Multithreading in Java?
Multithreading in Java is not just an advanced feature; it is essential for developing responsive, efficient, and scalable applications. Consider a web server scenario: if each incoming request had to be processed sequentially, users would experience significant delays. Multithreading allows you to handle multiple requests simultaneously, reducing latency and enhancing the user experience.
Java’s strong thread support, combined with the JVM's robust management, makes it an ideal choice for implementing multithreading. Features such as platform independence, automatic memory management, and built-in thread primitives allow developers to focus on business logic rather than low-level thread management details.
Benefits of Multithreading
Responsiveness
Applications like GUI-based systems can continue responding to user input while performing background tasks. For example, a file download manager can update progress in real time without freezing the user interface.
Resource Sharing
Multiple threads within the same process share memory and resources efficiently. For instance, in a data analysis application, different threads can process separate parts of a large dataset concurrently.
Parallelism
By dividing tasks across multiple threads, applications can utilize multi-core processors effectively. For instance, a video editor can render different segments of a video in parallel, significantly reducing total processing time.
Challenges of Multithreading
Despite the advantages, multithreading introduces its own set of challenges:
Concurrency Issues
When multiple threads access shared resources, inconsistencies can arise if proper synchronization is not enforced. For example, two threads incrementing the same counter can lead to race conditions.
Deadlocks
Improper locking strategies can result in deadlocks, where two or more threads wait indefinitely for resources held by each other.
Thread Management
Creating and managing threads has overhead. Excessive thread creation can exhaust system resources and degrade performance.
To mitigate these challenges, Java provides extensive concurrency tools, including synchronization mechanisms, locks, and the java.util.concurrent package.
How the JVM Manages Threads and Thread Scheduling
The Java Virtual Machine (JVM) abstracts much of the complexity of thread management. Key aspects include:
Thread Lifecycle
Threads move through states such as NEW, RUNNABLE, BLOCKED, WAITING, and TERMINATED. The JVM orchestrates these transitions based on resource availability and thread behavior.
Thread Scheduling
The JVM typically relies on the underlying OS to schedule threads in a preemptive manner. Thread priority in Java is only advisory and doesn’t guarantee strict ordering.
Garbage Collection
In multithreaded applications, Java’s garbage collector (e.g., G1GC, ZGC) operates concurrently to minimize pauses and maintain performance.
Thread Safety
The JVM ensures thread safety in critical areas (e.g., class loading, static initializers) using intrinsic locks. Developers can use these same primitives (synchronized, wait(), notify(), etc.) for custom synchronization needs.
Basic Multithreading
In this section, we will demonstrate three common ways to create and manage threads in Java:
Using a Dedicated Class that Implements Runnable
Extending the Thread Class
Using an Anonymous Class that Implements Runnable
Instead of a simple “Hello Thread” example, we will use a more illustrative scenario: summing all numbers from 1 to 1 billion using four threads. Each thread handles a portion of the range, calculates a partial sum, and we combine these partial sums to get the final result.
Using a Dedicated Class That Implements Runnable
When a class implements Runnable, you separate the task logic from the thread management. This approach is useful if your class already extends another class or if you simply want a clean separation of concerns.
Code Example
class SummationTask implements Runnable {
private long start;
private long end;
private long partialSum;
public SummationTask(long start, long end) {
this.start = start;
this.end = end;
}
@Override
public void run() {
for (long i = start; i <= end; i++) {
partialSum += i;
}
}
public long getPartialSum() {
return partialSum;
}
}
public class SummationUsingRunnable {
public static void main(String[] args) {
long range = 1_000_000_000L;
long quarter = range / 4;
SummationTask task1 = new SummationTask(1, quarter);
SummationTask task2 = new SummationTask(quarter + 1, quarter * 2);
SummationTask task3 = new SummationTask((quarter * 2) + 1, quarter * 3);
SummationTask task4 = new SummationTask((quarter * 3) + 1, range);
Thread t1 = new Thread(task1);
Thread t2 = new Thread(task2);
Thread t3 = new Thread(task3);
Thread t4 = new Thread(task4);
t1.start();
t2.start();
t3.start();
t4.start();
try {
t1.join();
t2.join();
t3.join();
t4.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
long totalSum = task1.getPartialSum()
+ task2.getPartialSum()
+ task3.getPartialSum()
+ task4.getPartialSum();
System.out.println("Total Sum (1 to " + range + "): " + totalSum);
}
}
Explanation
SummationTask implements Runnable and defines the logic in the run() method to add all numbers in a specified range.
We create four tasks (each summing a quarter of the overall range).
We wrap each task in a Thread and start them.
We use join() to ensure the main thread waits for all partial sums to be calculated.
Finally, we combine the partial sums from the four threads to get the final result.
This approach is flexible because the SummationTask is independent of the Thread lifecycle, making it easier to reuse or modify.
Extending the Thread Class
You can also create a thread by extending the Thread class and overriding its run() method. This technique is more direct but offers less flexibility, as you cannot extend any other class simultaneously.
Code Example
class SummationThread extends Thread {
private long start;
private long end;
private long partialSum;
public SummationThread(long start, long end) {
this.start = start;
this.end = end;
}
@Override
public void run() {
for (long i = start; i <= end; i++) {
partialSum += i;
}
}
public long getPartialSum() {
return partialSum;
}
}
public class SummationUsingThread {
public static void main(String[] args) {
long range = 1_000_000_000L;
long quarter = range / 4;
SummationThread t1 = new SummationThread(1, quarter);
SummationThread t2 = new SummationThread(quarter + 1, quarter * 2);
SummationThread t3 = new SummationThread((quarter * 2) + 1, quarter * 3);
SummationThread t4 = new SummationThread((quarter * 3) + 1, range);
t1.start();
t2.start();
t3.start();
t4.start();
try {
t1.join();
t2.join();
t3.join();
t4.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
long totalSum = t1.getPartialSum()
+ t2.getPartialSum()
+ t3.getPartialSum()
+ t4.getPartialSum();
System.out.println("Total Sum (1 to " + range + "): " + totalSum);
}
}
Explanation
We create a SummationThread class that extends Thread.
The summation logic is placed directly in the run() method.
Similar to the previous approach, we start the threads, wait for them to finish (join()), and then accumulate the partial sums.
This direct approach can be convenient for smaller tasks but offers less design flexibility. If you need more reusability or your class must extend a different base class, this approach might be limiting.
Using an Anonymous Class That Implements Runnable
In some cases, you may not want to create a separate class file for the summation logic. Instead, you can use anonymous classes to define the task directly when creating the Thread.
Code Example
public class SummationUsingAnonymousClass {
public static void main(String[] args) {
long range = 1_000_000_000L;
long quarter = range / 4;
final long[] partialSums = new long[4];
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
long sum = 0;
for (long i = 1; i <= quarter; i++) {
sum += i;
}
partialSums[0] = sum;
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
long sum = 0;
for (long i = quarter + 1; i <= quarter * 2; i++) {
sum += i;
}
partialSums[1] = sum;
}
});
Thread t3 = new Thread(new Runnable() {
@Override
public void run() {
long sum = 0;
for (long i = (quarter * 2) + 1; i <= quarter * 3; i++) {
sum += i;
}
partialSums[2] = sum;
}
});
Thread t4 = new Thread(new Runnable() {
@Override
public void run() {
long sum = 0;
for (long i = (quarter * 3) + 1; i <= range; i++) {
sum += i;
}
partialSums[3] = sum;
}
});
t1.start();
t2.start();
t3.start();
t4.start();
try {
t1.join();
t2.join();
t3.join();
t4.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
long totalSum = partialSums[0] + partialSums[1] + partialSums[2] + partialSums[3];
System.out.println("Total Sum (1 to " + range + "): " + totalSum);
}
}
Explanation
We declare anonymous classes that implement Runnable for each thread.
The summation logic is embedded within the thread creation code, which can be convenient for quick tasks.
Like before, we use an array (partialSums) to store the partial results from each thread.
By calling join() on each thread, we ensure the main thread waits until all partial computations are done, after which we combine them.
This approach is handy for quick and localized tasks but can become unwieldy if the logic grows too large.
Comparison of Approaches
Dedicated Class (Implements Runnable)
Pros: Clear separation of concerns, reusability, can extend another class if needed.
Cons: Requires writing a separate class.
Extending Thread
Pros: Straightforward for small tasks.
Cons: Less flexible because you cannot extend another class, and it merges task logic with thread management.
Anonymous Class (Implements Runnable)
Pros: Compact, no need for separate class files, good for quick tasks.
Cons: Can become less readable and harder to maintain if the logic is more complex.
All three approaches produce the same result and illustrate how Java threads work. The choice depends on coding style, the complexity of your application, and design requirements.
Understanding Race Conditions
A race condition arises because the increment operation (counter++) is not atomic—it breaks down into multiple steps at the machine level. If two threads interleave these steps, the final result can be incorrect or unpredictable. More specifically, the operating system (OS) can context switch between threads at any point, causing interleaving of operations. Below is a detailed explanation of how this happens.
Increment Is Not Atomic
Conceptually, counter++ involves at least three low-level actions:
Read the current value of counter from memory into a register (e.g., R1).
Increment that register value (R1 = R1 + 1).
Write the updated value back from the register to counter in memory.
In a single-threaded scenario, this works fine. However, with multiple threads, each thread might perform the above sequence interleaved with others.
Let’s imagine two threads: T1 and T2. We will illustrate a race condition that leads to one lost increment (this can happen in various ways depending on thread interleavings).
Shared variable: counter = 10
Thread T1: Thread T2:
-------------------- -----------------------
1. Read counter (10)
into register R1
2. Read counter (10)
into register R2
3. Increment R1
R1 = R1 + 1 => 11
4. Increment R2
R2 = R2 + 1 => 11
5. Write R1 (11)
back to counter
counter becomes 11
6. Write R2 (11)
back to counter
counter becomes 11
After these interleavings, counter is 11 when logically it should be 12 (two increments from 10).
The increments do not add up correctly because T1's updated result is effectively overwritten by T2.
Role of OS Context Switches
A context switch occurs when the operating system pauses one thread and resumes another. It can happen for many reasons (e.g., scheduling policies, time-slice expiration, I/O interrupts). These switches can happen at any point in the three-step increment sequence, leading to partial updates from either thread.
Why This Matters
If Thread T1 reads the value and Thread T2 context-switches in before T1 writes the updated value, T2 might also read the same un-updated value. Both threads end up writing the same incremented result, effectively losing one increment.
There is no guarantee about how often or when the OS will switch threads; hence the final outcome depends on unpredictable scheduling events.
Locks and Synchronization in Java
In Java, the synchronized keyword ensures that only one thread at a time can execute a critical section of code, preventing race conditions. This mechanism is based on intrinsic locks (also called monitor locks). Every Java object has an intrinsic lock that can be used to control synchronized access to blocks or methods.
Object Locks (Instance Locks)
An object lock applies to a specific instance of a class. When a thread enters a synchronized instance method or a synchronized block that locks on this (or another specific object), it acquires that instance’s lock. Other threads attempting to enter any synchronized method/block on the same object are blocked until the lock is released.
When a Thread Acquires an Object Lock
Synchronized Instance Method
public class Example {
public synchronized void doSomething() {
System.out.println("Thread " + Thread.currentThread().getName()
+ " has the object lock");
}
}
Internally, public synchronized void doSomething() is equivalent to:
public void doSomething() {
synchronized (this) {
}
}
Here, this refers to the current instance of the class. Only one thread can hold this instance lock at a time.
Synchronized Block on a Specific Object
public void performTask(Object lockObj) {
synchronized (lockObj) {
}
}
- This approach is often used when you want finer control over which object's lock you’re using, rather than always locking on the current instance (
this).
Snippet Demonstrating Instance Lock
public class InstanceLockExample {
private int counter = 0;
public synchronized void increment() {
counter++;
}
public void decrement() {
synchronized (this) {
counter--;
}
}
public int getCounter() {
return counter;
}
}
In increment(), the lock is on the current InstanceLockExample object, because it is a synchronized instance method.
In decrement(), the lock is explicitly on this within a synchronized block.
Class Locks (Static Locks)
A class lock applies to the Class object rather than an instance. When a thread enters a synchronized static method or a block synchronized on the class object (e.g., MyClass.class), it acquires the lock for the entire class. This means no other thread can execute any synchronized static method (or block on that same class object) until the lock is released.
When a Thread Acquires a Class Lock
Synchronized Static Method
public class Example {
public static synchronized void doStaticTask() {
System.out.println("Thread " + Thread.currentThread().getName()
+ " has the class lock");
}
}
Internally, public static synchronized void doStaticTask() is equivalent to:
public static void doStaticTask() {
synchronized (Example.class) {
}
}
Synchronized Block on the Class Object
public static void anotherStaticTask() {
synchronized (Example.class) {
}
}
Example: Using Both Instance and Class Locks
Below is a comprehensive example that demonstrates both instance-level and class-level locks in the same class. We have:
An instance counter and synchronized instance methods.
A class counter and synchronized static methods.
In the main method, multiple threads operate on the same class to illustrate how these locks work independently.
public class SharedCounter {
private int instanceCounter = 0;
private static int classCounter = 0;
public synchronized void incrementInstanceCounter() {
instanceCounter++;
}
public static synchronized void incrementClassCounter() {
classCounter++;
}
public int getInstanceCounter() {
return instanceCounter;
}
public static int getClassCounter() {
return classCounter;
}
public static void main(String[] args) {
SharedCounter sc1 = new SharedCounter();
SharedCounter sc2 = new SharedCounter();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
sc1.incrementInstanceCounter();
SharedCounter.incrementClassCounter();
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
sc2.incrementInstanceCounter();
SharedCounter.incrementClassCounter();
}
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("sc1 instanceCounter: " + sc1.getInstanceCounter());
System.out.println("sc2 instanceCounter: " + sc2.getInstanceCounter());
System.out.println("classCounter: " + SharedCounter.getClassCounter());
}
}
Explanation
Instance Counter (object lock)
incrementInstanceCounter() is declared synchronized, locking on the current SharedCounter instance. Thread t1 acquires sc1’s lock, and thread t2 acquires sc2’s lock. These are different locks, so there is no mutual exclusion between threads t1 and t2 for instance-level increments, unless they happen to use the same object instance.
Class Counter (class lock)
incrementClassCounter() is declared static synchronized, locking on SharedCounter.class. Regardless of whether t1 is working on sc1 and t2 is working on sc2, both need the same class lock when incrementing classCounter.
Thus, t1 and t2 cannot concurrently execute this static method; one will block while the other holds the SharedCounter.class lock.
Thread Coordination
We create two threads, each performing 1000 increments on the instance counter (using their own separate SharedCounter object) and 1000 increments on the class counter (shared lock).
After both threads finish (join()), we print the instance counters for sc1 and sc2, and the final class counter.
Expected Outcome
sc1.getInstanceCounter() will be 1000 (all increments by t1 only).
sc2.getInstanceCounter() will be 1000 (all increments by t2 only).
SharedCounter.getClassCounter() will be 2000 in total (1000 increments by each thread).
Locks in wait() and notify()
In Java, thread communication is often handled through wait(), notify(), and notifyAll() methods. This is a fundamental mechanism for coordinating the actions of multiple threads, especially in producer-consumer scenarios. Below is an extremely detailed explanation of why these methods are necessary, how they work, and what would happen if proper synchronization wasn’t in place.
Without Synchronization: The Chaos Scenario
Imagine we have a shared buffer that a producer thread fills with items, and a consumer thread removes items from it. If we omit synchronization (that is, if we do not use synchronized blocks/methods or do not properly use wait()/notify()), then:
Data Inconsistency
The producer might try to add an item to an already full buffer at the same time the consumer tries to remove from that buffer—both threads could be reading and writing the buffer’s size or elements simultaneously.
This could lead to race conditions: the buffer could end up with an incorrect number of items, pointers out of sync, or even corrupted data references.
Missed Signals
- If the consumer sees the buffer as empty (but it’s not truly empty) or the producer sees it as full (when it’s not truly full), they might get stuck or overwrite data.
Busy Waiting
- Threads might keep “spinning” in a loop, periodically checking the buffer and hoping something changes, which wastes CPU time and makes the application unresponsive.
In short, without the proper use of locks and thread communication (wait(), notify(), etc.), the producer-consumer pattern becomes unreliable and inefficient.
Intrinsic Locks and wait()/notify()
In Java, each object has an intrinsic lock (also called a monitor lock). The wait(), notify(), and notifyAll() methods must be used from within a synchronized context (i.e., inside a block or method that obtains the object’s lock). This is because:
Lock Ownership
When you call wait() on an object, you are asking the JVM to temporarily release that object’s lock and put the calling thread into the waiting state.
If a lock is not held, the thread cannot legally release it, hence Java enforces that wait() is only called while you are inside a synchronized block or method.
Coordination
notify() or notifyAll() must likewise be invoked by a thread that owns the same object’s lock. Only then can the JVM properly wake the threads that are waiting on that lock.
Understanding wait()
Understanding notify()
Understanding notifyAll()
When a thread calls notifyAll(), it wakes up all threads waiting on that object’s lock. Any of those awakened threads will then contend to acquire the lock once it’s released by the notifier.
In many producer-consumer use cases with multiple consumers or producers, notifyAll() ensures that every waiting thread gets a chance to proceed. However, in a simple single-producer, single-consumer setup, notify() is often sufficient.
Step-by-Step in the Producer-Consumer Example
public class ProducerConsumer {
private final Queue<Integer> buffer = new LinkedList<>();
private final int MAX_SIZE = 5;
public void produce() throws InterruptedException {
int value = 0;
while (true) {
synchronized (this) {
while (buffer.size() == MAX_SIZE) {
wait();
}
buffer.add(value);
System.out.println("Produced: " + value);
value++;
notify();
}
Thread.sleep(500);
}
}
public void consume() throws InterruptedException {
while (true) {
synchronized (this) {
while (buffer.isEmpty()) {
wait();
}
int val = buffer.poll();
System.out.println("Consumed: " + val);
notify();
}
Thread.sleep(500);
}
}
public static void main(String[] args) {
ProducerConsumer pc = new ProducerConsumer();
Thread producer = new Thread(new Runnable() {
@Override
public void run() {
try {
pc.produce();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread consumer = new Thread(new Runnable() {
@Override
public void run() {
try {
pc.consume();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
producer.start();
consumer.start();
}
}
Detailed Explanation of Producer Steps
synchronized (this)
- The producer thread obtains the intrinsic lock on the
ProducerConsumer instance pc.
Check Condition: while (buffer.size() == MAX_SIZE)
If the buffer is already at max capacity (MAX_SIZE = 5), the producer must wait before adding more items.
The while loop is used instead of if to re-check the condition if the thread is awakened but the buffer is still full (or became full again).
wait()
The producer calls wait() to release the lock on pc and enter the WAITING state.
This allows another thread (in this case, the consumer) to acquire the lock and consume an item.
Awakening from wait()
The producer thread will remain in the WAITING state until some other thread (the consumer) calls notify() or notifyAll() on the same lock object (pc).
Once awakened, the producer reacquires the lock on pc before continuing in the code.
Add Item to Buffer
- The producer can now safely add an integer (
value) to buffer.
notify()
- After adding an item, the producer calls
notify() to potentially wake up one waiting thread (the consumer thread), signaling that there might be something new to consume.
Release Lock & Sleep
The producer exits the synchronized block, releasing the lock.
It then sleeps for 500 ms to simulate the time it takes to produce another item.
Detailed Explanation of Consumer Steps
synchronized (this)
- The consumer thread acquires the intrinsic lock on the
ProducerConsumer instance pc.
Check Condition: while (buffer.isEmpty())
- If there are no items to consume, the consumer must wait until the buffer has something.
wait()
- The consumer calls
wait(), releasing the lock and entering the WAITING state.
Awakening from wait()
When the producer calls notify(), it may wake the consumer if the consumer is the thread that was waiting.
The consumer then reacquires the same lock before continuing.
Consume an Item
- The consumer thread removes an item from the
buffer.
notify()
- After consuming an item, the consumer calls
notify() to wake up one waiting producer (if the buffer was full, the producer is now free to add more items).
Release Lock & Sleep
The consumer exits the synchronized block, releasing the lock on pc.
It then sleeps for 500 ms to simulate consumption time.
Key Points
while vs. if
- We use
while (buffer.size() == MAX_SIZE) instead of if (buffer.size() == MAX_SIZE). This is critical because when the producer is awakened, the condition might still be true if multiple threads are involved or if spurious wake-ups occur. Using a while loop ensures the thread rechecks the condition each time it is awakened.
Single Lock Object
- Both
produce() and consume() methods synchronize on this, which is the same ProducerConsumer instance pc. Hence, the producer and consumer use the same lock to coordinate access to buffer.
Cooperative Threading
- The producer and consumer threads take turns being active. When the buffer is full, the producer waits. When the buffer is empty, the consumer waits. The
notify() calls ensure that only the relevant thread gets awakened at the right time.
Why Sleep 500 ms?
- This delay simulates “work” being done by the producer or consumer. It also helps visualize the output more clearly in real time. Without a delay, the console might simply print a large volume of messages too quickly.
By employing wait(), notify(), and notifyAll() within synchronized blocks or methods, multiple threads can coordinate access to shared resources safely and efficiently, ensuring that each thread performs its work only when the system is in a suitable state. Multithreading in Java empowers developers to create applications that are responsive, efficient, and scalable. However, it also demands a solid understanding of thread synchronization, race conditions, and thread lifecycle management.
Whether you choose to implement Runnable, extend Thread, or use anonymous classes (or other advanced frameworks like the Executors in java.util.concurrent), the core principles of concurrency, synchronization, and communication remain the same. Mastering these fundamentals will enable you to build robust, high-performance applications that can handle the concurrency demands of modern computing.