JUC并发工具类ReentrantLock原理分析
Lock
Lock 在 J.U.C 中是最核心的组件,绝大部分的组件都有用到了 Lock。
Lock 简介
在 Lock 接口出现之前,Java 中的应用程序对于多线程的并发安全处理只能基于synchronized 关键字来解决。但是 synchronized 在有些场景中会存在一些短板,也就是它并不适合于所有的并发场景。但是在 Java5 以后,Lock 的出现可以解决synchronized 在某些场景中的短板,它比 synchronized 更加灵活。
Lock的实现
Lock 本质上是一个接口,它定义了一个标准规范“释放锁和获得锁的抽象方法”,不同的锁有不同的实现
ReentrantLock:表示重入锁。重入锁指的是线程在获得锁之后,再次获取该锁不需要阻塞,而是直接关联一次计数器增加重入次数。
类的关系图:
用法:
void lock() // 如果锁可用就获得锁,如果锁不可用就阻塞直到锁释放。
void lockInterruptibly() // 和lock()方法相似, 但阻塞的线程 可 中 断 , java.lang.InterruptedException 异常。
boolean tryLock() // 非阻塞获取锁;尝试获取锁,如果成功返回 true
boolean tryLock(longtimeout, TimeUnit timeUnit)//带有超时时间的获取锁方法
void unlock() // 释放锁
ReentrantReadWriteLock:重入读写锁,它实现了 ReadWriteLock 接口,在这个类中维护了两个锁,一个是 ReadLock,一个是 WriteLock,他们都分别实现了 Lock接口。读写锁是一种适合读多写少的场景下解决线程安全问题的工具,基本原则是: 读和读不互斥、读和写互斥、写和写互斥。也就是说涉及到影响数据变化的操作都会存在互斥。
StampedLock: stampedLock 是 JDK8 引入的新的锁机制,可以简单认为是读写锁的一个改进版本,读写锁虽然通过分离读和写的功能使得读和读之间可以完全并发,但是读和写是有冲突的,如果大量的读线程存在,可能会引起写线程的饥饿。stampedLock 是一种乐观的读策略,使得乐观锁完全不会阻塞写线程。
ReentrantLock 重入锁
重入锁
表示支持重新进入的锁,也就是说,如果当前线程 t1 通过调用 lock 方法获取了锁之后,再次调用 lock,是不会再阻塞去获取锁的,直接增加重试次数就行了。synchronized 和 ReentrantLock 都是可重入锁。
场景
在多个加锁的方法的相互调用。
重入锁的设计目的
比如调用 demo 方法获得了当前的对象锁,然后在这个方法中再去调用demo2,demo2 中的存在同一个实例锁,这个时候当前线程会因为无法获得demo2 的对象锁而阻塞,就会产生死锁。重入锁的设计目的是避免线程的死锁。
public class ReentrantDemo{
public synchronized void demo(){
System.out.println("begin:demo");
demo2();
}
public void demo2(){
System.out.println("begin:demo1");
synchronized (this){
}
}
public static void main(String[] args) {
ReentrantDemo rd=new ReentrantDemo();
new Thread(rd::demo).start();
}
}
ReentrantLock 使用案例
public class AtomicDemo {
private static int count=0;
static Lock lock=new ReentrantLock();
public static void inc(){
lock.lock();
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
lock.unlock();
}
public static void main(String[] args) throws
InterruptedException {
for(int i=0;i<1000;i++){
new Thread(()->{AtomicDemo.inc();}).start();;
}
Thread.sleep(3000);
System.out.println("result:"+count);
}
}
ReentrantReadWriteLock
我们以前理解的锁,基本都是排他锁,也就是这些锁在同一时刻只允许一个线程进行访问,而读写所在同一时刻可以允许多个线程访问,但是在写线程访问时,所有的读线程和其他写线程都会被阻塞。读写锁维护了一对锁,一个读锁、一个写锁;一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量.
public class LockDemo {
static Map<String,Object> cacheMap=new HashMap<>();
static ReentrantReadWriteLock rwl=new ReentrantReadWriteLock();
static Lock read=rwl.readLock();
static Lock write=rwl.writeLock();
public static final Object get(String key) {
System.out.println(" 开始读取数据");
read.lock(); // 读锁
try {
return cacheMap.get(key);
}finally {
read.unlock();
}
}
public static final Object put(String key,Object value){
write.lock();
System.out.println(" 开始写数据");
try{
return cacheMap.put(key,value);
}finally {
write.unlock();
}
}
}
在这个案例中,通过 hashmap 来模拟了一个内存缓存,然后使用读写所来保证这个内存缓存的线程安全性。当执行读操作的时候,需要获取读锁,在并发访问的时候,读锁不会被阻塞,因为读操作不会影响执行结果。在执行写操作是,线程必须要获取写锁,当已经有线程持有写锁的情况下,当前线
程会被阻塞,只有当写锁释放以后,其他读写操作才能继续执行。使用读写锁提升读操作的并发性,也保证每次写操作对所有的读写操作的可见性。
⚫ 读锁与读锁可以共享
⚫ 读锁与写锁不可以共享(排他)
⚫ 写锁与写锁不可以共享(排他)
ReentrantLock 的实现原理
我们知道锁的基本原理是,基于将多线程并行任务通过某一种机制实现线程的串行执行,从而达到线程安全性的目的。在 synchronized 中,我们分析了偏向锁、轻量级锁、乐观锁。基于乐观锁以及自旋锁来优化了 synchronized 的加锁开销,同时在重量级锁阶段,通过线程的阻塞以及唤醒来达到线程竞争和同步的目的。那么在 ReentrantLock 中,也一定会存在这样的需要去解决的问题。就是在多线程竞争重入锁时,竞争失败的线程是如何实现阻塞以及被唤醒的呢?
AQS 是什么
在 Lock 中,用到了一个同步队列 AQS,全称 AbstractQueuedSynchronizer,它是一个同步工具也是 Lock 用来实现线程同步的核心组件
AQS 的两种功能
从使用层面来说,AQS 的功能分为两种:独占锁和共享锁
独占锁:每次只能有一个线程持有锁,比如前面给大家演示的 ReentrantLock 就是以独占方式实现的互斥锁。
共 享 锁 :允 许 多 个 线 程 同 时 获 取 锁 , 并 发 访 问 共 享 资 源 , 比 如ReentrantReadWriteLock
AQS 内部实现
AQS 队列内部维护的是一个 FIFO 的双向链表,这种结构的特点是每个数据结构都有两个指针,分别指向直接的后继节点和直接前驱节点。所以双向链表可以从任意一个节点开始很方便的访问前驱和后继。每个 Node 其实是由线程封装,当线程争抢锁失败后会封装成 Node 加入到 AQS 队列中去;当获取锁的线程释放锁以后,会从队列中唤醒一个阻塞的节点(线程)。AQS内部通过state来控制同步状态,当执行lock时,如果state=0时,说明没有任何线程占有共享资源的锁,此时线程会获取到锁并把state设置为1;当state=1时,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待。
释放锁以及添加线程对于队列的变化
一、当出现锁竞争以及释放锁的时候,AQS 同步队列中的节点会发生变化,首先看一下添加节点的场景
- 新的线程封装成 Node 节点追加到同步队列中,设置 prev 节点以及修改当前节点的前置节点的 next 节点指向自己
- 通过 CAS 将 tail 重新指向新的尾部节点。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cLrWtRyR-1611821110118)(C:\Users\Lenovo\AppData\Roaming\Typora\typora-user-images\image-20210128141807526.png)]
二、head 节点表示获取锁成功的节点,当头结点在释放同步状态时,会唤醒后继节点,如果后继节点获得锁成功,会把自己设置为头结点,节点的变化过程如下
ReentrantLock 的源码分析
以 ReentrantLock 作为切入点,来看看在这个场景中是如何使用 AQS 来实现线程的同步的。
ReentrantLock 的时序图
ReentrantLock#lock()
public void lock() {
sync.lock();
}
sync 实际上是一个抽象的静态内部类,它继承了 AQS 来实现重入锁的逻辑。
AQS 是一个同步队列,它能够实现线程的阻塞以及唤醒,但它并不具备业务功能,所以在不同的同步场景中,会继承 AQS 来实现对应场景的功能Sync 有两个具体的实现类,分别是:
NofairSync:表示可以存在抢占锁的功能,也就是说不管当前队列上是否存在其他
线程等待,新线程都有机会抢占锁
FailSync: 表示所有线程严格按照 FIFO 来获取锁
NofairSync #lock
- 非公平锁和公平锁最大的区别在于,在非公平锁中我抢占锁的逻辑是,不管有
没有线程排队,我先上来 cas 去抢占一下 - CAS 成功,就表示成功获得了锁
- CAS 失败,调用 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 中的一个属性,它在不同的实现中所表达的含义不一样,对于重入
锁的实现来说,表示一个同步状态。它有两个含义的表示
- 当 state=0 时,表示无锁状态
- 当 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)操作
这个方法的主要逻辑是
- 通过 tryAcquire 尝试获取独占锁,如果成功返回 true,失败返回 false
- 如果 tryAcquire 失败,则会通过 addWaiter 方法将当前线程封装成 Node 添加
到 AQS 队列尾部- acquireQueued,将 Node 作为参数,通过自旋去尝试获取锁。
AbstractQueuedSynchronizer#enq
enq 就是通过自旋操作把当前节点加入到队列中。
图解分析
假设 3 个线程来争抢锁,那么截止到 enq 方法运行结束之后,或者调用 addwaiter
方法结束后,AQS 中的链表结构图
AQS#acquireQueued
通过 addWaiter 方法把线程添加到链表后,会接着把 Node 作为参数传递给acquireQueued 方法,去竞争锁
- 获取当前节点的 prev 节点
- 如果 prev 节点为 head 节点,那么它就有资格去争抢锁,调用 tryAcquire 抢占锁
- 抢占锁成功以后,把获得锁的节点设置为 head,并且移除原来的初始化 head节点
- 如果获得锁失败,则根据 waitStatus 决定是否需要挂起线程
- 最后,通过 cancelAcquire 取消获得锁的操作通过 addWaiter 方法把线程添加到链表后,会接着把 Node 作为参数传递给acquireQueued 方法,去竞争锁