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

  1. 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.

  2. 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.

  3. 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:

  1. 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.

  2. Deadlocks
    Improper locking strategies can result in deadlocks, where two or more threads wait indefinitely for resources held by each other.

  3. 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:

  1. 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.

  2. 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.

  3. Garbage Collection
    In multithreaded applications, Java’s garbage collector (e.g., G1GC, ZGC) operates concurrently to minimize pauses and maintain performance.

  4. 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:

  1. Using a Dedicated Class that Implements Runnable

  2. Extending the Thread Class

  3. 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;  // 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

  1. SummationTask implements Runnable and defines the logic in the run() method to add all numbers in a specified range.

  2. We create four tasks (each summing a quarter of the overall range).

  3. We wrap each task in a Thread and start them.

  4. We use join() to ensure the main thread waits for all partial sums to be calculated.

  5. 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

  1. We create a SummationThread class that extends Thread.

  2. The summation logic is placed directly in the run() method.

  3. Similar to the previous approach, we start the threads, wait for them to finish (join()), and then accumulate the partial sums.

  4. 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

  1. We declare anonymous classes that implement Runnable for each thread.

  2. The summation logic is embedded within the thread creation code, which can be convenient for quick tasks.

  3. Like before, we use an array (partialSums) to store the partial results from each thread.

  4. 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

  1. Dedicated Class (Implements Runnable)

    • Pros: Clear separation of concerns, reusability, can extend another class if needed.

    • Cons: Requires writing a separate class.

  2. Extending Thread

    • Pros: Straightforward for small tasks.

    • Cons: Less flexible because you cannot extend another class, and it merges task logic with thread management.

  3. 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:

  1. Read the current value of counter from memory into a register (e.g., R1).

  2. Increment that register value (R1 = R1 + 1).

  3. 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

  1. 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.

  2. 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).

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 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

  1. 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
            }
        }
      
  2. 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:

  1. An instance counter and synchronized instance methods.

  2. 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

  1. 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.
  2. 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.

  3. 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.

  4. 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:

  1. 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.

  2. 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.
  3. 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:

  1. 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.

  2. 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()

  • When a thread calls wait() on an object (for example, this.wait() inside a synchronized block of the current object):

    1. The thread releases the lock on that object.

    2. The thread’s state changes to WAITING.

    3. The thread remains in WAITING state until another thread calls notify() or notifyAll() 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:

    1. It does not immediately release the lock. Instead, it signals one waiting thread that “a change has happened.”

    2. The lock is still held by the notifying thread until it exits the synchronized block (or method).

    3. 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

  1. synchronized (this)

    • The producer thread obtains the intrinsic lock on the ProducerConsumer instance pc.
  2. 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).

  3. 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.

  4. 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.

  5. Add Item to Buffer

    • The producer can now safely add an integer (value) to buffer.
  6. 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.
  7. 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

  1. synchronized (this)

    • The consumer thread acquires the intrinsic lock on the ProducerConsumer instance pc.
  2. Check Condition: while (buffer.isEmpty())

    • If there are no items to consume, the consumer must wait until the buffer has something.
  3. wait()

    • The consumer calls wait(), releasing the lock and entering the WAITING state.
  4. 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.

  5. Consume an Item

    • The consumer thread removes an item from the buffer.
  6. 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).
  7. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.

0
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.