抽象队列同步器AQS应用ReentrantLock
AQS
Java并发编程核心在于java.concurrent.util包而juc当中的大多数同步器实现都是围绕着共同的基础行为,比如等待队列、条件队列、独占获取、共享获取等,而这个行为的抽象就是基于AbstractQueuedSynchronizer简称AQS,AQS定义了一套多线程访问共享资源的同步器框架,是一个依赖状态(state)的同步器。
AQS的继承关系
AQS的继承关系如下图:
- ReentrantLock就是定义了内部类Sync,这个内部类继承承AbstractQueuedSynchronized.对该抽象类的部分方法做了实现;并且还定义了两个子类:FairSync和NonfairSync
- 除了Lock外,Java.concurrent.util当中同步器的实现如Latch,Barrier,BlockingQueue等,
都是基于AQS框架实现- 一般通过定义内部类Sync继承AQS
- 将同步器所有调用都映射到Sync对应的方法
AQS具备特性
- List item
- 阻塞等待队列
- 共享/独占
- 公平/非公平
- 可重入
- 允许中断
可中断特性就是因为阻塞用的是LockSupport.park(),这种阻塞可以通过interrupt唤醒
AbstractQueuedSynchronized类
重要属性
重要属性
-
超类的属性exclusiveOwnerThread:记录当前获取锁的线程是谁
AbstractQueuedSynchronized继承的AbstractOwnableSynchronizer的属性
-
AQS类下的变量state状态器,表明当前同步器的状态
state表示资源的可用状态,state为0表明是无锁状态,没有被任何一个线程持有volatile int state (32位)
state的三种访问方式:
- getState()
- setState()
- compareAndSetState()
-
AQS的head属性会指向Node的头部,tail属性会指向Node的尾部,形成同步等待队列CLH队列
形成的双向队列:
内部类:Node
AQS会基于Node构建双向队列
Node的重要属性:
-
Node的prev和next属性用来形成双向链表。
-
Node的thread属性用来保持对线程的引用
-
Node的SHARED属性表示锁是共享锁,Semaphore锁是共享的
-
Node的EXCLUSIVE属性表示锁是互斥的,ReentrantLock需要锁是互斥的
-
Node的waitestate属性表示当前结点的生命状态(信号量)
- SIGNAL=-1 可被唤醒
- CANCELLED=1 代表出现异常,中断引起的,需要废弃结束
- CONDITION=-2 条件等待
- PROPAGATE=-3 传播
- 0 是初始状态Init状态
AQS定义两种资源共享方式
- Exclusive-独占,只有一个线程能执行,如ReentrantLock Share-共享
- 多个线程可以同时执行,如Semaphore/CountDownLatch
AQS定义两种队列
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共 享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/ 唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:
- isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去 实现它。
- tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回 false。
- tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回 false。
- tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成 功,但没有剩余可用资源;正数表示成功,且有剩余资源。
- tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续 等待结点返回true,否则返回false。
同步等待队列 CLH
AQS当中的同步等待队列也称CLH队列,CLH队列是Craig、Landin、Hagersten三人 发明的一种基于双向链表数据结构的队列,是FIFO先入先出线程等待队列,Java中的CLH 队列是原CLH队列的一个变种,线程由原自旋机制改为阻塞机制。
条件等待队列
Condition是一个多线程间协调通信的工具类,使得某个,或者某些线程一起等待某个 条件(Condition),只有当该条件具备时,这些等待线程才会被唤醒,从而重新争夺锁
ReentrantLock
ReentrantLock是一种基于AQS框架的应用实现,是JDK中的一种线程并发访问的同步手段,它的功能类似于synchronized是一种互斥锁,可以保证线程安全。而且它具有比 synchronized更多的特性,比如它支持手动加锁与解锁,支持加锁的公平性。
//使用ReentrantLock进行同步
ReentrantLock lock = new ReentrantLock(false);//false为非公平锁,true为公平锁
lock.lock() //加锁
lock.unlock() //解锁
Lock的特性:
- 可重入
这个特性就是加了几次锁也要释放几次锁
synchronized也有可重入性 - 公平性与非公平性
ReentrantLock如何实现synchronized不具备的公平与非公平性呢?
在ReentrantLock内部定义了一个Sync的内部类,该类继承AbstractQueuedSynchronized,对该抽象类的部分方法做了实现;并且还定义了两个子类:
- FairSync 公平锁的实现
- NonfairSync 非公平锁的实现
这两个类都继承自Sync,也就是间接继承了AbstractQueuedSynchronized,所以这一个ReentrantLock同时具备公平与非公平特性。
上面主要涉及的设计模式:模板模式-子类根据需要做具体业务实现
实现锁的核心:
- CAS
保证加锁永远只有一个线程能够成功 - LockSupport
对线程阻塞和唤醒 - 自旋
cas加锁失败,则这些线程就要自旋,阻塞住 就不占用cpu资源 - queue
放阻塞的那些线程,用队列是因为队列的FIFO可以保证公平性
CAS
CAS: Compare and swap
CAS能够保证不管并发有多高,都能保证这个执行的原子性。通过cas算法去加锁,这样保证加锁永远只有一个线程能够成功。
CAS 工作原理
主内存中有一个expect=0,如果两个线程都要去修改expect,则CAS就会让这两个线程都复制一份到自己线程中,然后再用另一个变量比如是refresh存修改后的值, 线程A和线程B就都去和主内存比较 如果expect的值相等,则将refresh中的值修改主内存的值,如果expect值不等,则不改主内存的值。
CAS的使用
Unsafe类中提供了三个关于CAS的方法:
LockSupport
线程阻塞就不会占用cpu的资源
通过java的LockSupport.part() 就可以阻塞线程
LockSupport.part() 有参和无参
LockSupport的part有两种:
-
无参 LockSupport.part()
如果阻塞的线程被interrupt 唤醒,则永远不再会阻塞这个线程
所以结果会一直打印那两句:
-
有参
被中断只会唤醒一次,下一次继续阻塞
LockSupport.park和unpark的使用
AQS源码分析
源码解析Lock()
公平锁:
FairSync的lock()方法调用acquire()方法
acquire()方法:
tryAcquire 尝试去获取锁
-
tryAcquire 尝试去获取锁:
- 通过Thread.currentThread()获取当前线程的引用
- getState() 获取同步器的状态
- c==0 无锁状态
-
对于公平锁,首先要判断是否有线程在排队
通过判断队列的队头队尾是否一样 -
没有线程排队,才用CAS去加锁,加锁其实就是把改同步器的状态为1
-
把当前线程的引用赋给exclusiveOwnerThread
-
- c!=0
- 第一种情况,这个锁是被当前线程持有的,则再对state++
Lock的可重入性就是通过这部分逻辑做到的 - 第二种情况,这个锁是被其他线程持有的,则返回false表示加锁失败
- 第一种情况,这个锁是被当前线程持有的,则再对state++
- c==0 无锁状态
addWaiter 线程入队
-
addWaiter 线程入队
前面的tryAquire没有获取到锁,所以要添加当前线程到队列
返回队尾的node
-
创建Node结点
入参是当前线程引用和mode是EXCLUSIVE互斥锁,这时默认waiteState即为0 -
enq(node) 将node入队
-
t==null 则给队列初始化
构建队列要先给队列做初始化,即创建一个空结点,thread为null,队头head队尾tail同时指向这个创建好的空结点
-
t!=null 进行入队操作
入队也存在竞争,为了保证所有阻塞线程对象能够被唤醒即都能入队,所以要用CAS保证入队的原子性- 把prev指向t即队尾指向的node
- 用CAS的方式移动尾部指针
- 原来尾部即t的node的next指向当前入队的node
-
-
acquireQueued 阻塞函数
-
acquireQueued 阻塞
- 如果当前node是队列的第一个 则再通过tryAcquire尝试获取锁,尽可能避免线程被阻塞。
- 获取到锁了 节点就出队。 并且把head往后挪一个节点
通过setHead 把head指向当前node,并把当前node的thread、pred置位null,也就是变成了一个空结点 - 如果没有抢到锁 就阻塞
第一轮循环,通过shouldParkAfterFailedAcquire修改head的状态为-1即SIGNAL
第二轮循环,阻塞线程。-
shouldParkAfterFailedAcquire
取出前驱结点的状态waitStatus,当前结点能否被唤醒取决于前驱结点的状态
- 如果前驱结点的状态是signal ws==Node.SIGNAL 直接返回true代表是当前结点可唤醒的
- ws>0 代表前驱节点 出现异常要被cancelled
- ws是0或propagate,我们就通过CAS方式将前驱节点设置为可唤醒状态SIGNAL,即将ws设置为-1.
head的waitState设置为-1的原因:因为持有锁的线程T0在释放锁的时候,会去唤醒队列中排队的第一个线程T1,要判断head的waitState是否!=0。成立的话会把waitState改为0,然后把把T1被唤醒;T1接着走循环去抢锁,可能会再失败(在非公平锁场景下),就会再次被阻塞,head的节点就又经历两轮循环 waitState从0又变成-1.
-
parkAndCheckInterrupt 阻塞线程,并且需要判断线程是否是由中断信号唤醒的
-
调用LockSupport.park进行阻塞,唤醒park阻塞的线程有两种方式:
- unlock 调用LockSupport.unpark() 唤醒
- 给线程发一个中断信号(业务逻辑掉当前线程的.interrupt),线程就会从阻塞状态唤醒
-
Thread.interrupted() 获取当前线程中断的状态
这里的Thread.interrupted() 为true就说明他是通过中断信号方式被唤醒的
-
-
- 获取到锁了 节点就出队。 并且把head往后挪一个节点
- 如果当前node是队列的第一个 则再通过tryAcquire尝试获取锁,尽可能避免线程被阻塞。
selfInterrupt() 打上中断标记
- selfInterrupt()
当没有获取到锁且acquireQueued返回true的时候就会执行selfInterrupt(),Thread.currentThread.interrupt() 就是给当前线程打上一个中断的标记
因为在parkAndCheckInterrupt return 的是Thread.interrupted() 是true说明当前线程是通过中断的方式唤醒的,但是调用了Thread.interrupted()后那个中断标记就被清除了,所以我们这里要把中断标记打上,这样外围程序员自己定义的代码就能识别到中断信号,就知道是被中断唤醒的
源码解析unLock()
持有锁的这边逻辑,执行unlock(),unlock()中调用release()
release()
public final boolean release(int arg) {
//尝试解锁,因为重入锁,所以要stack=0 才可以完全解锁
if (tryRelease(arg)) {
//如果解锁成功,队列不为空,而且真实首节点状态要为-1
Node h = head;
if (h != null && h.waitStatus != 0)
//解除阻塞状态
unparkSuccessor(h);
return true;
}
return false;
}
unparkSuccessor() 唤醒
这里把-1又改成0的原因是,如果在非公平锁的情况下,当前线程被唤醒后有可能还是会抢不到锁,那这样就要保持是0的状态继续去执行阻塞的逻辑
源码解析 lockInteruptibly()
当用的是lockInteruptibly() 会调用doAcquireInterruptibly()
doAcquireInterruptibly()和acquireQueued 的区别在于 如果获取锁失败且是通过中断唤醒的就往外抛异常,抛异常之前会执行cancelAcquire()去把当前结点移出队列
-
doAcquireInterruptibly()
-
cancelAcquire()
- 将当前结点的thread=null
- 将前驱结点的waitStatus为CANCELLED的都忽略掉
- 将当前结点的waitstatus修改为CANCELLED
- 将当前结点从队列中移除
- 当前结点是尾结点
- 当前结点是头结点,就唤醒下一个结点
- 是其它结点