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.
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:
💡 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:
💡 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:
Now, if we add <span class="pink" >synchronized</span> to our methods in the <span class="teal" >SynchronizedAccount</span> version of the class:
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.
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>:
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:
The output reveals:
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.
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:
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:
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:
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 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:
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:
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 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:
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.
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.
Lock Upgrading: This allows a read lock to be promoted to a write lock without unlocking and relocking, streamlining certain operations.
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.
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.