# 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

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

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

```java
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**
    
    ```java
    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:
        
        ```java
        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**
    
    ```java
    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

```java
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**
    
    ```java
    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:
        
        ```java
        public static void doStaticTask() {
            synchronized (Example.class) {
                // critical section
            }
        }
        ```
        
2. **Synchronized Block on the Class Object**
    
    ```java
    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.

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

```java
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.
