zoukankan      html  css  js  c++  java
  • 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

  • 相关阅读:
    Leetcode Unique Binary Search Trees
    Leetcode Decode Ways
    Leetcode Range Sum Query 2D
    Leetcode Range Sum Query
    Leetcode Swap Nodes in Pairs
    Leetcode Rotate Image
    Leetcode Game of Life
    Leetcode Set Matrix Zeroes
    Leetcode Linked List Cycle II
    CF1321A
  • 原文地址:https://www.cnblogs.com/yxym2016/p/14866563.html
Copyright © 2011-2022 走看看