1. Overview

Java provides the Lock interface as an alternative to synchronized blocks, offering more extensive functionalities. In this tutorial, we're going to talk about ReentrantLock which is an important Lock implementation.

2. ReentrantLock Usage

ReentrantLock is a mutually exclusive lock which manages access to shared mutable data. Its behavior is similar to synchronized methods/blocks.

2.1. lock

We'll first look at the lock method. This method blocks the execution until the current thread acquires the lock. If the current thread holds the lock already, it returns immediately. While a thread performs work holding the lock, all other threads must wait for the owner thread to release the lock.

Let's see the basic usage:

public class LockDetails {

    private final Lock lock = new ReentrantLock();

    public void lockUsage() {
        lock.lock();
        try {
            doWork();
        } finally {
            lock.unlock();
        }
    }
}

Here, we're first creating a ReentrantLock instance. In the lockUsage method, we're acquiring the lock and immediately starting a try-finally block. Then we're releasing the lock via unlock in the finally block. This guarantees that the lock is released even if the method throws an exception. We must follow this pattern if we don't share the lock between methods.

Mutual exclusion states that if a thread holds a lock, other threads can't acquire it. On the other hand, reentrancy states that the owner thread can enter a lock guarded section multiple times. After each entry, the hold count is incremented:

public class LockDetails {

    private final ReentrantLock lock = new ReentrantLock();

    public void lockReentrancy(int times) {
        lock.lock();
        System.out.println(Thread.currentThread().getName() + " acquired: " + lock.getHoldCount());
        try {
            if (times != 0) {
                lockReentrancy(times - 1);
            }
        } finally {
            lock.unlock();
            System.out.println(Thread.currentThread().getName() + " released: " + lock.getHoldCount());
        }
    }
}

Here, we have the lockReentrancy method that is guarded by a ReentrantLock. Note that we're invoking lockReentrancy recursively.

Next, we'll invoke this method from the main method:

public static void main(String[] args) {
    final LockDetails lockDetails = new LockDetails();
    lockDetails.lockReentrancy(5);
}

A sample run prints:

main acquired: 1
main acquired: 2
main acquired: 3
main acquired: 4
main acquired: 5
main acquired: 6
main released: 5
main released: 4
main released: 3
main released: 2
main released: 1
main released: 0

As we can see here, the main thread acquires the lock multiple times. Whenever the hold count reaches zero - after a final unlock invocation - the lock is released.

2.2. lockInterruptibly

Next, we'll examine the lockInterruptibly method. Unline the lock method, lockInterruptibly responds to interrupts - Thread.interrupt. This is especially important when designing the cancellation policy of a task. For example, without a custom cancellation mechanism, ThreadPoolExecutor can't stop a task blocked on the lock method. Remember that task cancellation requires cooperation between the task and the thread pool. Luckily for us, lockInterruptibly checks the thread interruption state and throws InterruptedException if so:

public void lockInterruptiblyUsage() throws InterruptedException {
    lock.lockInterruptibly();
    try {
        doWork();
    } finally {
        lock.unlock();
    }
}

Here, we're acquiring the lock invoking lockInterruptibly. Then, similar to the previous example, we're releasing the lock in a finally block.

2.3. tryLock

So far we've seen uninterruptible and interruptible ways to acquire a lock. tryLock offers a third way. If the lock is free at the invocation time, it gets the lock and returns true. Otherwise, it returns false:

public void tryLockUsage() {
    if (lock.tryLock()) {
        try {
            doWork();
        } finally {
            lock.unlock();
        }
    }
}

Here, unlike the previous examples, we're introducing a conditional - that uses the result of tryLock - to manage the execution flow.

Lastly, we'll try to acquire the lock waiting for the given time:

public void tryLockWithTimeoutUsage() throws InterruptedException {
    if (lock.tryLock(1, TimeUnit.SECONDS)) {
        try {
            doWork();
        } finally {
            lock.unlock();
        }
    }
}

In this example, tryLock will return true if it acquires the lock within one second. Otherwise, it'll return false.

3. Sample Application

Now we'll build a small application that uses ReentrantLockWe'll create a course registration application. The students - represented by threads - will register their names and query whether someone is registered in the course:

public class Course {

    private final Lock lock = new ReentrantLock();
    private final Set registeredNames = new HashSet<>();

    public void register(String name) {
        lock.lock();
        try {
            registeredNames.add(name);
        } finally {
            lock.unlock();
        }
    }

    public boolean isRegistered(String name) {
        lock.lock();
        try {
            return registeredNames.contains(name);
        } finally {
            lock.unlock();
        }
    }
}

In the Course class, the shared data is the HashSet instance, registeredNames. Remember that HashSet isn't thread-safe so we must provide external locking. The register method modifies the set, whereas isRegistered only reads from it. It may be tempting to skip locking in the isRegistered method since we don't need mutual exclusion there. But we need synchronization because of its memory visibility affects. Without proper synchronization, a thread may not see the latest changes done by other threads. So we're using the same lock when both reading and writing.

4. ReentrantLock vs synchronized

Next, we'll look at the differences between a ReentrantLock and a synchronized block.

Firstly, the synchronized keyword mandates block usage in that synchronized can't span methods. On the other hand, we can pass a ReentrantLock to other methods. That being said, synchronized has a more compact form and the lock is released automatically when the block ends. When working with ReentrantLock, we must pay attention to releasing locks.

In terms of acquiring a lock, the synchronized blocks offer a single way in that the thread waits until the lock is free. ReentrantLock, on the other hand, provides the tryLock method which tries to acquire a lock waiting for the given amount of time.

Lastly, since ReentrantLock is a Java class, we can extend it or compose it with other classes. This way we can add functionalities such as monitoring or statistics gathering.

5. Summary

In this tutorial, we've investigated how we can use ReentrantLock to manage access to shared data. We also compared it with the synchronized blocks.

Lastly, check out the source code for all examples over on Github.