Java读写锁

读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排它锁有了很大的提升。

一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量。Java并发包提供读写锁的实现是ReentrantReadWriteLock。它支持的特性有:

  • 支持非公平和公平的锁获取方式,默认是非公平
  • 支持锁的重进入
  • 支持锁降级

ReentrantReadWriteLock是对接口ReadWriteLock的实现,ReadWriteLock中仅定义了获取读锁和获取写锁的两个方法,即:

image-20210609133554724

这两个方法皆由ReentrantReadWriteLock类具体实现。通过观察ReentrantReadWriteLock的源码发现,其内部含有ReadLock和WriteLock这两个类,代表ReentrantReadWriteLock拥有的一对读锁和写锁,而这两个类又都是靠一个静态内部类Sync实现的。Sync是继承了AbstractQueuedSynchronizer,用于管理读写锁的同步状态。

image-20210609134108644

读写锁的实现分析

1.读写状态的设计

读写锁依赖于自定义同步器来实现同步功能,其读写状态就是同步器的同步状态。读写锁的自定义同步器需要在同步状态(一个整型变量)上维护多个读线程和一个写线程的状态,所以就需要“按位切割使用”这个整型变量。此处,读写锁将这个整型变量切分为了两个部分,高16位表示读,低16位表示写。划分方式如下图所示:

image-20210609094750711

上图表示的同步状态显示有两个线程已经获取了读锁。读写锁是通过位运算迅速确定读和写各自的状态的,假设当前的同步状态为state,那么读状态和写状态的计算方式如下:

写状态: state & 0x0000ffff     写状态加1:state+1
读状态: state >>> 16			读状态加1:state+(1<<16)

2.写锁的获取与释放

首先看一下写锁的加锁源码:

protected final boolean tryAcquire(int acquires) {
    Thread current = Thread.currentThread();
    int c = getState(); // 获取同步状态
    int w = exclusiveCount(c); // 根据同步状态获取写锁状态
    // 已经有线程获取到了锁
    if (c != 0) {
        // 如果写线程数(w)为0(换言之存在读锁) 或者写锁不为0,同时持有锁的线程不是当前线程就返回失败
        if (w == 0 || current != getExclusiveOwnerThread())
            return false;
        
        // 如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。
        if (w + exclusiveCount(acquires) > MAX_COUNT)
            throw new Error("Maximum lock count exceeded");
        
        // 写锁重入
        setState(c + acquires);
        return true;
    }
    
    // 如果当且写线程数为0,并且当前线程需要阻塞那么就返回失败;或者如果通过CAS增加写线程数失败也返回失败。
    if (writerShouldBlock() ||
        !compareAndSetState(c, c + acquires))
        return false;
    
    // 如果c=0,w=0(没有写锁也没有读锁)或者c>0,w>0(重入),则设置当前线程为锁的拥有者
    setExclusiveOwnerThread(current);
    return true;
}

从上面的源码可以看出,写锁是一个支持重进入的排它锁。如果当前线程已经获取到了写锁,那么再次获取时,直接增加写状态。如果当前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程,则当前线程进入等待状态。

之所以要判断读锁是否存在,是因为读写锁要确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下还能允许写锁的获取,那么正在运行的其他线程可能就无所感知当前写线程的操作。

写锁释放时,每次释放均减少写状态,当写状态为0时表示写锁已经被释放,从而等待读写线程能够继续访问读写锁,同时前一次写线程的修改对后续读写线程可见。

3.读锁的获取与释放

下面是读锁的源码:

protected final int tryAcquireShared(int unused) {
    Thread current = Thread.currentThread();
    int c = getState();
    
     // 如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态
    if (exclusiveCount(c) != 0 &&
        getExclusiveOwnerThread() != current)
        return -1;
    // 获取读锁数量
    int r = sharedCount(c);
    
    if (!readerShouldBlock() &&
        r < MAX_COUNT &&
        compareAndSetState(c, c + SHARED_UNIT)) {
        if (r == 0) {
            firstReader = current;
            firstReaderHoldCount = 1;
        } else if (firstReader == current) {
            firstReaderHoldCount++;
        } else {
            HoldCounter rh = cachedHoldCounter;
            if (rh == null || rh.tid != getThreadId(current))
                cachedHoldCounter = rh = readHolds.get();
            else if (rh.count == 0)
                readHolds.set(rh);
            rh.count++;
        }
        return 1;
    }
    return fullTryAcquireShared(current);
}

读锁是一个支持重进入的共享锁,他能够被多个线程同时获取,在没有其他写线程访问(写状态为0)时,读锁总是会被成功地获取,而所做的也只是(线程安全地)增加读状态。

可以看到在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。

如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态(增加的值是1<<16),成功获取读锁。

需要注意的是,读状态是所有线程获取读锁次数的总和,而每个线程各自获取读锁的次数只能选择保存在ThreadLocal中,由线程自身维护。

读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是“1<<16”。所以读写锁才能实现读读的过程共享,而读写、写读、写写的过程互斥。

4.锁降级

锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放先前拥有的写锁的过程。

ReentrantReadWriteLock不支持锁升级,目的是为了保证数据可见性,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的。

读写锁的使用示例

package concurrent.lock;

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

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();
        }
    }

    public static final void clear(){

        w.lock();
        try {
            map.clear();
        }finally {
            w.unlock();
        }
    }
}

上述Cache类使用了一个非线程安全的HashMap作为缓存的实现,同时使用了读写锁和读锁和写锁来保证Cache是线程安全的。在数据的读方法get(String key)中,需要先获取读锁,然后读取数据,这样使得并发读数据时不会被阻塞。而对数据进行修改相关的put和clear方法中,需要先获取写锁,当获取了写锁之后,其他线程对于数据的读和写操作均会被阻塞,只有在写锁释放以后,其他的读写操作才能继续。

Cache类通过使用读写锁提升了读操作的并发性,也保证了每次写操作对所有的读写操作的可见性,同时还简化了编程方式。

参考:《Java并发编程的艺术》
https://tech.meituan.com/2018/11/15/java-lock.html

posted @ 2021-06-09 13:45  有心有梦  阅读(811)  评论(0编辑  收藏  举报