Java Concurrency - Quick Interview Notes (Crash Course)
Table of Content
- 1. Basics of Concurrency
- 2. Threads
- 3. Thread Pool (ThreadPoolExecutor)
- 3.1 Introduction to ThreadPoolExecutor
- 3.2 Initializing ThreadPoolExecutor
- 3.3 The Task Submission Process in a Thread Pool
- 3.4 Common Blocking Queues and Rejection Policies
- 3.5 Creating Thread Pools with the Executors Class
- 4. Locks
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)
orlock.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. TheRunnable
interface is a functional interface where you typically implement therun
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 therun()
method.run()
:Defines the thread’s execution behavior and contains the code to be executed. In a multithreaded environment, therun()
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 itsrun()
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. Unlikesleep()
, 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)
andgetPriority()
: 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 thejoin(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 callA.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 extendingThread
just to override itsrun
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 toRunnable
, but allows tasks to return a result.Future/FutureTask
: Used to retrieve the result of aCallable
task or check its completion status. (Future
is an interface;FutureTask
is its implementation.)Thread/ThreadPoolExecutor
: Executes theCallable
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:
- int corePoolSize:Core thread count。This is the number of threads that remain active in the thread pool, even if they are idle.
- int maximumPoolSize:Maximum 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.
- long keepAliveTime:Idle 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.
- TimeUnit unit:The time unit for the
keepAliveTime
parameter. This can be seconds, milliseconds, microseconds, etc. - BlockingQueue
workQueue :Blocking queue. This queue holds tasks that are waiting to be executed.BlockingQueue
is an interface with several implementation options to choose from. - 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, theDefaultThreadFactory
is typically used. - RejectedExecutionHandler handler:Rejection policy. When the thread pool cannot accept new tasks, this handler's
rejectedExecution(...)
method is invoked to manage the situation. The default policy isAbortPolicy
, 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:
- 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. - 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. - 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. - 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 aRejectedExecutionException
. 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'sRunnable
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 likewait()
andnotify()
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 withCondition
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, usingReentrantLock
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 ```