Blog
navigate_next
Java
Java Concurrency Unlocked: A Comparative Guide to Synchronization Tools
A N M Bazlur Rehman
November 12, 2023

Introduction

Java, well-known for its robust handling of concurrent tasks, offers a variety of built-in synchronization mechanisms that are essential for multi-threaded programming. Mastering these tools is crucial for writing programs that execute tasks simultaneously without encountering issues like data corruption or deadlocks. This article provides a comprehensive overview of some of the synchronization utilities Java developers have at their disposal within the JDK. We'll explore each tool's purpose and how you can leverage them to maintain harmony between your threads.

The basics

At the heart of Java's approach to concurrency is the <span  class="pink" >synchronized</span> keyword, a tool we've been using since the early days of the language.

Let's break down how it works:

Each Java object comes with a built-in feature known as an intrinsic lock or monitor lock. When a method is marked with <span  class="pink" >synchronized</span>, it means that a thread needs to get hold of this lock before it can execute the method. If one thread is using the lock, any other thread that wants to use any synchronized method of that object has to wait. This helps to avoid problems by making sure only one thread can access a critical section of code at a time.

💡 Critical section: A critical section is a part of a multi-threaded program that accesses shared resources, such as shared memory, and should not be simultaneously executed by multiple threads. It is a piece of code that needs exclusive access, usually enforced by mechanisms like locks, semaphores, and mutexes, to prevent race conditions that can cause unpredictable results or data corruption.

Why bother with synchronization? Java threads share memory, which is great for speed but not so great when they step on each other's toes. If one thread updates a piece of data, another thread might try to update it simultaneously, and that's where things get messy. For instance:

Imagine we've got a simple class named <span  class="pink" >SimpleAccount</span>, which manages a bank account balance. It has methods to handle withdrawing, depositing, and transferring money. Without the <span  class="pink" >synchronized</span> keyword, you might end up with more than one thread changing the balance simultaneously, causing a race condition.

Here's how our <span  class="pink" >SimpleAccount</span> might look:

 
public class SimpleAccount {
    private double balance;

    public void withdraw(double amount) {
        balance -= amount;
    }

    public void deposit(double amount) {
        balance += amount;
    }

    public void transfer(SimpleAccount to, double amount) {
        this.withdraw(amount);
        to.deposit(amount);
    }

    public double getBalance() {
        return balance;
    }
}

💡 Race condition: A race condition happens when multiple threads try to access and change shared data at the same time. Because the thread scheduling algorithm can switch between threads at any moment, the order in which the threads attempt to access the shared data is unpredictable. This unpredictability can result in unexpected behavior.

In this context of our <span  class="pink" >SimpleAccount </span>scenario, let's see what happens when we introduce multiple threads into the mix without synchronization:

 
package ca.bazlur;

public class SimpleAccountDemo {
    public static void main(String[] args) throws InterruptedException {
        var account1 = new SimpleAccount();
        var account2 = new SimpleAccount();

        int repeat = 1000;

        // Thread to handle transfers from account1 to account2
        var t1 = Thread.ofPlatform().unstarted(() -> {
            for (int i = 0; i < repeat; i++) {
                account1.transfer(account2, 100.00);
            }
        });

        // Thread to handle transfers from account2 to account1
        var t2 = Thread.ofPlatform().unstarted(() -> {
            for (int i = 0; i < repeat; i++) {
                account2.transfer(account1, 100.00);
            }
        });

        // Start both threads
        t1.start();
        t2.start();

        // Wait for both threads to finish
        t1.join();
        t2.join();

        // Print out the final balance of each account
        System.out.println("account1.getBalance() = " + account1.getBalance());
        System.out.println("account2.getBalance() = " + account2.getBalance());
    }
}


💡 Note: In this code, we have utilized the builder pattern for creating threads introduced in JDK 21. Since this release, there are two types of threads: virtual and platform threads. We will discuss virtual threads in an upcoming article, so stay tuned.

This code sets up two accounts and two threads, each running a loop a thousand times to transfer money back and forth between the two accounts. When you run this code, you might expect that because every transfer out of an account is matched by a transfer into the other account, the balances should end up as they started: at zero.

But without synchronization, running this code often results in different, incorrect balance values each time. Threads are trampling over each other to access the shared <span  class="teal" >balance</span> field, and some updates can be lost, leading to unpredictable results.

For example, I have run this on my computer several times and it produced the following results:

 
account1.getBalance() = -600.0
account2.getBalance() = -3100.0

Now, if we add <span  class="pink" >synchronized</span> to our methods in the <span  class="teal" >SynchronizedAccount</span> version of the class:

 
public class SynchronizedAccount {
    private double balance;

    public synchronized void withdraw(double amount) {
        balance -= amount;
    }

    public synchronized void deposit(double amount) {
        balance += amount;
    }

    public synchronized void transfer(SynchronizedAccount to, double amount) {
        this.withdraw(amount);
        to.deposit(amount);
    }

    public synchronized double getBalance() {
        return balance;
    }
}


Using the same SimpleAccountDemo, the balances should be consistently accurate because the synchronized methods prevent multiple threads from changing the balance at the same time.

So, the synchronized keyword is a straightforward way to avoid concurrent access issues. It ensures that only one thread can access a block of code at a time by using a lock associated with the object.

However, this simplicity comes with a cost. Locking down an entire object can lead to less-than-ideal performance because it can create bottlenecks, where threads are waiting in line for their turn to use an object.

Let's keep exploring and see what other tools we have in our Java concurrency toolkit.

ReentrantLock

The ReentrantLock class in Java offers similar basic behavior and semantics to intrinsic locking via the <span  class="teal" >synchronized </span>keyword, but it introduces additional capabilities. As its name implies, <span  class="pink" >ReentrantLock</span> allows threads to enter a lock they already hold, hence the term "reentrant."

Found within the java.util.concurrent.locks package, <span  class="pink" >ReentrantLock </span>extends locking operations to support interruptibility and timeouts, features not available with synchronized. It implements the Lock interface, which encapsulates the basic behavior of locking functionalities.

Consider the following refactored version of the <span  class="pink" >SynchronizedAccount </span>class using <span  class="pink" >ReentrantLock</span>:

 
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockAccount {
    private double balance;
    private final Lock lock = new ReentrantLock();

    public void withdraw(double amount) {
        lock.lock();
        try {
            balance -= amount;
        } finally {
            lock.unlock();
        }
    }

    public void deposit(double amount) {
        lock.lock();
        try {
            balance += amount;
        } finally {
            lock.unlock();
        }
    }

    public void transfer(ReentrantLockAccount to, double amount) {
        lock.lock(); // The current thread acquires the lock
        try {
            this.withdraw(amount); // The same thread reacquires the lock
            to.deposit(amount);
        } finally {
            lock.unlock(); // Release the lock
        }
    }

    public double getBalance() {
        lock.lock();
        try {
            return balance;
        } finally {
            lock.unlock();
        }
    }
}

Although we would expect similar behaviour in this class as we have seen earlier in <span  class="pink" >SynchronizedAccount</span>, but in both cases when we have two threads performing transfers between two accounts, we could stumble into a classic problem in concurrent programming: deadlocks. Deadlocks occur when one thread holds a lock on <span  class="teal" >Account A</span> while trying to acquire a lock on <span  class="teal" >Account B</span>, and another thread holds a lock on <span  class="teal" >Account B</span> while waiting for the lock on <span  class="teal" >Account A</span>.

Running this operation 10,000 times, we may reproduce the deadlock. Tools like jstack or  jconsole can be used to detect deadlocks. To start with jstack, find the process ID using the jps command in the terminal. After locating the PID, execute jstack with the identified PID:

 
$jstack 18359

The output reveals:

 
Java stack information for the threads listed above:
===================================================
"Thread-0":
        at jdk.internal.misc.Unsafe.park(java.base@21/Native Method)
        - parking to wait for  <0x000000043f814cf8> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
        at java.util.concurrent.locks.LockSupport.park(java.base@21/LockSupport.java:221)
        at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(java.base@21/AbstractQueuedSynchronizer.java:754)
        at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(java.base@21/AbstractQueuedSynchronizer.java:990)
        at java.util.concurrent.locks.ReentrantLock$Sync.lock(java.base@21/ReentrantLock.java:153)
        at java.util.concurrent.locks.ReentrantLock.lock(java.base@21/ReentrantLock.java:322)
        at ca.bazlur.concurrency101.ReentrantLockAccount.deposit(ReentrantLockAccount.java:19)
        at ca.bazlur.concurrency101.ReentrantLockAccount.transfer(ReentrantLockAccount.java:31)
        at ca.bazlur.concurrency101.ReentrantLockAccount.lambda$main$0(ReentrantLockAccount.java:55)
        at ca.bazlur.concurrency101.ReentrantLockAccount$$Lambda/0x0000000801000a28.run(Unknown Source)
        at java.lang.Thread.runWith(java.base@21/Thread.java:1596)
        at java.lang.Thread.run(java.base@21/Thread.java:1583)
"Thread-1":
        at jdk.internal.misc.Unsafe.park(java.base@21/Native Method)
        - parking to wait for  <0x000000043f814cb0> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
        at java.util.concurrent.locks.LockSupport.park(java.base@21/LockSupport.java:221)
        at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(java.base@21/AbstractQueuedSynchronizer.java:754)
        at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(java.base@21/AbstractQueuedSynchronizer.java:990)
        at java.util.concurrent.locks.ReentrantLock$Sync.lock(java.base@21/ReentrantLock.java:153)
        at java.util.concurrent.locks.ReentrantLock.lock(java.base@21/ReentrantLock.java:322)
        at ca.bazlur.concurrency101.ReentrantLockAccount.deposit(ReentrantLockAccount.java:19)
        at ca.bazlur.concurrency101.ReentrantLockAccount.transfer(ReentrantLockAccount.java:31)
        at ca.bazlur.concurrency101.ReentrantLockAccount.lambda$main$1(ReentrantLockAccount.java:61)
        at ca.bazlur.concurrency101.ReentrantLockAccount$$Lambda/0x0000000801000c48.run(Unknown Source)
        at java.lang.Thread.runWith(java.base@21/Thread.java:1596)
        at java.lang.Thread.run(java.base@21/Thread.java:1583)

Found 1 deadlock.


We can also use JConsole to detect deadlocks. Upon launching JConsole in the terminal and clicking the "Detect Deadlock" button, any deadlocks will be displayed.

Deadlocks displayed on the JConsole

To make deadlocks less likely, Java's <span  class="pink" >ReentrantLock</span> allows you to attempt to  try locking using tryLock() method.

A typical usage idiom for this method would be:

 
Lock lock = ...;
 if (lock.tryLock()) {
   try {
     // manipulate protected state
   } finally {
     lock.unlock();
   }
 } else {
   // perform alternative actions
 }


This means the thread will try to acquires the lock if it is available and returns immediately with the value <span  class="pink" >true</span>. If the lock is not available then this method will return immediately with the value <span  class="pink" >false</span>.

Let’s look at the implementation now:

 
public class ReentrantLockAccount {
    private double balance;
    private final Lock lock = new ReentrantLock();

    public boolean withdraw(double amount) {
        if (lock.tryLock()) {
            try {
                balance -= amount;
            } finally {
                lock.unlock();
            }
            return true;
        } else {
            // Could not acquire the lock
            return false;
        }
    }

    public boolean deposit(double amount) {
        if (lock.tryLock()) {
            try {
                balance += amount;
            } finally {
                lock.unlock();
            }
            return true;
        } else {
            // Could not acquire lock
            return false;
        }
    }

    public boolean transfer(ReentrantLockAccount2 to, double amount) {
        boolean success = false;
        while (!success) {
            if (this.lock.tryLock()) {
                try {
                    if (to.lock.tryLock()) {
                        try {
                            if (this.withdraw(amount)) {
                                if (to.deposit(amount)) {
                                    success = true;
                                }
                            }
                        } finally {
                            to.lock.unlock();
                        }
                    }
                } finally {
                    this.lock.unlock();
                }
            }
        }
        return success;
    }

    public double getBalance() {
        lock.lock();
        try {
            return balance;
        } finally {
            lock.unlock();
        }
    }
 }

This solution will fix the deadlock problem. However, please note that it uses a busy-wait approach, which may not be suitable in all situations because it can cause high CPU usage. But for this article, we will skip discussing it and move on to the next step.

💡 Busy-wait: Busy-waiting is when a program constantly checks if a condition is met instead of sleeping, which can waste CPU resources. It's generally avoided in Java and it's more common to use concurrency utilities such as <span  class="pink" >wait()</span> and <span  class="pink" >notify()</span>, or higher-level constructs like CountDownLatch, Semaphore, or CompletableFuture that handle the waiting more efficiently by suspending the thread until the condition is met, thereby reducing CPU usage. These techniques are not covered in this article.

The ReentrantLock has additional features, including the boolean tryLock(long time,TimeUnit unit) throws InterruptedException method. According to the documentation, this method attempts to acquire the lock within the given waiting time if it is available and the current thread has not been interrupted.

<span  class="pink" >ReentrantLock</span> also gives you the option to create fair locks, which ensure that threads acquire locks in the order they asked for them:

 
private final ReentrantLock lock = new ReentrantLock(true); // This is a fair lock

However, be mindful that while fair locks seem just, they come with a performance cost and are typically slower than the default setting.

In sum, <span  class="pink" >ReentrantLock</span> gives you more control but at the price of potential complexity. Proper use can lead to more robust concurrent code, while misuse can still lead to deadlocks or performance issues. It's like having a powerful car: it can go fast and give you a smooth ride, but you still need to know how to handle it properly to avoid accidents.

ReadWriteLock

ReadWriteLock is an interface in Java that provides a more sophisticated lock mechanism compared to synchronized or basic Lock interfaces. It features two locks: a read lock and a write lock. This distinction allows multiple threads to hold read locks concurrently as long as there's no thread holding the write lock, which is particularly beneficial when read operations are more frequent than write operations.

It is like a savvy traffic director for managing access to your data. It’s clever because it understands that sometimes, lots of folks (threads) just want to read information, and there’s no harm in letting them all in at once. But, when someone wants to write or change information, it needs to clear the room, so to speak.

For instance, if the <span  class="teal" >getBalance()</span> method of an account class is called more frequently than <span  class="teal" >deposit()</span> or <span  class="teal" >withdraw()</span>, it would be inefficient to acquire a write lock each time the balance is queried. The **ReadWriteLock** enables multiple threads to obtain read locks in parallel, unless a write lock is held.

Let’s make this real with an example in the context of an account where you might be checking the balance often, but you only occasionally make a deposit or a withdrawal. Here’s how <span  class="pink" >ReadWriteLock</span> can help streamline this:

 
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockAccount {
    private double balance;
    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();

    public boolean withdraw(double amount) {
        rwLock.writeLock().lock();
        try {
            balance -= amount;
            return true;
        } finally {
            rwLock.writeLock().unlock();
        }
    }

    public boolean deposit(double amount) {
        rwLock.writeLock().lock();
        try {
            balance += amount;
            return true;
        } finally {
            rwLock.writeLock().unlock();
        }
    }

    public boolean transfer(ReadWriteLockAccount to, double amount) {
        rwLock.writeLock().lock();
        try {
            if (!this.withdraw(amount)) return false; // Withdrawal failure
            return to.deposit(amount); // Deposit and return the result
        } finally {
            rwLock.writeLock().unlock();
        }
    }

    public double getBalance() {
        rwLock.readLock().lock();
        try {
            return balance;
        } finally {
            rwLock.readLock().unlock();
        }
    }
}

In this setup, when we're just looking at the balance with <span  class="teal" >getBalance()</span>, we can use a read lock. This lock is more relaxed and says, "Go ahead, everyone, look all you want." But when we need to make changes with <span  class="teal" >withdraw()</span> or <span  class="teal" >deposit()</span>, we switch to a write lock, which is more exclusive, like saying, "Everyone else, please wait outside for a moment."

Now, there’s also a neat trick you can do with <span  class="pink" >ReadWriteLock</span> called "lock downgrading." It's like starting a conversation in a private office (write lock) and then moving to a coffee shop (read lock) where others can join in. Here’s an example:

 
public void performComplexOperation(double amount) {
    rwLock.writeLock().lock(); // Start with exclusive access
    try {
        // ... do something that writes data
        rwLock.readLock().lock(); // Now switch to a shared access
    } finally {
        rwLock.writeLock().unlock(); // End exclusive access
    }

    try {
        // ... do things that read data
    } finally {
        rwLock.readLock().unlock(); // End shared access
    }
}

The <span  class="pink" >ReadWriteLock</span> in Java is a powerful tool that shines in scenarios with frequent read operations, allowing them to occur concurrently and thereby increasing system throughput. This approach gives developers more granular control over resource management compared to simpler locking mechanisms. However, this sophistication comes at the cost of added complexity, as managing two types of locks can be a bit like directing traffic at a busy intersection. Additionally, there's a risk of lock starvation, where frequent writes could potentially leave readers waiting in line.

StampedLock

StampedLock is a Java synchronizer introduced in Java 8 for lock management. It's an improvement over <span  class="pink" >ReadWriteLock</span>, providing higher throughput under read-heavy scenarios. It introduces the concept of "stamping" as an identifier for the locks, allowing for more flexible and efficient lock queries and upgrades.

Let’s look at the basic implementation:

 
import java.util.concurrent.locks.StampedLock;

public class StampLockAccount {
    private double balance;
    private final StampedLock sl = new StampedLock();

    public void withdraw(double amount) {
        long stamp = sl.writeLock();
        try {
            balance -= amount;
        } finally {
            sl.unlockWrite(stamp);
        }
    }

    public void deposit(double amount) {
        long stamp = sl.writeLock();
        try {
            balance += amount;
        } finally {
            sl.unlockWrite(stamp);
        }
    }

    public void transfer(StampLockAccount to, double amount) {
        long stamp = sl.writeLock();
        try {
            this.withdraw(amount);
            to.deposit(amount);
        } finally {
            sl.unlockWrite(stamp);
        }
    }

    public double getBalance() {
        long stamp = sl.readLock();
        try {
            return balance;
        } finally {
            sl.unlockRead(stamp);
        }
    }
}

The <span class="pink">StampedLock</span> shines with its key features:

Optimistic Reads: This is a fast read operation that doesn't block, but it requires some extra caution. You need to verify if the read was accurate and, if not, resort to a more traditional read lock.

 
long stamp = lock.tryOptimisticRead();
double currentBalance = balance;
if (!lock.validate(stamp)) {
    // Fallback to read lock
}

Read and Write Locks: These are similar to <span class="pink">ReadWriteLock</span>. However, you need to manage a stamp that acts like a key for releasing the lock, making the code a bit more involved.

 
long stamp = lock.writeLock();
try {
    // write logic
} finally {
    lock.unlockWrite(stamp);
}

Lock Upgrading: This allows a read lock to be promoted to a write lock without unlocking and relocking, streamlining certain operations.

 
long stamp = lock.readLock();
try {
    if (someCondition()) {
        long writeStamp = lock.tryConvertToWriteLock(stamp);
        if (writeStamp != 0L) { // Successfully upgraded
            stamp = writeStamp;
            // write logic
        }
    }
} finally {
    lock.unlock(stamp);
}

Nonetheless, <span class="pink">StampedLock</span> has its perks, like allowing many read operations at once, which can speed things up when lots of threads are just reading data. It also lets you switch from a read to a write lock smoothly, which is handy in some situations. But it's a bit trickier to use because you've got to keep track of these special stamps for each lock. Plus, it can't handle threads that need to lock things multiple times in a row, which could be a deal-breaker for certain tasks. And sometimes, if reads or writes are non-stop, the other type might get stuck waiting its turn, which isn't ideal. So, while <span class="pink">StampedLock</span> can be faster in some cases, you've got to weigh that against these quirks and decide if it's the right fit for your project.

Conclusion:

To sum up, Java's concurrency tools have improved a lot, offering different levels of control and efficiency. We started with <span class="pink">synchronized</span> for simplicity and then moved to <span class="pink">ReentrantLock</span> for flexibility. Each tool has its own purpose. <span class="pink">ReadWriteLock</span> enhances concurrency by having separate read and write locks, which is good for operations that involve more reading. Lastly, <span class="pink">StampedLock</span> gives even more control with its stamp-based system and optimistic reads, but it is more complex and doesn't have reentrant capabilities. Choosing the right tool depends on the specific requirements for thread safety and performance in Java applications.

A N M Bazlur Rehman
November 12, 2023
Use Unlogged to
mock instantly
record and replay methods
mock instantly
Install Plugin