6 ReentrantLock/Condition(AQS底层原理)
J.U.C 简介
Java.util.concurrent 是在并发编程中比较常用的工具类,里面包含很多用来在并发场景中使用的组件。比如线程池、阻塞队列、计时器、同步器、并发集合等等。
Lock 简介
在 Lock 接口出现之前,Java 中的应用程序对于多线程的并发安全处理只能基于synchronized 关键字来解决。但是 synchronized 在有些场景中会存在一些短板,也就是它并不适合于所有的并发场景。但是在 Java5 以后,Lock 的出现可以解决synchronized 在某些场景中的短板,它比 synchronized 更加灵活。
Lock 的实现
Lock 本质上是一个接口,它定义了释放锁和获得锁的抽象方法,定义成接口就意味着它定义了锁的一个标准规范,也同时意味着锁的不同实现。实现 Lock 接口的类有很多,以下为几个常见的锁实现
ReentrantLock:表示重入锁,它是唯一一个实现了 Lock 接口的类。重入锁指的是线程在获得锁之后,再次获取该锁不需要阻塞,而是直接关联一次计数器增加重入次数
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(){ //main获得对象锁
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(); //调用demo()方法,调用demo2()方法,demo2()中synchronized修饰的的this对象和demo()的对象为同一对象
}
}
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 加入到 ASQ 队列中去;当获取锁的线程释放锁以
后,会从队列中唤醒一个阻塞的节点(线程)。

释放锁以及添加线程对于队列的变化
当出现锁竞争以及释放锁的时候,AQS 同步队列中的节点会发生变化,首先看一下添加节点的场景。

里会涉及到两个变化
1. 新的线程封装成 Node 节点追加到同步队列中,设置 prev 节点以及修改当前节点的前置节点的 next 节点指向自己
2. 通过 CAS 讲 tail 重新指向新的尾部节点head 节点表示获取锁成功的节点,当头结点在释放同步状态时,会唤醒后继节点,如果后继节点获得锁成功,会把自己设置为头结点,节点的变化过程如下

这个过程也是涉及到两个变化
1. 修改 head 节点指向下一个获得锁的节点
2. 新的获得锁的节点,将 prev 的指针指向 null
设置 head 节点不需要用 CAS,原因是设置 head 节点是由获得锁的线程来完成的,而同步锁只能由一个线程获得,所以不需要 CAS 保证,只需要把 head 节点设置为原首节点的后继节点,并且断开原 head 节点的 next 引用即可。
ReentrantLock 的源码分析
以 ReentrantLock 作为切入点,来看看在这个场景中是如何使用 AQS 来实现线程的同步的
ReentrantLock 的时序图
调用 ReentrantLock 中的 lock()方法,源码的调用过程我使用了时序图来展现。
ReentrantLock.lock()
这个是 reentrantLock 获取锁的入口
public void lock() { sync.lock(); }
sync 实际上是一个抽象的静态内部类,它继承了 AQS 来实现重入锁的逻辑,我们前面说过 AQS 是一个同步队列,它能够实现线程的阻塞以及唤醒,但它并不具备业务功能,所以在不同的同步场景中,会继承 AQS 来实现对应场景的功能
Sync 有两个具体的实现类,分别是:
NofairSync:表示可以存在抢占锁的功能,也就是说不管当前队列上是否存在其他线程等待,新线程都有机会抢占锁
FailSync: 表示所有线程严格按照 FIFO 来获取锁
其余源码省略。。。。。。
Condition
在前面学习 synchronized 的时候,有讲到 wait/notify 的基本使用,结合 synchronized 可以实现对线程的通信。那么这个时候我就在思考了,既然 J.U.C 里面提供了锁的实现机制,那 J.U.C 里面有没有提供类似的线程通信的工具呢?于是找阿找,发现了一个 Condition 工具类。
Condition 是一个多线程协调通信的工具类,可以让某些线程一起等待某个条件(condition),只有满足条件时,线程才会被唤醒
Condition 的基本使用
public class ConditionWait implements Runnable{ private Lock lock; private Condition condition; public ConditionWait(Lock lock, Condition condition) { this.lock = lock; this.condition = condition; } @Override public void run() { try { lock.lock(); //竞争锁 try { System.out.println("begin - ConditionWait"); condition.await();//阻塞(1. 释放锁, 2.阻塞当前线程, FIFO(单向、双向)) System.out.println("end - ConditionWait");
} catch (InterruptedException e) { e.printStackTrace(); } }finally { lock.unlock();//释放锁 } } }
public class ConditionNotify implements Runnable{ private Lock lock; private Condition condition; public ConditionNotify(Lock lock, Condition condition) { this.lock = lock; this.condition = condition; } @Override public void run() { try{ lock.lock();//获得了锁. System.out.println("begin - conditionNotify"); condition.signal();//唤醒阻塞状态的线程
System.out.println("end - conditionNotify"); } finally { lock.unlock(); //释放锁 } } }
public class App { public static void main( String[] args ) { Lock lock=new ReentrantLock(); //重入锁 Condition condition=lock.newCondition(); //step 2 new Thread(new ConditionWait(lock,condition)).start();// 阻塞await //step 1 //(condtion为空) new Thread(new ConditionNotify(lock,condition)).start();// } }
运行结果:
begin - ConditionWait
begin - conditionNotify
end - conditionNotify
end - ConditionWait
通过这个案例简单实现了 wait 和 notify 的功能,当调用await 方法后,当前线程会释放锁并等待,而其他线程调用condition 对象的 signal 或者 signalall 方法通知并被阻塞的线程,然后自己执行 unlock 释放锁,被唤醒的线程获得之前的锁继续执行,最后释放锁。
所以,condition 中两个最重要的方法, await:把当前线程阻塞挂起 signal:唤醒阻塞的线程
await 和 signal 的总结
我把前面的整个分解的图再通过一张整体的结构图来表述,线程 awaitThread 先通过 lock.lock()方法获取锁成功后调用了 condition.await 方法进入等待队列,而另一个线程 signalThread 通过 lock.lock()方法获取锁成功后调用了 condition.signal 或者 signalAll 方法,使得线程awaitThread 能够有机会移入到同步队列中,当其他线程释放 lock 后使得线程 awaitThread 能够有机会获取lock,从而使得线程 awaitThread 能够从 await 方法中退出执行后续操作。如果 awaitThread 获取 lock 失败会直接进入到同步队列。
阻塞:await()方法中,在线程释放锁资源之后,如果节点不在 AQS 等待队列,则阻塞当前线程,如果在等待队列,则自旋等待尝试获取锁
释放:signal()后,节点会从 condition 队列移动到 AQS等待队列,则进入正常锁的获取流程

浙公网安备 33010602011771号