In - Depth Look at Java’s ReentrantLock for Thread Synchronization

In the world of multi - threaded programming in Java, thread synchronization is a crucial concept. It ensures that multiple threads can access shared resources in a controlled manner, preventing data inconsistencies and race conditions. One of the powerful tools for achieving thread synchronization in Java is the ReentrantLock class. ReentrantLock is part of the java.util.concurrent.locks package. It provides a more flexible alternative to the traditional synchronized keyword. In this blog, we will take an in - depth look at ReentrantLock, including its fundamental concepts, usage methods, common practices, and best practices.

Table of Contents

  1. Fundamental Concepts
  2. Usage Methods
  3. Common Practices
  4. Best Practices
  5. Conclusion
  6. References

1. Fundamental Concepts

What is ReentrantLock?

ReentrantLock is a mutual exclusion lock with the same basic behavior and semantics as the implicit monitor lock accessed using synchronized methods and statements, but with extended capabilities. The term “reentrant” means that a thread can acquire the same lock multiple times without blocking itself.

Key Features

  • Locking and Unlocking: A thread must explicitly acquire the lock using the lock() method and release it using the unlock() method. This is different from the synchronized keyword, where the lock is automatically released when the block or method exits.
  • Fairness: ReentrantLock can be configured to be fair. A fair lock will grant access to the lock in the order in which threads requested it. By default, it is non - fair, which can provide better performance in most cases.

Internal Working

ReentrantLock uses an AbstractQueuedSynchronizer (AQS) internally. AQS maintains a queue of threads waiting for the lock. When a thread tries to acquire the lock, it first checks if the lock is available. If it is, the thread acquires the lock and marks itself as the owner. If the lock is already held by another thread, the requesting thread is added to the waiting queue.

2. Usage Methods

Basic Locking and Unlocking

import java.util.concurrent.locks.ReentrantLock;

class Counter {
    private int count = 0;
    private final ReentrantLock lock = new ReentrantLock();

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }

    public int getCount() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }
}

public class ReentrantLockExample {
    public static void main(String[] args) {
        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();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Final count: " + counter.getCount());
    }
}

In this example, we have a Counter class with an integer count and a ReentrantLock. The increment() and getCount() methods use the lock to ensure thread - safe access to the count variable. The lock.lock() method is used to acquire the lock, and lock.unlock() is used to release it. The try - finally block is used to ensure that the lock is always released, even if an exception occurs.

Lock with Timeout

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class TimeoutLockExample {
    private final ReentrantLock lock = new ReentrantLock();

    public void performTask() {
        try {
            if (lock.tryLock(1, TimeUnit.SECONDS)) {
                try {
                    System.out.println("Lock acquired. Performing task...");
                    Thread.sleep(2000);
                } finally {
                    lock.unlock();
                }
            } else {
                System.out.println("Could not acquire lock within the timeout.");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        TimeoutLockExample example = new TimeoutLockExample();
        Thread t1 = new Thread(example::performTask);
        Thread t2 = new Thread(example::performTask);

        t1.start();
        t2.start();
    }
}

The tryLock(long timeout, TimeUnit unit) method attempts to acquire the lock within the specified timeout. If the lock is acquired, it returns true; otherwise, it returns false.

Interruptible Lock Acquisition

import java.util.concurrent.locks.ReentrantLock;

public class InterruptibleLockExample {
    private final ReentrantLock lock = new ReentrantLock();

    public void performTask() {
        try {
            lock.lockInterruptibly();
            try {
                System.out.println("Lock acquired. Performing task...");
                Thread.sleep(2000);
            } finally {
                lock.unlock();
            }
        } catch (InterruptedException e) {
            System.out.println("Thread interrupted while waiting for the lock.");
        }
    }

    public static void main(String[] args) {
        InterruptibleLockExample example = new InterruptibleLockExample();
        Thread t1 = new Thread(example::performTask);
        t1.start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        t1.interrupt();
    }
}

The lockInterruptibly() method allows a thread to be interrupted while waiting for the lock. If the thread is interrupted, an InterruptedException is thrown.

3. Common Practices

Using ReentrantLock in Producer - Consumer Scenarios

import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

class ProducerConsumer {
    private final Queue<Integer> queue = new LinkedList<>();
    private final int capacity = 5;
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();

    public void produce() throws InterruptedException {
        lock.lock();
        try {
            while (queue.size() == capacity) {
                notFull.await();
            }
            int item = (int) (Math.random() * 100);
            queue.add(item);
            System.out.println("Produced: " + item);
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }

    public void consume() throws InterruptedException {
        lock.lock();
        try {
            while (queue.isEmpty()) {
                notEmpty.await();
            }
            int item = queue.poll();
            System.out.println("Consumed: " + item);
            notFull.signal();
        } finally {
            lock.unlock();
        }
    }
}

public class ProducerConsumerExample {
    public static void main(String[] args) {
        ProducerConsumer pc = new ProducerConsumer();

        Thread producer = new Thread(() -> {
            try {
                for (int i = 0; i < 10; i++) {
                    pc.produce();
                    Thread.sleep(100);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        Thread consumer = new Thread(() -> {
            try {
                for (int i = 0; i < 10; i++) {
                    pc.consume();
                    Thread.sleep(200);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        producer.start();
        consumer.start();

        try {
            producer.join();
            consumer.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

In this producer - consumer example, ReentrantLock is used to control access to the shared queue. The Condition objects notFull and notEmpty are used to signal and wait for the appropriate conditions.

4. Best Practices

Always Use try - finally for Unlocking

As shown in the previous examples, always use a try - finally block when using ReentrantLock. This ensures that the lock is released even if an exception occurs during the execution of the critical section.

Consider Fairness Wisely

Fair locks can be useful in scenarios where thread starvation is a concern. However, fair locks generally have lower performance compared to non - fair locks. Use fair locks only when necessary.

Limit the Scope of the Lock

Keep the critical section as small as possible. Holding the lock for a long time can reduce the concurrency of the application and increase the chances of deadlocks.

5. Conclusion

ReentrantLock is a powerful and flexible tool for thread synchronization in Java. It provides more features than the traditional synchronized keyword, such as lock timeout, interruptible lock acquisition, and fairness. By understanding its fundamental concepts, usage methods, common practices, and best practices, developers can use ReentrantLock effectively to build robust and efficient multi - threaded applications.

6. References