• 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

  • 相关阅读:
    一个在线的C++帮助文档网站
    linux 学习笔记 (四)
    类的static成员函数和const成员函数
    Linux的inode、软链接、硬链接
    常用linux命令(三)
    多语言调用之 C++ 调用 Java JNI
    多语言调用之 Java调用C/C++
    NHibernate 操作原生SQL以及查询DataTable,DataSet
    DataGridView控件用法合集
    Java AOP实战 寻找SQL的引用路径
  • 原文地址:https://www.cnblogs.com/yxym2016/p/14866563.html
Copyright © 2020-2023  润新知