普通互斥锁会把读取也串行化。如果读取多、修改少,可以用读写锁让多个读者同时进入,写入时再独占。
读锁保护读取,写锁保护修改
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteCacheDemo {
private final Map<String, String> data = new HashMap<>();
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
public String get(String key) {
lock.readLock().lock();
try {
return data.get(key);
} finally {
lock.readLock().unlock();
}
}
public void put(String key, String value) {
lock.writeLock().lock();
try {
data.put(key, value);
} finally {
lock.writeLock().unlock();
}
}
public static void main(String[] args) throws InterruptedException {
ReadWriteCacheDemo cache = new ReadWriteCacheDemo();
Thread writer = new Thread(() -> cache.put("language", "Java"));
writer.start();
writer.join();
System.out.println(cache.get("language"));
}
}结果为 Java。多个 get 可以共同持有读锁,但 put 要等待读者退出。这里返回的是不可变字符串;如果返回 Map 本身或可变 value,让外部在锁外修改,就绕过了同步规则。
不要在读锁里升级
线程已经持有读锁,再尝试获取写锁,会等待读锁释放,而自己的读锁又不会释放,可能把自己卡住。不能用这种方式完成读锁升级。
需要修改时,先释放读锁,再取得写锁,并重新检查条件。释放与重新加锁之间可能有其他写者进来,前一次读取结果不能继续当成最新事实。
写锁持有者可以获取读锁,然后释放写锁,实现降级:
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class LockDowngradeDemo {
public static void main(String[] args) {
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
int value;
lock.writeLock().lock();
try {
value = 42;
lock.readLock().lock();
} finally {
lock.writeLock().unlock();
}
try {
System.out.println(value);
} finally {
lock.readLock().unlock();
}
}
}示例用局部值突出获取和释放的顺序。应用到共享数据时,先取得读锁再释放写锁,就能保证后续受读锁保护的使用阶段不会被其他写者插入修改。如果先释放写锁再加读锁,这个连续性就丢了。
state 如何表示两类锁
JDK 8 的实现把 AQS 的 int 状态拆成两部分:高 16 位表示读锁的总体持有计数,低 16 位表示写锁重入计数。还需要额外记录每个线程自己的读锁重入次数,不能只知道总数。
写锁支持 Condition,读锁不支持。公平与非公平策略也会影响新读者、新写者是否可以进入,不是只要“当前没有写锁”就永远让所有新读者抢入。
读多也要测量
读锁的获取和释放同样有维护共享状态的成本。读取内容非常短、竞争不高时,读写锁未必胜过普通互斥锁。如果只是单键访问,ConcurrentHashMap 也可能更适合。
读写锁更适合需要保护一组共享数据、读取可以并行且有一定工作量的情况。选择依据是实际临界区和负载,而不只是“读多写少”四个字。