Abstract: The Java ReentrantReadWriteLock can never ever upgrade a read lock to a write lock. Kotlin's extension function ReentrantReadWriteLock.write() cheats a bit by letting go of the read lock before upgrading, thus opening the door for race conditions. A better solution is StampedLock, which has a method to try to convert the lock to a write lock.
Welcome to the 279th edition of The Java(tm) Specialists' Newsletter, sent to you from the Island of Crete. Thank you for reading my newsletter, either via email or online. I appreciate your support so much. A lot has changed since I sent my first newsletter almost twenty years ago, but the Java classes from then still (mostly) run on a Java 15 JVM. That's an amazing feat!
I am still waiting to get bored during the lockdown. With four children at home, at various stages of emotional and physical development, we have had our hands full, in a satisfying way. But boredom? Nope, not yet. And Java also keeps on giving us new areas to explore. There's never a dull moment.
javaspecialists.teachable.com: Please visit our new self-study course catalog to see how you can upskill your Java knowledge.
In Java 5, we got the ReadWriteLock interface, with an implementation ReentrantReadWriteLock. It had the sensible restriction that we could downgrade a write lock to a read lock, but we could not upgrade a read lock to a write lock. When we tried, we would immediately get a deadlock. The reason for this restriction is that if two threads both had a read lock, what if they both tried to upgrade at the same time? Only one could succeed - but what about the other thread? To be safe, it consistently deadlocks any thread that attempts an upgrade.
Downgrading the ReentrantReadWriteLock works fine and we can in this case hold a read and a write lock simultaneously. A downgrade means that whilst holding a write lock, we also lock the read lock and then release the write lock. This means that we do not allow any other threads to write, but they may read.
import java.util.concurrent.locks.*; // This runs through fine public class DowngradeDemo { public static void main(String... args) { var rwlock = new ReentrantReadWriteLock(); System.out.println(rwlock); // w=0, r=0 rwlock.writeLock().lock(); System.out.println(rwlock); // w=1, r=0 rwlock.readLock().lock(); System.out.println(rwlock); // w=1, r=1 rwlock.writeLock().unlock(); // at this point other threads can also acquire read locks System.out.println(rwlock); // w=0, r=1 rwlock.readLock().unlock(); System.out.println(rwlock); // w=0, r=0 } }
Attempting to upgrade a ReentrantReadWriteLock from read to write causes a deadlock:
// This deadlocks public class UpgradeDemo { public static void main(String... args) { var rwlock = new ReentrantReadWriteLock(); System.out.println(rwlock); // w=0, r=0 rwlock.readLock().lock(); System.out.println(rwlock); // w=0, r=1 rwlock.writeLock().lock(); // deadlock System.out.println(rwlock); rwlock.readLock().unlock(); System.out.println(rwlock); rwlock.writeLock().unlock(); System.out.println(rwlock); } }
One of the fun things I've managed to do whilst in lockdown is to learn Kotlin. Of all the resources I looked at, the book I liked most was Kotlin in Action by Dmitry Jemerov and Svetlana Isakova. Highly recommended, especially for Java programmers.
Having studied Kotlin for a couple of months, I can believe that it has a positive impact on application development time. There are a lot of time savers in there. I can see why it has so many fans. I've looked at a lot of languages besides Java in the last two decades, from Ruby to Clojure to Scala to Swift. However, Kotlin has been the winner so far of my second languages. I can imagine rewriting part of my JavaSpecialists website infrastructure with Kotlin, just for kicks.
On a seemingly off-topic note, in my opinion the only time that we are really reading a book is when we spot mistakes. Thus the highest compliment I can give an author is to send a long list of mistakes. OK, some publishing houses are incredibly bad at their editorial process and every page is littered with mistakes. I'm not talking about those, but rather, excellent books like Effective Java, Mastering Lambdas, Head First Design Patterns.
In the same way, I know that I am learning a new computer language properly when I start spotting mistakes in the actual language or API. After all, isn't that what The Java(tm) Specialists' Newsletter is all about? For twenty years I've been poking fun at all the weird anomalies in the Java Programming Language.
However, it is more challenging with Kotlin. Normally when I point out an error in a book, the authors will thank me, admit they made a mistake and try to fix it in a future version. With Kotlin I find it far more difficult to get my arguments across. It might be because I am a well-known "Java Guy" that the Kotlin community feels they need to defend their language. It could also be that I am not experienced enough in Kotlin to add something useful to the discussion. I was even a bit apprehensive writing this newsletter, in the fear that it would be misunderstood.
After that long defense, let us have a look at how Kotlin manages ReadWriteLock.
But before we do, one last glimpse at Java. A lot of programmers have asked me why ReentrantReadWriteLock does not support try-with-resource to automatically unlock. I wrote about this in newsletters 190 and 190b. Then lambdas came along and it would have been sensible to create an idiomatic implementation of locking/unlocking with the body contained inside a lambda. However, Java 8 lambdas do not support the throwing of checked exceptions all that well. Thus in Java we need to write all of this locking/unlocking code by hand. Tedious and error prone. A hint of a problem is that IntelliJ IDEA has predefined live templates to generate that code. Our IDE generating code for us (getter/setter, toString, equals/hashCode, constructors, locking/unlocking) is a sign of a language smell.
In Kotlin, lambdas are compiled in a slightly different way to Java. There are advantages and disadvantages, but I will not go into that here. Kotlin also has a great feature of extension functions, to allow us to supposedly add functionality to existing classes. It is a sleight-of-hand, but in a good way.
The following Kotlin code is similar to our DowngradeDemo.
The only difference is with the fourth println(), which in
our Java version shows
// w=0, r=1
and in Kotlin is
// w=1, r=0
.
In Java we did not unlock the read and write locks in the
same order that they were locked. As soon as we did the
downgrade, from write to read, other threads would have been
able to acquire the read lock. This means that the Kotlin
version does not allow other threads to get the read lock.
It is not a true lock downgrade.
// DowngradeDemoKotlin.kt import java.util.concurrent.locks.* import kotlin.concurrent.* fun main() { val rwlock = ReentrantReadWriteLock() println(rwlock) // w=0, r=0 rwlock.write { println(rwlock) // w=1, r=0 rwlock.read { println(rwlock) // w=1, r=1 } println(rwlock) // w=1, r=0 } println(rwlock) // w=0, r=0 }
Imagine my surprise when I tried upgrading the read lock to a write lock:
// UpgradeDemoKotlin.kt fun main() { val rwlock = ReentrantReadWriteLock() println(rwlock) // w=0, r=0 rwlock.read { println(rwlock) // w=0, r=1 rwlock.write { println(rwlock) // w=1, r=0 } println(rwlock) // w=0, r=1 } println(rwlock) // w=0, r=0 }
No deadlock! Hurray!
However, look at the status of each println()
.
In the DowngradeDemoKotlin.kt code, we have // w=1, r=1
in the middle. But not this time. We only hold the write lock, but not the read lock.
If we peek into the implementation of the Kotlin extension
function ReentrantReadWriteLock.write()
we see the following:
/** * Executes the given [action] under the write lock of this lock. * * The function does upgrade from read to write lock if needed, * but this upgrade is not atomic as such upgrade is not * supported by [ReentrantReadWriteLock]. * In order to do such upgrade this function first releases all * read locks held by this thread, then acquires write lock, and * after releasing it acquires read locks back again. * * Therefore if the [action] inside write lock has been initiated * by checking some condition, the condition must be rechecked * inside the [action] to avoid possible races. * * @return the return value of the action. */ @kotlin.internal.InlineOnly public inline fun <T> ReentrantReadWriteLock.write(action: () -> T): T { val rl = readLock() val readCount = if (writeHoldCount == 0) readHoldCount else 0 repeat(readCount) { rl.unlock() } val wl = writeLock() wl.lock() try { return action() } finally { repeat(readCount) { rl.lock() } wl.unlock() } }
An equivalent version of UpgradeDemoKotlin.kt in Java would look like this:
public class UpgradeDemoKotlinAsJava { public static void main(String... args) { var rwlock = new ReentrantReadWriteLock(); System.out.println(rwlock); // w=0, r=0 rwlock.readLock().lock(); try { System.out.println(rwlock); // w=0, r=1 int readCount = rwlock.getWriteHoldCount() == 0 ? rwlock.getReadHoldCount() : 0; for (int i = 0; i < readCount; i++) rwlock.readLock().unlock(); rwlock.writeLock().lock(); try { System.out.println(rwlock); // w=1, r=0 } finally { for (int i = 0; i < readCount; i++) rwlock.readLock().lock(); rwlock.writeLock().unlock(); } System.out.println(rwlock); // w=0, r=1 } finally { rwlock.readLock().unlock(); } System.out.println(rwlock); // w=0, r=0 } }
The documentation in the Kotlin function explicitly states that the read locks will be released before acquiring the write lock and that any condition before, has to be checked again inside the write lock. This is due to a (sensible) limitation in the ReentrantReadWriteLock, already mentioned at the beginning of this newsletter.
I would be surprised to see code like this in the JDK. In Java we are more careful about avoiding race conditions. A thread deadlock is preferable to a mystery race condition. Writing a warning into the documentation is not good enough IMHO. Who reads that anyway? Principle of least astonishment FTW (POLA).
The Java 8 StampedLock gives us more control over how a failed upgrade should be handled. A few things before we start.
The StampedLock is not reentrant, which means that we cannot hold both a read and a write lock at the same time. A stamp is not tied to a particular thread, thus we also cannot hold two write locks at the same time from one thread. We can hold lots of read locks at the same time, each with a different stamp. But we can only get a single write lock. Here is a demo:
public class StampedLockDemo { public static void main(String... args) { var sl = new StampedLock(); var stamps = new ArrayList<Long>(); System.out.println(sl); // Unlocked for (int i = 0; i < 42; i++) { stamps.add(sl.readLock()); } System.out.println(sl); // Read-Locks:42 stamps.forEach(sl::unlockRead); System.out.println(sl); // Unlocked var stamp1 = sl.writeLock(); System.out.println(sl); // Write-Locked var stamp2 = sl.writeLock(); // deadlocked System.out.println(sl); // Not seen... } }
Since StampedLock does not know which thread owns the locks, the DowngradeDemo would deadlock:
public class StampedLockDowngradeFailureDemo { public static void main(String... args) { var sl = new StampedLock(); System.out.println(sl); // Unlocked long wstamp = sl.writeLock(); System.out.println(sl); // Write-Locked long rstamp = sl.readLock(); // deadlocked System.out.println(sl); // Not seen... } }
However, StampedLock does allow us to try to upgrade or downgrade our locks. This will also convert the stamp to the new type. For example, here is how we could do the downgrade correctly. Note that we do not need to unlock the write lock, since the stamp was converted from write to read.
public class StampedLockDowngradeDemo { public static void main(String... args) { var sl = new StampedLock(); System.out.println(sl); // Unlocked long wstamp = sl.writeLock(); System.out.println(sl); // Write-locked long rstamp = sl.tryConvertToReadLock(wstamp); if (rstamp != 0) { System.out.println("Converted write to read"); System.out.println(sl); // Read-locks:1 sl.unlockRead(rstamp); System.out.println(sl); // Unlocked } else { // this cannot happen (famous last words) sl.unlockWrite(wstamp); throw new AssertionError("Failed to downgrade lock"); } } }
One little story that might amaze you. My friend Victor Grazi discovered a bug in an early version of StampedLock. When we downgraded a write to a read, threads waiting for a read lock stayed blocked until the read lock was finally released. The amazing part of the story is that he discovered this bug whilst clicking around in his Java Concurrent Animated program.
We can also try to convert a read lock to a write lock.
Unlike the Kotlin ReentrantReadWriteLock.write()
extension function, this
will do the conversion atomically. However, it may still fail, for example
if another thread currently holds the read lock as well. In that case, a
reasonable approach would be to bail out and try again or perhaps start with
a write lock. Let's first have a look at the simple case of converting read
to write:
public class StampedLockUpgradeDemo { public static void main(String... args) { var sl = new StampedLock(); System.out.println(sl); // Unlocked long rstamp = sl.readLock(); System.out.println(sl); // Read-locks:1 long wstamp = sl.tryConvertToWriteLock(rstamp); if (wstamp != 0) { // works if no one else has a read-lock System.out.println("Converted read to write"); System.out.println(sl); // Write-locked sl.unlockWrite(wstamp); } else { // we do not have an exclusive hold on read-lock System.out.println("Could not convert read to write"); sl.unlockRead(rstamp); } System.out.println(sl); // Unlocked } }
The StampedLock Javadoc documentation shows several idioms of how the StampedLock could be used. Two of these demonstrate how upgrades could be done, either from a pessimistic or an optimistic read. The upgrade idioms perform best when we have a relatively small chance of needing to upgrade to write and when that upgrade has a high chance of succeeding.
The idioms take some getting used to. At first they look a
bit obscure, with labelled breaks and seemingly misconstructed
for-loops. The optimistic read idioms in Java 8
were simpler to understand. However, the benefit of the more modern code is that we
have less repetition of our reading code.
I am not convinced that the check for if (stamp == 0L) continue retryHoldingLock;
makes the code faster.
Usually with optimistic reads, we want to go from tryOptimisticRead()
to validate()
as quickly as possible,
to minimize the chances of another thread writing in the meantime. I did have a benchmark
to prove this, but it was for an old version of
StampedLock and I will have to redo that research.
To see the optimistic read idiom in action, have a look at today's commit of jdk.internal.foreign.MemoryScope. (Complete coincidence that this was checked in today whilst I am busy writing a Java newsletter featuring StampedLock. Thank you Doug Lea for pointing it out :-))
Kind regards from Crete
Heinz
We are always happy to receive comments from our readers. Feel free to send me a comment via email or discuss the newsletter in our JavaSpecialists Slack Channel (Get an invite here)
We deliver relevant courses, by top Java developers to produce more resourceful and efficient programmers within their organisations.