Abstract: ReentrantReadWriteLock has a limit of 65536 concurrent read locks. That is an unattainable number with platform threads, but definitely achievable with virtual threads. In this newsletter we explore some of the issues if we want to switch to the StampedLock.asReadWriteLock() instead.
Welcome to the 321st edition of The Java(tm) Specialists' Newsletter, sent from the beautiful Island of Crete. One of the best businesses to own here has to be a tyre shop. But let me backtrack a bit. When we arrived in 2006, we bought a brand new Toyota Corolla 1.6. We didn't know anyone and thought it would be best to get a new car, rather than buy someone else's troubles. A few weeks later, Helene phoned me to tell me she had dinged our new car on someone's very narrow driveway gate. We had full insurance, but the excess was more than the damage, so we sighed and paid to have it repaired. It didn't take long for the second bump. We fixed that too at our expense. Then we learned how things work here on Crete. No one fixes every little bump. You'll see when you come visit. Next, whilst looking at real estate, Helene drove too close to the rocky side and slashed two tyres at once! You cannot patch the sidewall, and so we had to replace them. It's not only Helene. We have all bumped our cars and trashed our tyres. Once I was driving through Chorafakia, and a ten ton truck came rushing towards me. I dodged to the right to avoid turning into spam, and heard the whoosh as the breath rushed out of my tyres. Last Sunday evening, we helped a friend change a tyre. And then two days ago, our son told us that we had a slow puncture on the back wheel. Again, too much damage and we had to replace it. I have lost count how many tyres we destroyed on Crete. It must be close to two dozen.
javaspecialists.teachable.com: Please visit our new self-study course catalog to see how you can upskill your Java knowledge.
In 2008, I wrote about Starvation
with ReadWriteLocks. I discovered that in Java 5,
the ReentrantReadWriteLock
would not yield to write locks.
Since read locks are allocated concurrently, readers could tag-team
each other, thus potentially preventing the write lock from ever
being issued. I like to compare this to stopping at a pedestrian crossing
outside a college in the morning.
Before the first student has meandered past our car, the
next students have already moved in front our vehicle.
We might be there for a while.
This was changed in Java 6. If a thread asks to lock a write lock, no new read locks are issued until the write lock has been serviced. This was a much better approach. However, in the very unlikely case that we have a lot of writers and only one reader, we can get starvation on the reader. See my Newsletter #165 from 17 years ago :-)
The ReentrantReadWriteLock
recently made news on the loom-dev
mailing list. Turns out, someone wanted to use it with virtual
threads. When the ReentrantReadWriteLock
was written, we could
not have imagined systems with millions of threads. However,
since Java 21, this is indeed possible with virtual threads.
(For the most thorough course on virtual threads, please get my
Mastering
Virtual Threads in Java). However, the ReentrantReadWriteLock
is based on the AbstractQueuedSynchronizer
, as opposed to the
AbstractQueuedLongSynchronizer
, and thus only has a 32 bit
int for the state of reads vs writes. They allow a maximum
of 65536 threads to acquire a read lock at one time. If we
exceed this, they throw an Error
. This class was written a
long time ago, and at the time we could not have gotten
close to that limit with platform threads. Virtual threads
change this. The same issue occurs with Phaser
, and I must
admit that I have already run into issues with Phaser
running out of parties during
my Project Loom experimentations. The Loom engineers raised a bug for this
a couple of days ago - JDK-8349031 -
and perhaps in future the ReentrantReadWriteLock
will be
based on the AbstractQueuedLongSynchronizer
, giving it more
than enough concurrent read locks - for now :-)
Here is a class that demonstrates the issue:
import java.util.concurrent.*; import java.util.concurrent.locks.*; public class HugeNumberOfReadLocks { private final ReadWriteLock rwlock; private final int numberOfReaders; public HugeNumberOfReadLocks(ReadWriteLock rwlock, int numberOfReaders) { this.rwlock = rwlock; this.numberOfReaders = numberOfReaders; } public void run() { try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { for (int i = 0; i < numberOfReaders; i++) { scope.fork(() -> { rwlock.readLock().lock(); try { Thread.sleep(1000); } finally { rwlock.readLock().unlock(); } return true; }); } scope.join().throwIfFailed(); System.out.println("Done"); } catch (ExecutionException e) { e.getCause().printStackTrace(); } catch (InterruptedException e) { throw new CancellationException("interrupted"); } } }
We can call this with a million concurrent readers and a
ReentrantReadWriteLock
:
import java.util.concurrent.locks.*; public class ReentrantReadWriteLockDemo { public static void main(String... args) { new HugeNumberOfReadLocks( new ReentrantReadWriteLock(), 1_000_000 ).run(); } }
We see the following output at the moment:
java.lang.Error: Maximum lock count exceeded at ...ReentrantReadWriteLock$Sync.fullTryAcquireShared(ReentrantReadWriteLock.java:536) at ...ReentrantReadWriteLock$Sync.tryAcquireShared(ReentrantReadWriteLock.java:495) at ...AbstractQueuedSynchronizer.acquireShared(AbstractQueuedSynchronizer.java:1117) at ...ReentrantReadWriteLock$ReadLock.lock(ReentrantReadWriteLock.java:739) at HugeNumberOfReadLocks.lambda$run$0(HugeNumberOfReadLocks.java:18) at ...StructuredTaskScope$SubtaskImpl.run(StructuredTaskScope.java:879) at ...VirtualThread.run(VirtualThread.java:458)
StampedLock
does not have this restriction. For example:
import java.util.concurrent.locks.*; public class StampedLockReadWriteLockDemo { public static void main(String... args) { new HugeNumberOfReadLocks( new StampedLock().asReadWriteLock(), 1_000_000 ).run(); } }
Output is simply "Done".
It might be tempting to simply use the StampedLock
as a
drop-in replacement for the ReentrantReadWriteLock
. However,
I would advise against that. There are two issues. First off
ReentrantReadWriteLock
is reentrant on the write lock,
hence the name. StampedLock
is not. Here is a demo:
import java.util.concurrent.locks.*; public class ReentrancyDemo { public static void main(String... args) { test(new ReentrantReadWriteLock()); test(new StampedLock().asReadWriteLock()); } private static void test(ReadWriteLock rwlock) { System.out.println(rwlock.getClass().getTypeName()); rwlock.writeLock().lock(); try { System.out.println("Acquired write lock #1"); inner(rwlock); } finally { rwlock.writeLock().unlock(); } System.out.println(); } private static void inner(ReadWriteLock rwlock) { if (rwlock.writeLock().tryLock()) { try { System.out.println("Acquired write lock #2"); } finally { rwlock.writeLock().unlock(); } } else { System.out.println("Failed to acquire write lock #2"); } } }
The output is as follows:
java.util.concurrent.locks.ReentrantReadWriteLock Acquired write lock #1 Acquired write lock #2 java.util.concurrent.locks.StampedLock$ReadWriteLockView Acquired write lock #1 Failed to acquire write lock #2
The second issue is more serious. StampedLock
's write locks
have the same issue with starvation as we saw in Java 5.
Here is an example:
import java.util.concurrent.*; import java.util.concurrent.locks.*; public class WriteLockStarvationDemo { private final ReadWriteLock rwlock; public WriteLockStarvationDemo(ReadWriteLock rwlock) { this.rwlock = rwlock; } public void run() { if (checkForWriterStarvation(rwlock) > 1_000) { throw new AssertionError("Writer starvation occurred!!!"); } else { System.out.println("No writer starvation"); } } private long checkForWriterStarvation(ReadWriteLock rwlock) { System.out.println("Checking " + rwlock.getClass()); try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { scope.fork(() -> { System.out.println("Going to start readers ..."); for (int i = 0; i < 10; i++) { int reader = i; scope.fork(() -> { rwlock.readLock().lock(); try { System.out.println("Reader " + reader + " is reading ..."); Thread.sleep(1000); } finally { rwlock.readLock().unlock(); } System.out.println("Reader " + reader + "" + " is done"); return true; }); Thread.sleep(500); } return true; }); Thread.sleep(1800); System.out.println("Going to try to write now ..."); long time = System.nanoTime(); rwlock.writeLock().lock(); try { time = System.nanoTime() - time; time /= 1_000_000; // convert to ms System.out.printf( "time to acquire write lock = %dms%n", time); System.out.println("Writer is writing ..."); Thread.sleep(1000); } finally { rwlock.writeLock().unlock(); } System.out.println("Writer is done"); scope.join().throwIfFailed(IllegalStateException::new); return time; } catch(InterruptedException e) { throw new CancellationException("interrupted"); } } }
When we run it with the ReentrantReadWriteLock
, we see the
following:
import java.util.concurrent.locks.*; public class ReentrantReadWriteLockNoWriterStarvation { public static void main(String... args) { new WriteLockStarvationDemo( new ReentrantReadWriteLock() ).run(); } }
Checking class java.util.concurrent.locks.ReentrantReadWriteLock Going to start readers ... Reader 0 is reading ... Reader 1 is reading ... Reader 2 is reading ... Reader 0 is done Reader 1 is done Reader 3 is reading ... Going to try to write now ... Reader 2 is done Reader 3 is done time to acquire write lock = 709ms Writer is writing ... Writer is done Reader 4 is reading ... Reader 5 is reading ... Reader 6 is reading ... Reader 7 is reading ... Reader 8 is reading ... Reader 6 is done Reader 5 is done Reader 4 is done Reader 7 is done Reader 9 is reading ... Reader 8 is done Reader 9 is done No writer starvation
However, when we run it with the StampedLock
's version of
the ReadWriteLock
, we get:
import java.util.concurrent.locks.*; public class StampedLockWriterStarvation { public static void main(String... args) { new WriteLockStarvationDemo( new StampedLock().asReadWriteLock() ).run(); } }
Checking class java.util.concurrent.locks.StampedLock$ReadWriteLockView Going to start readers ... Reader 0 is reading ... Reader 1 is reading ... Reader 2 is reading ... Reader 0 is done Reader 3 is reading ... Reader 1 is done Going to try to write now ... Reader 2 is done Reader 4 is reading ... Reader 3 is done Reader 5 is reading ... Reader 4 is done Reader 6 is reading ... Reader 5 is done Reader 7 is reading ... Reader 6 is done Reader 8 is reading ... Reader 7 is done Reader 9 is reading ... Reader 8 is done Reader 9 is done time to acquire write lock = 3725ms Writer is writing ... Writer is done Exception in thread "main" java.lang.AssertionError: Writer starvation occurred!!! at WriteLockStarvationDemo.run(WriteLockStarvationDemo.java:13) at StampedLockWriterStarvation.main(StampedLockWriterStarvation.java:7)
StampedLock
has optimistic read support with
StampedLock.tryOptimisticRead()
. Most of the
time, when using StampedLock
, we would first try to read
optimistically, and if that fails, downgrade to a fully
fledged read lock. The problem with an optimistic read is
that it can be quite tricky to write the code correctly and
optimally. There are examples of optimistic read algorithms
in the StampedLock
class, but we could be reading whilst the
state is being updated. This could cause temporarily broken
invariants, which might result in strange exceptions. I won't
go over the details of StampedLock
here - I do cover it in my
Extreme Java -
Concurrency Performance Course. However, the
Javadocs for StampedLock
is quite good and will show you all
the necessary locking idioms you need.
Kind regards
Heinz
P.S. We also have a bundle of Java concurrency courses, called the Java Concurrency Aficionados 2024, which contains our new courses on platform and virtual threads, as well as some "teardown" courses that I recorded looking in great detail at some of the java.util.concurrent data structures.
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.