代码理解java多线程 (三) - JDK工具篇(3)- 锁接口和类

目录

第十四章 锁接口和类

14.1 synchronized的不足之处

14.2 锁的几种分类

14.2.1 可重入锁和非可重入锁

14.2.2 公平锁与非公平锁

14.2.3 读写锁和排它锁

14.3 JDK中有关锁的一些接口和类

14.3.1 抽象类AQS/AQLS/AOS

14.3.2 接口Condition/Lock/ReadWriteLock

14.3.3 ReentrantLock

14.3.4 ReentrantReadWriteLock

14.3.5 StampedLock


第十四章 锁接口和类

前面我们介绍了Java原生的锁——基于对象的锁,它一般是配合synchronized关键字来使用的。实际上,Java在java.util.concurrent.locks包下,还为我们提供了几个关于锁的类和接口。它们有更强大的功能或更高的性能。

14.1 synchronized的不足之处

我们先来看看synchronized有什么不足之处。

  • 如果临界区是只读操作,其实可以多线程一起执行,但使用synchronized的话,同一时间只能有一个线程执行
  • synchronized无法知道线程有没有成功获取到锁
  • 使用synchronized,如果临界区因为IO或者sleep方法等原因阻塞了,而当前线程又没有释放锁,就会导致所有线程等待

而这些都是locks包下的锁可以解决的。

14.2 锁的几种分类

锁可以根据以下几种方式来进行分类,下面我们逐一介绍。

14.2.1 可重入锁和非可重入锁

所谓重入锁,顾名思义。就是支持重新进入的锁,也就是说这个锁支持一个线程对资源重复加锁

synchronized关键字就是使用的重入锁。比如说,你在一个synchronized实例方法里面调用另一个本实例的synchronized实例方法,它可以重新进入这个锁,不会出现任何异常。

如果我们自己在继承AQS实现同步器的时候,没有考虑到占有锁的线程再次获取锁的场景,可能就会导致线程阻塞,那这个就是一个“非可重入锁”。

ReentrantLock的中文意思就是可重入锁。也说本文后续要介绍的重点类。

14.2.2 公平锁与非公平锁

这里的“公平”,其实通俗意义来说就是“先来后到”,也就是FIFO。如果对一个锁来说,先对锁获取请求的线程一定会先被满足,后对锁获取请求的线程后被满足,那这个锁就是公平的。反之,那就是不公平的。

一般情况下,非公平锁能提升一定的效率。但是非公平锁可能会发生线程饥饿(有一些线程长时间得不到锁)的情况。所以要根据实际的需求来选择非公平锁和公平锁。

ReentrantLock支持非公平锁和公平锁两种。

14.2.3 读写锁和排它锁

我们前面讲到的synchronized用的锁和ReentrantLock,其实都是“排它锁”。也就是说,这些锁在同一时刻只允许一个线程进行访问。

而读写锁可以再同一时刻允许多个读线程访问。Java提供了ReentrantReadWriteLock类作为读写锁的默认实现,内部维护了两个锁:一个读锁,一个写锁。通过分离读锁和写锁,使得在“读多写少”的环境下,大大地提高了性能。

注意,即使用读写锁,在写线程访问时,所有的读线程和其它写线程均被阻塞。

可见,只是synchronized是远远不能满足多样化的业务对锁的要求的。接下来我们介绍一下JDK中有关锁的一些接口和类。

14.3 JDK中有关锁的一些接口和类

众所周知,JDK中关于并发的类大多都在java.util.concurrent(以下简称juc)包下。而juc.locks包看名字就知道,是提供了一些并发锁的工具类的。前面我们介绍的AQS(AbstractQueuedSynchronizer)就是在这个包下。下面分别介绍一下这个包下的类和接口以及它们之间的关系。

14.3.1 抽象类AQS/AQLS/AOS

这三个抽象类有一定的关系,所以这里放到一起讲。

首先我们看AQS(AbstractQueuedSynchronizer),之前专门有章节介绍这个类,它是在JDK 1.5 发布的,提供了一个“队列同步器”的基本功能实现。而AQS里面的“资源”是用一个int类型的数据来表示的,有时候我们的业务需求资源的数量超出了int的范围,所以在JDK 1.6 中,多了一个AQLS(AbstractQueuedLongSynchronizer)。它的代码跟AQS几乎一样,只是把资源的类型变成了long类型。

AQS和AQLS都继承了一个类叫AOS(AbstractOwnableSynchronizer)。这个类也是在JDK 1.6 中出现的。这个类只有几行简单的代码。从源码类上的注释可以知道,它是用于表示锁与持有者之间的关系(独占模式)。可以看一下它的主要方法:

 
  1. // 独占模式,锁的持有者
  2. private transient Thread exclusiveOwnerThread;
  3.  
  4. // 设置锁持有者
  5. protected final void setExclusiveOwnerThread(Thread t) {
  6. exclusiveOwnerThread = t;
  7. }
  8.  
  9. // 获取锁的持有线程
  10. protected final Thread getExclusiveOwnerThread() {
  11. return exclusiveOwnerThread;
  12. }

14.3.2 接口Condition/Lock/ReadWriteLock

juc.locks包下共有三个接口:ConditionLockReadWriteLock。其中,Lock和ReadWriteLock从名字就可以看得出来,分别是锁和读写锁的意思。Lock接口里面有一些获取锁和释放锁的方法声明,而ReadWriteLock里面只有两个方法,分别返回“读锁”和“写锁”:

 
  1. public interface ReadWriteLock {
  2. Lock readLock();
  3. Lock writeLock();
  4. }

Lock接口中有一个方法是可以获得一个Condition:

 
  1. Condition newCondition();

之前我们提到了每个对象都可以用继承自Objectwait/notify方法来实现等待/通知机制。而Condition接口也提供了类似Object监视器的方法,通过与Lock配合来实现等待/通知模式。

那为什么既然有Object的监视器方法了,还要用Condition呢?这里有一个二者简单的对比:

对比项Object监视器Condition
前置条件获取对象的锁调用Lock.lock获取锁,调用Lock.newCondition获取Condition对象
调用方式直接调用,比如object.notify()直接调用,比如condition.await()
等待队列的个数一个多个
当前线程释放锁进入等待状态支持支持
当前线程释放锁进入等待状态,在等待状态中不中断不支持支持
当前线程释放锁并进入超时等待状态支持支持
当前线程释放锁并进入等待状态直到将来的某个时间不支持支持
唤醒等待队列中的一个线程支持支持
唤醒等待队列中的全部线程支持支持

Condition和Object的wait/notify基本相似。其中,Condition的await方法对应的是Object的wait方法,而Condition的signal/signalAll方法则对应Object的notify/notifyAll()。但Condition类似于Object的等待/通知机制的加强版。我们来看看主要的方法:

方法名称描述
await()当前线程进入等待状态直到被通知(signal)或者中断;当前线程进入运行状态并从await()方法返回的场景包括:(1)其他线程调用相同Condition对象的signal/signalAll方法,并且当前线程被唤醒;(2)其他线程调用interrupt方法中断当前线程;
awaitUninterruptibly()当前线程进入等待状态直到被通知,在此过程中对中断信号不敏感,不支持中断当前线程
awaitNanos(long)当前线程进入等待状态,直到被通知、中断或者超时。如果返回值小于等于0,可以认定就是超时了
awaitUntil(Date)当前线程进入等待状态,直到被通知、中断或者超时。如果没到指定时间被通知,则返回true,否则返回false
signal()唤醒一个等待在Condition上的线程,被唤醒的线程在方法返回前必须获得与Condition对象关联的锁
signalAll()唤醒所有等待在Condition上的线程,能够从await()等方法返回的线程必须先获得与Condition对象关联的锁

14.3.3 ReentrantLock

ReentrantLock是一个非抽象类,它是Lock接口的JDK默认实现,实现了锁的基本功能。从名字上看,它是一个”可重入“锁,从源码上看,它内部有一个抽象类Sync,是继承了AQS,自己实现的一个同步器。同时,ReentrantLock内部有两个非抽象类NonfairSyncFairSync,它们都继承了Sync。从名字上看得出,分别是”非公平同步器“和”公平同步器“的意思。这意味着ReentrantLock可以支持”公平锁“和”非公平锁“。

通过看着两个同步器的源码可以发现,它们的实现都是”独占“的。都调用了AOS的setExclusiveOwnerThread方法,所以ReentrantLock的锁的”独占“的,也就是说,它的锁都是”排他锁“,不能共享。

在ReentrantLock的构造方法里,可以传入一个boolean类型的参数,来指定它是否是一个公平锁,默认情况下是非公平的。这个参数一旦实例化后就不能修改,只能通过isFair()方法来查看。

14.3.4 ReentrantReadWriteLock

这个类也是一个非抽象类,它是ReadWriteLock接口的JDK默认实现。它与ReentrantLock的功能类似,同样是可重入的,支持非公平锁和公平锁。不同的是,它还支持”读写锁“。

ReentrantReadWriteLock内部的结构大概是这样:

 
  1. // 内部结构
  2. private final ReentrantReadWriteLock.ReadLock readerLock;
  3. private final ReentrantReadWriteLock.WriteLock writerLock;
  4. final Sync sync;
  5. abstract static class Sync extends AbstractQueuedSynchronizer {
  6. // 具体实现
  7. }
  8. static final class NonfairSync extends Sync {
  9. // 具体实现
  10. }
  11. static final class FairSync extends Sync {
  12. // 具体实现
  13. }
  14. public static class ReadLock implements Lock, java.io.Serializable {
  15. private final Sync sync;
  16. protected ReadLock(ReentrantReadWriteLock lock) {
  17. sync = lock.sync;
  18. }
  19. // 具体实现
  20. }
  21. public static class WriteLock implements Lock, java.io.Serializable {
  22. private final Sync sync;
  23. protected WriteLock(ReentrantReadWriteLock lock) {
  24. sync = lock.sync;
  25. }
  26. // 具体实现
  27. }
  28.  
  29. // 构造方法,初始化两个锁
  30. public ReentrantReadWriteLock(boolean fair) {
  31. sync = fair ? new FairSync() : new NonfairSync();
  32. readerLock = new ReadLock(this);
  33. writerLock = new WriteLock(this);
  34. }
  35.  
  36. // 获取读锁和写锁的方法
  37. public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
  38. public ReentrantReadWriteLock.ReadLock readLock() { return readerLock; }

可以看到,它同样是内部维护了两个同步器。且维护了两个Lock的实现类ReadLock和WriteLock。从源码可以发现,这两个内部类用的是外部类的同步器。

ReentrantReadWriteLock实现了读写锁,但它有一个小弊端,就是在“写”操作的时候,其它线程不能写也不能读。我们称这种现象为“写饥饿”,将在后文的StampedLock类继续讨论这个问题。

14.3.5 StampedLock

StampedLock类是在Java 8 才发布的,也是Doug Lea大神所写,有人号称它为锁的性能之王。它没有实现Lock接口和ReadWriteLock接口,但它其实是实现了“读写锁”的功能,并且性能比ReentrantReadWriteLock更高。StampedLock还把读锁分为了“乐观读锁”和“悲观读锁”两种。

前面提到了ReentrantReadWriteLock会发生“写饥饿”的现象,但StampedLock不会。它是怎么做到的呢?它的核心思想在于,在读的时候如果发生了写,应该通过重试的方式来获取新的值,而不应该阻塞写操作。这种模式也就是典型的无锁编程思想,和CAS自旋的思想一样。这种操作方式决定了StampedLock在读线程非常多而写线程非常少的场景下非常适用,同时还避免了写饥饿情况的发生。

这里篇幅有限,就不介绍StampedLock的源码了,只是分析一下官方提供的用法(在JDK源码类声明的上方或Javadoc里可以找到)。

 
  1. class Point {
  2. private double x, y;
  3. private final StampedLock sl = new StampedLock();
  4.  
  5. // 写锁的使用
  6. void move(double deltaX, double deltaY) {
  7. long stamp = sl.writeLock(); // 获取写锁
  8. try {
  9. x += deltaX;
  10. y += deltaY;
  11. } finally {
  12. sl.unlockWrite(stamp); // 释放写锁
  13. }
  14. }
  15.  
  16. // 乐观读锁的使用
  17. double distanceFromOrigin() {
  18. long stamp = sl.tryOptimisticRead(); // 获取乐观读锁
  19. double currentX = x, currentY = y;
  20. if (!sl.validate(stamp)) { // //检查乐观读锁后是否有其他写锁发生,有则返回false
  21. stamp = sl.readLock(); // 获取一个悲观读锁
  22. try {
  23. currentX = x;
  24. currentY = y;
  25. } finally {
  26. sl.unlockRead(stamp); // 释放悲观读锁
  27. }
  28. }
  29. return Math.sqrt(currentX * currentX + currentY * currentY);
  30. }
  31.  
  32. // 悲观读锁以及读锁升级写锁的使用
  33. void moveIfAtOrigin(double newX, double newY) {
  34. long stamp = sl.readLock(); // 悲观读锁
  35. try {
  36. while (x == 0.0 && y == 0.0) {
  37. // 读锁尝试转换为写锁:转换成功后相当于获取了写锁,转换失败相当于有写锁被占用
  38. long ws = sl.tryConvertToWriteLock(stamp);
  39.  
  40. if (ws != 0L) { // 如果转换成功
  41. stamp = ws; // 读锁的票据更新为写锁的
  42. x = newX;
  43. y = newY;
  44. break;
  45. }
  46. else { // 如果转换失败
  47. sl.unlockRead(stamp); // 释放读锁
  48. stamp = sl.writeLock(); // 强制获取写锁
  49. }
  50. }
  51. } finally {
  52. sl.unlock(stamp); // 释放所有锁
  53. }
  54. }
  55. }}

乐观读锁的意思就是先假定在这个锁获取期间,共享变量不会被改变,既然假定不会被改变,那就不需要上锁。在获取乐观读锁之后进行了一些操作,然后又调用了validate方法,这个方法就是用来验证tryOptimisticRead之后,是否有写操作执行过,如果有,则获取一个悲观读锁,这里的悲观读锁和ReentrantReadWriteLock中的读锁类似,也是个共享锁。

可以看到,StampedLock获取锁会返回一个long类型的变量,释放锁的时候再把这个变量传进去。简单看看源码:

 
  1. // 用于操作state后获取stamp的值
  2. private static final int LG_READERS = 7;
  3. private static final long RUNIT = 1L; //0000 0000 0001
  4. private static final long WBIT = 1L << LG_READERS; //0000 1000 0000
  5. private static final long RBITS = WBIT - 1L; //0000 0111 1111
  6. private static final long RFULL = RBITS - 1L; //0000 0111 1110
  7. private static final long ABITS = RBITS | WBIT; //0000 1111 1111
  8. private static final long SBITS = ~RBITS; //1111 1000 0000
  9.  
  10. // 初始化时state的值
  11. private static final long ORIGIN = WBIT << 1; //0001 0000 0000
  12.  
  13. // 锁共享变量state
  14. private transient volatile long state;
  15. // 读锁溢出时用来存储多出的读锁
  16. private transient int readerOverflow;

StampedLock用这个long类型的变量的前7位(LG_READERS)来表示读锁,每获取一个悲观读锁,就加1(RUNIT),每释放一个悲观读锁,就减1。而悲观读锁最多只能装128个(7位限制),很容易溢出,所以用一个int类型的变量来存储溢出的悲观读锁。

写锁用state变量剩下的位来表示,每次获取一个写锁,就加0000 1000 0000(WBIT)。需要注意的是,写锁在释放的时候,并不是减WBIT,而是再加WBIT。这是为了让每次写锁都留下痕迹,解决CAS中的ABA问题,也为乐观锁检查变化validate方法提供基础。

乐观读锁就比较简单了,并没有真正改变state的值,而是在获取锁的时候记录state的写状态,在操作完成后去检查state的写状态部分是否发生变化,上文提到了,每次写锁都会留下痕迹,也是为了这里乐观锁检查变化提供方便。

总的来说,StampedLock的性能是非常优异的,基本上可以取代ReentrantReadWriteLock的作用。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值