Java - Week 11

Monitors, Thread Lifecycle, Concurrent Programming, and Thread-Safe Collections

The Lifecycle of a Java Thread

In Java, a thread goes through various states from the moment it is created until it finishes execution. Understanding these states is crucial for debugging concurrent programs. You can check a thread's current state using the t.getState() method.

The Six Thread States

  1. New: A thread is in this state when the object is created using the new operator (e.g., Thread t = new Thread()) but the start() method has not been called yet.
  2. Runnable: After calling start(), the thread becomes runnable. It is ready to be scheduled for execution by the thread scheduler. Note that "Runnable" doesn't necessarily mean it is actively running on the CPU at that exact moment; it could be waiting for its time-slice.
  3. Blocked: A thread enters this state when it is waiting to acquire a lock to enter a synchronized block or method. It is unblocked when the lock becomes available.
  4. Waiting: A thread is in a waiting state when it temporarily suspends its execution by calling wait(). It will remain in this state until another thread wakes it up by calling notify() or notifyAll().
  5. Timed Waiting: Similar to Waiting, but with a specified time limit. A thread enters this state when calling methods like sleep(milliseconds) or wait(timeout). It returns to the Runnable state once the timer expires.
  6. Terminated (Dead): The thread has finished executing its run() method naturally, or it was forcefully stopped due to an unhandled exception. Once a thread is dead, it cannot be restarted.

Monitors and Synchronization in Java

Java natively supports the concept of Monitors. Every object in Java can act as a monitor, meaning it can use a built-in lock to enforce mutual exclusion. This ensures that only one thread can execute a critical section of code at a time.

The synchronized Keyword

You apply the synchronized keyword to methods or code blocks. When a thread invokes a synchronized method, it must first acquire the intrinsic lock of that object. If another thread already holds the lock, the new thread is Blocked.

Java
public class BankAccount {
    private double balance = 1000.0;

    // Only one thread can execute this method at a time on a given object
    public synchronized void deposit(double amount) {
        balance += amount;
    }

    public synchronized void withdraw(double amount) {
        if (balance >= amount) {
            balance -= amount;
        }
    }

    // Unsynchronized method - multiple threads can call this simultaneously
    public double getBalance() {
        return balance; 
    }
}

Synchronized Blocks

Sometimes you don't want to lock an entire method, just a small critical section. You can use a synchronized block, explicitly specifying the object whose lock you want to acquire.

Java
public class DataProcessor {
    private final Object lockObject = new Object();

    public void process() {
        System.out.println("Doing some independent work...");
        
        // Acquiring the lock on a specific object
        synchronized(lockObject) {
            System.out.println("Inside critical section.");
            // Shared resource modification goes here
        }
    }
}

Inter-Thread Communication: wait() and notify()

Inside a synchronized area, a thread might realize it cannot proceed (e.g., trying to withdraw money, but the balance is too low). Instead of looping endlessly ("busy waiting"), it should call wait().

  • wait(): The thread gives up the lock and enters the Waiting state. It joins the object's internal waiting queue.
  • notifyAll(): Wakes up all threads that are waiting on this object's lock. They will then compete to re-acquire the lock.
  • notify(): Wakes up a single, arbitrary thread from the waiting queue.
Java
public class SharedBuffer {
    private boolean dataAvailable = false;

    public synchronized void produce() {
        dataAvailable = true;
        System.out.println("Data produced!");
        notifyAll(); // Wake up consumers waiting for data
    }

    public synchronized void consume() {
        // Always use a while loop for wait() to handle spurious wakeups
        while (!dataAvailable) {
            try {
                wait(); // Release the lock and wait
            } catch (InterruptedException e) {
                System.out.println("Thread interrupted.");
            }
        }
        System.out.println("Data consumed!");
        dataAvailable = false;
    }
}

Reentrant Locks

While the synchronized keyword is convenient, Java provides a more flexible and explicit locking mechanism in the java.util.concurrent.locks package: the ReentrantLock.

Using ReentrantLock

A ReentrantLock works similarly to a standard object lock but requires you to manually call lock() and unlock(). Because you manually acquire it, you must ensure it gets released, even if an exception occurs. Therefore, the unlock() call is almost always placed inside a finally block.

Java
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Bank {
    private Lock bankLock = new ReentrantLock();
    private double[] accounts = new double[100];

    public void transfer(int from, int to, double amount) {
        bankLock.lock(); // Acquire the lock
        try {
            if (accounts[from] < amount) return;
            accounts[from] -= amount;
            accounts[to] += amount;
        } finally {
            bankLock.unlock(); // Always release the lock in finally block
        }
    }
}

Why is it called "Reentrant"?

A lock is "reentrant" if a thread that already holds the lock can acquire it again without blocking itself. The lock maintains a "hold count." Every time the owning thread calls lock(), the count increases. Every time it calls unlock(), the count decreases. The lock is only released to other threads when the count reaches zero.

This is extremely useful when a locked method calls another method that also requires the same lock. Without reentrancy, the thread would deadlock itself.


Concurrent Programming: Thread Operations

When working with multiple threads, you need ways to coordinate their execution, pause them, or signal them to stop.

1. Thread.sleep()

Thread.sleep(milliseconds) causes the currently executing thread to pause its execution for the specified duration. This does not release any locks the thread holds. It is useful for simulating work or pacing an application. Because another thread might interrupt the sleeping thread, it throws an InterruptedException.

2. The join() Method

The join() method allows one thread to wait for the completion of another. If Thread A calls ThreadB.join(), Thread A will pause execution until Thread B finishes completely.

Java
public class JoinExample {
    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            for (int i = 1; i <= 3; i++) {
                System.out.println("Worker thread processing: " + i);
                try { Thread.sleep(500); } catch (InterruptedException e) {}
            }
        });

        worker.start();
        
        // The main thread will stop here and wait for 'worker' to finish
        worker.join(); 
        
        System.out.println("Worker finished. Main thread resuming.");
    }
}

3. Thread Interruption

You cannot safely "kill" a thread from the outside in Java (methods like stop() are deprecated). Instead, you politely ask it to stop using the interrupt() method.

If a thread is currently blocked in a sleep() or wait() call, calling interrupt() on it will immediately wake it up and throw an InterruptedException. If the thread is executing normally, calling interrupt() just sets a flag. The thread itself should periodically check isInterrupted() and decide how to terminate gracefully.

Java
public class InterruptExample {
    public static void main(String[] args) throws InterruptedException {
        Thread task = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                // Periodically check if we have been asked to stop
                if (Thread.currentThread().isInterrupted()) {
                    System.out.println("Interrupted! Terminating early.");
                    break;
                }
                System.out.print(i + " ");
            }
        });

        task.start();
        Thread.sleep(1); // Let it run for a tiny bit
        
        // Signal the thread to stop
        task.interrupt(); 
    }
}

Thread-Safe Collections

Standard Java collections like ArrayList, HashMap, and HashSet are not thread-safe. If multiple threads try to add or remove elements from a standard ArrayList at the same time, it can result in corrupted data, infinite loops, or a ConcurrentModificationException.

While you could manually wrap every collection operation in a synchronized block, this is error-prone and severely hurts performance because it prevents any parallel access. To solve this, Java provides specialized concurrent collections in the java.util.concurrent package.

1. ConcurrentHashMap

ConcurrentHashMap is a highly optimized, thread-safe alternative to HashMap. Instead of locking the entire map during an update, it divides the map into segments (or buckets) and only locks the specific segment being modified. This allows multiple threads to read and write to different parts of the map simultaneously without blocking each other.

Java
import java.util.concurrent.ConcurrentHashMap;
import java.util.Map;

public class ConcurrentMapExample {
    public static void main(String[] args) {
        // Safe for multiple threads to access simultaneously
        Map<String, Integer> scores = new ConcurrentHashMap<>();
        
        scores.put("Alice", 10);
        
        // Thread-safe atomic operation: only puts if absent
        scores.putIfAbsent("Bob", 20); 
        
        System.out.println(scores);
    }
}

2. CopyOnWriteArrayList

CopyOnWriteArrayList is a thread-safe variant of ArrayList. It achieves thread safety through a radical approach: every time an element is added, modified, or removed, it creates a brand new, completely separate copy of the underlying array.

When to use it: Because copying arrays is expensive, this collection is only suitable when you have many threads reading the list, but very few threads modifying it (e.g., maintaining a list of event listeners). Reading operations never require locks and are extremely fast.

Java
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.List;

public class CopyOnWriteExample {
    public static void main(String[] args) {
        List<String> activeUsers = new CopyOnWriteArrayList<>();
        
        activeUsers.add("UserA");
        activeUsers.add("UserB");
        
        // A thread can safely iterate over this list even if another
        // thread adds/removes elements concurrently. The iterator works
        // on a snapshot of the array taken at the time iterator() was called.
        for (String user : activeUsers) {
            System.out.println("Broadcasting to: " + user);
        }
    }
}

3. BlockingQueues

The BlockingQueue interface represents a queue that is thread-safe and supports operations that wait for the queue to become non-empty when retrieving an element, and wait for space to become available in the queue when storing an element. They are the backbone of the Producer-Consumer pattern.

Common implementations include ArrayBlockingQueue (backed by a fixed-size array) and LinkedBlockingQueue.

Java
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public class BlockingQueueExample {
    public static void main(String[] args) throws InterruptedException {
        // A queue with a strict limit of 5 items
        BlockingQueue<String> queue = new ArrayBlockingQueue<>(5);

        // put() will block (pause the thread) if the queue is full
        queue.put("Task 1"); 
        
        // take() will block (pause the thread) if the queue is empty
        String task = queue.take(); 
        System.out.println("Processing: " + task);
    }
}