一、简介

读写锁在同一时刻允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了两把锁,一把读锁和一把写锁。获取读写锁可分为下面两种情况:

  • 同一线程:该线程获取读锁后,能够再次获取读锁,但不能获取写锁。该线程获取写锁后,能够再次获取写锁,也可以再获取读锁。
  • 不同线程:A线程获取读锁后,B线程可以再次获取读锁,不可以获取写锁。A线程获取写锁后,B线程无法获取读锁和写锁。

二、读写锁示例

public class Cache {
    static Map<String, Object> map = new HashMap<String, Object>();
    static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    static Lock r = rwl.readLock();
    static Lock w = rwl.writeLock();
    public static final Object get(String key) {
        r.lock();
        try {
            return map.get(key);
        } finally {
            r.unlock();
        }
    }
    public static final Object put(String key, Object value) {
        w.lock();
        try {
            return map.put(key, value);
        } finally {
            w.unlock();
        }
    }
}

上述示例中,Cache组合一个非线程安全的HashMap作为缓存的实现,同时使用读写锁来保证Cache是线程安全的。

三、读写锁的实现分析

3.1 读写状态的设计

回想AQS的实现,同步状态表示锁被获取的次数,而读写锁的自定义同步器需要在同步状态(一个整型变量)上维护多个读线程和一个写线程的状态,该状态的设计成为设计读写锁的关键

如果在一个整型变量上维护多种状态,就需要“按位切割使用”这个变量,读写锁将变量切分成了两部分,高16位表示读,低16位表示写,划分方式如下图。
clipboard.png

上图同步状态表示一个线程已经获取了写锁,且重进入了两次,同时也连续获取了两次读锁。读写锁时如何确定读和写的状态?答案是通过位运算。假设当前同步状态为S,写状态等于 S & 0x0000FFFF(将高16位抹去)读状态等于S >>> 16(无符号补0右移16位)。当写状态增加1时,等于S + 1,当读状态增加1时,等于S + (1 << 16),也就是 S + 0x00010000。

3.2 写锁的获取与释放

写锁是一个支持重入的排它锁。如果当前线程已经获取了写锁,则增加写状态。如果当前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程,则当前线程进入等待状态。代码如下

protected final boolean tryAcquire(int acquires) {
    Thread current = Thread.currentThread();
    int c = getState();
    int w = exclusiveCount(c);
    if (c != 0) {
        // 存在读锁或者当前获取线程不是已经获取写锁的线程
        if (w == 0 || current != getExclusiveOwnerThread()) {
            return false;
        }
        if (w + exclusiveCount(acquires) > MAX_COUNT) 
            throw new Error("Maximum lock count exceeded");
        setState(c + acquires);
        return true;
    }
    if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) {
        return false;
    }
    setExclusiveOwnerThread(current);
    return true;
}

如果存在读锁(即使只有当前线程获取了读锁也不行),则写锁不能被获取,原因在于:读写锁要确保写锁的操作对读锁可见,因此只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦获取,其他读写线程的后续访问均被阻塞

3.3 读锁的获取与释放

读锁是一个支持重入的共享锁,它能够被多个线程同时获取,在写状态为0时,读锁总会被成功获取,而所做的也只是增加读状态。如果当前线程在获取读锁时,写锁已被其他线程获取,则进入等待状态。

protected final int tryAcquireShared(int unused) {
    for (;;) {
        int c = getState();
        int nextc = c + (1 << 16);
        if (nextc < c) 
            throw new Error("Maximum lock count exceeded");
        if (exclusiveCount(c) != 0 && owner != Thread.currentThread())
            return -1;
        if (compareAndSetState(c, nextc))
            return 1;
    }
}

如果其他线程已经获取了写锁,则获取读锁失败,进入等待状态。
读锁的每次释放均减少读状态,减少的值是1 << 16。

3.4 锁降级

锁降级指的是写锁降级成读锁。锁降级是持有了写锁之后,在获取到读锁,随后释放之前拥有的写锁,那么只剩下读锁,这个过程是锁降级(读写锁不支持锁升级)。代码如下

public void processData() {
    readLock.lock();
    if (!update) {
        // 必须先释放读锁
        readLock.unlock();
        // 锁降级从写锁获取开始
        writeLock.lock();
        try {
            if (!update) {
                update = true;
            }
            readLock.lock();
        } finally {
            writeLock.unlock();
        }
        // 锁降级完成,写锁降级为读锁
    }
    try {
        // 使用update
    } finally {
        readLock.unlock();
    }
}

上面代码中锁降级中,读锁的获取是否必要?是必要的,因为修改update之后,后续还要使用到update,所以为了防止其他线程修改update,所以需要加读锁

四、总结

一般情况下,读写锁的性能优于ReentrantLock,因为大多数场景读是远大于写的,所以在读多于写的情况下,读写锁能够提供更好的并发性和吞吐量。


kamier
1.5k 声望493 粉丝