38、Lock(下)
在上上节中我们讲到,JUC 提供的锁有三类:普通互斥锁(Lock 和 ReentrantLock)、读写锁(ReadWriteLock 和 ReentrantReadWriteLock)、StampedLock
上两节我们介绍了 JUC 中的 Lock,并且讲解了其底层实现原理,特别是 AQS,本节我们讲解读写锁和 StampedLock
1、读写锁的基本用法
为了提高多线程环境下代码执行的并发度,两个读操作是可以并发执行的,但是读操作和写操作不能并发执行,同理写操作和写操作也不能并发执行
为了满足这样特殊的加锁需求,JUC 提供了读写锁(ReadWriteLock 接口和 ReentrantReadWriteLock 类)
1.1、ReadWriteLock 接口
ReadWriteLock 接口的定义如下所示,跟 Lock 和 ReentrantLock 的关系类似,ReadWriteLock 也只有一个可重入的实现类 ReentrantReadWriteLock
public interface ReadWriteLock { Lock readLock(); Lock writeLock(); }
ReadWriteLock 接口中只包含两个函数
- readLock() 函数返回读锁,用来给读操作加锁
- writeLock() 函数返回写锁,用来给写操作加锁
1.2、共享锁和排它锁
读锁是一种 "共享锁",读锁可以被多个线程同时获取,写锁是 "排它锁",写锁同时只能被一个线程获取
除此之外,读锁和写锁之间也是排它的,因此读写锁一般用于读多写少的场景,读写锁的使用示例代码如下所示,两个线程允许并发执行 get() 函数
public class Demo { private List<String> list = new LinkedList<>(); private ReadWriteLock rwLock = new ReentrantReadWriteLock(); private Lock rLock = rwLock.readLock(); // 读锁 private Lock wLock = rwLock.writeLock(); // 写锁 public void add(int idx, String elem) { wLock.lock(); // 加写锁 try { list.add(idx, elem); } finally { wLock.unlock(); // 释放写锁 } } public String get(int idx) { rLock.lock(); // 加读锁 try { return list.get(idx); } finally { rLock.unlock(); // 释放读锁 } } }
ReentrantReadWriteLock 既支持公平锁又支持非公平锁,跟 ReentrantLock 的公平锁和非公平锁的构建方法一样,ReentrantReadWriteLock 默认为非公平锁
如果要成创建公平锁,我们只需要在创建 ReentrantReadWriteLock 对象时,将构造函数的参数设置为 true 即可,示例代码如下所示
ReadWriteLock rwLock = new ReentrantReadWriteLock(true); // 公平锁 ReadWriteLock rwLock = new ReentrantReadWriteLock(false); // 非公平锁 ReadWriteLock rwLock = new ReentrantReadWriteLock(); // 默认为非公平锁
2、锁升级和锁降级
前面讲到绝大部分锁都是可重入锁,读写锁也不例外
一个线程获取读锁之后,在读锁释放前,还可以再次获取读锁,同理,一个线程获取写锁之后,在写锁释放前,还可以再次获取写锁
- 但是一个线程在获取读锁之后,在读锁释放前,是否还能再获取写锁?读锁不能升级为写锁
- 还有一个线程在获取写锁之后,在写锁释放前,是否还能再获取读锁?写锁可以降级为读锁
2.1、锁升级
读写锁不支持锁升级:一个线程获取读锁之后,在读锁释放前,不可以再获取写锁
这是因为在一个线程获取读锁时,有可能同时还有其他线程也获取了读锁
如果将一个线程的读锁升级为写锁,那么就有可能违背了读写锁中读锁和写锁互斥的要求,示例代码如下所示
2.2、锁降级
读写锁支持锁降级:一个线程在获取写锁之后,在写锁释放前,可以再获取读锁,当写锁释放之后,线程持有的锁从写锁降级为读锁,示例代码如下所示
2.3、写 + 读
当临界区中既有写操作又有读操作时
- 如果我们用写锁来给整个临界区加锁,那么代码的并行度就不高
- 如果我们先加写锁,写操作完成之后释放写锁,再加读锁执行读操作,如下图所示
这样做就有可能存在多线程安全问题,我们无法保证 "写操作和读操作的组合起来" 的原子性
线程 A 写操作完成之后释放写锁,切换到线程 B 执行时,更新了共享变量的值,那么线程 A 读操作变无法读取之前写操作之后的值 - 而使用下图中的锁降级,我们便既可以保证临界区线程安全,又能提到代码的并行度
3、读写锁的实现原理
3.1、ReentrantReadWriteLock
前面讲到读写锁跟上一节讲到的普通锁(JUC Lock)一样,既支持公平锁,也支持非公平锁,ReentrantReadWriteLock 的代码结构如下所示
public class ReentrantReadWriteLock implements ReadWriteLock { private final ReadLock readerLock; private final WriteLock writerLock; final Sync sync; // 注意 public ReentrantReadWriteLock() { this(false); } public ReentrantReadWriteLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); readerLock = new ReadLock(this); // 注意 writerLock = new WriteLock(this); // 注意 } public WriteLock writeLock() { return writerLock; } public ReadLock readLock() { return readerLock; } // AQS 的子类, NonfairSync 和 FairSync 的公共父类: Sync abstract static class Sync extends AbstractQueuedSynchronizer { abstract boolean readerShouldBlock(); // 用来区分公平锁和非公平锁 abstract boolean writerShouldBlock(); // 用来区分公平锁和非公平锁 // 以下为 AQS 模板方法的抽象方法的代码实现 protected final boolean tryAcquire(int acquires) { ... } protected final boolean tryRelease(int releases) { ... } protected final int tryAcquireShared(int unused) { ... } protected final boolean tryReleaseShared(int unused) { ... } // ... 省略其他方法 ... final boolean tryWriteLock() { ... } final boolean tryReadLock() { ... } } // 非公平锁 static final class NonfairSync extends Sync { final boolean writerShouldBlock() { return false; } final boolean readerShouldBlock() { return apparentlyFirstQueuedIsExclusive(); } } // 公平锁 static final class FairSync extends Sync { final boolean writerShouldBlock() { return hasQueuedPredecessors(); } final boolean readerShouldBlock() { return hasQueuedPredecessors(); } } }
上述代码结构跟 ReentrantLock 的代码结构类似,NonfairSync 和 FairSync 具体化抽象的模板类 AQS,并且实现了其中的抽象方法
ReentrantReadWriteLock 使用 NonfairSync 或 FairSync 来编程实现读锁(ReadLock)和写锁(WriteLock)
3.2、ReadLock 和 WriteLock
ReadLock 和 WriteLock 均实现了 Lock 接口,使用相同的 AQS,实现了 Lock 接口中的所有加锁和解锁函数,ReadLock 和 WriteLock 的代码实现如下所示
// 写锁中的加锁和解锁方法使用 AQS 的 "独占模式" 下的几个模板方法来实现 public static class WriteLock implements Lock, java.io.Serializable { private final Sync sync; protected WriteLock(ReentrantReadWriteLock lock) { sync = lock.sync; } // 注意 public void lock() { sync.acquire(1); } public void unlock() { sync.release(1); } public boolean tryLock( ) { return sync.tryWriteLock(); } public void lockInterruptibly() throws InterruptedException { sync.acquireInterruptibly(1); } public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException { return sync.tryAcquireNanos(1, unit.toNanos(timeout)); } }
// 读锁中的加锁和解锁方法使用 AQS 的 "共享模式" 下的几个模板方法来实现 public static class ReadLock implements Lock { private final Sync sync; protected ReadLock(ReentrantReadWriteLock lock) { sync = lock.sync; } // 注意 public void lock() { sync.acquireShared(1); } public void unlock() { sync.releaseShared(1); } public boolean tryLock() { return sync.tryReadLock(); } public void lockInterruptibly() throws InterruptedException { sync.acquireSharedInterruptibly(1); } public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException { return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout)); } }
从上述代码我们可以发现,读锁和写锁共用一个 AQS,在上一节中我们讲到,对于 JUC Lock,我们使用 AQS 中 state 变量来表示加锁情况
- 0 表示没有加锁
- 1 表示已经加锁
- 大于 1 的值表示重入次数
对于读写锁来说,我们不仅需要知道有没有加锁、重入次数,还需要知道加的是读锁还是写锁,但是 AQS 中只有一个表示加锁情况的 int 类型的 state 变量
为了让 state 变量表达更多的信息,我们用 state 变量中的低 16 位表示写锁的使用情况,高 16 位表示读锁的使用情况
对于低 16 位所表示的数:表示写锁的使用情况
- 值为 0 表示没有加写锁
- 值为 1 表示已加写锁
- 值大于 1 表示写锁的重入次数
对于高 16 位所表示的数:表示读锁的使用情况
- 值为 0 表示没有加读锁
- 值为 1 表示已加读锁
- 不过值大于 1 并不表示读锁的重入次数,而是表示读锁总共被获取了多少次(每个线程对读锁重入的次数相加)
那么读锁的重入次数在哪里记录呢?毕竟重入次数是有用信息,只有重入次数大于 0 时,才可以继续重入
- 当多个线程同时持有读锁时,每个线程都可以对读锁重复加锁,也就就是说,重入次数是跟每个线程相关的数据
- 我们可以使用 ThreadLocal 变量来存储
对于 ThreadLocal,我们在后面的章节中信息讲解,在这里你就简单将它看做线程的一个属性或者局部变量即可
接下来,我们详细看下,读锁和写锁的实现原理
3.3、写锁的实现原理
WriteLock 写锁是排它锁,因此它的实现原理跟上一节讲到的 ReentrantLock 的实现原理类似
WriteLock 实现了 Lock 接口,因此它也支持各种不同的加锁方式,比如:可中断加锁、非阻塞加锁、可超时加锁
接下来我们重点讲解 WriteLock 中的 lock() 函数和 unlock() 函数的实现原理
对于 WriteLock 中的 tryLock()、带超时时间的 tryLock()、lockInterruptibly() 这三个加锁函数,你可以参考 ReentrantLock 中这三个函数的实现原理,以及结合源码,自行研究
我们先来看 WriteLock 中的 lock() 函数
lock() -> acquire() -> tryAcquire() -> addWaiter() -> acquireQeuued()
实现比较简单,直接调用了 AQS 中的 acquire() 模板方法
public void lock() { sync.acquire(1); }
AQS 中的 acquire() 模板方法如下所示,在上一节中已经讲解
使用 tryAcquire() 竞争锁,如果竞争锁成功,则直接返回
如果竞争锁失败,则调用 addWaiter() 将线程放入等待队列的尾部,然后调用 acquireQueued() 阻塞线程等待被唤醒
public final void acquire(int arg) { // tryAcquire() -> addWaiter() -> acquireQeuued() if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }
上一节我们已经详细讲解了 addWaiter() 函数和 acquiredQueued() 函数,这里就不再赘述
我们重点看下 tryAcquire() 竞争锁的逻辑,它是 AQS 中的抽象方法,在 NonfairSync 和 FairSync 的公共父类 Sync 类中实现,代码如下所示
protected final boolean tryAcquire(int acquires) { Thread current = Thread.currentThread(); int c = getState(); // c 为 state 的值 int w = exclusiveCount(c); // 低 16 位的值, 也就是写锁的加锁情况 // 1、已经加读锁或写锁(state != 0) if (c != 0) { // 已加读锁(w == 0)或者当前加写锁的线程不是自己 if (w == 0 || current != getExclusiveOwnerThread()) return false; // 去排队 if (w + exclusiveCount(acquires) > MAX_COUNT) throw new Error("Maximum lock count exceeded"); setState(c + acquires); // 更新写锁的重入次数 return true; // 获取到了锁 } // 2、没有加锁(state == 0) if (writerShouldBlock()) return false; // 去排队 if (!compareAndSetState(c, c + acquires)) return false; // 去排队 setExclusiveOwnerThread(current); return true; // 获取到了锁 }
我们重点看下 writerShouldBlock() 这个函数,这个函数控制着锁是否为公平锁,在 state = 0,也就是没有加读锁和写锁的情况下
- 如果 writerShouldBlock() 函数返回值为 true,那么线程不尝试竞争锁,而是直接去排队
- 如果 writerShouldBlock() 函数返回值为 false,那么线程先尝试竞争锁,不行再去排队
对于非公平锁,writerShouldBlock() 总是返回 false
对于公平锁,如果等待队列中有线程,那么 writerShouldBlock() 返回 true,如果等待队列中没有线程,那么 writerShouldBlock() 返回 false
static final class NonfairSync extends Sync { final boolean writerShouldBlock() { return false; } final boolean readerShouldBlock() { return apparentlyFirstQueuedIsExclusive(); } } static final class FairSync extends Sync { final boolean writerShouldBlock() { return hasQueuedPredecessors(); } final boolean readerShouldBlock() { return hasQueuedPredecessors(); } }
在注释中我对代码逻辑做了详细的介绍,这里就不再赘述,我将 tryAcquire() 的执行逻辑梳理并绘制成了一张流程图,如下所示,你可以对比着流程和注释来理解 tryAcquire() 的代码逻辑
我们再来看 WriteLock 的 unlock() 函数
unlock() -> release() -> tryRelease()
代码实现也比较简单,直接调用了 AQS 的 release() 模板方法
public void unlock() { sync.release(1); }
AQS 中的 release() 模板方法如下所示,在上一节中已经讲解,使用 tryRelease() 释放锁,然后唤醒等待队列中位于队首的线程
public final boolean release(int arg) { if (tryRelease(arg)) { Node h = head; if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false; }
我们重点看下在写锁中 tryRelease() 抽象方法的代码实现,代码如下所示
tryRelease() 的代码实现比较简单,在代码中我详细作了注释,你可以参看注释了解其代码逻辑,这里就不再赘述了
protected final boolean tryRelease(int releases) { // tryRelease() 是 AQS 工作在独占模式下的函数, 只能用于排它锁, 也就是写锁 if (!isHeldExclusively()) throw new IllegalMonitorStateException(); // 更新 state 值, 写锁的重入次数 - releases, 对于锁来说, releases 总是等于 1 int nextc = getState() - releases; // 只有更新之后的 state 值为 0 时, 才可以将写锁释放 boolean free = exclusiveCount(nextc) == 0; if (free) setExclusiveOwnerThread(null); setState(nextc); return free; }
3.4、读锁的实现原理
刚刚我们讲了读写锁中的写锁的实现原理,现在我们再来看下读锁的实现原理,写锁是排它锁,实现原理比较简单,而读锁是共享锁,实现原理相对来说更加复杂
跟 WriteLock 相同,ReadLock 也实现了 Lock 接口,同样支持各种不同的加锁方式(lock()、tryLock()、带超时时间的 tryLock()、lockInterruptibly())
接下来我们还是重点讲解 lock() 和 unlock() 这两个函数,对于其他加锁方式,你可以参看上一节的内容和源码,自行研究
我们先来看 ReadLock 中的 lock() 函数
lock() -> acquireShared() -> tryAcquireShared() -> doAcquireShared()
前面讲到,WriteLock 中的 lock() 函数调用了 AQS 中的 acquire() 模板方法,这里 ReadLock 的 lock() 函数调用的是 AQS 中的 acquireShared() 模板方法
acquire() 模板方法用于 "独占模式",acquireShared() 模板方法用于 "共享模式"
public void lock() { sync.acquireShared(1); }
我们再来看下 AQS 中 acquireShared() 的代码实现,如下所示
对比 acquire() 的代码实现,acquireShared() 的代码实现同样也比较简单
调用 tryAquireShared() 去竞争锁,如果竞争成功,则直接返回,如果竞争失败,则调用 doAcquireShared() 去排队等待唤醒
public final void acquireShared(int arg) { if (tryAcquireShared(arg) < 0) // 竞争读锁 doAcquireShared(arg); // 竞争失败去排队 }
tryAcquiredShared() 为 AQS 的抽象方法,其在 AQS 的子类 Sync 中实现,具体代码如下所示,tryAcquireShared() 为了提高性能做了很多代码层面的优化,导致代码量很大
为了聚焦在基本实现原理上,在不改变基本实现原理的情况下,我对 tryAcquireShared() 中的代码做了简化,如果你想了解完成的代码,请自行查看源码
// 返回 -1 表示竞争锁失败, 返回 1 表示竞争锁成功 protected final int tryAcquireShared(int unused) { Thread current = Thread.currentThread(); // 如果 state 没加锁或者是加了读锁, 那么线程会通过 CAS 操作改变 state 值来竞争锁 // 如果其他线程也在竞争读锁, 并且竞争成功, 那么此线程就会竞争失败 // 于是, 此线程就要自旋(for 循环)再次尝试去竞争读锁 for (; ; ) { int c = getState(); if (exclusiveCount(c) != 0) { // 已加写锁 // 如果加写锁的线程不是此线程, 那么读锁也加不成, 直接返回 -1 // 否则, 读写锁支持锁降级, 加了写锁的线程可以再加读锁 if (getExclusiveOwnerThread() != current) return -1; } // 理论上讲, 如果没有加写锁, 不管有没有加读锁, 都可以去竞争读锁了, 毕竟读锁是共享锁 // 但是, 存在两个特殊情况 // 1、对于公平锁来说, 如果等待队列不为空, 并且当前线程没有持有读锁(重入加锁), 那么, 线程就要去排队 // 2、对于非公平锁来说, 如果等待队列中队首线程(接下来要被唤醒的)是写线程 // 那么, 线程就要去排队, 这样做是为了避免请求写锁的线程迟迟获取不到写锁 if (sharedCount(c) != 0) { // 已加读锁 if (readerShouldBlock()) { // 上述 1、2 两种情况对应此函数的返回值为 true if (readHolds.get().count == 0) // 此线程没有持有读锁, 不能重入 return -1; } } // 以下是对上述代码中 readHolds 的解释 // readHolds 是 ThreadLocal 变量, 保存跟这个线程的读锁重入次数 // 如果重入次数为 0, 表示没有加读锁, 返回 -1 去排队 // 如果重入次数大于等于 0, 表示已加读锁, 可以继续重入, 不用排队 if (sharedCount(c) == MAX_COUNT) throw new Error("Maximum lock count exceeded"); // CAS 竞争读锁, 此时有可能还有其他线程在竞争读锁或写锁 if (compareAndSetState(c, c + SHARED_UNIT)) { // SHARED_UNIT = 1<<16 // 竞争读锁成功 readHolds.get().count++; // 更新线程重入次数 return 1; // 成功获取读锁 } } }
从上述代码我们可以发现,相对于 tryAcquire() 抽象方法,tryAcquireShared() 要复杂很多,在注释中我对代码逻辑做了详细的介绍,这里就不再赘述
我将 tryAcquireShared() 的执行逻辑梳理并绘制成了一张流程图,如下所示,你可以对比着流程和注释来理解 tryAcquireShared() 的代码逻辑
接下来,我们再来看下 doAcquireShared() 函数,此函数负责排队和等待唤醒,代码如下所示
doAcquireShared() 函数跟上一节讲到的 acquireQueued() 函数非常类似,区别主要有两点,如下注释所示
- 区别一:等待读锁的线程标记为 SHARED
- 区别二:线程获取到读锁之后,如果下一个节点对应的线程也在等待读锁,那么也会被唤醒
下一个节点对应的线程获取到读锁之后,又会去唤醒下下个节点对应的线程(如果下下个节点对应的线程也在等待读锁的话)
唤醒操作一直传播下去,直到遇到等待写锁的线程为止
private void doAcquireShared(int arg) { final Node node = addWaiter(Node.SHARED); // 区别一: 标记此线程等待的是共享锁(读锁) boolean failed = true; try { boolean interrupted = false; for (; ; ) { final Node p = node.predecessor(); if (p == head) { int r = tryAcquireShared(arg); if (r >= 0) { // 区别二: 如果下一个节点对应的线程也在等待读锁, 那么顺道唤醒它 setHeadAndPropagate(node, r); p.next = null; // help GC if (interrupted) selfInterrupt(); failed = false; return; } } if (parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } }
我们先来看 ReadLock 中的 unlock() 函数
unlock() -> releaseShared() -> tryReleaseShared() -> doReleaseShared()
代码实现也比较简单,直接调用了 AQS 的 releaseShared() 模板方法
public void unlock() { sync.releaseShared(1); }
AQS 中的 releaseShared() 模板方法如下所示,调用 tryReleaseShared() 释放读锁,只有当所有的读锁都释放之后,state 变为 0,才会调用 doReleaseShared() 唤醒等待队列中位于队首的线程
public final boolean releaseShared(int arg) { if (tryReleaseShared(arg)) { doReleaseShared(); return true; } return false; }
doReleaseShared() 的代码实现比较简单,我们重点看下 tryReleaseShared(),tryReleaseShared() 是 AQS 中的抽象方法,在 Sync 中实现,代码如下所示
// 当所有的读锁都释放之后(state 变成 0)才会返回 true protected final boolean tryReleaseShared(int unused) { Thread current = Thread.currentThread(); readHolds.get().count--; // 更新本线程对读锁的重入次数 // 因为有可能多个线程同时释放读锁, 同时 CAS 更新 state, 因此要自旋 + CAS for (; ; ) { int c = getState(); // c - SHARED_UNIT: 相当于将读锁的加锁次数 - 1 int nextc = c - SHARED_UNIT; // SHARED_UNIT = 1<<16 if (compareAndSetState(c, nextc)) return nextc == 0; // state 变为 0 才会返回 true, 才会去唤醒等待队列中的线程 } }
4、读写锁的升级版
StampedLock 是对 ReadWriteLock 的进一步优化,在读锁和写锁的基础之上,又提供了 "乐观读锁",实际上乐观读锁并没有加任何锁
在读多写少的应用场景中
- 大部分读操作都不会被写操作干扰,因此我们甚至可以将读锁也省略掉
- 只有验证读操作真正有被写操作干扰的情况下,线程再加读锁重复执行读操作
我们举一个例子解释一下,代码如下所示
public class Demo { private List<String> list = new LinkedList<>(); private StampedLock slock = new StampedLock(); // 提供了 "乐观读锁" public void add(int idx, String elem) { long stamp = slock.writeLock(); // 加写锁 try { list.add(idx, elem); } finally { slock.unlockWrite(stamp); // 释放写锁 } } public String get(int idx) { long stamp = slock.tryOptimisticRead(); // 加乐观读锁 String res = list.get(idx); // 没写操作干扰, validate 验证 if (slock.validate(stamp)) { return res; } // 有写操作干扰, 重新使用读锁, 重新执行读操作 stamp = slock.readLock(); // 加读锁 try { return list.get(idx); } finally { slock.unlockRead(stamp); // 释放读锁 } } }
在上述代码中,tryOptimisticRead() 获取的是乐观读锁,返回一个时间戳 stamp,因为乐观读锁并非真正加锁,所以乐观读锁并不需要解锁
在执行完读操作之后,我们只需要验证 stamp 是否有被更改,如果有被更改,说明执行读操作期间,writeLock() 函数有被执行
也就说明有对共享资源的写操作发生(也就是执行了add() 函数),此时之前得到的结果需要作废,使用读锁来重新获取数据
5、课后思考题
如果一个线程在获取读锁之后,在读锁释放前,再次请求写锁,将会发生什么事情?
答案:程序会出现死锁,验证代码如下所示
public class LockDemo { private static ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); private static Lock readLock = readWriteLock.readLock(); private static Lock writeLock = readWriteLock.writeLock(); public static void main(String[] args) { readLock.lock(); // 获得读锁 writeLock.lock(); // 获得写锁 System.out.println("read locked -> write locked."); writeLock.unlock(); // 释放写锁 readLock.unlock(); // 释放读锁 } }
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17484513.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步