用ReentrantLock和Condition实现线程间通信
Posted xiuqiao
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了用ReentrantLock和Condition实现线程间通信相关的知识,希望对你有一定的参考价值。
1、听故事把知识掌握了
在一个村子里面,有一口井水,水质非常的好,村民们都想打井里的水。这井只有一口,村里的人那么多,所以得出个打水的规则才行。村长绞尽脑汁,最终想出了一个比较合理的方案,咱们来仔细的看看聪明的村长大人的智慧。
井边安排一个看井人,维护打水的秩序。
打水时,以家庭为单位,哪个家庭任何人先到井边,就可以先打水,而且如果一个家庭占到了打水权,其家人这时候过来打水不用排队。而那些没有抢占到打水权的人,一个一个挨着在井边排成一队,先到的排在前面。打水示意图如下 :
是不是感觉很和谐,如果打水的人打完了,他会跟看井人报告,看井人会让第二个人接着打水。这样大家总都能够打到水。是不是看起来挺公平的,先到的人先打水,当然不是绝对公平的,自己看看下面这个场景 :
看着,一个有娃的父亲正在打水,他的娃也到井边了,所以女凭父贵直接排到最前面打水,羡煞旁人了。
以上这个故事模型就是所谓的公平锁模型,当一个人想到井边打水,而现在打水的人又不是自家人,这时候就得乖乖在队列后面排队。
事情总不是那么一帆风顺的,总会有些人想走捷径,话说看井人年纪大了,有时候,眼力不是很好,这时候,人们开始打起了新主意。新来打水的人,他们看到有人排队打水的时候,他们不会那么乖巧的就排到最后面去排队,反之,他们会看看现在有没有人正在打水,如果有人在打水,没辄了,只好排到队列最后面,但如果这时候前面打水的人刚刚打完水,正在交接中,排在队头的人还没有完成交接工作,这时候,新来的人可以尝试抢打水权,如果抢到了,呵呵,其他人也只能睁一只眼闭一只眼,因为大家都默认这个规则了。这就是所谓的非公平锁模型。新来的人不一定总得乖乖排队,这也就造成了原来队列中排队的人可能要等很久很久。
java可重入锁-ReentrantLock实现细节
ReentrantLock支持两种获取锁的方式,一种是公平模型,一种是非公平模型。在继续之前,咱们先把故事元素转换为程序元素。
咱们先来说说公平锁模型:
初始化时, state=0,表示无人抢占了打水权。这时候,村民A来打水(A线程请求锁),占了打水权,把state+1,如下所示:
线程A取得了锁,把 state原子性+1,这时候state被改为1,A线程继续执行其他任务,然后来了村民B也想打水(线程B请求锁),线程B无法获取锁,生成节点进行排队,如下图所示:
初始化的时候,会生成一个空的头节点,然后才是B线程节点,这时候,如果线程A又请求锁,是否需要排队?答案当然是否定的,否则就直接死锁了。当A再次请求锁,就相当于是打水期间,同一家人也来打水了,是有特权的,这时候的状态如下图所示:
此处可能有人会问 在代码里边怎么理解这种可重入锁的形态呢?
public static ReentrantLock lock = new ReentrantLock();
public static int i = 0;
public void run()
{
for (int j = 0;j<100000;j++)
{
lock.lock();
lock.lock();
try {
i++;
}finally {
lock.unlock();
lock.unlock();
}
}
}
为什么需要使用可重入锁 在故事描述完后进行具体说明;
到了这里,相信大家应该明白了什么是可重入锁了吧。就是一个线程在获取了锁之后,再次去获取了同一个锁,这时候仅仅是把状态值进行累加。如果线程A释放了一次锁,就成这样了:
仅仅是把状态值减了,只有线程A把此锁全部释放了,状态值减到0了,其他线程才有机会获取锁。当A把锁完全释放后,state恢复为0,然后会通知队列唤醒B线程节点,使B可以再次竞争锁。当然,如果B线程后面还有C线程,C线程继续休眠,除非B执行完了,通知了C线程。注意,当一个线程节点被唤醒然后取得了锁,对应节点会从队列中删除。
非公平锁模型
如果你已经明白了前面讲的公平锁模型,那么非公平锁模型也就非常容易理解了。当线程A执行完之后,要唤醒线程B是需要时间的,而且线程B醒来后还要再次竞争锁,所以如果在切换过程当中,来了一个线程C,那么线程C是有可能获取到锁的,如果C获取到了锁,B就只能继续乖乖休眠了。这里就不再画图说明了。
其它知识点
java5中添加了一个并发包, java.util.concurrent,里面提供了各种并发的工具类,通过此工具包,可以在java当中实现功能非常强大的多线程并发操作。对于每个java攻城狮,我觉得非常有必要了解这个包的功能。虽然做不到一步到位,但慢慢虚心学习,沉下心来,总能慢慢领悟到java多线程编程的精华。
本问故事情节转载自其他博客,原文地址:https://blog.csdn.net/yanyan19880509/article/details/52345422/
2、为什么使用可重入锁?
ReentrantLock 是一个可重入的互斥(/独占)锁,又称为“独占锁”。
ReentrantLock通过自定义队列同步器(AQS-AbstractQueuedSychronized,是实现锁的关键)来实现锁的获取与释放。
其可以完全替代 synchronized 关键字。JDK 5.0 早期版本,其性能远好于 synchronized,但 JDK 6.0 开始,JDK 对 synchronized 做了大量的优化,使得两者差距并不大。
“独占”,就是在同一时刻只能有一个线程获取到锁,而其它获取锁的线程只能处于同步队列中等待,只有获取锁的线程释放了锁,后继的线程才能够获取锁。
“可重入”,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。
该锁还支持获取锁时的公平和非公平性选择。“公平”是指“不同的线程获取锁的机制是公平的”,而“不公平”是指“不同的线程获取锁的机制是非公平的”。
2.1 中断响应(lockInterruptibly)
对于 synchronized 来说,如果一个线程在等待锁,那么结果只有两种情况,获得这把锁继续执行,或者线程就保持等待。
而使用重入锁,提供了另一种可能,这就是线程可以被中断。也就是在等待锁的过程中,程序可以根据需要取消对锁的需求。
下面的例子中,产生了死锁,但得益于锁中断,最终解决了这个死锁:
public class IntLock implements Runnable{
public static ReentrantLock lock1 = new ReentrantLock();
public static ReentrantLock lock2 = new ReentrantLock();
int lock;
/**
* 控制加锁顺序,产生死锁
*/
public IntLock(int lock) {
this.lock = lock;
}
public void run() {
try {
if (lock == 1) {
lock1.lockInterruptibly(); // 如果当前线程未被 中断,则获取锁。
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
lock2.lockInterruptibly();
System.out.println(Thread.currentThread().getName()+",执行完毕!");
} else {
lock2.lockInterruptibly();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
lock1.lockInterruptibly();
System.out.println(Thread.currentThread().getName()+",执行完毕!");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 查询当前线程是否保持此锁。
if (lock1.isHeldByCurrentThread()) {
lock1.unlock();
}
if (lock2.isHeldByCurrentThread()) {
lock2.unlock();
}
System.out.println(Thread.currentThread().getName() + ",退出。");
}
}
public static void main(String[] args) throws InterruptedException {
IntLock intLock1 = new IntLock(1);
IntLock intLock2 = new IntLock(2);
Thread thread1 = new Thread(intLock1, "线程1");
Thread thread2 = new Thread(intLock2, "线程2");
thread1.start();
thread2.start();
Thread.sleep(1000);
thread2.interrupt(); // 中断线程2
}
}
上述例子中,线程 thread1 和 thread2 启动后,thread1 先占用 lock1,再占用 lock2;thread2 反之,先占 lock2,后占 lock1。这便形成 thread1 和 thread2 之间的相互等待。
代码 56 行,main 线程处于休眠(sleep)状态,两线程此时处于死锁的状态,代码 57 行 thread2 被中断(interrupt),故 thread2 会放弃对 lock1 的申请,同时释放已获得的 lock2。这个操作导致 thread1 顺利获得 lock2,从而继续执行下去。
执行代码,输出如下:
2.2锁申请等待限时(tryLock)
除了等待外部通知(中断操作 interrupt )之外,限时等待也可以做到避免死锁。
通常,无法判断为什么一个线程迟迟拿不到锁。也许是因为产生了死锁,也许是产生了饥饿。但如果给定一个等待时间,让线程自动放弃,那么对系统来说是有意义的。可以使用 tryLock() 方法进行一次限时的等待。
public class TimeLock implements Runnable{
public static ReentrantLock lock = new ReentrantLock();
public void run() {
try {
if (lock.tryLock(5, TimeUnit.SECONDS)) {
Thread.sleep(6 * 1000);
}else {
System.out.println(Thread.currentThread().getName()+" get Lock Failed");
}
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
// 查询当前线程是否保持此锁。
if (lock.isHeldByCurrentThread()) {
System.out.println(Thread.currentThread().getName()+" release lock");
lock.unlock();
}
}
}
/**
* 在本例中,由于占用锁的线程会持有锁长达6秒,故另一个线程无法再5秒的等待时间内获得锁,因此请求锁会失败。
*/
public static void main(String[] args) {
TimeLock timeLock = new TimeLock();
Thread t1 = new Thread(timeLock, "线程1");
Thread t2 = new Thread(timeLock, "线程2");
t1.start();
t2.start();
}
}
上述例子中,由于占用锁的线程会持有锁长达 6 秒,故另一个线程无法在 5 秒的等待时间内获得锁,因此,请求锁失败。
ReentrantLock.tryLock()方法也可以不带参数直接运行。这种情况下,当前线程会尝试获得锁,如果锁并未被其他线程占用,则申请锁成功,立即返回 true。否则,申请失败,立即返回 false,当前线程不会进行等待。这种模式不会引起线程等待,因此也不会产生死锁。
2.3 公平锁
·默认情况下,锁的申请都是非公平的。也就是说,如果线程 1 与线程 2,都申请获得锁 A,那么谁获得锁不是一定的,是由系统在等待队列中随机挑选的。这就好比,买票的人不排队,售票姐姐只能随机挑一个人卖给他,这显然是不公平的。而公平锁,它会按照时间的先后顺序,保证先到先得。公平锁的特点是:不会产生饥饿现象。
重入锁允许对其公平性进行设置。构造函数如下:
public ReentrantLock(boolean fair)
public class FairLock implements Runnable{
public static ReentrantLock fairLock = new ReentrantLock(true);
public void run() {
while (true) {
try {
fairLock.lock();
System.out.println(Thread.currentThread().getName()+",获得锁!");
}finally {
fairLock.unlock();
}
}
}
public static void main(String[] args) {
FairLock fairLock = new FairLock();
Thread t1 = new Thread(fairLock, "线程1");
Thread t2 = new Thread(fairLock, "线程2");
t1.start();t2.start();
}
}
测试结果:
1.当参数设置为 true 时:线程1 和 线程2 交替进行 公平竞争 交替打印
线程1,获得锁!
线程2,获得锁!
线程1,获得锁!
线程2,获得锁!
线程1,获得锁!
线程2,获得锁!
线程1,获得锁!
线程2,获得锁!
线程1,获得锁!
线程2,获得锁!
线程1,获得锁!
线程2,获得锁!
线程1,获得锁!
线程2,获得锁!
线程1,获得锁!
2.当参数设置为 false 时: 此时可以看到线程1 可以持续拿到锁 等线程1 执行完后 线程2 才可以拿到线程 然后多次执行 ; 这就是使用 可重入锁后 是非公平机制 线程可以优先多次拿到执行权
线程1,获得锁!
线程1,获得锁!
线程1,获得锁!
线程1,获得锁!
线程1,获得锁!
线程1,获得锁!
线程1,获得锁!
线程1,获得锁!
线程1,获得锁!
线程1,获得锁!
线程1,获得锁!
线程1,获得锁!
线程2,获得锁!
线程2,获得锁!
线程2,获得锁!
线程2,获得锁!
修改重入锁是否公平,观察输出结果,如果公平,输出结果始终为两个线程交替的获得锁,如果是非公平,输出结果为一个线程占用锁很长时间,然后才会释放锁,另个线程才能执行。
引出第二个问题:为什么公平锁例子中出现,公平锁线程是不断切换的,而非公平锁出现同一线程连续获取锁的情况?
何为重进入(重入)?
重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁阻塞,该特性的实现需要解决以下两个问题:
- 线程再次获取锁:锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
- 锁的最终释放。线程重复 n 次获取了锁,随后在第 n 次释放该锁后,其它线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于 0 时表示锁已经成功释放。
首先ReentrantLock和NonReentrantLock都继承父类AQS,其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。
当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status == 0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。
释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。
要点:
- 可重入锁的一个优点是可一定程度避免死锁
- AQS通过控制status状态来判断锁的状态,对于非可重入锁状态不是0则去阻塞;对于可重入锁如果是0则执行,非0则判断当前线程是否是获取到这个锁的线程,是的话把status状态+1,释放的时候,只有status为0,才将锁释放。
以非公平锁源码分析:
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
acquireQueued 方法增加了再次获取同步状态的处理逻辑:通过判断当前线程是否为获取锁的线程,来决定获取操作是否成功,如果获取锁的线程再次请求,则将同步状态值进行增加并返回 true,表示获取同步状态成功。
成功获取锁的线程再次获取锁,只是增加了同步状态值,也就是要求 ReentrantLock 在释放同步状态时减少同步状态值,释放锁源码如下:
public void unlock() {
sync.release(1);
}
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
如果锁被获取 n 次,那么前 (n-1) 次 tryRelease(int releases) 方法必须返回 false,只有同步状态完全释放了,才能返回 true。该方法将同步状态是否为 0 作为最终释放的条件,当同步状态为 0 时,将占有线程设置为 null,并返回 true,表示释放成功。
通过对获取与释放的分析,就可以解释,以上两个例子中出现的两个问题:为什么 ReentrantLock 锁能够支持一个线程对资源的重复加锁?为什么公平锁例子中出现,公平锁线程是不断切换的,而非公平锁出现同一线程连续获取锁的情况?
为什么支持重复加锁?因为源码中用变量 c 来保存当前锁被获取了多少次,故在释放时,对 c 变量进行减操作,只有 c 变量为 0 时,才算锁的最终释放。所以可以 lock 多次,同时 unlock 也必须与 lock 同样的次数。
为什么非公平锁出现同一线程连续获取锁的情况?tryAcquire 方法中增加了再次获取同步状态的处理逻辑;
小结
对上面ReentrantLock的几个重要方法整理如下:
- lock():获得锁,如果锁被占用,进入等待。
- lockInterruptibly():获得锁,但优先响应中断。
- tryLock():尝试获得锁,如果成功,立即放回 true,反之失败返回 false。该方法不会进行等待,立即返回。
- tryLock(long time, TimeUnit unit):在给定的时间内尝试获得锁。
- unLock():释放锁。
对于其实现原理,下篇博文将详细分析,其主要包含三个要素:
- 原子状态:原子状态有 CAS(compareAndSetState) 操作来存储当前锁的状态,判断锁是否有其他线程持有。
- 等待队列:所有没有请求到锁的线程,会进入等待队列进行等待。待有线程释放锁后,系统才能够从等待队列中唤醒一个线程,继续工作。详见:队列同步器——AQS(待更新)
- 阻塞原语 park() 和 unpark(),用来挂起和恢复线程。没有得到锁的线程将会被挂起。关于阻塞原语,详见:线程阻塞工具类——LockSupport(待更新)。
以上是关于用ReentrantLock和Condition实现线程间通信的主要内容,如果未能解决你的问题,请参考以下文章
用ReentrantLock和Condition实现线程间通信
Java多线程之ReentrantLock与Condition