Skip to content

ReadWriteLock:读写锁原理

你见过图书馆的借书系统吗?

  • 一个人借书时:其他人可以同时借(只要不是同一本书)
  • 管理员整理书架时:所有人等着,不能借不能还

这就是读写锁的思想:读读不互斥,读写互斥,写写互斥


为什么需要读写锁?

先看一个反例——普通锁导致的性能问题:

java
public class SlowCache {
    private final Map<String, Object> cache = new HashMap<>();
    private final Lock lock = new ReentrantLock();

    public Object get(String key) {
        lock.lock();
        try {
            return cache.get(key);
        } finally {
            lock.unlock();
        }
    }

    public void put(String key, Object value) {
        lock.lock();
        try {
            cache.put(key, value);
        } finally {
            lock.unlock();
        }
    }
}

问题:读操作也要排队!1000 个线程同时读,都要一个个等。

读写锁的解决方案

java
public class FastCache {
    private final Map<String, Object> cache = new HashMap<>();
    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final Lock readLock = rwLock.readLock();
    private final Lock writeLock = rwLock.writeLock();

    public Object get(String key) {
        readLock.lock();
        try {
            return cache.get(key);
        } finally {
            readLock.unlock();
        }
    }

    public void put(String key, Object value) {
        writeLock.lock();
        try {
            cache.put(key, value);
        } finally {
            writeLock.unlock();
        }
    }
}

读写锁的三种规则

操作组合是否互斥说明
读 + 读不互斥多个线程可以同时读
读 + 写互斥读的时候不能写,写的时候不能读
写 + 写互斥只能一个线程写

为什么这样设计?

  • 读操作不会修改数据,多个线程同时读是安全的
  • 写操作会修改数据,必须独占

读写锁的内部实现

读写状态分离

ReentrantReadWriteLock 用一个整数的高 16 位表示读锁状态,低 16 位表示写锁状态:

 32位整数
┌──────────────────┬──────────────────┐
│     读锁计数(高16位)     │     写锁计数(低16位)      │
└──────────────────┴──────────────────┘
java
// 获取读锁
if (sharers == 0 && writer == 0) {
    sharers++;
}

// 获取写锁
if (sharers == 0 && writer == 0) {
    writer++;
}

写锁降级为读锁

java
public void processData() {
    writeLock.lock();
    try {
        System.out.println("获取写锁,写入数据");
        // 写锁降级为读锁
        readLock.lock();
        try {
            System.out.println("降级为读锁");
            // 此时仍然持有写锁,但多了一个读锁
        } finally {
            // 不能释放写锁!只能最后释放
        }
    } finally {
        writeLock.unlock(); // 写锁释放
    }
}

注意:读锁不能升级为写锁(死锁风险)。


实战:用读写锁实现缓存

java
public class ReadWriteCache<K, V> {
    private final Map<K, V> cache = new HashMap<>();
    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final Lock readLock = rwLock.readLock();
    private final Lock writeLock = rwLock.writeLock();

    // 缓存要设置容量,防止无限膨胀
    private final int maxSize;
    private final LinkedHashMap<K, V> lruCache;

    public ReadWriteCache(int maxSize) {
        this.maxSize = maxSize;
        this.lruCache = new LinkedHashMap<K, V>(16, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
                return size() > ReadWriteCache.this.maxSize;
            }
        };
    }

    public V get(K key) {
        readLock.lock();
        try {
            return lruCache.get(key);
        } finally {
            readLock.unlock();
        }
    }

    public void put(K key, V value) {
        writeLock.lock();
        try {
            lruCache.put(key, value);
        } finally {
            writeLock.unlock();
        }
    }

    public void clear() {
        writeLock.lock();
        try {
            lruCache.clear();
        } finally {
            writeLock.unlock();
        }
    }

    // 缓存不存在时加载(防止缓存击穿)
    public V getOrLoad(K key, Supplier<V> loader) {
        readLock.lock();
        try {
            V value = lruCache.get(key);
            if (value != null) {
                return value;
            }
        } finally {
            readLock.unlock();
        }

        // 缓存未命中,尝试加载
        writeLock.lock();
        try {
            // 可能其他线程已经加载了
            V value = lruCache.get(key);
            if (value == null) {
                value = loader.get();
                lruCache.put(key, value);
            }
            return value;
        } finally {
            writeLock.unlock();
        }
    }
}

读写锁的「坑」

坑1:读锁不能升级为写锁

java
// 错误代码!会导致死锁
readLock.lock();
try {
    writeLock.lock(); // 永远等不到
    try {
        // 永远不会执行
    } finally {
        writeLock.unlock();
    }
} finally {
    readLock.unlock();
}

坑2:读写锁不是重入的

java
ReadWriteLock rwLock = new ReentrantReadWriteLock();

// 线程A获取读锁后,不能再获取写锁
readLock.lock();
try {
    // 不能在这里获取写锁
    // writeLock.lock(); // 死锁
} finally {
    readLock.unlock();
}

坑3:公平模式下读锁可能「饿死」写锁

线程A(读)→ 线程B(读)→ 线程C(读)→ ...

                               线程Z(写)在等...

如果一直有新读线程进来,写锁可能永远拿不到。ReentrantReadWriteLock 默认非公平模式。


读写锁适用场景

适合的场景

  • 读多写少:如配置信息、缓存数据、商品详情
  • 读操作远多于写操作
  • 读操作耗时长(如数据库查询)

不适合的场景

  • 读写操作差不多
  • 写操作耗时很长(会阻塞所有读操作)
  • 有大量的短写操作(读锁持有时间短,非公平模式更快)

StampedLock:读写锁的升级版

JDK 8 引入了 StampedLock,提供了乐观读的能力:

java
StampedLock sl = new StampedLock();

// 乐观读(不阻塞,性能最高)
long stamp = sl.tryOptimisticRead();
V value = cache.get(key);
if (!sl.validate(stamp)) {
    // 乐观读失败,有写操作,获取正式读锁
    stamp = sl.readLock();
    try {
        value = cache.get(key);
    } finally {
        sl.unlockRead(stamp);
    }
}

StampedLock 详细内容见 StampedLock:乐观读 vs 悲观读


面试追问方向

  1. 读写锁的写锁是什么类型的锁? 排他锁,一次只能一个线程持有。

  2. ReadWriteLock 和 ReentrantReadWriteLock 的关系? ReadWriteLock 是接口,ReentrantReadWriteLock 是实现。ReentrantReadWriteLock 还支持重入、公平/非公平模式。

  3. 为什么读锁不能升级为写锁? 假设读锁 A 获取后升级为写锁,需要等待其他读锁释放。但如果其他读锁不释放,就会死锁。

  4. 如何防止「写锁饥饿」?

    • 使用公平锁(按等待顺序)
    • 使用 StampedLock 的乐观读
    • 限制读锁持有时间

基于 VitePress 构建