Unit II: Exception Handling, I/O, and Multithreading

Master exception handling, file operations, and concurrent programming in Java

Exception Handling in Java

Definition

An exception is an unwanted or unexpected event that occurs during the execution of a program and disrupts the normal flow of instructions. Exception handling is a mechanism to handle runtime errors gracefully without terminating the program abruptly.

Key Points

Exception Hierarchy

Exception Hierarchy
java.lang.Object
    └── java.lang.Throwable
            ├── java.lang.Error
            │       ├── OutOfMemoryError
            │       ├── StackOverflowError
            │       └── VirtualMachineError
            │
            └── java.lang.Exception
                    ├── IOException (Checked)
                    ├── SQLException (Checked)
                    ├── ClassNotFoundException (Checked)
                    │
                    └── RuntimeException (Unchecked)
                            ├── NullPointerException
                            ├── ArrayIndexOutOfBoundsException
                            ├── ArithmeticException
                            └── IllegalArgumentException

Note

Errors are typically caused by problems outside the application's control (e.g., out of memory). Exceptions are conditions that can be anticipated and recovered from within the application.

try, catch, and finally Blocks

Syntax

TryCatchDemo.java
public class TryCatchDemo {
    public static void main(String[] args) {
        try {
            // Code that may throw an exception
            int result = 10 / 0;
            System.out.println(result);
        } catch (ArithmeticException e) {
            // Handle the exception
            System.out.println("Error: Division by zero!");
            System.out.println("Message: " + e.getMessage());
        } finally {
            // Always executed
            System.out.println("Finally block executed");
        }
        
        System.out.println("Program continues...");
    }
}

Multiple catch Blocks

MultipleCatch.java
public class MultipleCatch {
    public static void main(String[] args) {
        try {
            int[] arr = {1, 2, 3};
            System.out.println(arr[5]);  // ArrayIndexOutOfBoundsException
            
            int result = 10 / 0;  // ArithmeticException
            
        } catch (ArrayIndexOutOfBoundsException e) {
            System.out.println("Array index error: " + e.getMessage());
            
        } catch (ArithmeticException e) {
            System.out.println("Arithmetic error: " + e.getMessage());
            
        } catch (Exception e) {
            // Catches any other exception
            System.out.println("General error: " + e.getMessage());
        }
    }
}

Multi-catch (Java 7+)

MultiCatch.java
try {
    // Code that may throw multiple exceptions
    riskyOperation();
} catch (IOException | SQLException e) {
    // Handle multiple exception types with single catch block
    System.out.println("IO or SQL error: " + e.getMessage());
}

Nested try Blocks

NestedTry.java
try {
    // Outer try block
    System.out.println("Outer try");
    
    try {
        // Inner try block
        int result = 10 / 0;
    } catch (ArithmeticException e) {
        System.out.println("Inner catch: " + e.getMessage());
    }
    
} catch (Exception e) {
    System.out.println("Outer catch: " + e.getMessage());
} finally {
    System.out.println("Outer finally");
}

Exam Tip

The finally block always executes except when System.exit() is called or the JVM crashes. Even if there is a return statement in try or catch, finally still executes before the method returns.

Try-with-Resources (Java 7+)

TryWithResources.java
import java.io.*;

public class TryWithResources {
    public static void main(String[] args) {
        // Resources automatically closed after try block
        try (FileReader fr = new FileReader("file.txt");
             BufferedReader br = new BufferedReader(fr)) {
            
            String line;
            while ((line = br.readLine()) != null) {
                System.out.println(line);
            }
            
        } catch (IOException e) {
            System.out.println("Error: " + e.getMessage());
        }
        // No need for finally to close resources!
    }
}

Real-World Example: File Processing with Exception Handling

FileProcessor.java
import java.io.*;
import java.util.*;

public class FileProcessor {
    public static void processFile(String filename) {
        BufferedReader reader = null;
        try {
            reader = new BufferedReader(new FileReader(filename));
            String line;
            int lineCount = 0;
            
            while ((line = reader.readLine()) != null) {
                lineCount++;
                try {
                    // Process each line
                    int number = Integer.parseInt(line.trim());
                    System.out.println("Line " + lineCount + ": " + number);
                } catch (NumberFormatException e) {
                    System.out.println("Line " + lineCount + ": Invalid number format");
                }
            }
            
            System.out.println("Total lines processed: " + lineCount);
            
        } catch (FileNotFoundException e) {
            System.out.println("File not found: " + filename);
        } catch (IOException e) {
            System.out.println("Error reading file: " + e.getMessage());
        } finally {
            // Always close the reader
            if (reader != null) {
                try {
                    reader.close();
                } catch (IOException e) {
                    System.out.println("Error closing file: " + e.getMessage());
                }
            }
        }
    }
    
    public static void main(String[] args) {
        processFile("data.txt");
    }
}

Practice Questions

Q1: What is the difference between try-catch-finally and try-with-resources?

Q2: Can we have multiple catch blocks for a single try? In what order should they be arranged?

Q3: What happens if an exception occurs in the finally block?

Q4: Write a program that demonstrates nested try-catch blocks.

Q5: Can we have a try block without a catch block? Explain.

throw and throws Keywords

throw Keyword

Definition: The throw keyword is used to explicitly throw an exception from a method or any block of code.

ThrowDemo.java
public class ThrowDemo {
    public static void validateAge(int age) {
        if (age < 0) {
            throw new IllegalArgumentException("Age cannot be negative");
        }
        if (age < 18) {
            throw new ArithmeticException("Not eligible to vote");
        }
        System.out.println("Valid age: " + age);
    }
    
    public static void main(String[] args) {
        try {
            validateAge(15);
        } catch (ArithmeticException e) {
            System.out.println("Exception: " + e.getMessage());
        }
    }
}

throws Keyword

Definition: The throws keyword is used in method declaration to specify that the method might throw certain exceptions. It delegates the responsibility of exception handling to the caller.

ThrowsDemo.java
import java.io.*;

public class ThrowsDemo {
    // Method declares that it may throw IOException
    public static void readFile(String filename) throws IOException {
        FileReader file = new FileReader(filename);
        BufferedReader reader = new BufferedReader(file);
        String line = reader.readLine();
        System.out.println(line);
        reader.close();
    }
    
    // Method can throw multiple exceptions
    public static void process() throws IOException, SQLException {
        // Method implementation
    }
    
    public static void main(String[] args) {
        try {
            readFile("test.txt");
        } catch (IOException e) {
            System.out.println("File error: " + e.getMessage());
        }
    }
}

Custom Exceptions

CustomExceptionDemo.java
// Custom checked exception
class InsufficientFundsException extends Exception {
    private double amount;
    
    public InsufficientFundsException(double amount) {
        super("Insufficient funds. Required: " + amount);
        this.amount = amount;
    }
    
    public double getAmount() {
        return amount;
    }
}

// Custom unchecked exception
class InvalidUserException extends RuntimeException {
    public InvalidUserException(String message) {
        super(message);
    }
}

class BankAccount {
    private double balance;
    
    public void withdraw(double amount) throws InsufficientFundsException {
        if (amount > balance) {
            throw new InsufficientFundsException(amount - balance);
        }
        balance -= amount;
    }
}
throw throws
Used to throw an exception explicitly Used to declare an exception
Used inside a method Used in method signature
Followed by an exception object Followed by exception class names
Can throw only one exception at a time Can declare multiple exceptions

Checked vs Unchecked Exceptions

Checked Exceptions

Definition: Checked exceptions are exceptions that are checked at compile-time. The compiler verifies that these exceptions are either caught or declared in the method signature.

Unchecked Exceptions

Definition: Unchecked exceptions are exceptions that are not checked at compile-time. They occur at runtime and are also called runtime exceptions.

Checked Exceptions Unchecked Exceptions
Checked at compile-time Checked at runtime
Must be declared or handled Not required to declare or handle
Extends Exception class Extends RuntimeException class
Recoverable errors Programming errors
IOException, SQLException NullPointerException, ArithmeticException

Comprehensive Example: Exception Handling in Banking System

BankingSystem.java
import java.io.*;

// Custom Checked Exception
class InsufficientBalanceException extends Exception {
    private double required;
    private double available;
    
    public InsufficientBalanceException(double required, double available) {
        super("Insufficient balance. Required: " + required + 
              ", Available: " + available);
        this.required = required;
        this.available = available;
    }
    
    public double getShortfall() {
        return required - available;
    }
}

// Custom Unchecked Exception
class InvalidAccountException extends RuntimeException {
    public InvalidAccountException(String message) {
        super(message);
    }
}

class Account {
    private String accountNumber;
    private double balance;
    private boolean active;
    
    public Account(String accountNumber, double initialBalance) {
        if (accountNumber == null || accountNumber.trim().isEmpty()) {
            throw new InvalidAccountException("Account number cannot be empty");
        }
        this.accountNumber = accountNumber;
        this.balance = initialBalance;
        this.active = true;
    }
    
    public void withdraw(double amount) throws InsufficientBalanceException {
        if (!active) {
            throw new InvalidAccountException("Account is inactive");
        }
        
        if (amount <= 0) {
            throw new IllegalArgumentException("Amount must be positive");
        }
        
        if (amount > balance) {
            throw new InsufficientBalanceException(amount, balance);
        }
        
        balance -= amount;
        System.out.println("Withdrawn: $" + amount);
    }
    
    public void deposit(double amount) {
        if (!active) {
            throw new InvalidAccountException("Account is inactive");
        }
        
        if (amount <= 0) {
            throw new IllegalArgumentException("Amount must be positive");
        }
        
        balance += amount;
        System.out.println("Deposited: $" + amount);
    }
    
    public void saveToFile(String filename) throws IOException {
        try (PrintWriter writer = new PrintWriter(new FileWriter(filename))) {
            writer.println("Account: " + accountNumber);
            writer.println("Balance: " + balance);
            writer.println("Active: " + active);
        }
    }
    
    public double getBalance() {
        return balance;
    }
}

public class BankingSystem {
    public static void main(String[] args) {
        try {
            // Create account
            Account acc = new Account("ACC001", 1000.0);
            
            // Deposit money
            acc.deposit(500);
            
            // Withdraw money
            acc.withdraw(300);
            
            // Try to withdraw more than balance (checked exception)
            acc.withdraw(2000);
            
        } catch (InsufficientBalanceException e) {
            System.out.println("Transaction failed: " + e.getMessage());
            System.out.println("Shortfall: $" + e.getShortfall());
            
        } catch (InvalidAccountException e) {
            System.out.println("Account error: " + e.getMessage());
            
        } catch (IllegalArgumentException e) {
            System.out.println("Invalid input: " + e.getMessage());
        }
        
        // Demonstrating unchecked exception
        try {
            Account invalidAcc = new Account("", 100);  // Throws InvalidAccountException
        } catch (InvalidAccountException e) {
            System.out.println("Error creating account: " + e.getMessage());
        }
    }
}

Common Exception Types

CommonExceptions.java
public class CommonExceptions {
    public static void main(String[] args) {
        // 1. NullPointerException (Unchecked)
        String str = null;
        try {
            System.out.println(str.length());
        } catch (NullPointerException e) {
            System.out.println("NPE: Trying to access null object");
        }
        
        // 2. ArrayIndexOutOfBoundsException (Unchecked)
        int[] arr = {1, 2, 3};
        try {
            System.out.println(arr[5]);
        } catch (ArrayIndexOutOfBoundsException e) {
            System.out.println("Array index out of bounds");
        }
        
        // 3. ArithmeticException (Unchecked)
        try {
            int result = 10 / 0;
        } catch (ArithmeticException e) {
            System.out.println("Cannot divide by zero");
        }
        
        // 4. NumberFormatException (Unchecked)
        try {
            int num = Integer.parseInt("abc");
        } catch (NumberFormatException e) {
            System.out.println("Invalid number format");
        }
        
        // 5. ClassCastException (Unchecked)
        try {
            Object obj = "Hello";
            Integer num = (Integer) obj;
        } catch (ClassCastException e) {
            System.out.println("Invalid type cast");
        }
    }
}

Best Practices

Exception Handling Best Practices

  • Catch specific exceptions: Always catch the most specific exception type first
  • Don't swallow exceptions: Always log or handle exceptions appropriately
  • Use try-with-resources: For automatic resource management
  • Document exceptions: Use @throws in JavaDoc
  • Don't catch Throwable or Error: These represent serious problems
  • Clean up resources: Use finally or try-with-resources
  • Custom exceptions: Create meaningful custom exceptions for business logic

Practice Questions

Q1: What is the difference between throw and throws? Provide examples.

Q2: Create a custom checked exception for InvalidEmailException and demonstrate its usage.

Q3: Why are unchecked exceptions called runtime exceptions?

Q4: Can a method throw both checked and unchecked exceptions? Demonstrate with code.

Q5: What is exception propagation? How does it work in Java?

IOException, SQLException NullPointerException, ArithmeticException Recoverable conditions Programming errors
CheckedVsUnchecked.java
import java.io.*;

public class CheckedVsUnchecked {
    // Checked exception - must declare with throws
    public void readFile() throws IOException {
        FileReader fr = new FileReader("file.txt");
    }
    
    // Unchecked exception - no declaration needed
    public void divide(int a, int b) {
        int result = a / b;  // May throw ArithmeticException
    }
    
    public static void main(String[] args) {
        CheckedVsUnchecked demo = new CheckedVsUnchecked();
        
        // Must handle checked exception
        try {
            demo.readFile();
        } catch (IOException e) {
            System.out.println("File error");
        }
        
        // Unchecked - can handle but not required
        demo.divide(10, 2);
    }
}

Exam Tip

If a method throws a checked exception, the calling code must either handle it with try-catch or declare it with throws. Unchecked exceptions do not have this requirement.

Java I/O Basics

Definition

Java I/O (Input/Output) is used to process the input and produce the output. Java uses the concept of streams to make I/O operations fast and efficient.

Types of Streams

Stream Hierarchy

Stream Classes
Byte Streams:
InputStream (abstract)
    ├── FileInputStream
    ├── BufferedInputStream
    ├── DataInputStream
    └── ObjectInputStream

OutputStream (abstract)
    ├── FileOutputStream
    ├── BufferedOutputStream
    ├── DataOutputStream
    └── ObjectOutputStream

Character Streams:
Reader (abstract)
    ├── FileReader
    ├── BufferedReader
    ├── InputStreamReader
    └── StringReader

Writer (abstract)
    ├── FileWriter
    ├── BufferedWriter
    ├── OutputStreamWriter
    └── PrintWriter

Note

Use byte streams for binary data (images, audio, video) and character streams for text data. Character streams automatically handle character encoding.

Byte Streams and Character Streams

Byte Streams

ByteStreamDemo.java
import java.io.*;

public class ByteStreamDemo {
    public static void main(String[] args) {
        // Writing bytes to file
        try (FileOutputStream fos = new FileOutputStream("output.dat")) {
            byte[] data = {65, 66, 67, 68, 69};  // A, B, C, D, E
            fos.write(data);
            System.out.println("Data written successfully");
        } catch (IOException e) {
            System.out.println("Error: " + e.getMessage());
        }
        
        // Reading bytes from file
        try (FileInputStream fis = new FileInputStream("output.dat")) {
            int data;
            while ((data = fis.read()) != -1) {
                System.out.print((char) data);  // Prints: ABCDE
            }
        } catch (IOException e) {
            System.out.println("Error: " + e.getMessage());
        }
    }
}

Character Streams

CharStreamDemo.java
import java.io.*;

public class CharStreamDemo {
    public static void main(String[] args) {
        // Writing characters to file
        try (FileWriter fw = new FileWriter("output.txt")) {
            fw.write("Hello, Java I/O!\n");
            fw.write("Learning character streams.");
            System.out.println("Written to file");
        } catch (IOException e) {
            System.out.println("Error: " + e.getMessage());
        }
        
        // Reading characters from file
        try (FileReader fr = new FileReader("output.txt")) {
            int ch;
            while ((ch = fr.read()) != -1) {
                System.out.print((char) ch);
            }
        } catch (IOException e) {
            System.out.println("Error: " + e.getMessage());
        }
    }
}

Buffered Streams (Efficient I/O)

BufferedStreamDemo.java
import java.io.*;

public class BufferedStreamDemo {
    public static void main(String[] args) {
        // BufferedWriter - efficient writing
        try (BufferedWriter bw = new BufferedWriter(
                                  new FileWriter("buffered.txt"))) {
            bw.write("Line 1");
            bw.newLine();  // Platform-independent newline
            bw.write("Line 2");
            bw.newLine();
            bw.write("Line 3");
        } catch (IOException e) {
            e.printStackTrace();
        }
        
        // BufferedReader - efficient reading
        try (BufferedReader br = new BufferedReader(
                                  new FileReader("buffered.txt"))) {
            String line;
            while ((line = br.readLine()) != null) {
                System.out.println(line);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
Byte Streams Character Streams
Handle raw binary data Handle character data
8-bit (1 byte) at a time 16-bit (2 bytes) Unicode at a time
InputStream, OutputStream Reader, Writer
Used for images, audio, video Used for text files

File Reading and Writing

File Class

FileDemo.java
import java.io.File;

public class FileDemo {
    public static void main(String[] args) {
        File file = new File("test.txt");
        
        // File information
        System.out.println("Exists: " + file.exists());
        System.out.println("Name: " + file.getName());
        System.out.println("Path: " + file.getAbsolutePath());
        System.out.println("Is File: " + file.isFile());
        System.out.println("Is Directory: " + file.isDirectory());
        System.out.println("Can Read: " + file.canRead());
        System.out.println("Can Write: " + file.canWrite());
        System.out.println("Size: " + file.length() + " bytes");
        
        // Create new file
        try {
            if (file.createNewFile()) {
                System.out.println("File created");
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        
        // Create directory
        File dir = new File("myFolder");
        dir.mkdir();  // Creates single directory
        
        // List files in directory
        File folder = new File(".");
        String[] files = folder.list();
        for (String f : files) {
            System.out.println(f);
        }
    }
}

Complete File Operations Example

FileOperations.java
import java.io.*;

public class FileOperations {
    public static void main(String[] args) {
        String filename = "students.txt";
        
        // Write to file
        try (PrintWriter writer = new PrintWriter(new FileWriter(filename))) {
            writer.println("John,20,CS");
            writer.println("Jane,21,IT");
            writer.println("Bob,22,ECE");
            System.out.println("Data written successfully");
        } catch (IOException e) {
            e.printStackTrace();
        }
        
        // Read from file
        System.out.println("\nReading file:");
        try (BufferedReader reader = new BufferedReader(
                                       new FileReader(filename))) {
            String line;
            while ((line = reader.readLine()) != null) {
                String[] parts = line.split(",");
                System.out.println("Name: " + parts[0] + 
                                   ", Age: " + parts[1] + 
                                   ", Dept: " + parts[2]);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        
        // Append to file
        try (FileWriter fw = new FileWriter(filename, true)) {  // true = append mode
            fw.write("Alice,23,ME\n");
            System.out.println("\nData appended");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Warning

Always close streams after use to release system resources. Use try-with-resources (Java 7+) to automatically close streams.

Multithreading in Java

Definition

Multithreading is a feature that allows concurrent execution of two or more parts of a program to maximize CPU utilization. Each part of such a program is called a thread.

Thread Lifecycle

  1. New: Thread is created but not yet started
  2. Runnable: Thread is ready to run and waiting for CPU
  3. Running: Thread is executing
  4. Blocked/Waiting: Thread is waiting for a resource or another thread
  5. Terminated: Thread has completed execution

Creating Threads

There are two ways to create a thread:

Method 1: Extending Thread Class

ExtendThread.java
class MyThread extends Thread {
    private String threadName;
    
    public MyThread(String name) {
        this.threadName = name;
    }
    
    @Override
    public void run() {
        for (int i = 1; i <= 5; i++) {
            System.out.println(threadName + ": " + i);
            try {
                Thread.sleep(500);  // Sleep for 500 milliseconds
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class ExtendThread {
    public static void main(String[] args) {
        MyThread t1 = new MyThread("Thread-1");
        MyThread t2 = new MyThread("Thread-2");
        
        t1.start();  // Starts the thread
        t2.start();
        
        System.out.println("Main thread finished");
    }
}

Method 2: Implementing Runnable Interface

ImplementRunnable.java
class MyRunnable implements Runnable {
    private String threadName;
    
    public MyRunnable(String name) {
        this.threadName = name;
    }
    
    @Override
    public void run() {
        for (int i = 1; i <= 5; i++) {
            System.out.println(threadName + ": " + i);
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class ImplementRunnable {
    public static void main(String[] args) {
        MyRunnable r1 = new MyRunnable("Thread-1");
        MyRunnable r2 = new MyRunnable("Thread-2");
        
        Thread t1 = new Thread(r1);
        Thread t2 = new Thread(r2);
        
        t1.start();
        t2.start();
    }
}

Thread Priority

ThreadPriority.java
public class ThreadPriority {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                System.out.println("Low priority: " + i);
            }
        });
        
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                System.out.println("High priority: " + i);
            }
        });
        
        // Priority values: 1 (MIN) to 10 (MAX), 5 is NORMAL
        t1.setPriority(Thread.MIN_PRIORITY);   // 1
        t2.setPriority(Thread.MAX_PRIORITY);   // 10
        
        System.out.println("t1 priority: " + t1.getPriority());
        System.out.println("t2 priority: " + t2.getPriority());
        
        t1.start();
        t2.start();
    }
}

Thread Methods

Method Description
start() Starts the thread execution
run() Contains the code to be executed
sleep(ms) Pauses execution for specified milliseconds
join() Waits for thread to complete
isAlive() Checks if thread is still running
yield() Suggests scheduler to give chance to other threads
interrupt() Interrupts the thread

Common Mistake

Calling run() directly instead of start() will not create a new thread. The code will execute in the current thread. Always use start() to create a new thread.

Synchronization and Inter-thread Communication

Definition

Synchronization is a mechanism that ensures that only one thread can access a shared resource at a time. It prevents thread interference and memory consistency errors.

Synchronized Method

SyncMethodDemo.java
class Counter {
    private int count = 0;
    
    // Synchronized method
    public synchronized void increment() {
        count++;
    }
    
    public int getCount() {
        return count;
    }
}

public class SyncMethodDemo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        });
        
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        });
        
        t1.start();
        t2.start();
        
        t1.join();  // Wait for t1 to finish
        t2.join();  // Wait for t2 to finish
        
        System.out.println("Count: " + counter.getCount());  // Always 2000
    }
}

Synchronized Block

SyncBlockDemo.java
class BankAccount {
    private double balance;
    
    public BankAccount(double initialBalance) {
        this.balance = initialBalance;
    }
    
    public void withdraw(double amount) {
        // Synchronized block - locks on 'this' object
        synchronized (this) {
            if (balance >= amount) {
                System.out.println(Thread.currentThread().getName() + 
                                   " withdrawing: " + amount);
                balance -= amount;
                System.out.println("Balance: " + balance);
            } else {
                System.out.println("Insufficient funds");
            }
        }
    }
}

Inter-thread Communication

Java provides methods for threads to communicate with each other:

ProducerConsumer.java
class SharedQueue {
    private int data;
    private boolean hasData = false;
    
    public synchronized void produce(int value) throws InterruptedException {
        while (hasData) {
            wait();  // Wait until data is consumed
        }
        data = value;
        System.out.println("Produced: " + value);
        hasData = true;
        notify();  // Notify consumer
    }
    
    public synchronized int consume() throws InterruptedException {
        while (!hasData) {
            wait();  // Wait until data is produced
        }
        System.out.println("Consumed: " + data);
        hasData = false;
        notify();  // Notify producer
        return data;
    }
}

public class ProducerConsumer {
    public static void main(String[] args) {
        SharedQueue queue = new SharedQueue();
        
        // Producer thread
        Thread producer = new Thread(() -> {
            for (int i = 1; i <= 5; i++) {
                try {
                    queue.produce(i);
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        
        // Consumer thread
        Thread consumer = new Thread(() -> {
            for (int i = 1; i <= 5; i++) {
                try {
                    queue.consume();
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        
        producer.start();
        consumer.start();
    }
}

Exam Tip

Remember: wait(), notify(), and notifyAll() must be called from within a synchronized context (synchronized method or block). They are methods of the Object class, not Thread class.