Java Concurrency - Quick Interview Notes (Crash Course)


中文 | English

1. Basics of Concurrency

What is Concurrency?:Concurrency refers to multiple threads taking turns to use the CPU.

Why Use Concurrency?:The primary goal of concurrency is to maximize CPU utilization. When a program is idle, such as waiting for an I/O operation to complete, it can temporarily release the CPU. This allows other threads to use the CPU, ensuring it remains busy and thereby improving overall efficiency.

What Problems Can Arise from Concurrency?:

  • Thread Safety Issues:When multiple threads share the same variable, one thread’s modification might overwrite another thread's changes, leading to inconsistent or incorrect results.
  • Deadlocks:If multiple threads compete for resources, it may result in a situation where two or more threads are waiting indefinitely for each other to release resources, preventing further progress.

2. Threads

2.1 Basics of Threads

A thread is the smallest unit of execution that the operating system can schedule on the CPU. Threads exist within processes and serve as the actual units of operation within those processes.

A process must have at least one main thread, but it can create additional threads to handle tasks. These threads can either run concurrently (taking turns) or in parallel (executed simultaneously by multiple CPU cores).

While threads have their own independent memory (such as stacks), they share the process's heap memory. This shared memory can lead to concurrency issues, such as one thread overwriting another thread’s changes to shared variables.

The Five States of a Thread


在这里插入图片描述

  • New State:A thread is in the new (New) state when it is created, and the operating system allocates resources for it. Calling the thread.start() method transitions the thread to this state.
  • Ready State:In the ready (Runnable/Ready) state, a thread is waiting to be scheduled by the CPU.
  • Running State: A thread in the running (Running) state is actively executing on the CPU.
  • Blocked State:A thread enters the blocked (Blocked/Waiting) state when it is waiting for a specific event to complete, such as an I/O operation, acquiring a lock, or completing a sleep period.
  • Terminated State:In the terminated (Terminated/Dead) state, the thread has completed its execution or exited due to an exception. The operating system releases its resources.

State Transitions:

  • New → Ready:Once a thread is created, it transitions to the ready state, waiting in a queue to be scheduled by the CPU.
  • Ready → Running:Threads in the ready state take turns in the queue. When a thread is selected by the CPU scheduler, it transitions to the running state.
  • Running → Ready::A thread returns to the ready state if its CPU time slice expires. Alternatively, calling the Thread.yield() method within the code voluntarily releases the CPU, causing the thread to re-enter the ready queue.
  • Running → Blocked:When a thread encounters an event that requires waiting, it transitions to the blocked state. Common scenarios include:
    • Thread.sleep(...): The thread sleeps for a specified period.
    • thread.wait(): The thread waits for a specific resource.
    • synchronized(obj) or lock.lock(): The thread waits to acquire a lock.
    • Other events: Waiting for I/O completion (e.g., file reads), network operations (e.g., accept()), or other threads (e.g., thread.join(), futureTask.get()).
  • Blocked → Ready:When the event the thread was waiting for is resolved, the thread transitions back to the ready state and waits for CPU scheduling.
  • Running → Terminated:A thread transitions to the terminated state when it finishes its execution or exits due to an exception. Common ways threads terminate include:
    • Normal execution completes.
    • An unhandled exception occurs.
    • The thread.stop() method is invoked (though this is discouraged due to safety concerns).

2.2 The Thread Class in Java

In Java, all the common ways to create a thread actually create a Thread instance and invoking its start method.

2.2.1 Initializing a Thread

When initializing a Thread object, you can pass up to four parameters using the constructor: Thread(ThreadGroup group, Runnable target, String name, long stackSize) :

  • ThreadGroup group: Specifies the thread group to which the thread belongs. Thread groups are used to organize multiple threads for easier management and control. If no thread group is specified, Java assigns the thread to the current thread's group.
  • Runnable target: Defines the task the thread will execute. The Runnable interface is a functional interface where you typically implement the run method to define the thread's behavior.
  • String name: Sets the name of the thread. The thread name is a string used to identify the thread. If no name is specified, Java automatically assigns a default name, such as "Thread-0", "Thread-1", and so on.
  • long stackSize: Specifies the thread's stack size. The stack is used to store method calls and local variables, and its size affects the thread's memory usage and performance. If not specified, Java uses a default stack size.

2.2.2 Common Methods in the Thread Class

Here are some commonly used methods in the Thread class:

  • start():Initiates the thread, transitioning it to the ready state and starting the execution of the code defined in the run() method.
  • run():Defines the thread’s execution behavior and contains the code to be executed. In a multithreaded environment, the run() method is automatically called when the thread is in the ready state and scheduled for execution.
  • Thread.sleep(long millis): Pauses the current thread for the specified number of milliseconds. During this period, the thread is in a blocked state. After the sleep duration ends, the thread moves to the ready state, waiting for CPU scheduling.
  • isAlive(): Checks whether a thread is currently active. Returns false if the thread hasn’t been started or has already completed its execution (including cases where it terminates due to an exception). If the thread has started and hasn’t completed its run() method yet, it returns true, regardless of whether the thread is in the ready, running, or blocked state.
  • Thread.currentThread(): A static method used to obtain the thread object of the currently executing thread.
  • Thread.yield(): Suggests that the current thread should give up the CPU and transition to the ready state. Unlike sleep(), which moves a thread to a blocked state and waits for the sleep duration to end before returning to the ready state, yield() transitions directly from running to ready. Use this method cautiously, as frequent thread switching can be resource-intensive.
  • setPriority(int priority) and getPriority(): Used to set and retrieve a thread’s priority. The priority range is from 1 (lowest) to 10 (highest). Higher priorities suggest that the thread is more likely to be scheduled by the JVM’s thread scheduler, but this does not necessarily affect the operating system's thread scheduler.
  • join(): Causes the calling thread to wait until the specified thread has completed its execution. During this time, the calling thread is blocked. You can use the join(long millis) method to specify a maximum waiting time. If the specified time elapses, the calling thread will resume even if the other thread hasn’t finished. For example, if a segment of code must run only after thread A completes, you can call A.join() before executing that segment.

2.2.3 Thread Interruption

The Thread class provides two important methods for managing thread interruptions:

  • interrupt(): Sends a request to interrupt a thread.
  • isInterrupted(): Checks if a thread is in an interrupted state.

Here’s how it works: If threadA wants to request threadB to stop (for example, if threadA needs threadB to release some resource it holds), threadA can call threadB.interrupt(). After this call, threadB.isInterrupted() will return true. At this point, it is up to threadB to check its own isInterrupted() status. If it finds the status to be true, it can gracefully terminate itself. However, if threadB does not monitor this status within its run method, nothing will happen, and the thread will continue running.

In summary, thread interruption is essentially one thread requesting another to stop, but it is entirely up to the second thread to decide whether to handle that request and terminate itself.

Why not use stop to terminate a thread?

Using stop method is highly discouraged because it is too abrupt. It immediately kills the thread without giving it a chance to complete any necessary cleanup tasks, such as releasing resources. For this reason, stop is not a recommended way to terminate threads.

Why does Thread.sleep require handling InterruptedException?

When a thread is interrupted using the interrupt method, it is essentially being notified that it should stop as soon as possible. If the thread is currently sleeping, it needs to be "woken up" so it can handle the interruption. The way the JVM achieves this is by throwing an InterruptedException to the sleeping thread. Without handling this exception, the thread would remain stuck in its sleep state and miss the interruption signal. This is why it is mandatory to handle the InterruptedException when using Thread.sleep.

Example 1: Continuous Loop in a Worker Thread

```java
public class ThreadInterruptExample {

    public static void main(String[] args) {
        WorkerThread worker = new WorkerThread();
        worker.start();  // Start the background worker thread
        // ...
        worker.interrupt(); // Send an interruption request
    }

    static class WorkerThread extends Thread {
        @Override
        public void run() {
            while (!isInterrupted()) { // Check if the thread is interrupted
                System.out.println("Working...");
            }

            // Perform cleanup tasks, such as releasing resources
            System.out.println("Worker thread exiting.");
        }
    }
}
```

Example 2: Sequential Execution Without Looping

```java
static class WorkerThread extends Thread {
    @Override
    public void run() {
        try {
            doSomeWork(); // Execute the main task
        } catch (InterruptedException e) {
            // Handle the interruption, performing cleanup if necessary
            System.out.println("Interrupted. Cleaning up...");
        }

        // Perform any additional cleanup after the interruption
        System.out.println("Worker thread exiting.");
    }

    private void doSomeWork() throws InterruptedException {
        // Perform some tasks...
        if (Thread.currentThread().isInterrupted()) {
            // Check for interruption and throw an exception if interrupted
            throw new InterruptedException();
        }
        // Continue with other tasks...
        if (Thread.currentThread().isInterrupted()) {
            // Check for interruption again
            throw new InterruptedException();
        }
        // Perform further operations...
    }
}
```

2.3 Different Ways to Create Threads

2.3.1 Extending the Thread Class

You can create a thread by directly extending the Thread class and calling its start method.

However, this approach is not recommended for several reasons:

  • Not aligned with object-oriented design principles: The task logic and the thread class are tightly coupled, which makes the design less modular.
  • Single inheritance restriction:Extending the Thread class prevents your class from inheriting other classes, which might limit functionality. Moreover, you are extending Thread just to override its run method, which is not ideal.
  • Inconvenient for thread pool management: If you later want to manage threads using a thread pool, this approach complicates the process.

Exception: If you need to modify or extend the Thread class itself, inheriting from it may be appropriate.

Example:

```java
class MyThread extends Thread {
    public void run() {
        // Task logic
    }
}

public class Main {
    public static void main(String[] args) {
        MyThread thread = new MyThread();
        thread.start();
    }
}
```

2.3.2 Implementing the Runnable Interface

A better approach is to implement the Runnable interface and pass the instance to a Thread object.

Example 1:

```java
class MyRunnable implements Runnable {
    public void run() {
        // Task logic
    }
}

public class Main {
    public static void main(String[] args) {
        MyRunnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable);
        thread.start();
    }
}
```

Example 2: Using an anonymous inner class to implement Runnable:

```java
Thread thread1 = new Thread(new Runnable() {
    public void run() {
        // Task logic
    }
});
thread1.start();
```

2.3.3 FutureTask with the Callable Interface

The run method in both Thread and Runnable does not return a value. If you need a thread that can return a result, you can use a combination of FutureTask and Callable.

This approach is ideal for scenarios where you want to: Start a thread to execute a task and retrieve the result later.

Key Components:

  • Callable Inteface: Similar to Runnable, but allows tasks to return a result.
  • Future/FutureTask: Used to retrieve the result of a Callable task or check its completion status. (Future is an interface; FutureTask is its implementation.)
  • Thread/ThreadPoolExecutor: Executes the Callable task.

Example:

```java
public static void main(String[] args) {
    // Create a Callable task
    Callable<Integer> callableTask = new Callable<Integer>() {
        @Override
        public Integer call() {
            return 10; // Return the result
        }
    };

    // Submit the Callable task to a FutureTask
    FutureTask<Integer> futureTask = new FutureTask<>(callableTask);

    // Use a thread pool to execute the task
    ExecutorService executor = Executors.newFixedThreadPool(1);
    executor.execute(futureTask);

    // Alternatively, you can use a Thread to execute the task
//        Thread thread = new Thread(futureTask);
//        thread.start();

    // Do some other works ...

    // Wait for the task to complete and retrieve the result
    try {
        // This blocks until the task is finished
        Integer result = futureTask.get();
        System.out.println(result);
    } catch (InterruptedException | ExecutionException e) {
        e.printStackTrace();
    }
}
```

3. Thread Pool (ThreadPoolExecutor)

3.1 Introduction to ThreadPoolExecutor

In Java backend development, threads are typically not created directly using new Thread(). Instead, a thread pool is used to manage threads. This approach helps avoid frequent thread creation, which can lead to resource exhaustion and potential server crashes.

ThreadPoolExecutor is a Java class designed to manage thread pools. It provides a flexible way to control the lifecycle of threads and task execution strategies. With ThreadPoolExecutor, you can manage the pool size, execution policies, and rejection strategies.

3.2 Initializing ThreadPoolExecutor

The ThreadPoolExecutor class requires the following seven parameters for initialization:

  1. int corePoolSizeCore thread count。This is the number of threads that remain active in the thread pool, even if they are idle.
  2. int maximumPoolSizeMaximum thread count. This includes both core and non-core threads. When the task queue is full and all core threads are busy, the thread pool creates additional threads up to this maximum count.
  3. long keepAliveTimeIdle timeout. When the number of threads exceeds the core pool size, this specifies how long excess idle threads will wait for new tasks before being terminated.
  4. TimeUnit unit:The time unit for the keepAliveTime parameter. This can be seconds, milliseconds, microseconds, etc.
  5. BlockingQueue workQueueBlocking queue. This queue holds tasks that are waiting to be executed. BlockingQueue is an interface with several implementation options to choose from.
  6. ThreadFactory threadFactory:This is a factory interface used to create new threads. It includes the method Thread newThread(Runnable r). You can customize the ThreadFactory to set thread names, priorities, and other attributes. By default, the DefaultThreadFactory is typically used.
  7. RejectedExecutionHandler handlerRejection policy. When the thread pool cannot accept new tasks, this handler's rejectedExecution(...) method is invoked to manage the situation. The default policy is AbortPolicy, which discards the task and throws an exception.

3.3 The Task Submission Process in a Thread Pool

When a task is submitted to a thread pool, it undergoes the following process:

  1. Core Threads Handling Tasks: If the number of active threads is less than the configured corePoolSize, the new task is immediately assigned to a core thread for execution, bypassing the blocking queue.
  2. Queueing New Tasks: If the number of active threads has reached corePoolSize and the blocking queue is not yet full, the new task will be placed into the blocking queue, where it waits for an available thread.
  3. Creating Additional Threads: If both the core threads are fully utilized and the blocking queue is full, but the total number of threads is still below maximumPoolSize, the thread pool will create additional (non-core) threads to handle the task.
  4. Rejection Policy for Excess Tasks: If the thread pool has reached its maximumPoolSize and the blocking queue is full, the thread pool will apply a rejection policy to handle the new task. By default, this policy throws a RejectedExecutionException. However, custom rejection policies can be implemented to define alternative behaviors.

The following example demonstrates this process:

```java
// Create a thread pool with a core size of 2, max size of 4, and a blocking queue capacity of 6
ThreadPoolExecutor executor = new ThreadPoolExecutor(2, 4, 0, TimeUnit.MILLISECONDS,
        new ArrayBlockingQueue<>(6));

// Submit 12 tasks to the thread pool
for (int i = 1; i <= 12; i++) {
    Thread.sleep(100);

    try {
        executor.submit(() -> {
            while (true) {} // Simulate a long-running task
        });
    } catch (RejectedExecutionException e) {
        // Default AbortPolicy throws RejectedExecutionException
        System.out.print("Task rejected;  ");
    }

    System.out.print(i + ": ");
    System.out.print("Queue size:" + executor.getQueue().size() + ";  ");
    System.out.print("Active threads:" + executor.getActiveCount() + ";  ");
    System.out.println();
}

// Shut down the thread pool
executor.shutdown();
```

Output:

```
// Tasks 1 to 2: Core threads handle tasks directly, no queueing
1: Queue size: 0;  Active threads: 1;  
2: Queue size: 0;  Active threads: 2; 
// Tasks 3 to 8: Tasks are queued as core threads are busy, and the queue isn't full yet
3: Queue size: 1;  Active threads: 2;  
4: Queue size: 2;  Active threads: 2;  
5: Queue size: 3;  Active threads: 2;  
6: Queue size: 4;  Active threads: 2;  
7: Queue size: 5;  Active threads: 2;  
8: Queue size: 6;  Active threads: 2; 
 // Tasks 9 to 10: Queue is full, new threads are created up to `maximumPoolSize`
9: Queue size: 6;  Active threads: 3;  
10: Queue size: 6;  Active threads: 4; 
// Tasks 11 to 12: Queue and thread pool are full, tasks are rejected
Task rejected;  11: Queue size: 6;  Active threads: 4;   
Task rejected;  12: Queue size: 6;  Active threads: 4;   
```

3.4 Common Blocking Queues and Rejection Policies

Java provides developers with the following blocking queues, all of which implement the java.util.concurrent.BlockingQueue interface:

  • ArrayBlockingQueue:A blocking queue based on an array implementation. Its capacity must be explicitly specified during creation.
  • LinkedBlockingQueue:A blocking queue based on a linked list implementation. By default, its initial capacity is set to Integer.MAX_VALUE. However, if a capacity is specified during creation, it cannot be modified later.
  • PriorityBlockingQueue:A priority blocking queue implemented with a heap (its capacity is the same as LinkedBlockingQueue). The user's Runnable class must also implement the Comparable interface to define task priority. For example: class MyTask implements Runnable, Comparable<Integer>.
  • DelayQueue:A delayed queue that allows tasks to be executed only after a specified delay.
  • SynchronousQueue:A synchronous queue that can only hold one element at a time. Unlike other blocking queues, it does not store elements. Instead, when a producer thread attempts to add an element, it must wait for a consumer thread to retrieve it. Similarly, when a consumer thread tries to retrieve an element, it must wait for a producer thread to add one. When used in a ThreadPoolExecutor, any incoming task will either result in a new thread being created or the task being rejected.

When a blocking queue is full and the number of active threads reaches the maximum pool size, additional incoming tasks will be rejected. Developers can implement a custom rejection policy by providing their own implementation of the RejectedExecutionHandler interface. Java also offers several built-in rejection policies:

  • AbortPolicy(default policy):Directly throws a RejectedExecutionException.
  • CallerRunsPolicy: When the thread pool cannot accept new tasks, the thread submitting the task (the one calling the execute method) executes the task itself. This policy does not discard tasks but shifts the burden to the calling thread.
  • DiscardPolicy: Silently discards the rejected task without any exception or further processing.
  • DiscardOldestPolicy: Discards the oldest task in the queue (the one waiting the longest) and then attempts to resubmit the current task.

3.5 Creating Thread Pools with the Executors Class

In addition to manually creating a ThreadPoolExecutor instance, Java provides the java.util.concurrent.Executors class, which offers convenient methods for creating and managing thread pools.

The Executors class includes the following commonly used methods for creating thread pools:

  • Executors.newFixedThreadPool(int nThreads): Creates a thread pool with a fixed number of threads.
  • Executors.newCachedThreadPool(): Creates a thread pool that dynamically creates threads as needed. If there are no idle threads, a new thread will be created for each incoming task.
  • Executors.newSingleThreadExecutor(): Creates a single-threaded pool where tasks are executed sequentially in the order they arrive.
  • Executors.newScheduledThreadPool(int corePoolSize): Creates a thread pool that supports scheduling tasks to run after a delay or periodically.
  • Executors.newWorkStealingPool(int parallelism):Creates a work-stealing thread pool designed to maximize CPU utilization. The number of threads adjusts dynamically based on the number of CPU cores. (This method is rarely used, as maximizing CPU usage might slow down responsiveness for user-facing tasks.)

Why Avoid Using Executors for Creating Thread Pools?

It is generally not recommended to use Executors to create thread pools. A look at the implementation of its newXXXPool methods reveals why:

newFixedThreadPool:

```java
public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                   // The queue size is not explicitly configured, so tasks keep being added.
                                  // This may lead to an excessively long queue and eventually an OutOfMemoryError (OOM).
                                  new LinkedBlockingQueue<Runnable>());  
}
```

newCachedThreadPool:

```java
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,  // No core threads, and maximum threads are unlimited.
                              60L, TimeUnit.SECONDS,
                              // The queue spawns new threads whenever no idle threads are available.
                              // This can lead to unbounded thread creation and result in OOM.
                              new SynchronousQueue<Runnable>());  
```

Other Executors methods have same issues.

4. Locks

A lock is a synchronization mechanism used to control access to shared resources by multiple threads.

Synchronization: Two tasks must execute sequentially. Asynchrony: Two tasks can execute concurrently.

4.1 Common Types of Locks

In programming, there are various types of locks, each with its own principles and use cases.

Locks can be categorized from different perspectives:

Based on Scope:

  • Global Lock: Affects all resources in the entire application or process.
  • Local Lock: Only affects specific resources or code blocks.

Based on Granularity:

  • Coarse-grained Lock:Locks entire data structures or resources, often resulting in lower concurrency performance.
  • Fine-grained Lock:Divides data structures or resources into smaller parts and locks each part separately, improving concurrency performance.

Based on Control Mechanism:

  • Pessimistic Lock:Acquires the lock before accessing a resource to prevent other threads from accessing it. This is suitable for scenarios with frequent writes.
  • Optimistic Lock:Assumes that no other threads will modify the data while it's being modified. The check happens only during the commit phase to verify if other threads have modified the data, suitable for scenarios with frequent reads.

Based on Lock Acquisition Order:

  • Fair Lock:Threads acquire the lock in the order they requested it. If multiple threads are waiting for the lock, it is allocated based on a first-come, first-served principle. Fair locks ensure that all waiting threads get a chance to acquire the lock and avoid starvation of certain threads.
  • Unfair Lock: There is no queuing mechanism; the order in which threads acquire the lock is random, depending on the thread scheduler's choice. Compared to fair locks, unfair locks allow faster execution because new threads can immediately try to acquire the lock without waiting for previously queued threads.

Based on Implementation Type:

  • Mutex Lock:The most common type of lock, which allows only one thread to access the protected resource at any given time.
  • Read-Write Lock: Allows multiple threads to read shared resources concurrently, but requires exclusive access when writing. Typically, the read lock is referred to as shared lock, and the write lock is referred to as exclusive lock.
  • Spin Lock: When attempting to acquire the lock, the thread does not get blocked but repeatedly tries to acquire the lock until it succeeds or reaches the maximum number of attempts.
  • Recursive Lock: Allows the same thread to acquire the same lock multiple times, with each acquisition requiring a corresponding release. Also known as a reentrant lock.

4.2 Different Locking Mechanisms in Java

4.2.1 Synchronized

The synchronized keyword is the most basic locking mechanism in Java. It implements synchronization by adding the synchronized keyword before methods or code blocks.

When a thread enters a method or code block marked with synchronized, it attempts to acquire the object's lock. If successful, other threads must wait until the current thread finishes and releases the lock.

Use Cases for synchronized Locking:

  • Access control for shared resources in a multithreaded environment:synchronized ensures that access to shared resources is safe in multithreading, preventing race conditions.
  • Inter-thread communication: synchronized can be used in conjunction with methods like wait() and notify() to implement a waiting and notification mechanism between threads, enabling cooperation between them.

Advantages of synchronized Locking:

  • Simple and easy to use: Implementing synchronization with the synchronized keyword is straightforward, and there's no need to explicitly create lock objects or manually release the lock.

Disadvantages of synchronized Locking:

  • Coarse granularity: synchronized typically synchronizes the entire method or code block, which can lead to performance issues, especially under high concurrency. If a method is locked, the entire object is locked, preventing other threads from calling any other method on that object. If a static method is locked, the entire "class object" is locked, preventing other threads from calling any other static method of the class.
  • Non-interruptible: Once a thread acquires the lock on an object, other threads must wait for the lock to be released. Threads cannot be interrupted while waiting for the lock.
  • Non-fairness: synchronized is a non-fair lock, meaning it does not guarantee that the thread that has waited the longest will acquire the lock first.

Example 1: Locking a Method

```java
/**
The 'synchronized' keyword applied to a method locks the instance of the object. 
When a thread accesses a synchronized method of the object, other threads can 
access non-synchronized methods of that object, but they cannot access any 
synchronized methods.
*/
public synchronized void increment() {
    count++;
}

public synchronized int getCount() {
    return count;
}
```

Example 2: Locking a Static Method

```java
/**
The 'synchronized' keyword applied to a static method locks the "class object" itself (XXXX.class). 
When a thread accesses a synchronized static method, other threads can access any method 
of the object (including synchronized ones), and can also access non-synchronized static methods. 
However, they cannot access other synchronized static methods of the class.
*/
public static synchronized void increment() {
    count++;
}

public static synchronized int getCount() {
    return count;
}
```

Example 3: Locking an Object with a Code Block

```java
// Locking the obj object. The lock is released after the code block finishes executing.
synchronized (obj) {
    count++;
}
```

Example 4: Reentrancy Demonstration

```java
synchronized (obj) {
    // do something

    // 'synchronized' is a reentrant lock, meaning the same thread can acquire the lock again.
    synchronized (obj) { 
        // do something
    }
}
```

4.2.2 ReentrantLock Class

ReentrantLock is a reentrant lock in Java that provides similar functionality to the synchronized keyword but with more flexibility.

Use Cases for ReentrantLock:

  • Controlling access to shared resources: When multiple threads need to access a shared resource, ReentrantLock can be used to ensure thread safety, such as managing connections in a database connection pool.
  • Suitable for complex locking scenarios: ReentrantLock offers additional features like conditional waiting, interruptible lock acquisition, and fairness policies, making it ideal for more complex scenarios.

Advantages of ReentrantLock:

  • Conditional waiting: ReentrantLock can be combined with Condition to implement conditional waiting and notification between threads, such as managing queues in the producer-consumer pattern.
  • Interruptible lock acquisition: ReentrantLock allows locks to be acquired in an interruptible way, meaning a thread can be interrupted while waiting for a lock.
  • Fair lock: ReentrantLock supports a fairness policy, ensuring that threads that have been waiting the longest are given priority when acquiring the lock, thus preventing starvation.
  • Timeout waiting: It allows threads attempting to acquire a lock to specify a timeout; if the lock is not acquired within that time, the thread can give up.

Disadvantages of ReentrantLock:

  • Complexity:Compared to the synchronized keyword, using ReentrantLock introduces more complexity in the code.
  • Risk of forgetting to release the lock: Since lock acquisition and release are manually managed, there is a higher risk of forgetting to release the lock, which can lead to deadlocks and other issues.
  • Performance overhead: ReentrantLock incurs slightly higher performance overhead compared to synchronized.

Example 1: Default Lock (Non-Fair)

```java
// By default, the lock is non-fair. If you need a fair lock, use `new ReentrantLock(true);`
private final ReentrantLock lock = new ReentrantLock();

public void performTask() {
    lock.lock(); // Acquire the lock
    try {
        // Execute the critical section
        System.out.println(Thread.currentThread().getName() + " is performing the task...");
    } finally {
        lock.unlock(); // Release the lock
    }
}
```

Example 2: Interruptible Lock

```java
Thread thread1 = new Thread(() -> {
    try {
        // Acquire the lock in an interruptible way, allowing the thread to be interrupted using `interrupt()`
        lock.lockInterruptibly();
        System.out.println(Thread.currentThread().getName() + " acquired the lock");
        Thread.sleep(100000);  // Simulate a long-running task
    } catch (InterruptedException e) {
        // Handle the interruption by catching the InterruptedException
        System.out.println(Thread.currentThread().getName() + " was interrupted");
    } finally {
        // Check if the thread still holds the lock before unlocking
        if (lock.isHeldByCurrentThread()) {
            System.out.println(Thread.currentThread().getName() + " is releasing the lock");
            lock.unlock();
        }
    }
});

Thread thread2 = new Thread(() -> {
    lock.lock(); // Acquire the lock in a non-interruptible way
    System.out.println(Thread.currentThread().getName() + " acquired the lock");
    lock.unlock();
});

thread1.start();
Thread.sleep(100); // // Ensure thread 1 has started and has already acquired the lock
thread2.start();
System.out.println(thread2.getName() + " started!");
Thread.sleep(1000); // Let the main thread wait before interrupting thread 1
System.out.println("Interrupting " + thread1.getName());
thread1.interrupt(); // Interrupt thread 1
```

Output:

```
Thread-0 acquired the lock
Thread-1 started!
Interrupting Thread-0
Thread-0 was interrupted
Thread-0 is releasing the lock
Thread-1 acquired the lock
```

Example 3: Timeout Lock

```java
Runnable task = () -> {
    try {
         // Attempt to acquire the lock, waiting for up to 2 seconds. If unable to acquire, execute the "else" logic
        if (lock.tryLock(2, TimeUnit.SECONDS)) {
            try {
                System.out.println(Thread.currentThread().getName() + " acquired the lock.");
                Thread.sleep(100000); // Simulate task execution
            } finally {
                lock.unlock();
            }
        } else {
            System.out.println(Thread.currentThread().getName() + " timed out waiting for the lock.");
        }
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
};

// reate and start multiple threads
for (int i = 0; i < 3; i++) {
    Thread.sleep(100);
    new Thread(task).start();
}
```

Output:

```
Thread-0 acquired the lock.
Thread-1 timed out waiting for the lock.
Thread-2 timed out waiting for the lock.
```

4.2.3 ReadWriteLock Interface

The Read-Write Lock (ReadWriteLock) interface in the java.util.concurrent.locks package provides support for read-write locks. A read-write lock allows multiple threads to read a shared resource concurrently but enforces exclusive access when writing.

The ReadWriteLock is an interface in Java:

```java
public interface ReadWriteLock {
    Lock readLock();
    Lock writeLock();
}
```

The most commonly used implementation of this interface is ReentrantReadWriteLock.

Common Use Cases for ReadWriteLock:

  • Read-heavy scenarios: Ideal for situations where the frequency of read operations far exceeds that of write operations. It allows multiple threads to read simultaneously, improving concurrency performance.
  • Caching systems: In caching systems, read operations are typically far more frequent than writes. Using a read-write lock can enhance efficiency for concurrent access.

Advantages of ReadWriteLock:

  • Improved concurrency: Enables multiple threads to read shared resources simultaneously, with exclusive locks only required for writes.
  • Reduced lock contention: In scenarios with frequent reads and infrequent writes, read-write locks reduce contention between threads, boosting overall system throughput.

Drawbacks of ReadWriteLock:

  • Exclusive nature of write locks: A write lock is exclusive. When a thread holds a write lock, no other thread can perform read or write operations. This may lead to prolonged blocking, especially for high-contention write locks, reducing system responsiveness.

Example 1: ReentrantReadWriteLock

```java
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();

// Read thread
new Thread(() -> {
    try {
        readLock.lock();
        System.out.println("Thread 1 acquired read lock");
        Thread.sleep(1000);
        readLock.unlock();
        System.out.println("Thread 1 released read lock");
    } catch (InterruptedException e) { }
}).start();

new Thread(() -> {
    try {
        readLock.lock();  // Read lock can be acquired as long as no write lock is held
        System.out.println("Thread 2 acquired read lock");
        Thread.sleep(500);
        readLock.unlock();
        System.out.println("Thread 2 released read lock");
    } catch (InterruptedException e) { }
}).start();

new Thread(() -> {
    try {
        writeLock.lock();  // Write lock can only be acquired after all read locks are released
        System.out.println("Thread 3 acquired write lock");
        Thread.sleep(2000);
        writeLock.unlock();
        System.out.println("Thread 3 released write lock");
    } catch (InterruptedException e) { }
}).start();

new Thread(() -> {
    readLock.lock();  // Must wait for write lock to be released before acquiring read lock
    System.out.println("Thread 4 acquired read lock");
    readLock.unlock();
    System.out.println("Thread 4 released read lock");
}).start();
```

Output:

```
Thread 1 acquired read lock
Thread 2 acquired read lock // Thread 2 can acquire the read lock even while Thread 1 holds it, as no write lock is present
Thread 2 released read lock
Thread 1 released read lock
Thread 3 acquired write lock // Thread 3 waits for all read locks to be released before acquiring the write lock
Thread 3 released write lock
Thread 4 acquired read lock // Thread 4 waits for the write lock to be released before acquiring the read lock
Thread 4 released read lock
```
Next Post Previous Post
No Comment
Add Comment
comment url