Running on Java 24+36-3646 (Preview)
Home of The JavaSpecialists' Newsletter

321StampedLock ReadWriteLock Dangers

Author: Dr Heinz M. KabutzDate: 2025-01-31Java Version: 21Category: Concurrency
 

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.

StampedLock ReadWriteLock Dangers

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.


 

Comments

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)

When you load these comments, you'll be connected to Disqus. Privacy Statement.

Related Articles

Browse the Newsletter Archive

About the Author

Heinz Kabutz Java Conference Speaker

Java Champion, author of the Javaspecialists Newsletter, conference speaking regular... About Heinz

Superpack '24

Superpack '24 Our entire Java Specialists Training in one huge bundle more...

Free Java Book

Dynamic Proxies in Java Book
Java Training

We deliver relevant courses, by top Java developers to produce more resourceful and efficient programmers within their organisations.

Java Consulting

We can help make your Java application run faster and trouble-shoot concurrency and performance bugs...