本章主要包括以下几部分内容:
1、Lock+Condition与synchronized相同与差异。
2、Lock的实现原理。
3、Lock+Condition的使用。
4、Lock+Condition的实现原理。
Lock +Conditon 与synchronized对比
我们经常会将Lock与synchronized作比较,不过大多数人都是针对性能方面的对比,但其实Lock和synchronized的区别远不止性能上的区别,如果你的了解也仅限于此,那么这篇文章对你来说有很有意义。
相同的设计模型
不管是Lock 还是synchronized都是基于管程模型设计的,所以它们的实现互斥、同步的思路都一样,管程是解决并发编程问题的一个通用模型,如果你理解了管程模型那么其实你对lock和synchronized已经有了一个全局的认识了,如果还不了解的话可以通过“管程模型”篇进行了解。
等待队列数量的不同
synchronized 只有一个队列,不管是因为竞争不到锁,还是因为某个条件没达到而阻塞,它们阻塞后都是放到一个等待队列里。
但是lock与Condition结合,可以创建多个条件队列,因为不同的条件不满足而阻塞的线程都可以放到不同的队列里, 这样就可以做到按需排队,按需通知。
我们以去医院看病排队的场景来理解synchronized与Lock在队列上的不同有什么影响:
去医院看病的时候通常要经过挂号、检查、就医这么几个流程,每个流程都有一个对应的负责人。如果是使用synchronized,那么我们挂号、检查、就医的人员是全部排在一个队伍里面的,这样的话就会造成一个情况, 就是当我们就医的负责人叫到队伍下一个时,这个人很有可能还没有挂完号或者没做完检查,所以导致经常叫到队伍里的人都不满足条件而做很多的无用功。
如果是使用lock+condition的话,那么在这里就可以通过创建多个condition来表示不同的排队,挂号、检查、就医分别为不同 的队列,当某个挂号或者检查、就医的负责人空闲下来后,就唤醒对应队列的等待线程。
公平非公平差异
synchronized是一种非公平锁,Lock对于锁进行了扩展,它既实现了公平锁的机制也有对非公平锁的实现。
公平锁和非公平锁的区别在于,公平锁的场景线程在进行加锁的时候,首先会看有没有人排队,有人排队的话那么自己就乖乖到队伍里排队,没有人排队的话才去加锁,而非公平锁则是上来就直接加锁,不管你有没有人排队,类似于现实生活中的排队与插队的区别。
性能差异
性能是网上对于synchronized的与Lock问题讨论得最多的,在之前Lock的确要比synchronized的性能要高,但是现在的synchronized对锁进行了优化,增加了对锁的升级使得两者的差异并不大, 现阶段官方更推荐使用synchronized。
死锁问题
是否synchronized进行了性能优化后,Lock就完全没有必要存在了呢?其实并非这样,Lock从一开始也并非是在性能上来取代synchronized,而是在另一个方面的考虑,就是“死锁”的控制。我们通常聊到Lock时,通常也会说到Lock要比synchronized要灵活些,这句话的确没错,但是灵活并不是体现在使用上,而是在Lock可以有几种手段避免死锁。
有前辈在死锁方面总结过一套理论,要避免死锁只要满足可中断、可超时中的任何一个条件即可, 所以了解了这个之后,我们就会知道在Lock里面提供的超时和中断方法真实意图了。
在synchronized里面,只要获取锁失败,就会进入等待队列线程进入Wating状态。而在Lock里面我们可以条用tryLock去尝试获取锁,没有获取到的话并不会阻塞线程,而是交由我们来决定是接下来去做什么,决定权交到了我们手里这样就要更灵活了。
举个案例我们来体会这两种的区别:
工具箱里有钉子和锤子两个工具(锤子和钉子分别为两个不同的锁),现在张三准备去装门、李四准备去修理床铺,但是他们都需要使用锤子和钉子(拿锤子和钉子的过程看做申请两个不同的锁)。此时张三和李四同时从工具箱里面拿工具(并发申请锁)这个时候他们很有可能一人拿到锤子而一人拿到钉子(各自持有一把锁),那么这种情况下在synchronized和Lock分别可以如何处理呢。
如果是使用的synchronized的话,如果已经拿到一把锁,然后再去申请另外一把锁的时候申请失败,这个时候它会直接阻塞线程进入等待队列,试想一下,这个时候张三拿着锤子等待着钉子,李四拿着钉子等待着锤子,然后他们都在等对方释放,这不就死锁了么。
所以这个时候是否会想起lock里面的那几个方法,lock在用trylock的时候并不会直接阻塞线程,而是返回你的获取锁的结果,你在知道结果以后可以根据情况做决定,那么这时张三拿到锤子再去拿钉子发现失败后,张三知道很有可能是其他人先拿到了钉子,为了避免死锁,张三可以先把自己手里的锤子放回去,让其他人先用完,过一会再来申请,这样就可以避免死锁了。如果trylock是采用谦让的方式避免死锁,而lockInterruptibly方法则是采用比较强势的通知的方式了,当张三去拿钉子失败之后,他想到了可能谁已经拿到了钉子,然后调用李四线程的lockInterruptibly方法来告诉李四,你把钉子放回去,我要使用。
Lock 原理分析
如果你在之前的文章中已经了解过了sychronized实现原理,那么再来看Lock 的实现就会非常简单了,虽然使用的加锁技术有不同之处,但是他们都是基于同样的理念去实现的。和synchronized一样,Lock也是维护了一个锁(state),和一个等待队列(AQS),这也是Lock在底层实现的两个核心元素。AQS队列解决了线程同步的问题,volatile定义的锁状态解决保证了线程对于临界区代码访问的互斥,并且解决了各个线程对于锁状态的可见性问题。
我想你现在已经对Lock的实现有个大概的模型了,一个锁状态加上一个AQS的等待队列就是Lock的全部,下面我们以ReentrantLock为例,通过下面一段代码来更深入的了解Lock的实现原理。
一、AQS的初始化。
当我们调用new ReentrantLock()时其实就是初始化了一个AQS对象,该对象包括如下几个属性
1、state (锁的状态 0代表未加锁,>0则代表已加锁,和重入次数)
2、exclusiveOwnerThread (拥有当前锁的线程)
3、由 Node节点组成的双向链表,Node节点保存了等待线程的相关信息。
当AQS初始化之后,会初始化一个如下对象
1、state状态为0
2、exclusiveOwnerThread =null
3、由head 和tail两个空节点组成的首尾双向链表。
二、加锁过程
一个线程通过ReentrantLock .lock() 首先会获得当前AQS锁的状态,然后根据锁的对应状态做出不同的处理,具体分为以下几种情况;
第一种情况:初次加锁,还没有任何线程获得AQS的锁;
第二种情况:已经有线程获得了AQS的锁,但是加锁的线程和当前线程是同一个;
第三种情况:已经有线程获得了AQS的锁,并且加锁的线程和当前线程不是同一个;
第一种情况:初次加锁,还没有任何线程获得AQS的锁;
线程A调用ReentrantLock .lock() 进行加锁,当还没有任何对象获得AQS锁时候会执下面逻辑
1、判断队列中是否有正在等待锁的节点,因为是初次加锁,所以这里head和tail节点都是空;
2、使用compareAndSetState()方法对AQS进行加锁(此方法能保证操作的原子性)。
3、设置当前获得锁对象的线程;
当还没有任何对象获得AQS锁时候会执FairSync.tryAcquire()方法,源码如下:
第二种情况:已经有线程获得了AQS的锁,获得锁的线程和申请加锁的线程是同一个;
当获得锁的线程和申请加锁的线程为同一个时,这种是否允许同一个线程多次获得同一把锁的情况称为可重入锁,ReemtrantLock支持重入锁,所以会执行如下逻辑
1、首先拿当前线程和已经获得AQS锁的线程对比是否是同一个线程;
2、是同一个线程的话获得 当前AQS的state ,执行state+1累计重入次数;
3、修改AQS的state;
如下代码,当线程A调用demo1()方法,已经获得了AQS锁,当调用demo2时又会去竞争AQS锁,这样允许同一个线程多次获得同一把锁的情况称为 可重入锁
当线程A再次调用ReentrantLock .lock() 时会执行FairSync.tryAcquire(),对应源码如下:
第三种情况:已经有线程获得了AQS的锁,但获得锁的线程和申请加锁的线程不是同一个;
已经有线程加锁还未释放,后面的线程继续加锁是会失败的,这时加锁失败的线程会进入阻塞队列,具体逻辑如下:
1、首先执行 addWaiter()把获得锁不成功的线程加入到阻塞队列
A、把线程B封装为一个Node节点(这里我们定义为nodeB);
B、如果当前AQS中双向链表tail节点不为空,则把nodeB设置为tail节点,把nodeB.pre指向原来的tail节点,并把原来的tail节点的nex指向nodeB;
C、如果当前AQS中双向链表tail节点为空,则说明当前链表里面没有其他等待的节点,那么首先创建一个Node 节点(这里定义为nodeN)作为head节点,然后把nodeN.nex指向nodeB节点,把tail指向nodeB节点,nodeB.pre指向nodeN节点;
2、addWwaiter()成功后调用 acquireQueued()方法;
F、首先会再次尝试获取一下锁;
G、当获取锁失败后,把双向链表中nodeB.pre指向的节点的waitStatus 设置为 -1;
因为我们的案例是第一加锁,所以head 和tail节点都为null ,所以代码会走A、C、F、G逻辑 。
注:
waitStatus=0 新加的节点,处于阻塞状态。
waitStatus= 1 表示该线程节点已释放(超时、中断),已取消的节点不会再阻塞。
waitStatus=-1 表示该线程的后续线程需要阻塞,即只要前置节点释放锁,就会通知标识为 SIGNAL 状态的后续节点的线程 。
waitStatus=-2 表示该节点的线程处于等待Condition条件状态,不会被当作是同步队列上的节点,直到被唤醒(signal),设置其值为0,重新进入阻塞状。
waitStatus=-3 表示该线程以及后续线程进行无条件传播(CountDownLatch中有使用)共享模式下, PROPAGATE 状态的线程处于可运行状态。
执行AbstractQueuedSynchronizer.acquire(),对应源码如下:
最后经过了线程A两次加锁、线程B再加锁后整个AQS队列和状态数据如下图:
三、解锁过程
通过上面的流程,我们已经了解了AQS的加锁过程,当线程加锁不成功之后,会把当前线程放到一个等待队列中去,这个队列是由head和tail构建出来的一个双向链表,下面我们继续上面的案例继续分析AQS的解锁过程。
因为上面AQS的锁获得线程为线程A ,所以现在只有线程A可以进行释放锁,当线程A调用ReentrantLock .unlock() 时,最终执行ReentrantLock.tryRelease()方法,代码如下:
1、获得当前AQS的state 并进行减1(state每减1代表释放一次锁);
2、当state=0的时候说明当前锁已经完全释放了,此时会设置拥有AQS 锁的线程为null;
3、当state不等于0说明锁还没有释放完全,此时修改state的值。
因为上面案例中线程A获得了2次锁,所以线程A需要调用两次ReentrantLock .unlock()才能释放锁,整个流程如下图:
解锁后唤醒队列线程
当线程A释放完锁之后程序会调用AbstractQueuedSynchronizer.release()方法的如下源码:
我想如果明白了AQS的加锁过程,那么你已经猜到了,当线程释放锁完毕之后接下来肯定是唤醒等待队列里面的线程了,这段代码也的确是在做这些事情:
1、获得等待队列链表中的head节点;
2、当head节点不为空,并且head节点的waitStatus!=0(这里代表线程状态正常)时,调用unparkSuccessor()方法唤醒链表中head.next节点中的线程,。
3、当前链表里面head.next 为nodeB,所以线程B会被唤醒,然 后重新去获取锁,同时重构链表节点.
最后结果如图:
Lock+Condition的使用
用Condition实现消息队列
前面我们也知道了Lock+Condition能实现多队列,那么多个队列到底在什么样的使用场景呢,下面我们以一个消息队列的例子来了解Lock+Condition的使用。
场景:实现一个消息队列,有多个线程会往该队列里面写消息,同时也会存在多个线程会从消息里面读消息,队列的容量只有10个。
条件:
队列不为满条件:队列里面消息没有满的情况下才能往队里面添加消息。
队列不为空条件:消费消息的时候队列里必须有消息才进行消费。
加锁:因为是多线程所以需要防止消息被多个线程同时消费,同时也要防止写消息的时候一个线程存的消息被其他线程覆盖,所以队列操作的时候必须加锁。
实现思路:
1、防止多个线程写消息覆盖、多个线程读取到同一个消息,那么我们可以用一把锁来控制消息的读写,保证线程操作的互斥。
2、定义两个条件,一个条件“队列消息不为满”,一个条件"队列消息不为空"。
3、队列消息不为满的Condition队列是用来阻塞写入的线程的,当队列容量=10的时候,说明队列已经满了不能再写了,这个时候写入线程就阻塞到消息不满的Condition队列等待,意思是当队列容量
4、队列消息不为空的Condition队列是读取消息的线程排队队列,当队列容量=0的时候,说明队列已经空了没有消息读取了,这个时候读取消息的线程就阻塞进入 消息不空的Condition等待队列,意思是当队列容量>1时,就可以可以唤醒此队列的线程进行消息的读取。
当队列已满的时候就不再进行消息的写入,写入消息的线程进入队列是否已满的队列等待,然后唤醒
队列代码:
测试:
测试结果:
根据上面Lock+Condition实现队列的案例,我们大概已经可以在脑海中推导出来一个Lock+Condition的类似下图的流程了
Lock+Condition原理分析
下面我们根据一个简单的模拟场景进行Lock+Condition的原理分析
对线程A,线程B,按先后顺序线程A调用Lock.lock()、condition.await(),线程B调用 Lock.lock()、condition.signal(),分析其整个执行过程。
第一步:创建AQS同步队列
因为ReentrantLock 内部维护的是一个AQS同步队列,在掉员工new ReentrantLock()的时候就会初始化了一个AQS队列,队列信息如下图:
第二步:线程A调用lock.lock();
因为线程A是第一个加锁的线程所以,所以加锁成功后AQS队列里面信息如下
第三步:线程A调用condition.await()方法;
condition.await()方法的主要逻辑:
1、首先调用addConditionWaiter();创建一个节点,把当前节点加入到Condition的队列(此队列为单向链表)
2、然后调用fullyRelease(node);释放AQS锁,并唤醒AQS同步队列等待节点中的一个线程。
3、最后LockSupport.park(this);挂起当前线程。
那么此时会产生两个队列,一个是AQS队列,一个是Condition队列,最后信息如下图:
源码如下:
第四步:线程B调用lock.lock()进行加锁:
此时线程B进行加锁,发现AQS是没有加锁的状态所以加锁成功。
第五步:线程B调用condition.signal()唤醒 condition队列里的一个阻塞线程:
主要逻辑:
1、首先验证当前线程是否获取了锁,没有获取锁直接抛异常,没有获得锁的线程是不允许调用condition.signal()方法的;
2、获得condition队列的firstWaiter首部节点,把此节点从condition队列移出,添加到AQS队列中。
3、唤醒AQS中一个等待节点的线程。
最后执行结果如下图:
condition.signal() 源码:
热爱技术才能学好技术
每天进步一点点
领取专属 10元无门槛券
私享最新 技术干货