Locking Strategies: Managing Shared State

Concurrency is easy until two threads try to modify the same variable at the same time. This is the Critical Section problem. To prevent data corruption, we use locks. But not all locks are created equal.

In Java, we’ve evolved from the simple synchronized keyword to sophisticated ReadWriteLock and the high-performance StampedLock.

1. The Evolution of Locks

  1. synchronized: Built-in, reentrant, simple. But you can’t interrupt a thread waiting for it, and it’s strictly block-structured.
  2. ReentrantLock: Explicit lock object. Supports fairness, tryLock, and interruptibility.
  3. ReadWriteLock: Allows multiple readers OR one writer. Great for read-heavy workloads.
  4. StampedLock (Java 8): Optimistic locking. Incredibly fast for reads if contention is low.

2. Interactive: Lock Contention Simulator

See how different locking strategies perform under load. Compare Mutex (Exclusive Lock) vs Read-Write Lock (Shared Readers).

SHARED RESOURCE
UNLOCKED
Throughput 0 ops/s
Wait Queue 0 threads

3. Code Implementation: StampedLock

StampedLock is complex but powerful. It introduces “optimistic reads” which don’t block writers unless a write actually happens during the read.

Java Implementation

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

public class OptimisticCache {
    private final Map<String, String> map = new HashMap<>();
    private final StampedLock lock = new StampedLock();

    public void put(String key, String value) {
        long stamp = lock.writeLock();
        try {
            map.put(key, value);
        } finally {
            lock.unlockWrite(stamp);
        }
    }

    public String get(String key) {
        // 1. Try Optimistic Read (Non-blocking)
        long stamp = lock.tryOptimisticRead();
        String value = map.get(key); // Warning: map state might be inconsistent here!

        // 2. Validate the stamp
        // If the stamp changed (write occurred), this returns false
        if (!lock.validate(stamp)) {
            // 3. Fallback to Read Lock (Blocking)
            stamp = lock.readLock();
            try {
                value = map.get(key);
            } finally {
                lock.unlockRead(stamp);
            }
        }
        return value;
    }
}

[!CAUTION] StampedLock is NOT Reentrant! If a thread holds a write lock and tries to acquire it again, it will deadlock itself. Always use it inside try-finally blocks.

Go Implementation: RWMutex

Go’s sync.RWMutex is the standard way to handle multiple readers. It does not have an “optimistic” mode like StampedLock, but it is highly optimized for read-heavy workloads.

package main

import (
	"fmt"
	"sync"
	"time"
)

type SafeCache struct {
	mu    sync.RWMutex
	store map[string]string
}

func (c *SafeCache) Put(key, value string) {
	c.mu.Lock() // Exclusive Write Lock
	defer c.mu.Unlock()
	c.store[key] = value
}

func (c *SafeCache) Get(key string) string {
	c.mu.RLock() // Shared Read Lock
	defer c.mu.RUnlock()
	return c.store[key]
}

func main() {
	cache := &SafeCache{store: make(map[string]string)}

	// Writer
	go func() {
		for i := 0; i < 5; i++ {
			cache.Put("key", fmt.Sprintf("val-%d", i))
			time.Sleep(100 * time.Millisecond)
		}
	}()

	// Readers
	for i := 0; i < 3; i++ {
		go func(id int) {
			for {
				val := cache.Get("key")
				fmt.Printf("Reader %d: %s\n", id, val)
				time.Sleep(50 * time.Millisecond)
			}
		}(id)
	}

	time.Sleep(1 * time.Second)
}

4. When to Use What?

Scenario Recommended Lock Why?
General Purpose ReentrantLock Safe, debuggable, interruptible.
Read-Heavy (90%+) ReadWriteLock Allows concurrent readers.
High Performance StampedLock Optimistic reads avoid cache contention.
Simple State AtomicReference No locks needed for single variables.

5. Summary

Choosing the right lock is a trade-off between safety and performance. While synchronized is sufficient for most apps, high-throughput systems benefit from ReadWriteLock or StampedLock. In Go, sync.RWMutex is your workhorse for shared state.