1、什么是CAS
CAS
的全称是Compare-And-Swap,意为:比较并替换,它是CPU并发原语
它的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的
CAS
并发原语体现在Java语言中就是sun.misc.Unsafe
类的各个方法。调用UnSafe类中的CAS方法,JVM会帮我们实现出CAS汇编指令,这是一种完全依赖于硬件的功能,通过它实现了原子操作,再次强调,由于CAS是一种系统原语,原语属于操作系统用于范畴,是由若干条指令组成,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致的问题,也就是说CAS是线程安全的。
代码使用
首先调用AtomicInteger创建了一个实例, 并初始化为5
// 创建一个原子类
AtomicInteger atomicInteger = new AtomicInteger(5);
然后调用CAS方法,企图更新成2019,这里有两个参数,一个是5,表示期望值,第二个就是我们要更新的值
atomicInteger.compareAndSet(5, 2019)
然后再次使用了一个方法,同样将值改成1024
atomicInteger.compareAndSet(5, 1024)
完整代码如下:
/**
* CASDemo
*
* 比较并交换:compareAndSet
*
* @author: 陌溪
* @create: 2020-03-10-19:46
*/
public class CASDemo {
public static void main(String[] args) {
// 创建一个原子类
AtomicInteger atomicInteger = new AtomicInteger(5);
/**
* 一个是期望值,一个是更新值,但期望值和原来的值相同时,才能够更改
* 假设三秒前,我拿的是5,也就是expect为5,然后我需要更新成 2019
*/
System.out.println(atomicInteger.compareAndSet(5, 2019) + "\t current data: " + atomicInteger.get());
System.out.println(atomicInteger.compareAndSet(5, 1024) + "\t current data: " + atomicInteger.get());
}
}
上面代码的执行结果为
这是因为我们执行第一个的时候,期望值和原本值是满足的,因此修改成功,但是第二次后,主内存的值已经修改成了2019,不满足期望值,因此返回了false,本次写入失败
这个就类似于SVN或者Git的版本号,如果没有人更改过,就能够正常提交,否者需要先将代码pull下来,合并代码后,然后提交
CAS底层原理
首先我们先看看 atomicInteger.getAndIncrement()
方法的源码
从这里能够看到,底层又调用了一个unsafe类的getAndAddInt
方法。变量valueOffset
表示该变量值在内存中的偏移地址,因为Unsafe就是根据内存偏移地址获取数据的。从这里我们能够看到,通过valueOffset
,直接通过内存地址,获取到值,然后进行加1的操作
Unsafe是CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地(Native)方法来访问,Unsafe相当于一个后门,基于该类可以直接操作特定的内存数据。Unsafe类存在sun.misc
包中,其内部方法操作可以像C的指针一样直接操作内存,因为Java中的CAS操作的执行依赖于Unsafe类的方法。
注意Unsafe类的所有方法都是native修饰的,也就是说unsafe类中的方法都直接调用操作系统底层资源执行相应的任务
为什么Atomic修饰的包装类,能够保证原子性,依靠的就是底层的unsafe类
var5:就是我们从主内存中拷贝到工作内存中的值(每次都要从主内存拿到最新的值到自己的本地内存,然后执行compareAndSwapInt()
再和主内存的值进行比较。因为线程不可以直接越过高速缓存,直接操作主内存,所以执行上述方法需要比较一次,在执行加1操作)
那么操作的时候,需要比较工作内存中的值,和主内存中的值进行比较
假设执行 compareAndSwapInt返回false,那么就一直执行 while方法,直到期望的值和真实值一样(自旋锁思想)
- val1:AtomicInteger对象本身
- var2:该对象值得引用地址
- var4:需要变动的数量
- var5:用var1和var2找到的内存中的真实值
- 用该对象当前的值与var5比较
- 如果相同,更新var5 + var4 并返回true
- 如果不同,继续取值然后再比较,直到更新完成
这里没有用synchronized,而用CAS,这样提高了并发性,也能够实现一致性,是因为每个线程进来后,进入的do while循环,然后不断的获取内存中的值,判断是否为最新,然后在进行更新操作。
假设线程A和线程B同时执行getAndInt操作(分别跑在不同的CPU上)
- AtomicInteger里面的value原始值为3,即主内存中AtomicInteger的 value 为3,根据JMM模型,线程A和线程B各自持有一份价值为3的副本,分别存储在各自的工作内存
- 线程A通过getIntVolatile(var1 , var2) 拿到value值3,这是线程A被挂起(该线程失去CPU执行权)
- 线程B也通过getIntVolatile(var1, var2)方法获取到value值也是3,此时刚好线程B没有被挂起,并执行了compareAndSwapInt方法,比较内存的值也是3,成功修改内存值为4,线程B打完收工,一切OK
- 这时线程A恢复,执行CAS方法,比较发现自己手里的数字3和主内存中的数字4不一致,说明该值已经被其它线程抢先一步修改过了,那么A线程本次修改失败,只能够重新读取后在来一遍了,也就是在执行do while
- 线程A重新获取value值,因为变量value被volatile修饰,所以其它线程对它的修改,线程A总能够看到,线程A继续执行compareAndSwapInt进行比较替换,直到成功。
Unsafe类 + CAS思想: 也就是自旋,自我旋转
CAS缺点
CAS不加锁,保证一次性,但是需要多次比较
- 循环时间长,开销大(因为执行的是do while,如果比较不成功一直在循环,最差的情况,就是某个线程一直取到的值和预期值都不一样,这样就会无限循环)
- 只能保证一个共享变量的原子操作
- 当对一个共享变量执行操作时,我们可以通过循环CAS的方式来保证原子操作
- 但是对于多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候只能用锁来保证原子性
- 引出来ABA问题?
2、ABA问题
CAS的好处就是保证数据一致性的同时,还保证了并发性,CPU指令原语的原子性是指再修改的时候本线程不受其他线程干扰但其他线程也可随时修改主内存的变量值,由此便产生了ABA问题
上述图片解释:
- 线程A和线程B同时获取到初始bia变量100
- A线程被挂起没有往下执行,B线程将变量改为101,并写回主内存
- 线程C获取线程B更改后的数据,将数据改回100
- 此时线程A开始执行,进行修改操作,期望值和主存内的值相同将会修改成功
👀 在A被挂起的这段时间内,主存中变量值发生的变化,对线程A是无感知的,也就是它不知道主存中的值被修改过了。
怎样解决?
使用AtomicStampedReference
加版本号解决
有ABA问题的代码举例
@Test
public void ABADemo(){
AtomicReference<Integer> atomicReference = new AtomicReference<>(100);
new Thread(() -> {
atomicReference.compareAndSet(100, 101);
atomicReference.compareAndSet(101, 100);
}, "t1").start();
new Thread(() -> {
try {
Thread.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean compareAndSet = atomicReference.compareAndSet(100, 2022);
System.out.println(compareAndSet + " " + atomicReference.get());
}, "t2").start();
}
/*
true 2022
*/
解决ABA问题的代码
@Test
public void UnABADemo(){
AtomicStampedReference<Integer> stampedReference = new AtomicStampedReference<Integer>(100, 1);
new Thread(() -> {
System.out.println("初始版本号" + stampedReference.getStamp());
stampedReference.compareAndSet(100, 101, stampedReference.getStamp(), stampedReference.getStamp() + 1);
stampedReference.compareAndSet(101, 100, stampedReference.getStamp(), stampedReference.getStamp() + 1);
}, "t1").start();
new Thread(() -> {
int stamp = stampedReference.getStamp();
System.out.println("线程二获取版本号" + stamp);
try {
Thread.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean b = stampedReference.compareAndSet(100, 2022, stamp, stamp + 1);
System.out.println("是否更改成功"+b+" "+"当前数值"+stampedReference.getReference()+"当前事务号 "+stampedReference.getStamp());
}, "T2").start();
}
/*
初始版本号1
线程二获取版本号1
是否更改成功false 当前数值100当前事务号 3
*/
3、公平锁与非公平锁
公平锁
是指多个线程按照锁的申请顺序来获取锁,类似于排队买饭,先到先得先来先服务,不允许加塞,也就是队列
非公平锁
多个线程获取锁的顺序,并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,在高并发的环境下,有可能造成优先级翻转,或饥饿的线程(也就是某一个线程一直得不到锁)
如何创建一个公平锁
/**
* 创建一个可重入锁,true 表示公平锁,false 表示非公平锁。默认非公平锁
*/
Lock lock = new ReentrantLock(true);
并发包中ReentrantLock
的创建可以指定析构函数的boolean类型来得到公平锁或者非公平锁,默认是非公平锁,synchronized
为不公平锁
公平锁与非公平锁的区别
公平锁:就是很公平,在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列中的第一个,就占用锁,否则就会加入到等待队列中,以后安装FIFO的规则从队列中取到自己
非公平锁: 非公平锁比较粗鲁,上来就直接尝试占有锁,如果尝试失败,就再采用类似公平锁那种方式。
题外话
Java ReenttrantLock通过构造函数指定该锁是否公平,默认是非公平锁,因为非公平锁的优点在于吞吐量比公平锁大,对于synchronized而言,也是一种非公平锁
4、可重入锁和递归锁ReentrantLock
1、概念
可重入锁就是递归锁
指的是同一线程外层函数获得锁之后,内层递归函数仍然能获取到该锁的代码,在同一线程在外层方法获取锁的时候,在进入内层方法会自动获取锁
也就是说:线程可以进入任何一个它已经拥有的锁所同步的代码块
ReentrantLock / Synchronized 就是一个典型的可重入锁
2、代码
可重入锁就是,在一个method1方法中加入一把锁,方法2也加锁了,那么他们拥有的是同一把锁
public synchronized void method1() {
method2();
}
public synchronized void method2() {
}
也就是说我们只需要进入method1后,那么它也能直接进入method2方法,因为他们所拥有的锁,是同一把。
3、作用
可重入锁的最大作用就是避免死锁
4、可重入锁验证
证明Synchronized
/**
* 可重入锁(也叫递归锁)
* 指的是同一线程外层函数获得锁之后,内层递归函数仍然能获取到该锁的代码,在同一线程在外层方法获取锁的时候,在进入内层方法会自动获取锁
* 也就是说:`线程可以进入任何一个它已经拥有的锁所同步的代码块`
*/
/**
* 资源类
*/
class Phone {
/**
* 发送短信
* @throws Exception
*/
public synchronized void sendSMS() throws Exception{
System.out.println(Thread.currentThread().getName() + "\t invoked sendSMS()");
// 在同步方法中,调用另外一个同步方法
sendEmail();
}
/**
* 发邮件
* @throws Exception
*/
public synchronized void sendEmail() throws Exception{
System.out.println(Thread.currentThread().getId() + "\t invoked sendEmail()");
}
}
public class ReenterLockDemo {
public static void main(String[] args) {
Phone phone = new Phone();
// 两个线程操作资源列
new Thread(() -> {
try {
phone.sendSMS();
} catch (Exception e) {
e.printStackTrace();
}
}, "t1").start();
new Thread(() -> {
try {
phone.sendSMS();
} catch (Exception e) {
e.printStackTrace();
}
}, "t2").start();
}
}
在这里,我们编写了一个资源类phone,拥有两个加了synchronized的同步方法,分别是sendSMS 和 sendEmail,我们在sendSMS方法中,调用sendEmail。最后在主线程同时开启了两个线程进行测试,最后得到的结果为:
t1 invoked sendSMS() t1线程在外层方法获取锁的时候
t1 invoked sendEmail() t1在进入内层方法会自动获取锁
t2 invoked sendSMS() t2线程在外层方法获取锁的时候
t2 invoked sendEmail() t2在进入内层方法会自动获取锁
这就说明当 t1 线程进入sendSMS的时候,拥有了一把锁,同时t2线程无法进入,直到t1线程拿着锁,执行了sendEmail 方法后,才释放锁,这样t2才能够进入
证明ReentrantLock
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* 资源类
*/
class Phone implements Runnable{
Lock lock = new ReentrantLock();
/**
* set进去的时候,就加锁,调用set方法的时候,能否访问另外一个加锁的set方法
*/
public void getLock() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + "\t get Lock");
setLock();
} finally {
lock.unlock();
}
}
public void setLock() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + "\t set Lock");
} finally {
lock.unlock();
}
}
@Override
public void run() {
getLock();
}
}
public class ReenterLockDemo {
public static void main(String[] args) {
Phone phone = new Phone();
/**
* 因为Phone实现了Runnable接口
*/
Thread t3 = new Thread(phone, "t3");
Thread t4 = new Thread(phone, "t4");
t3.start();
t4.start();
}
}
现在我们使用ReentrantLock进行验证,首先资源类实现了Runnable接口,重写Run方法,里面调用get方法,get方法在进入的时候,就加了锁
public void getLock() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + "\t get Lock");
setLock();
} finally {
lock.unlock();
}
}
然后在方法里面,又调用另外一个加了锁的setLock方法
public void setLock() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + "\t set Lock");
} finally {
lock.unlock();
}
}
最后输出结果我们能发现,结果和加synchronized方法是一致的,都是在外层的方法获取锁之后,线程能够直接进入里层
t3 get Lock
t3 set Lock
t4 get Lock
t4 set Lock
当我们在getLock方法加两把锁会是什么情况呢? (阿里面试)
public void getLock() {
lock.lock();
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + "\t get Lock");
setLock();
} finally {
lock.unlock();
lock.unlock();
}
}
最后得到的结果也是一样的,因为里面不管有几把锁,其它他们都是同一把锁,也就是说用同一个钥匙都能够打开
当我们在getLock方法加两把锁,但是只解一把锁会出现什么情况呢?
public void getLock() {
lock.lock();
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + "\t get Lock");
setLock();
} finally {
// lock.unlock();
lock.unlock();
}
}
得到结果
t3 get Lock
t3 set Lock
也就是说程序直接卡死,线程不能出来,也就说明我们申请几把锁,最后需要解除几把锁
当我们只加一把锁,但是用两把锁来解锁的时候,又会出现什么情况呢?
public void getLock() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + "\t get Lock");
setLock();
} finally {
lock.unlock();
lock.unlock();
}
}
这个时候,运行程序会直接报错
t3 get Lock
t3 set Lock
t4 get Lock
t4 set Lock
Exception in thread "t3" Exception in thread "t4" java.lang.IllegalMonitorStateException
at java.util.concurrent.locks.ReentrantLock$Sync.tryRelease(ReentrantLock.java:151)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.release(AbstractQueuedSynchronizer.java:1261)
at java.util.concurrent.locks.ReentrantLock.unlock(ReentrantLock.java:457)
at com.moxi.interview.study.thread.Phone.getLock(ReenterLockDemo.java:52)
at com.moxi.interview.study.thread.Phone.run(ReenterLockDemo.java:67)
at java.lang.Thread.run(Thread.java:745)
java.lang.IllegalMonitorStateException
at java.util.concurrent.locks.ReentrantLock$Sync.tryRelease(ReentrantLock.java:151)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.release(AbstractQueuedSynchronizer.java:1261)
at java.util.concurrent.locks.ReentrantLock.unlock(ReentrantLock.java:457)
at com.moxi.interview.study.thread.Phone.getLock(ReenterLockDemo.java:52)
at com.moxi.interview.study.thread.Phone.run(ReenterLockDemo.java:67)
at java.lang.Thread.run(Thread.java:745)
5、自旋锁
5.1、概括
自旋锁:spinlock,是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU
原来提到的比较并交换,底层使用的就是自旋,自旋就是多次尝试,多次访问,不会阻塞的状态就是自旋。
5.2、优缺点
优点:循环比较获取锁,直到成功为止,没有阻塞过程
缺点:当不断的自旋线程变多时,会消耗的CPU资源变多,还可能产生线程饥饿问题
5.3、手写一个自旋锁
public class ziXuan {
AtomicReference<Thread> atomicReference = new AtomicReference<Thread>();
/**
* 加锁
*/
public void myLock(){
Thread thread = Thread.currentThread();
System.out.println(Thread.currentThread().getName() + "\t come in ");
while (!atomicReference.compareAndSet(null, thread)) {
}
}
/**
* 解锁
*/
public void myUnLock(){
// 获取当前进来的线程
Thread thread = Thread.currentThread();
// 自己用完了后,把atomicReference变成null
atomicReference.compareAndSet(thread, null);
System.out.println(Thread.currentThread().getName() + "\t invoked myUnlock()");
}
public static void main(String[] args) {
ziXuan ziXuan = new ziXuan();
new Thread(() -> {
// 占有锁
ziXuan.myLock();
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
ziXuan.myUnLock();
}, "AA").start();
// 睡一秒,保证AA先执行
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(() -> {
// 开始占有锁
ziXuan.myLock();
// 开始释放锁
ziXuan.myUnLock();
}, "BB").start();
}
}
输出结果
AA come in
BB come in
AA invoked myUnlock()
BB invoked myUnlock()
首先输出的是 t1 come in
然后1秒后,tt线程启动,发现锁被t1占有,所有不断的执行 compareAndSet方法,来进行比较,直到t1释放锁后,也就是5秒后,t2成功获取到锁,然后释放
6、 独占锁(写锁) / 共享锁(读锁) / 互斥锁
6.1、概念
独占锁:指该锁一次只能被一个线程所拥有,对ReentrantLock
和Synchronized
而言都是独占锁
共享锁:指该锁可以被多个线程锁持有
对ReentrantReadWriteLock
其读锁是共享,其写锁是独占
写的时候只能一个人写,但是读的时候,可以多个人同时读
6.2、为什么会有写锁和读锁
原来我们使用ReentrantLock创建锁的时候,是独占锁,也就是说一次只能一个线程访问,但是有一个读写分离场景,读的时候想同时进行,因此原来独占锁的并发性就没这么好了,因为读锁并不会造成数据不一致的问题,因此可以多个人共享读
多个线程 同时读一个资源类没有任何问题,所以为了满足并发量,读取共享资源应该可以同时进行,但是如果一个线程想去写共享资源,就不应该再有其它线程可以对该资源进行读或写
6.3、代码实现
实现一个读写缓存的操作,假设开始没有加锁的时候,会出现什么情况
class CaChe {
private volatile Map<String, Object> map = new HashMap<>();
// 添加数据
public void put(String key, Object value) {
System.out.println(Thread.currentThread().getName() + "\t 正在写入:" + key);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
map.put(key, value);
System.out.println(Thread.currentThread().getName() + "\t 写入完成");
}
// 获取数据
public void get(String key) {
System.out.println(Thread.currentThread().getName() + "\t 正在读取:");
try {
// 模拟网络拥堵,延迟0.3秒
TimeUnit.MILLISECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
Object value = map.get(key);
System.out.println(Thread.currentThread().getName() + "\t 读取完成:" + value);
}
}
运行
public static void main(String[] args) {
CaChe caChe = new CaChe();
// 创建五个线程,用于存数据
for (int i = 1; i <= 5; i++) {
int finalI = i;
new Thread(() -> {
caChe.put(finalI + "", finalI);
}, String.valueOf(i)).start();
}
// 取数据
for (int i = 1; i <= 5; i++) {
int finalI = i;
new Thread(() -> {
caChe.get(finalI + "");
}, String.valueOf(i)).start();
}
}
结果:我们可以看到,在写入的时候,写操作都没其它线程打断了,这就造成了,还没写完,其它线程又开始写,这样就造成数据不一致
1 正在写入:1
5 正在写入:5
4 正在写入:4
3 正在写入:3
2 正在写入:2
1 正在读取:
2 正在读取:
3 正在读取:
4 正在读取:
5 正在读取:
2 读取完成:null
4 读取完成:null
1 读取完成:null
3 读取完成:null
5 读取完成:null
3 写入完成
1 写入完成
2 写入完成
5 写入完成
4 写入完成
解决办法
上面的代码是没有加锁的,这样就会造成线程在进行写入操作的时候,被其它线程频繁打断,从而不具备原子性,这个时候,我们就需要用到读写锁来解决了
/**
* 创建一个读写锁
* 它是一个读写融为一体的锁,在使用的时候,需要转换
*/
private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
当我们在进行写操作的时候,就需要转换成写锁
// 创建一个写锁
rwLock.writeLock().lock();
// 写锁 释放
rwLock.writeLock().unlock();
当们在进行读操作的时候,在转换成读锁
// 创建一个读锁
rwLock.readLock().lock();
// 读锁 释放
rwLock.readLock().unlock();
这里的读锁和写锁的区别在于,写锁一次只能一个线程进入,执行写操作,而读锁是多个线程能够同时进入,进行读取的操作
完整代码
class CaChe {
private volatile Map<String, Object> map = new HashMap<>();
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
public void put(String key, Object value) {
// 上锁
lock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName() + "\t 正在写入:" + key);
TimeUnit.SECONDS.sleep(1);
map.put(key, value);
System.out.println(Thread.currentThread().getName() + "\t 写入完成");
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
lock.writeLock().unlock();
}
}
public void get(String key) {
// 加读锁
lock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName() + "\t 正在读取:");
// 模拟网络拥堵,延迟0.3秒
TimeUnit.MILLISECONDS.sleep(300);
Object value = map.get(key);
System.out.println(Thread.currentThread().getName() + "\t 读取完成:" + value);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.readLock().unlock();
}
}
}
运行main方法同上
1 正在写入:1
1 写入完成
3 正在写入:3
3 写入完成
2 正在写入:2
2 写入完成
4 正在写入:4
4 写入完成
5 正在写入:5
5 写入完成
1 正在读取:
5 正在读取:
3 正在读取:
4 正在读取:
2 正在读取:
2 读取完成:2
3 读取完成:3
5 读取完成:5
4 读取完成:4
1 读取完成:1
从运行结果我们可以看出,写入操作是一个一个线程进行执行的,并且中间不会被打断,而读操作的时候,是同时5个线程进入,然后并发读取操作
7、Synchronized和Lock的区别
synchronized
是关键词属于JVM层面,Lock
是一个类synchronized
不需要手动去释放锁,执行完后系统会自动让线程释放对锁的占用。ReentrantLock
则需要用户手动去释放锁,若没有手动去释放锁,就可能出现死锁的情况synchronized
不可中断,除非抛出异常或正常运行退出。ReentrantLock
可中断(1、设置超时时间tryLock(Long timeout,TimeUnit unit)
2、LockInterruptibly()
放代码块中,调用interrupt()方法可中断)synchronized
是非公平锁,ReentrantLock
可以设置公平锁和不公平锁synchronized
不可以绑定Condition,ReentrantLock
可以绑定多个条件Condition,可以用来实现分组唤醒特定的线程,可精确唤醒,而不是像synchronized
随机唤醒一个线程或唤醒全部线程。
参考