并发编程-ReetrantLock底层分析
J.U.C简介
Java.util.concurrent是在并发编程中比较常用的工具类,里面包含很多用来在并发场景中使用的组件。比如线程池、阻塞队列、计时器、同步器、并发集合等等。
Lock
Lock在J.U.C中是最核心的组件,前面我们讲synchronized的时候说过,锁最重要的特性就是解决并发安全问题。为什么要以Lock作为切入点呢?如果有看过J.U.C包中的所有组件,一定会发现绝大部分的组件都有用到了Lock。
Lock的实现
Lock本质上是一个接口,它定义了释放锁和获得锁的抽象方法,定义成接口就意味着它定义了锁的一个标准规范,也同时意味着锁的不同实现。实现Lock接口的
类有很多,以下为几个常见的锁实现
ReentrantLock:表示重入锁,它是唯一一个实现了Lock接口的类。重入锁指的是线程在获得锁之后,再次获取该锁不需要阻塞,而是直接关联一次计数器增加重入次数
ReentrantReadWriteLock:重入读写锁,它实现了ReadWriteLock接口,在这个类中维护了两个锁,一个是ReadLock,一个是WriteLock,他们都分别实现了Lock接口。读写锁是一种适合读多写少的场景下解决线程安全问题的工具,基本原则是:读和读不互斥、读和写互斥、写和写互斥。也就是说涉及到影响数据变化的操作都会存在互斥。
StampedLock:stampedLock是JDK8引入的新的锁机制,可以简单认为是读写锁的一个改进版本,读写锁虽然通过分离读和写的功能使得读和读之间可以完全并发,但是读和写是有冲突的,如果大量的读线程存在,可能会引起写线程的饥饿。stampedLock是一种乐观的读策略,使得乐观锁完全不会阻塞写线程
Lock的方法
void lock() //如果锁可用就获得锁,如果锁不可用就阻塞直到锁释放 void lockInterruptibly() //和lock() 方法相似 , 但阻塞的线程可中断,抛出java.lang.InterruptedException 异常 boolean tryLock() //非阻塞获取锁 尝试获取锁,如果成功返回 true boolean tryLock(longtimeout, TimeUnit timeUnit)带有超时时间的获取锁方法 void unlock() //释放锁
ReentrantLock重入锁
重入锁,表示支持重新进入的锁,也就是说,如果当前线程t1通过调用lock方法获取了锁之后,再次调用lock,是不会再阻塞去获取锁的,直接增加重试次数就行了。synchronized和ReentrantLock都是可重入锁。
重入锁的设计目的是避免线程的死锁。
ReentrantReadWriteLock
我们以前理解的锁,基本都是排他锁,也就是这些锁在同一时刻只允许一个线程进行访问,而读写锁在同一时刻可以允许多个线程访问,但是在写线程访问时,所有的读线程和其他写线程都会被阻塞。读写锁维护了一对锁,一个读锁、一个写锁; 一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量.
ReentrantLock的实现原理
AQS是什么
在Lock中,用到了一个同步队列AQS,全称AbstractQueuedSynchronizer,它是一个同步工具也是Lock用来实现线程同步的核心组件。
AQS的两种功能
从使用层面来说,AQS的功能分为两种:独占和共享
独占锁,每次只能有一个线程持有锁,比如ReentrantLock就是以独占方式实现的互斥锁
共享锁,允许多个线程同时获取锁,并发访问共享资源,比如ReentrantReadWriteLock
AQS的内部实现
AQS队列内部维护的是一个FIFO的双向链表,这种结构的特点是每个数据结构都有两个指针,分别指向直接的后继节点和直接前驱节点。所以双向链表可以从任意一个节点开始很方便的访问前驱和后继。每个Node其实是由线程封装,当线程争抢锁失败后会封装成Node加入到AQS队列中去;当获取锁的线程释放锁以后,会从队列中唤醒一个阻塞的节点(线程)。
释放锁以及添加线程对于队列的变化
当出现锁竞争以及释放锁的时候,AQS同步队列中的节点会发生变化.
1. 新的线程封装成Node节点追加到同步队列中,设置prev节点以及修改当前节点的前置节点的next节点指向自己
2. 通过CAS讲tail重新指向新的尾部节点
head节点表示获取锁成功的节点,当头结点在释放同步状态时,会唤醒后继节点,如果后继节点获得锁成功,会把自己设置为头结点
设置head节点不需要用CAS,原因是设置head节点是由获得锁的线程来完成的,而同步锁只能由一个线程获得,所以不需要CAS保证,只需要把head节点设置为原首节点的后继节点,并且断开原head节点的next引用即可
ReentrantLock的源码分析
ReentrantLock.lock()
这个是reentrantLock获取锁的入口
public void lock() { sync.lock(); }
sync实际上是一个抽象的静态内部类,它继承了AQS来实现重入锁的逻辑,我们
前面说过AQS是一个同步队列,它能够实现线程的阻塞以及唤醒,但它并不具备业务功能,所以在不同的同步场景中,会继承AQS来实现对应场景的功能
Sync有两个具体的实现类,分别是:
NofairSync:表示可以存在抢占锁的功能,也就是说不管当前队列上是否存在其他线程等待,新线程都有机会抢占锁
FailSync:表示所有线程严格按照FIFO来获取锁
NofairSync.lock
以非公平锁为例,来看看lock中的实现
1. 非公平锁和公平锁最大的区别在于,在非公平锁中我抢占锁的逻辑是,不管有没有线程排队,我先上来cas去抢占一下
2. CAS成功,就表示成功获得了锁
3. CAS失败,调用acquire(1)走锁竞争逻辑
final void lock() { if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else acquire(1);
}
CAS的实现原理
protected final boolean compareAndSetState(int expect, int update) {
// See below for intrinsics setup to support this return
unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
通过cas乐观锁的方式来做比较并替换,这段代码的意思是,如果当前内存中的state的值和预期值expect相等,则替换为update。更新成功返回true,否则返回false.
这个操作是原子的,不会出现线程安全问题,这里面涉及到Unsafe这个类的操作,以及涉及到state这个属性的意义。
state是AQS中的一个属性,它在不同的实现中所表达的含义不一样,对于重入锁的实现来说,表示一个同步状态。它有两个含义的表示
1.当state=0时,表示无锁状态
2.当state>0时,表示已经有线程获得了锁,也就是state=1,但是因为ReentrantLock允许重入,所以同一个线程多次获得同步锁的时候,state会递增,比如重入5次,那么state=5。而在释放锁的时候,同样需要释放5次直到state=0其他线程才有资格获得锁
Unsafe类
Unsafe类是在sun.misc包下,不属于Java标准。但是很多Java的基础类库,包括一些被广泛使用的高性能开发库都是基于Unsafe类开发的,比如Netty、Hadoop、Kafka等;
Unsafe可认为是Java中留下的后门,提供了一些低层次操作,如直接内存访问、线程的挂起和恢复、CAS、线程同步、内存屏障
而CAS就是Unsafe类中提供的一个原子操作,第一个参数为需要改变的对象,第二个为偏移量(即之前求出来的headOffset的值),第三个参数为期待的值,第四个为更新后的值整个方法的作用是如果当前时刻的值等于预期值var4相等,则更新为新的期望值var5,如果更新成功,则返回true,否则返回false;
stateOffset
一个Java对象可以看成是一段内存,每个字段都得按照一定的顺序放在这段内存里,通过这个方法可以准确地告诉你某个字段相对于对象的起始内存地址的字节偏移。用于在后面的compareAndSwapInt中,去根据偏移量找到对象在内存中的具体位置
所以stateOffset表示state这个字段在AQS类的内存中相对于该类首地址的偏移量
AQS.accquire
acquire是AQS中的方法,如果CAS操作未能成功,说明state已经不为0,此时继续acquire(1)操作
这个方法的主要逻辑是
1. 通过tryAcquire尝试获取独占锁,如果成功返回true,失败返回false
2. 如果tryAcquire失败,则会通过addWaiter方法将当前线程封装成Node添加到AQS队列尾部
3. acquireQueued,将Node作为参数,通过自旋去尝试获取锁。
public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }
NonfairSync.tryAcquire
这个方法的作用是尝试获取锁,如果成功返回true,不成功返回false
它是重写AQS类中的tryAcquire方法,并且大家仔细看一下AQS中tryAcquire方法的定义,并没有实现,而是抛出异常。按照一般的思维模式,既然是一个不实
protected final boolean tryAcquire(int acquires) { return nonfairTryAcquire(acquires); }
ReentrantLock.nofairTryAcquire
1. 获取当前线程,判断当前的锁的状态
2. 如果state=0表示当前是无锁状态,通过cas更新state状态的值
3. 当前线程是属于重入,则增加重入次数
AQS.addWaiter
当tryAcquire方法获取锁失败以后,则会先调用addWaiter将当前线程封装成Node.
入参mode表示当前节点的状态,传递的参数是Node.EXCLUSIVE,表示独占状态。意味着重入锁用到了AQS的独占锁功能
1. 将当前线程封装成Node
2. 当前链表中的tail节点是否为空,如果不为空,则通过cas操作把当前线程的node添加到AQS队列
3. 如果为空或者cas失败,调用enq将节点添加到AQS队列
enq
enq就是通过自旋操作把当前节点加入到队列中
AQS.acquireQueued
通过addWaiter方法把线程添加到链表后,会接着把Node作为参数传递给acquireQueued方法,去竞争锁
1. 获取当前节点的prev节点
2. 如果prev节点为head节点,那么它就有资格去争抢锁,调用tryAcquire抢占锁
3. 抢占锁成功以后,把获得锁的节点设置为head,并且移除原来的初始化head节点
4. 如果获得锁失败,则根据waitStatus决定是否需要挂起线程
5. 最后,通过cancelAcquire取消获得锁的操作
NofairSync.tryAcquire
这个方法在前面分析过,就是通过state的状态来判断是否处于无锁状态,然后在通过cas进行竞争锁操作。成功表示获得锁,失败表示获得锁失败
shouldParkAfterFailedAcquire
如果ThreadA的锁还没有释放的情况下,ThreadB和ThreadC来争抢锁肯定是会失败,那么失败以后会调用shouldParkAfterFailedAcquire方法
Node有5中状态,分别是:CANCELLED(1),SIGNAL(-1)、CONDITION(-2)、PROPAGATE(-3)、默认状态(0)
CANCELLED:在同步队列中等待的线程等待超时或被中断,需要从同步队列中取消该Node的结点,其结点的waitStatus为CANCELLED,即结束状态,进入该状态后的结点将不会再变化
SIGNAL:只要前置节点释放锁,就会通知标识为SIGNAL状态的后续节点的线程
CONDITION:和Condition有关系,后续会讲解
PROPAGATE:共享模式下,PROPAGATE状态的线程处于可运行状态
0:初始状态
这个方法的主要作用是,通过Node的状态来判断,ThreadA竞争锁失败以后是否应该被挂起。
1. 如果ThreadA的pred节点状态为SIGNAL,那就表示可以放心挂起当前线程
2. 通过循环扫描链表把CANCELLED状态的节点移除
3. 修改pred节点的状态为SIGNAL,返回false.
返回false时,也就是不需要挂起,返回true,则需要调用parkAndCheckInterrupt挂起当前线程