为何要有读写锁
ReentrantLock锁和其他锁基本上都是排他锁,排他锁指的是在同一时刻只允许一个线程持有锁,访问共享资源。虽然ReentrantLock支持同一个线程重入,但是允许重入的是同一个线程。因此ReentrantReadWriteLock是为了支持多个线程在同一个时刻对于锁的访问孕育而生的。
读写锁—简单介绍
读写锁ReentrantReadWriteLock允许多个线程在同一时刻对于锁的访问。但是,写线程获取写锁时,所有的读线程和其他写线程均会被阻塞。读写锁是通过维护一对锁(一个读锁、一个写锁)来实现的,读写锁在很多读多于写得场景中有很大的性能提升。举个例子,当我们对数据库中的一条记录进行读取时,不应该阻塞其他读取这条数据的线程,而如果是有线程对该记录进行写操作,数据库就应该阻止其他线程对这条数据的读取和写入,这种场景就可以用类似读写锁的方式来处理。
特性
使用示例
通过ReentrantReadWriteLock来实现一个线程安全的简单内存缓存设计,通过给缓存获取get(String key)方法加上读锁,允许其他线程在同一个时刻进行数据读取;给put(String key,String value)方法加上写锁,禁止在对缓存写入的时候其他线程对于缓存的读取和写入操作。
package com.lizba.p6; import java.util.HashMap; import java.util.Map; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantReadWriteLock; /** * <p> * 使用ReentrantReadWriteLock实现一个简单的线程安全基于map的缓存设计 * </p> * * @Author: Liziba * @Date: 2021/6/22 22:11 */ public class CacheDemo { /** 存储数据容器 */ private static Map<String, Object> cache = new HashMap<>(); /** 读写锁ReentrantReadWriteLock */ private static ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); /** 读锁 */ static Lock readLock = lock.readLock(); /** 写锁 */ static Lock writeLock = lock.writeLock(); /** * 获取数据,使用读锁加锁 * @param key * @return */ public static Object get(String key) { readLock.lock(); try { return cache.get(key); } finally { readLock.unlock(); } } /** * 设置key&value,使用写锁,禁止其他线程的读取和写入 * @param key * @param value * @return */ public static void put(String key, Object value) { writeLock.lock(); try { cache.put(key, value); } finally { writeLock.unlock(); } } /** * 清空缓存 */ public static void flush() { writeLock.lock(); try { cache.clear(); } finally { writeLock.unlock(); } } }
实现分析
ReentrantReadWriteLock是基于AQS来实现的,我们知道AQS是以单个int类型的原子变量来表示其状态的,那么一个状态如何即表示读锁又表示写锁呢?观察源码发现(后续会有源码解析)ReentrantReadWriteLock通过使用一个32位的整型变量的高16位表示读,低16位表示写来维护读线程和写线程对于锁的获取情况。此时部分读者可能又会产生一个疑问,写锁是互斥的,只支持有一个线程持有写锁,用低16位记录同一个线程对写锁的多次重入是没问题的;那么对于允许多个线程获取的读锁,高16位又是如何维护持有锁的线程数和单个线程对读锁的获取次数的呢?其实这里引入了ThreadLocal来记录单个线程自己对于读锁的获取次数,高16位存储的是获取读锁的线程的个数。这里只是简单说下疑问,后续源码会有详细分析。我们先来看看这个int类型的变量的划分方式。
如上个这个图写状态=1,表示当前线程获取了写锁,读状态=2,表示该线程同时获取了两次读锁。使用高低位来表示读写状态,那么状态的获取和设值是如何实现的呢?
写状态的get()
此时假设同步状态,也就是32的整型变量的值为S,写状态的获取方式为S&0x0000FFFF,这种办法会保留低16位的同时抹去高16位,也就能计算出写状态的获取情况。
源码分析
源码分析主要分为四块,分别是ReentrantReadWriteLock中一个int分为两半计算方式、写锁的获取与释放、读锁的获取与释放以及锁降级这几个方面来展开。
1、一个int分为两半计算方式
int拆分为高16位和低16位的计算是在ReentrantReadWriteLock的内部类在Sync中实现的,其主要核心如下
2、写锁的获取与释放
WriteLock写锁是一个支持重入的排它锁,它的获取与释放通过tryAcquire(int arg)和tryRelease(int arg)来实现,获取到写锁的条件是,当前读锁未被获取或者当前线程是持有写锁的线程,否则获取失败进入等待状态。
写锁获取3、读锁的获取与释放
读锁的获取与释放相比写锁的获取与释放相对来说要复杂一些,因为它是支持重入的共享锁,它能被多个线程同时获取,因此我们不仅需要记录获取读锁的每一个线程,同时需要记录每个线程对于读锁的重入次数,因此我们首先来看读锁的重入计数。
读锁的重入计数
读锁的重入计数,巧妙的使用每个线程自己来记录,通过存在在ThreadLocal中的HoldCounter中的变量count来增加和减少,其是线程隔离的因此也是线程安全的,具体实现在Sync中。
读锁获取
/* * tryAcquireShared方法实现在Sync中,unused如其定义未被使用 */ protected final int tryAcquireShared(int unused) { // 获取当前线程 Thread current = Thread.currentThread(); // 获取同步状态值 int c = getState(); // exclusiveCount(c)获取写锁的值,如果不为0表示写锁已被持有 // getExclusiveOwnerThread(),如果是写锁则需要判断当前线程和持有写锁(互斥锁)的线程是否相等 // 因为写锁被允许获取读锁,如果不是同一个线程则直接返回-1 if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current) return -1; // 获取读锁持有的线程数 int r = sharedCount(c); // readerShouldBlock() 有两个实现分别是FairSync和NonfairSync,两种的获取锁策略实现不一样,用于判断是否获取读锁 // r < MAX_COUNT 判断读锁获取线程数是否小于最大值 // compareAndSetState(c, c + SHARED_UNIT) 尝试获取读锁 if (!readerShouldBlock() && r < MAX_COUNT && compareAndSetState(c, c + SHARED_UNIT)) { // 如果获取所成功 // 当前线程为第一个获取锁的线程,并且是第一次 if (r == 0) { // 设置当前线程为firstReader firstReader = current; // 设置当前线程重入读锁的次数为1 firstReaderHoldCount = 1; } else if (firstReader == current) { // 如果不是第一次,但是是第一个获取读锁的线程 // 当前线程重入读锁的次数为累加1 firstReaderHoldCount++; } else { // 获取缓存cachedHoldCounter HoldCounter rh = cachedHoldCounter; // 如果缓存为空,或者缓存的线程ID与当前线程ID不一致 if (rh == null || rh.tid != getThreadId(current)) // 从ThreadLocalHoldCounter中读取当前线程的读锁重入次数,设置缓存 cachedHoldCounter = rh = readHolds.get(); else if (rh.count == 0) // 缓存存在,但是重入次数为空 // 设置当前线程readHolds中的HoldCounter readHolds.set(rh); // 重入次数+1 rh.count++; } return 1; } // 获取读锁失败,自旋重试 return fullTryAcquireShared(current); }