23 What Are the Common Methods of Lock and Their Uses

23 What Are the Common Methods of Lock and Their Uses #

In this lesson, we will mainly explain the commonly used methods of Lock and their respective uses.

Introduction #

The Lock interface was introduced in Java 5, and the most common implementation class is ReentrantLock, which can act as a “lock”.

Lock and synchronized are the two most common locks. A lock is a tool used to control access to shared resources, and both Lock and synchronized can achieve thread safety. However, they have significant differences in usage and functionality. Therefore, Lock is not used to replace synchronized, but to provide more advanced features when synchronized is not suitable or sufficient.

In general, Lock allows only one thread to access the shared resource. However, in some special implementations, concurrent access may be allowed, such as the ReadLock in ReadWriteLock.

Method Overview #

Let’s first look at the various methods in the Lock interface, as shown in the code.

public interface Lock {

    void lock();
    
    void lockInterruptibly() throws InterruptedException;
    
    boolean tryLock();
    
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    
    void unlock();
    
    Condition newCondition();

}

We can see that there are 5 main methods related to locking and unlocking in the Lock interface. We will now analyze the purposes and usages of these 5 methods in detail. These 5 methods are lock(), tryLock(), tryLock(long time, TimeUnit unit), lockInterruptibly(), and unlock().

lock() Method #

In the Lock interface, 4 methods (lock(), tryLock(), tryLock(long time, TimeUnit unit), and lockInterruptibly()) are declared for acquiring locks. So, what are the differences between these 4 methods?

Firstly, lock() is the most basic method for acquiring a lock. If the lock is already acquired by another thread, the thread waits. It is the most primitive method for acquiring a lock.

For the Lock interface, acquiring and releasing locks are explicit, unlike synchronized, which is implicit. Therefore, Lock does not automatically release locks when an exception occurs (synchronized can release locks even without corresponding code). Locking and unlocking with lock() must be explicitly written in code. Therefore, when using lock(), we must actively release the lock ourselves. The best practice is to perform operations on the synchronized resource within the try{} block after executing lock(), and catch exceptions if necessary. Then, release the lock in the finally{} block to ensure that the lock is always released in case of an exception. The following example code demonstrates this.

Lock lock = ...;

lock.lock();

try {

    // Access the resource protected by this lock and process the task

    // Catch exceptions

} finally {

    lock.unlock(); // Release the lock

}

In this code snippet, we create a Lock and acquire the lock using the lock() method. Then, we immediately perform the business logic related to the resource within the try block. If necessary, we can also catch exceptions. However, the most important thing is the finally block. Make sure not to forget to add the unlock() method in the finally block to ensure that the lock is absolutely released.

If we do not follow the rule of releasing the lock in the finally block, the Lock can become very dangerous. If, in the future, an exception occurs and the unlock() statement is skipped, the lock will never be released, and other threads will not be able to acquire this lock. This is a disadvantage of Lock compared to synchronized. When using synchronized, you don’t have to worry about this issue.

At the same time, the lock() method cannot be interrupted, which can cause a significant hazard. Once it enters a deadlock, lock() will be stuck in an infinite wait. Therefore, we generally use more advanced methods like tryLock() instead of lock(). Now let’s take a look at the tryLock() method.

tryLock() #

tryLock() is used to attempt to acquire a lock. If the lock is not currently held by another thread, the acquisition is successful, and it returns true. Otherwise, it returns false, indicating that the lock acquisition failed. Comparing to lock(), this method is obviously more powerful, as we can decide the subsequent program’s behavior based on whether we can acquire the lock.

Because this method returns immediately, even if the lock is not acquired, it does not wait indefinitely. Therefore, in most cases, we use an if statement to check the return result of tryLock() and execute different business logic based on whether the lock is acquired. The typical usage is as follows.

Lock lock = ...;

if (lock.tryLock()) {

    try {

        // Process the task

    } finally {

        lock.unlock(); // Release the lock

    }

} else {

    // Do something else if the lock cannot be acquired

}
After we create the `lock()` method, we use the `tryLock()` method and an if statement to determine its result. If the if statement returns true, we use `try finally` to handle the related business logic. If the if statement returns false, we enter the else statement, indicating that the lock cannot be acquired temporarily. In this case, we can do something else, such as waiting for a few seconds and then retrying, or skipping this task. With this powerful `tryLock()` method, we can solve the deadlock problem. The code is shown as follows:

```java

public void tryLock(Lock lock1, Lock lock2) throws InterruptedException {

    while (true) {

        if (lock1.tryLock()) {

            try {

                if (lock2.tryLock()) {

                    try {

                        System.out.println("Both locks acquired, completing the business logic");

                        return;

                    } finally {

                        lock2.unlock();

                    }

                }

            } finally {

                lock1.unlock();

            }

        } else {

            Thread.sleep(new Random().nextInt(1000));

        }

    }

}

If we don’t use the tryLock() method in the code, a deadlock may occur. For example, if two threads simultaneously invoke this method and pass in lock1 and lock2 in reverse order, if the first thread acquires lock1 while the second thread acquires lock2, they will then try to acquire the lock held by each other, but they cannot. As a result, they will fall into a deadlock. However, with the tryLock() method, we can avoid deadlock. We first check if lock1 can be acquired, and then try to acquire lock2. If lock1 cannot be acquired, it is okay. We will wait for a random period of time below to allow other threads to complete their tasks and release the locks they hold, so that we can use them later. Similarly, if lock1 is acquired but lock2 is not, lock1 will be released and a random wait will follow. Only when both lock1 and lock2 are acquired will the method enter and execute the business logic, such as printing “Both locks acquired, completing the business logic”, and then return.

tryLock(long time, TimeUnit unit) #

The overloaded tryLock() method is tryLock(long time, TimeUnit unit). This method is similar to tryLock(), except that tryLock(long time, TimeUnit unit) has a timeout, and if the lock cannot be obtained after waiting for a certain period of time, it returns false; if the lock is obtained at the beginning or during the waiting period, it returns true.

This method solves the problem of deadlock that can easily occur with the lock() method. When using tryLock(long time, TimeUnit unit), the thread will actively give up acquiring the lock after waiting for a specified timeout, avoiding indefinite waiting. During the waiting period, the thread can also be interrupted at any time, thus avoiding deadlock. This method is very similar to lockInterruptibly(), which we will discuss below. Let’s take a look at the lockInterruptibly() method.

lockInterruptibly() #

The purpose of this method is to acquire the lock. If the lock is currently available, this method will return immediately. However, if the lock is not available (held by another thread), the current thread will start waiting unless it acquires the lock or is interrupted during the waiting process. In this case, the thread will keep executing this line of code until it acquires the lock or is interrupted.

As the name suggests, lockInterruptibly() can respond to interrupts. Compared to synchronized locks which cannot respond to interrupts, lockInterruptibly() allows the program to be more flexible and responsive to interrupts while acquiring the lock. We can think of this method as an infinitely long tryLock(long time, TimeUnit unit), as both tryLock(long time, TimeUnit unit) and lockInterruptibly() can respond to interrupts, but lockInterruptibly() never times out.

This method itself throws an InterruptedException, so when using it, if the exception is not declared in the method signature, two try blocks should be written, as shown below:

public void lockInterruptibly() {

    try {

        lock.lockInterruptibly();

        try {

            System.out.println("Operating the resource");

        } finally {

            lock.unlock();

        }

    } catch (InterruptedException e) {

        e.printStackTrace();

    }

}

In this method, we first execute the lockInterruptibly() method and wrap it with a try-catch. Assuming we can acquire this lock, just like before, we must use try-finally to ensure that the lock is released unconditionally.

unlock() #

The last method to introduce is unlock(), which is used to release the lock. The unlock() method for ReentrantLock is relatively simple. When unlock() is executed, the internal “hold count” of the lock is decremented by 1. When the count reaches 0, it means that the lock has been completely released. If the count is not 0 after decrementing by 1, it means that the lock has been “reentered” before, and the lock has not been truly released, only the number of holds has been decreased.