原则
- 允许多个线程同时读共享变量
- 只允许一个线程写共享变量
- 如果一个线程正在写共享变量,此时禁止线程读共享变量。
通过以上原则,我们不难看出:读写锁与互斥锁最大区别是:读写锁允许多个线程同时读共享变量,而互斥锁不允许。这也是读写锁在读多写少环境下性能优于互斥锁的关键。
概念
- 可重入:当线程拿到锁的时候,在临界区再次尝试获取同一把锁,此时会锁的状态*volatile int state++*会加一。这种特性叫做可重入。
- 锁升级:在拿到读锁且没有释放读锁的时候,尝试去获取写锁,这种情况被称为锁升级。读写锁不支持锁升级
- 锁降级:在拿到写锁且没有释放写锁的时候,尝试去获取读锁,这种情况被称为锁降级。读写锁支持锁降级。
- CAS指令 : compareAndSetState:只有当前 count 的值与期望值 expect 相等时,才会将 count 更新为newValue. 在解决并发时,CAS和自旋更搭哦~~
CAS 的伪代码:
class SimulatedCAS {
valotile int count;
addOne() {
int newValue = 0;
do {
newValue = count + 1; // 1
} while (count != compareAndSetState(count, newValue) // 2
)
}
synchronized compareAndSetState(int expect, int newValue) {
// 读取当前值
int curValue = count;
// 比较期望值与当前值
if(expect == curValue) {
// 更新count值
count = newValue;
}
// 返回写入前的值
return curValue;
}
}
从它的实现不难看出,它重视值的变化,而忽略属性的存在,因此会存在 ABA的问题。
什么是ABA问题那? 假设 count 值原本是 A,线程T1 执行完 1 处代码后,执行 2 处代码前, count 可能会被线程 T2 更新为 B,之后又被线程 T3 更新为了 A,这样线程 T1 看到的 count 是 A,但其实中间该值已经被其他线程修改过,这就是 ABA 问题。如果我们只关心值的修改,而忽略中间状态的改变,就不存在 ABA 问题,例如: 原子类(AtomicLong)的值递增。但是当关心中间状态时,就会出现 ABA 问题。此时,我们可以添加一个版本号来解决ABA。可以参考 AtomicMarkableReference的实现机制
原理
读写锁将同步状态描述为一个 int 型变量 state, 将该变量按位切割后,高 16 位用于表示读锁的状态,低 16 位用于表示写锁的状态。
- 写锁:一种支持可重入的互斥锁,即:同一时刻该锁只允许一个线程持有。
- 读锁:一种支持可重入的共享锁,即:同一时刻该锁允许多个线程持有。
写锁的获取与释放
写锁是一种支持可重入的独占锁,如果当前线程持有了写锁,再次尝试获取时,会增加写状态;如果当前线程获取写锁时,读锁已经被获取或者该线程不是拿到写锁的线程,则该线程进入等待状态。
public class ReentrantReadWriteLock implements ReadWriteLock {
abstract static class Sync extends AbstractQueuedSynchronizer {
// 获取写锁
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState(); // 获取同步状态
int w = exclusiveCount(c); // 获取低16位的写状态
if(c != 0) {
// 1.如果写锁被某一线程持有,且持有写锁的线程与当前线程不一致; 2. 如果读锁存在,且持有写锁的线程与当前线程不一致
if(w == 0 || current != getExclusiveOwnerThread())
return false;
// 成功获取锁,更新同步状态
setState(c + acquires);
return true;
}
// 通过CAS更新状态
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
// 释放写锁
protected final boolean tryRelease(int releases) {
// 如果当前线程不是持有写锁的线程,则抛出异常
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
int nextc = getState() - releases;
boolean free = exclusiveCount(nextc) == 0;
if (free)
setExclusiveOwnerThread(null);
setState(nextc);
return free;
}
}
}
读锁的获取
读锁是一把支持可重入的共享锁,即同一时刻该锁能被多个线程持有。
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
// 如果有其他线程获取了写锁,则获取失败
if(exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current) {
return -1;
}
int r = shareCount(c); // 获取读锁的持有数量
// 通过CAS修改读锁的同步状态并记录数量
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);
}
实战
使用缓存首先要解决的就是数据的初始化问题。缓存数据的初始化,可以采用一次性加载的方式,也可以采用按需加载的方式。如果数据量不大,可以采用在程序启动时一次性加载的方式。如果数据量非常大,则使用按需加载的方式,即:当应用查询缓存且数据不再缓存的时候,才触发加载源头相关数据进行缓存的操作。
使用读写锁实现一个通用的缓存工具。
public final class Cache<K,V> {
// HashMap 本身是非线程安全的
private final Map<K,V> cache = new HashMap<K,V>();
private final ReadWriteLock rwl = new ReadWriteLock();
private final Lock rlock = rwl.readLock();
private final Lock wLock = rwl.writeLock();
public V get(K key) {
V value = null;
rlock.lock();
try{
value = cache.get(key);
// 注意: 不能在拿到读锁的时候,去获取写锁,即不支持锁升级。原因是为了保证数据的可见性。
} finally {
rlock.unlock();
}
// 如果命中缓存,则直接返回
if(value != null) {
return value;
}
// 如果没有缓存,则查找源头(可能是网络,也可能是数据库)然后写入缓存
wLock.lock();
try {
// 再获取一次缓存,因为此时有可能被其他线程将数据写入缓存
value = cache.get(key);
if(value == null) {
// 查找数据源头,得到 value
cache.put(key,value);
}
} finally {
wLock.unlock();
}
return value;
}
public void put(K key, V value) {
wLock.lock();
try {
cache.put(key, value);
} finally {
wLock.unlock();
}
}
}
总结
ReadWriteLock 是一个接口,它的实现类是 ReentrantReadWriteLock。ReentrantReadWriteLock 提供了两把锁 ReadLock 和 WriteLock。 同ReentrantLock一样,可以指定锁的公平/非公平特性,且都是可重入的。另外,同步的核心也是使用的AQS(AbstractQueuedStnchronized)。