Java Concurrency: Synchronization
Check out this post to learn more about Java concurrency and synchronization.
Join the DZone community and get the full member experience.
Join For FreeIn a previous post, we looked at running tasks across multiple threads using the ExecutorService
. Accessing and manipulating an object from multiple threads can pose a problem when the object in question holds state. If multiple threads attempt to modify shared state, behavior can unpredictable and result in data being left in an inconsistent state.
You may also like: Java Concurrency in Depth: Part 1
Unsynchronized Code
Take the simple BankAccount
class below. It holds state in the form of a balance
, which can be increased and decreased using the credit
and debit
methods. When used in a single-threaded context, this object will behave as expected, crediting and debiting the amounts specified.
public class BankAccount {
private double balance;
public void credit(double amount){
balance = balance + amount;
}
public void debit(double amount){
balance = balance - amount;
}
public double getBalance(){
return balance;
}
}
However, when multiple threads call the credit
and debit
methods simultaneously, we can end up with unpredictable behavior and an incorrect balance. To see this in action, the test method below uses an ExecutorService
to submit 100 account debits and 100 account credits, each for £100.
@Test
public void testBankAccountWithoutSynchronization1(){
BankAccount bankAccount = new BankAccount();
ExecutorService executorService = Executors.newFixedThreadPool(10);
IntStream.rangeClosed(1, 100).forEach(i->{
executorService.submit(()-> {
bankAccount.debit(100);
});
executorService.submit(()-> {
bankAccount.credit(100);
});
});
executorService.shutdown();
System.out.println("Final Balance: " + bankAccount.getBalance());
}
After all debits and credits have run, we'd expect the account to have a balance of zero. However, what I ended up with was a balance of -100 as shown below. Note that if you're running the sample code, it may take a number of runs before you see a non-zero result.
Final Balance: -100.0
So, What's the Problem?
The credit method takes the current balance, adds a specified amount to it, and then assigns the new value back to balance
. The problem is that adding a value to balance
and then assigning that result back to balance
is not an atomic operation. That means that it doesn't happen as one distinct unit of work. Instead, a thread may add the specified amount to the current balance
, and before it has a chance to assign the new value back to balance
, the balance
variable may be updated by another thread. At this point, the balance
value held by the first thread is stale and the resulting variable assignment is incorrect.
public void credit(double amount){
balance = balance + amount;
}
To resolve the issue, we need to ensure that balance = balance + amount becomes an atomic operation, performed in isolation by one thread at a time.
Synchronized Methods
The simplest way to achieve this is by marking the method as synchronized
. A synchronized
method uses an implicit lock to ensure that only 1 thread at a time can enter the method. As a result, any shared state referenced inside the method is no longer vulnerable to being manipulated by multiple threads at the same time. In this instance, the shared state we're protecting is the account balance
.
public synchronized void credit(double amount){
balance = balance + amount;
}
Note: Using synchronized
on an instance method will use the current objects intrinsic lock for controlling access to the method. Using synchronized
on a static method, however, uses the intrinsic lock associated with the class, not the object. This is an important distinction, as it is possible for one thread to hold an objects' intrinsic lock, while another thread, at the same time, can hold the intrinsic lock of the class.
Synchronized Blocks
While synchronizing an entire method can be useful, it's sometimes preferable to only synchronize a portion of a method instead. If you think about it, synchronizing a method creates a bottleneck by allowing only 1 thread into the method at a time. This bottleneck, known as contention, is a result of multiple threads competing to acquire a single lock. Contention can have an adverse effect on performance, so it can be preferable to synchronize only vulnerable code rather than an entire method. Thankfully, the synchronized
block below allows us to do exactly that.
public void debit(double amount){
// execute some non vulnerable code here...
//vulnerable code is executed inside synchronized block
synchronized (this) {
balance = balance - amount;
}
// execute some more non vulnerable code here...
}
When defining a synchronized
block, you must specify an object on which to lock. The intrinsic lock of the specified object is used to control access to the synchronized
block. A typical approach is to use the current object by specifying this (shown above).
A final thread-safe version of the original BankAccount
class is shown below.
public class SynchronizedBankAccount {
private double balance;
public synchronized void credit(double amount){
balance = balance + amount;
}
public void debit(double amount){
synchronized (this) {
balance = balance - amount;
}
}
public double getBalance(){
return balance;
}
}
Granular Control With the Lock Interface
While synchronized
methods and blocks are sufficient in most instances, there are times when greater control is required. The Lock
interface defines a set of locking operations that provide developers with more granular control for writing thread-safe code. ReentrantLock
is a common implementation of the Lock
interface and one we're going to discuss in next few sections.
ReentrantLock — What Is Reentrance?
The term reentrant refers to a locks ability to be acquired multiple times by the same thread. Implicit locking implemented via a synchronized
method or block is reentrant. This means that a thread in a synchronized
method may call into another synchronized
method without blocking itself. While a reentrant lock may be acquired many times by the same thread, it must also be released the same number of times before the lock can be acquired by another thread.
Creating a ReentrantLock
The code snippet below creates a ReentrantLock
using its no argument constructor.
private Lock lock = new ReentrantLock();
A second constructor takes a boolean to indicate whether or not the lock should apply a fairness policy. If set to true, a fairness policy is implemented that ensures that the longest waiting thread will acquire the lock when it becomes available. This avoids high priority threads monopolizing CPU time, while lower-priority threads are left to wait for long periods.
private Lock lock = new ReentrantLock(true);
Locking and Unlocking
The code snippet below is an updated version of the BankAccount credit
method we looked at earlier. Instead of using the synchronized
keyword I've used a ReentrantLock
to control access to the vulnerable code. When a thread enters the method it will call lock
in an attempt to acquire mutually exclusive access to the ReentrantLock
. If the lock hasn't already been acquired by another thread, it will be acquired by the current thread, which will then be allowed to proceed.
If the lock has already been acquired by another thread when lock
is called, the current thread will block until the lock becomes available again.
public void credit(double amount){
try{
lock.lock();
balance = balance + amount;
}
finally{
lock.unlock();
}
}
After the balance has been updated the unlock
method is called to signal that the current thread is finished and the lock is released. At this point, the lock can be acquired by a waiting thread.
Note that unlock
should always be called inside a finally block. This ensures that the lock is always released, even if an exception is thrown after the lock is acquired. Its imperative that the lock is released, as failing to do so will result in other threads being blocked indefinitely.
More Flexible Locking and Unlocking
The lock
method we looked at above attempts to acquire a lock, waiting indefinitely if it's not available. This is the same behavior as a synchronized
method or block. There are, however, times when we may want to limit the amount of time we're willing to wait for a lock, especially if we have a large number of threads competing for the same lock. Rather than have many threads blocked waiting for access, we could take some other course of action when a lock isn't available. Luckily, ReentrantLock
provides this flexibility via two flavours of the tryLock
method.
Acquiring a Lock With tryLock()
ThetryLock
method checks for the availability of the lock, returning true if the lock is available. When tryLock
returns true the current thread acquires the lock and is free to execute whatever vulnerable code is being protected. If the lock is held by another thread and not immediately available tryLock
will return false, allowing the application to immediately take some other course of action.
Acquiring a Lock With tryLock(long time, TimeUnit unit)
An overloaded version of tryLock
takes a time and time unit that determines how long the current thread should wait for the lock
to become available. If thelock
is available when tryLock
is called, the current thread will acquire the lock and the method will return true immediately. At this point, the current thread is free to execute whatever vulnerable code is being protected.
If on the other hand the lock has already been acquired by another thread, the current thread will wait for the lock
to be released. If the lock
is released within the specified period of time, the current thread will acquire the lock
and tryLock
will return true. If the time period elapses and the lock still hasn't been released, tryLock
will return false. At this point, the current thread has not acquired the lock and an alternative course of action can be taken.
The debit
method below shows tryLock
in action. On line 3, the current thread waits up to two seconds for the lock
to become available. If the lock
is available or becomes available within two seconds, the current thread is free to execute whatever vulnerable code is being protected. If thelock
doesn't become available after two seconds, the debit
amount is added to a queue for processing later.
public void debit(double amount) throws InterruptedException{
if(lock.tryLock(2000, TimeUnit.MILLISECONDS)){
try{
balance-=amount;
}
finally{
lock.unlock();
}
}
/* lock isn't available right now so do something else */
else{
/* add debit amount to queue for processing later */
}
}
ReentrantReadWriteLock
Another lock
implementation worth looking at is the ReentrantReadWriteLock
. As the names suggests, the ReentrantReadWriteLock
encapsulates both a read and write lock inside a single lock implementation.
Why Would I Need a Read & Write Lock?
When a thread attempts to read from or write to a HashMap
using any of the above approaches, it will obtain mutually exclusive access to the lock, blocking all other threads from reading or writing at the same time. While this is the desired behavior when writing to a HashMap
, there is no reason why we should stop multiple threads reading from the HashMap
concurrently. Reads do not manipulate the HashMap
in any way, so there is no reason to limit reads to only 1 thread at a time.
ReentrantReadWriteLock
Like ReentrantLock
, the ReentrantReadWriteLock
can be instantiated with an optional boolean to indicate whether or not a fairness policy should be established. If a fairness policy is chosen, threads will acquire the lock in the order they've requested it.
private ReadWriteLock readWriteLock = new ReentrantReadWriteLock(true);
Acquiring a Write Lock
public void saveTransaction(String transaction) throws InterruptedException {
try{
readWriteLock.writeLock().lock();
transactionHistory.put(UUID.randomUUID().toString(), transaction);
}
finally{
readWriteLock.writeLock().unlock();
}
}
When the write lock
is acquired by a thread, that thread has mutually exclusive access to the ReentrantReadWriteLock
. At this point, no other thread can obtain a read or write lock, meaning that the HashMap
can not be read from or written to by any other thread.
Acquiring a Read Lock
public List<String> getTransactions(){
List<String> transactions = null;
try{
readWriteLock.readLock().lock();
transactions = transactionHistory.values().stream().collect(Collectors.toList());
}
finally{
readWriteLock.readLock().unlock();
}
return transactions;
}
Unlike the write lock, the read lock can be held by multiple threads. In the sample code, this allows multiple threads to read from the Map
concurrently.
Side Note
While the example above serves to demonstrate the fundamentals of ReentrantReadWriteLock
, in reality, we wouldn’t use it to synchronize access to a HashMap
. Instead, we’d make use of the Collections class, which has a method that takes a Map
and returns a thread-safe equivalent, as shown below:
Map<String, String> transactionHistory = Collections.synchronizedMap(new HashMap<String, String>());
Wrapping Up
The sample code for this post is available on GitHub, so feel free to pull it down and have a play around. If you have any comments, questions, or suggestions, please leave a comment below.
Further Reading
Java Concurrency in Depth: Part 1
A Birds-Eye-View on Java Concurrency Frameworks
[DZone Refcard] Core Java Concurrency
Published at DZone with permission of Brian Hannaway, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments