Java Concurrency Tutorial – Locking: Explicit locks
1. Introduction
In many cases, using implicit locking is enough. Other times, we will need more complex functionalities. In such cases, java.util.concurrent.locks package provides us with lock objects. When it comes to memory synchronization, the internal mechanism of these locks is the same as with implicit locks. The difference is that explicit locks offer additional features.
The main advantages or improvements over implicit synchronization are:
- Separation of locks by read or write.
- Some locks allow concurrent access to a shared resource (ReadWriteLock).
- Different ways of acquiring a lock:
- Blocking: lock()
- Non-blocking: tryLock()
- Interruptible: lockInterruptibly()
2. Classification of lock objects
Lock objects implement one of the following two interfaces:
- Lock: Defines the basic functionalities that a lock object must implement. Basically, this means acquiring and releasing the lock. In contrast to implicit locks, this one allows the acquisition of a lock in a non-blocking or interruptible way (additionally to the blocking way). Main implementations:
- ReentrantLock
- ReadLock (used by ReentrantReadWriteLock)
- WriteLock (used by ReentrantReadWriteLock)
- ReadWriteLock: It keeps a pair of locks, one for read-only operations and another one for writing. The read lock can be acquired simultaneously by different reader threads (as long as the resource isn’t already acquired by a write lock), while the write lock is exclusive. In this way, we can have several threads reading the resource concurrently as long as there is not a writing operation. Main implementations:
- ReentrantReadWriteLock
The following class diagram shows the relation among the different lock classes:
3. ReentrantLock
This lock works the same way as the synchronized block; one thread acquires the lock as long as it is not already acquired by another thread, and it does not release it until unlock is invoked. If the lock is already acquired by another thread, then the thread trying to acquire it becomes blocked until the other thread releases it.
We are going to start with a simple example without locking, and then we will add a reentrant lock to see how it works.
public class NoLocking { public static void main(String[] args) { Worker worker = new Worker(); Thread t1 = new Thread(worker, "Thread-1"); Thread t2 = new Thread(worker, "Thread-2"); t1.start(); t2.start(); } private static class Worker implements Runnable { @Override public void run() { System.out.println(Thread.currentThread().getName() + " - 1"); System.out.println(Thread.currentThread().getName() + " - 2"); System.out.println(Thread.currentThread().getName() + " - 3"); } } }
Since the code above is not synchronized, threads will be interleaved. Let’s see the output:
Thread-2 - 1 Thread-1 - 1 Thread-1 - 2 Thread-1 - 3 Thread-2 - 2 Thread-2 - 3
Now, we will add a reentrant lock in order to serialize the access to the run method:
public class ReentrantLockExample { public static void main(String[] args) { Worker worker = new Worker(); Thread t1 = new Thread(worker, "Thread-1"); Thread t2 = new Thread(worker, "Thread-2"); t1.start(); t2.start(); } private static class Worker implements Runnable { private final ReentrantLock lock = new ReentrantLock(); @Override public void run() { lock.lock(); try { System.out.println(Thread.currentThread().getName() + " - 1"); System.out.println(Thread.currentThread().getName() + " - 2"); System.out.println(Thread.currentThread().getName() + " - 3"); } finally { lock.unlock(); } } } }
The above code will safely be executed without threads being interleaved. You may realize that we could have used a synchronized block and the effect would be the same. The question that arises now is what advantages does the reentrant lock provides us?
The main advantages of using this type of lock are described below:
- Additional ways of acquiring the lock are provided by implementing Lock interface:
- lockInterruptibly: The current thread will try to acquire de lock and become blocked if another thread owns the lock, like with the lock() method. However, if another thread interrupts the current thread, the acquisition will be cancelled.
- tryLock: It will try to acquire the lock and return immediately, regardless of the lock status. This will prevent the current thread from being blocked if the lock is already acquired by another thread. You can also set the time the current thread will wait before returning (we will see an example of this).
- newCondition: Allows the thread which owns the lock to wait for a specified condition.
- Additional methods provided by the ReentrantLock class, primarily for monitoring or testing. For example, getHoldCount or isHeldByCurrentThread methods.
Let’s look at an example using tryLock before moving on to the next lock class.
3.1 Trying lock acquisition
In the following example, we have got two threads, trying to acquire the same two locks.
One thread acquires lock2 and then it blocks trying to acquire lock1:
public void lockBlocking() { LOGGER.info("{}|Trying to acquire lock2...", Thread.currentThread().getName()); lock2.lock(); try { LOGGER.info("{}|Lock2 acquired. Trying to acquire lock1...", Thread.currentThread().getName()); lock1.lock(); LOGGER.info("{}|Both locks acquired", Thread.currentThread().getName()); } finally { lock1.unlock(); lock2.unlock(); } }
Another thread, acquires lock1 and then it tries to acquire lock2.
public void lockWithTry() { LOGGER.info("{}|Trying to acquire lock1...", Thread.currentThread().getName()); lock1.lock(); try { LOGGER.info("{}|Lock1 acquired. Trying to acquire lock2...", Thread.currentThread().getName()); boolean acquired = lock2.tryLock(4, TimeUnit.SECONDS); if (acquired) { try { LOGGER.info("{}|Both locks acquired", Thread.currentThread().getName()); } finally { lock2.unlock(); } } else { LOGGER.info("{}|Failed acquiring lock2. Releasing lock1", Thread.currentThread().getName()); } } catch (InterruptedException e) { //handle interrupted exception } finally { lock1.unlock(); } }
Using the standard lock method, this would cause a dead lock, since each thread would be waiting forever for the other to release the lock. However, this time we are trying to acquire it with tryLock specifying a timeout. If it doesn’t succeed after four seconds, it will cancel the action and release the first lock. This will allow the other thread to unblock and acquire both locks.
Let’s see the full example:
public class TryLock { private static final Logger LOGGER = LoggerFactory.getLogger(TryLock.class); private final ReentrantLock lock1 = new ReentrantLock(); private final ReentrantLock lock2 = new ReentrantLock(); public static void main(String[] args) { TryLock app = new TryLock(); Thread t1 = new Thread(new Worker1(app), "Thread-1"); Thread t2 = new Thread(new Worker2(app), "Thread-2"); t1.start(); t2.start(); } public void lockWithTry() { LOGGER.info("{}|Trying to acquire lock1...", Thread.currentThread().getName()); lock1.lock(); try { LOGGER.info("{}|Lock1 acquired. Trying to acquire lock2...", Thread.currentThread().getName()); boolean acquired = lock2.tryLock(4, TimeUnit.SECONDS); if (acquired) { try { LOGGER.info("{}|Both locks acquired", Thread.currentThread().getName()); } finally { lock2.unlock(); } } else { LOGGER.info("{}|Failed acquiring lock2. Releasing lock1", Thread.currentThread().getName()); } } catch (InterruptedException e) { //handle interrupted exception } finally { lock1.unlock(); } } public void lockBlocking() { LOGGER.info("{}|Trying to acquire lock2...", Thread.currentThread().getName()); lock2.lock(); try { LOGGER.info("{}|Lock2 acquired. Trying to acquire lock1...", Thread.currentThread().getName()); lock1.lock(); LOGGER.info("{}|Both locks acquired", Thread.currentThread().getName()); } finally { lock1.unlock(); lock2.unlock(); } } private static class Worker1 implements Runnable { private final TryLock app; public Worker1(TryLock app) { this.app = app; } @Override public void run() { app.lockWithTry(); } } private static class Worker2 implements Runnable { private final TryLock app; public Worker2(TryLock app) { this.app = app; } @Override public void run() { app.lockBlocking(); } } }
If we execute the code it will result in the following output:
13:06:38,654|Thread-2|Trying to acquire lock2... 13:06:38,654|Thread-1|Trying to acquire lock1... 13:06:38,655|Thread-2|Lock2 acquired. Trying to acquire lock1... 13:06:38,655|Thread-1|Lock1 acquired. Trying to acquire lock2... 13:06:42,658|Thread-1|Failed acquiring lock2. Releasing lock1 13:06:42,658|Thread-2|Both locks acquired
After the fourth line, each thread has acquired one lock and is blocked trying to acquire the other lock. At the next line, you can notice the four second lapse. Since we reached the timeout, the first thread fails to acquire the lock and releases the one it had already acquired, allowing the second thread to continue.
4. ReentrantReadWriteLock
This type of lock keeps a pair of internal locks (a ReadLock and a WriteLock). As explained with the interface, this lock allows several threads to read from the resource concurrently. This is specially convenient when having a resource that has frequent reads but few writes. As long as there isn’t a thread that needs to write, the resource will be concurrently accessed.
The following example shows three threads concurrently reading from a shared resource. When a fourth thread needs to write, it will exclusively lock the resource, preventing reading threads from accessing it while it is writing. Once the write finishes and the lock is released, all reader threads will continue to access the resource concurrently:
public class ReadWriteLockExample { private static final Logger LOGGER = LoggerFactory.getLogger(ReadWriteLockExample.class); final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); private Data data = new Data("default value"); public static void main(String[] args) { ReadWriteLockExample example = new ReadWriteLockExample(); example.start(); } private void start() { ExecutorService service = Executors.newFixedThreadPool(4); for (int i=0; i<3; i++) service.execute(new ReadWorker()); service.execute(new WriteWorker()); service.shutdown(); } class ReadWorker implements Runnable { @Override public void run() { for (int i = 0; i < 2; i++) { readWriteLock.readLock().lock(); try { LOGGER.info("{}|Read lock acquired", Thread.currentThread().getName()); Thread.sleep(3000); LOGGER.info("{}|Reading data: {}", Thread.currentThread().getName(), data.getValue()); } catch (InterruptedException e) { //handle interrupted } finally { readWriteLock.readLock().unlock(); } } } } class WriteWorker implements Runnable { @Override public void run() { readWriteLock.writeLock().lock(); try { LOGGER.info("{}|Write lock acquired", Thread.currentThread().getName()); Thread.sleep(3000); data.setValue("changed value"); LOGGER.info("{}|Writing data: changed value", Thread.currentThread().getName()); } catch (InterruptedException e) { //handle interrupted } finally { readWriteLock.writeLock().unlock(); } } } }
The console output shows the result:
11:55:01,632|pool-1-thread-1|Read lock acquired 11:55:01,632|pool-1-thread-2|Read lock acquired 11:55:01,632|pool-1-thread-3|Read lock acquired 11:55:04,633|pool-1-thread-3|Reading data: default value 11:55:04,633|pool-1-thread-1|Reading data: default value 11:55:04,633|pool-1-thread-2|Reading data: default value 11:55:04,634|pool-1-thread-4|Write lock acquired 11:55:07,634|pool-1-thread-4|Writing data: changed value 11:55:07,634|pool-1-thread-3|Read lock acquired 11:55:07,635|pool-1-thread-1|Read lock acquired 11:55:07,635|pool-1-thread-2|Read lock acquired 11:55:10,636|pool-1-thread-3|Reading data: changed value 11:55:10,636|pool-1-thread-1|Reading data: changed value 11:55:10,636|pool-1-thread-2|Reading data: changed value
As you can see, when writer thread acquires the write lock (thread-4), no other threads can access the resource.
5. Conclusion
This post shows which are the main implementations of explicit locks and explains some of its improved features with respect to implicit locking. This post is part of the Java Concurrency Tutorial series. Check here to read the rest of the tutorial.
- You can find the source code at Github.
Reference: | Java Concurrency Tutorial – Locking: Explicit locks from our JCG partner Xavier Padro at the Xavier Padró’s Blog blog. |