Introduction to Multithreading in Java

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
ClassUsing 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; // 250,000,000
// Create tasks for four ranges
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);
// Create Threads
Thread t1 = new Thread(task1);
Thread t2 = new Thread(task2);
Thread t3 = new Thread(task3);
Thread t4 = new Thread(task4);
// Start threads
t1.start();
t2.start();
t3.start();
t4.start();
// Wait for all threads to finish
try {
t1.join();
t2.join();
t3.join();
t4.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// Combine partial sums
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 therun()
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;
// Create four SummationThread objects
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);
// Start threads
t1.start();
t2.start();
t3.start();
t4.start();
// Wait for all threads to complete
try {
t1.join();
t2.join();
t3.join();
t4.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// Calculate final sum
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;
// Array to store partial sums
final long[] partialSums = new long[4];
// Thread 1: Sums from 1 to quarter
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 2: Sums from quarter+1 to quarter*2
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 3: Sums from (quarter*2)+1 to (quarter*3)
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 4: Sums from (quarter*3)+1 to range
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;
}
});
// Start all threads
t1.start();
t2.start();
t3.start();
t4.start();
// Wait for threads to finish
try {
t1.join();
t2.join();
t3.join();
t4.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// Combine partial sums
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() { // This entire method is guarded by this object's intrinsic lock System.out.println("Thread " + Thread.currentThread().getName() + " has the object lock"); // critical section } }
Internally,
public synchronized void doSomething()
is equivalent to:public void doSomething() { synchronized (this) { // critical section } }
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) { // Critical section that only one thread can access // using lockObj's intrinsic lock } }
- 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
).
- 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 (
Snippet Demonstrating Instance Lock
public class InstanceLockExample {
private int counter = 0;
// Instance-level lock via synchronized method
public synchronized void increment() {
counter++;
}
// Instance-level lock via synchronized block
public void decrement() {
synchronized (this) {
counter--;
}
}
public int getCounter() {
return counter;
}
}
In
increment()
, the lock is on the currentInstanceLockExample
object, because it is a synchronized instance method.In
decrement()
, the lock is explicitly onthis
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() { // Entire method is guarded by Example.class intrinsic lock System.out.println("Thread " + Thread.currentThread().getName() + " has the class lock"); // critical section } }
Internally,
public static synchronized void doStaticTask()
is equivalent to:public static void doStaticTask() { synchronized (Example.class) { // critical section } }
Synchronized Block on the Class Object
public static void anotherStaticTask() { synchronized (Example.class) { // critical section guarded by Example.class lock } }
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 {
// Instance-level counter
private int instanceCounter = 0;
// Class-level counter (shared across all instances)
private static int classCounter = 0;
// Instance method synchronized on "this" (object lock)
public synchronized void incrementInstanceCounter() {
instanceCounter++;
}
// Static method synchronized on SharedCounter.class (class lock)
public static synchronized void incrementClassCounter() {
classCounter++;
}
// Getters for demonstration
public int getInstanceCounter() {
return instanceCounter;
}
public static int getClassCounter() {
return classCounter;
}
public static void main(String[] args) {
// Create two distinct instances
SharedCounter sc1 = new SharedCounter();
SharedCounter sc2 = new SharedCounter();
// Thread A operates on sc1 instance and also increments class counter
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
sc1.incrementInstanceCounter(); // lock on sc1
SharedCounter.incrementClassCounter(); // lock on SharedCounter.class
}
}
});
// Thread B operates on sc2 instance and also increments class counter
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
sc2.incrementInstanceCounter(); // lock on sc2
SharedCounter.incrementClassCounter(); // lock on SharedCounter.class
}
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// Each instance's counter is incremented only by threads using that instance lock
System.out.println("sc1 instanceCounter: " + sc1.getInstanceCounter());
System.out.println("sc2 instanceCounter: " + sc2.getInstanceCounter());
// The classCounter is shared, incremented by both t1 and t2
System.out.println("classCounter: " + SharedCounter.getClassCounter());
}
}
Explanation
Instance Counter (object lock)
incrementInstanceCounter()
is declaredsynchronized
, locking on the currentSharedCounter
instance. Threadt1
acquiressc1
’s lock, and threadt2
acquiressc2
’s lock. These are different locks, so there is no mutual exclusion between threadst1
andt2
for instance-level increments, unless they happen to use the same object instance.
Class Counter (class lock)
incrementClassCounter()
is declaredstatic synchronized
, locking onSharedCounter.class
. Regardless of whethert1
is working onsc1
andt2
is working onsc2
, both need the same class lock when incrementingclassCounter
.Thus,
t1
andt2
cannot concurrently execute this static method; one will block while the other holds theSharedCounter.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 forsc1
andsc2
, and the final class counter.
Expected Outcome
sc1.getInstanceCounter()
will be 1000 (all increments byt1
only).sc2.getInstanceCounter()
will be 1000 (all increments byt2
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 asynchronized
block or method.
Coordination
notify()
ornotifyAll()
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()
When a thread calls
wait()
on an object (for example,this.wait()
inside a synchronized block of the current object):The thread releases the lock on that object.
The thread’s state changes to WAITING.
The thread remains in WAITING state until another thread calls
notify()
ornotifyAll()
on the same object lock.
Why does it sleep?
Because the thread is literally telling the JVM, “I can’t proceed meaningfully right now. Another thread must do some work (e.g., produce data or consume data) before I can continue.”
This is different from simply sleeping for a fixed time;
wait()
depends on some other thread to provide a signal that conditions have changed.
Understanding notify()
When a thread calls
notify()
on an object:It does not immediately release the lock. Instead, it signals one waiting thread that “a change has happened.”
The lock is still held by the notifying thread until it exits the synchronized block (or method).
Once the notifying thread exits the synchronized region, the lock becomes available. The waiting thread can then attempt to re-acquire the lock and move from WAITING to RUNNABLE, and eventually continue execution.
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) {
// 1. Acquire the lock on 'this'
while (buffer.size() == MAX_SIZE) {
// 2. If buffer is full, call wait():
// This releases the lock and the producer goes to WAITING state
wait();
}
// 3. The producer is awakened when 'notify()' is called
// somewhere else (the consumer in this case).
// The lock is re-acquired before proceeding here.
buffer.add(value);
System.out.println("Produced: " + value);
value++;
// 4. Call notify() to wake a waiting consumer
notify();
}
// 5. Thread sleeps for 500 ms to simulate production time
Thread.sleep(500);
}
}
public void consume() throws InterruptedException {
while (true) {
synchronized (this) {
// 1. Acquire the lock on 'this'
while (buffer.isEmpty()) {
// 2. If buffer is empty, call wait():
// This releases the lock and consumer goes to WAITING state
wait();
}
// 3. The consumer is awakened when 'notify()' is called
// (the producer in this case).
// The lock is re-acquired before continuing.
int val = buffer.poll();
System.out.println("Consumed: " + val);
// 4. Call notify() to wake a waiting producer
notify();
}
// 5. Thread sleeps for 500 ms to simulate consumption time
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
instancepc
.
- The producer thread obtains the intrinsic lock on the
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 ofif
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 onpc
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()
ornotifyAll()
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
) tobuffer
.
- The producer can now safely add an integer (
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.
- After adding an item, the producer calls
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
instancepc
.
- The consumer thread acquires the intrinsic lock on the
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.
- The consumer calls
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
.
- The consumer thread removes an item from the
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).
- After consuming an item, the consumer calls
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 ofif (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 awhile
loop ensures the thread rechecks the condition each time it is awakened.
- We use
Single Lock Object
- Both
produce()
andconsume()
methods synchronize onthis
, which is the sameProducerConsumer
instancepc
. Hence, the producer and consumer use the same lock to coordinate access tobuffer
.
- Both
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.
- 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
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.
Subscribe to my newsletter
Read articles from Jyotiprakash Mishra directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Jyotiprakash Mishra
Jyotiprakash Mishra
I am Jyotiprakash, a deeply driven computer systems engineer, software developer, teacher, and philosopher. With a decade of professional experience, I have contributed to various cutting-edge software products in network security, mobile apps, and healthcare software at renowned companies like Oracle, Yahoo, and Epic. My academic journey has taken me to prestigious institutions such as the University of Wisconsin-Madison and BITS Pilani in India, where I consistently ranked among the top of my class. At my core, I am a computer enthusiast with a profound interest in understanding the intricacies of computer programming. My skills are not limited to application programming in Java; I have also delved deeply into computer hardware, learning about various architectures, low-level assembly programming, Linux kernel implementation, and writing device drivers. The contributions of Linus Torvalds, Ken Thompson, and Dennis Ritchie—who revolutionized the computer industry—inspire me. I believe that real contributions to computer science are made by mastering all levels of abstraction and understanding systems inside out. In addition to my professional pursuits, I am passionate about teaching and sharing knowledge. I have spent two years as a teaching assistant at UW Madison, where I taught complex concepts in operating systems, computer graphics, and data structures to both graduate and undergraduate students. Currently, I am an assistant professor at KIIT, Bhubaneswar, where I continue to teach computer science to undergraduate and graduate students. I am also working on writing a few free books on systems programming, as I believe in freely sharing knowledge to empower others.