Java’s synchronized keyword, ReentrantLock, and StampedLock all aim to provide thread-safe access to shared resources, but they do so with different philosophies and capabilities.

The most surprising truth about these locking mechanisms is that sometimes, the "simplest" option, synchronized, can be the most performant, even though it lacks the advanced features of its counterparts. This is often due to JVM optimizations that can make intrinsic locks (those managed by synchronized) nearly as fast as explicit locks under certain conditions, especially when contention is low.

Let’s see them in action. Imagine a simple cache that needs to be accessed by multiple threads.

First, using synchronized:

import java.util.HashMap;
import java.util.Map;

public class SynchronizedCache<K, V> {
    private final Map<K, V> cache = new HashMap<>();

    public synchronized V get(K key) {
        System.out.println(Thread.currentThread().getName() + " acquiring lock for get(" + key + ")");
        try {
            // Simulate some work
            Thread.sleep(50);
            return cache.get(key);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return null;
        } finally {
            System.out.println(Thread.currentThread().getName() + " releasing lock for get(" + key + ")");
        }
    }

    public synchronized void put(K key, V value) {
        System.out.println(Thread.currentThread().getName() + " acquiring lock for put(" + key + ")");
        try {
            // Simulate some work
            Thread.sleep(50);
            cache.put(key, value);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            System.out.println(Thread.currentThread().getName() + " releasing lock for put(" + key + ")");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        SynchronizedCache<String, Integer> cache = new SynchronizedCache<>();
        Runnable task = () -> {
            cache.put("key1", 1);
            cache.get("key1");
        };

        Thread t1 = new Thread(task, "Thread-1");
        Thread t2 = new Thread(task, "Thread-2");

        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println("Synchronized cache operations completed.");
    }
}

Next, ReentrantLock:

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockCache<K, V> {
    private final Map<K, V> cache = new HashMap<>();
    private final Lock lock = new ReentrantLock();

    public V get(K key) {
        lock.lock(); // Acquire the lock
        System.out.println(Thread.currentThread().getName() + " acquired lock for get(" + key + ")");
        try {
            // Simulate some work
            Thread.sleep(50);
            return cache.get(key);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return null;
        } finally {
            System.out.println(Thread.currentThread().getName() + " releasing lock for get(" + key + ")");
            lock.unlock(); // Release the lock
        }
    }

    public void put(K key, V value) {
        lock.lock(); // Acquire the lock
        System.out.println(Thread.currentThread().getName() + " acquired lock for put(" + key + ")");
        try {
            // Simulate some work
            Thread.sleep(50);
            cache.put(key, value);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            System.out.println(Thread.currentThread().getName() + " releasing lock for put(" + key + ")");
            lock.unlock(); // Release the lock
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ReentrantLockCache<String, Integer> cache = new ReentrantLockCache<>();
        Runnable task = () -> {
            cache.put("key1", 1);
            cache.get("key1");
        };

        Thread t1 = new Thread(task, "Thread-1");
        Thread t2 = new Thread(task, "Thread-2");

        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println("ReentrantLock cache operations completed.");
    }
}

Finally, StampedLock:

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.StampedLock;
import java.util.concurrent.locks.LockSupport;

public class StampedLockCache<K, V> {
    private final Map<K, V> cache = new HashMap<>();
    private final StampedLock lock = new StampedLock();

    public V get(K key) {
        long stamp = lock.tryOptimisticRead(); // Try an optimistic read
        V value = cache.get(key);

        if (!lock.validate(stamp)) { // If the stamp is no longer valid (data changed)
            stamp = lock.readLock(); // Acquire a full read lock
            System.out.println(Thread.currentThread().getName() + " acquired read lock for get(" + key + ")");
            try {
                value = cache.get(key); // Re-read the value
            } finally {
                lock.unlockRead(stamp); // Release the read lock
                System.out.println(Thread.currentThread().getName() + " released read lock for get(" + key + ")");
            }
        } else {
            System.out.println(Thread.currentThread().getName() + " performed optimistic read for get(" + key + ")");
            // Simulate some work if optimistic read succeeded
            try {
                Thread.sleep(25);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        return value;
    }

    public void put(K key, V value) {
        long stamp = lock.writeLock(); // Acquire a write lock
        System.out.println(Thread.currentThread().getName() + " acquired write lock for put(" + key + ")");
        try {
            // Simulate some work
            Thread.sleep(50);
            cache.put(key, value);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            lock.unlockWrite(stamp); // Release the write lock
            System.out.println(Thread.currentThread().getName() + " released write lock for put(" + key + ")");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        StampedLockCache<String, Integer> cache = new StampedLockCache<>();
        Runnable task = () -> {
            cache.put("key1", 1);
            cache.get("key1");
        };

        Thread t1 = new Thread(task, "Thread-1");
        Thread t2 = new Thread(task, "Thread-2");

        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println("StampedLock cache operations completed.");
    }
}

The mental model for these locks revolves around exclusivity and fairness. synchronized provides exclusive access; only one thread can hold the lock at a time. ReentrantLock is similar but offers more control, like the ability to attempt to acquire a lock without blocking (tryLock) or to interrupt a thread waiting for a lock. StampedLock introduces a more nuanced approach with optimistic reads, shared (read) locks, and exclusive (write) locks. An optimistic read (tryOptimisticRead) allows threads to read data without acquiring a lock, but it must be validated afterward. If validation fails (meaning a write occurred during the read), a full read lock (readLock) is acquired. This is particularly useful when reads are frequent and writes are infrequent, as it can significantly improve concurrency.

The core problem these solve is race conditions. Without proper synchronization, multiple threads trying to modify shared data can lead to inconsistent or corrupted states. For example, if two threads read a value, increment it, and then write it back, one of the increments might be lost.

synchronized uses an object’s intrinsic monitor. Every Java object has a monitor associated with it. When a thread enters a synchronized block or method, it attempts to acquire the monitor for the object. If successful, it holds the monitor until it exits.

ReentrantLock is an explicit lock. You call lock() to acquire it and unlock() to release it. The "reentrant" part means a thread that already holds the lock can acquire it again without blocking itself. This is crucial for recursive methods or when one synchronized method calls another on the same object.

StampedLock is the most complex. It uses "stamps" – opaque long values – to represent lock states. writeLock() returns a stamp that grants exclusive access. readLock() returns a stamp for shared access. tryOptimisticRead() returns a stamp that doesn’t prevent writes but allows you to check if a write has occurred since you got the stamp using validate().

A key detail about StampedLock is its potential for livelock if not used carefully. If a thread repeatedly tries an optimistic read, finds it invalidated, and then tries again, while another thread is constantly writing, neither might ever successfully complete their operation. The readLock() fallback is essential to prevent this, but it means you must always have a mechanism to acquire a proper read lock if the optimistic approach fails.

The next step in managing concurrency often involves exploring concurrent collections like ConcurrentHashMap or CopyOnWriteArrayList, which encapsulate these locking strategies internally and provide higher-level, more efficient APIs for common data structure operations.

Want structured learning?

Take the full Java course →