显式锁是什么?
我们一般喊synchronized就叫synchronized。其实synchronized又被称为隐式锁,但我们就爱喊它synchronized。而显式锁就是我们一般说的Lock锁,但大家就是爱叫它显式锁。大概是Lock很容易和别的单词搞混吧?但无论如何,显式锁就是说的Lock锁。
那么Lock为啥要叫它显式锁呢?八竿子打不着一边我很难记住啊!
我们来看一下Lock加锁的范式:
如上图,我们注意到,lock.unlock();是被放入 finally 代码块里的,这是为了保证出现异常时,锁依然能被释放掉,避免死锁的产生。顺便提一下,我们的synchronized方法或synchronized代码块中的代码,在执行期间发生异常,变会自动释放锁,因此没有显示的退出(unlock)。
Lock是一个接口,提供了无条件的、可轮询的、定时的、可中断的锁获取操作,所有的加锁和解锁操作方法都是显示的(必须得写出来),因而称为显式锁。这下印象深刻了吧。
同时,我们还要注意到,加锁的过程:lock.lock();,并没有放在 try 代码块内,而且你会发现,JDK 文档中很多使用 lock 的地方都是将加锁过程:lock.lock();放在了 try 的外部。
lock.lock()放在try语句中的后果
博主在浏览一下网上的技术博客时,发现竟然有人将lock.lock();加在了try语句内,如下面这种格式:
这样做合适么?答案是肯定不合适。
假如我们在try语句和lock.lock();之间发生了异常,或者直接在获取锁的时候发生异常。那么就会在未成功执行lock.lock();时,执行finally代码块的lock.unlock();去释放锁。可是此时我们并没有获取锁,直接执行释放锁会出现问题么?这个问题,我们留到后文的AQS中继续探讨。
synchronized和lock的区别
那么此处就对我们的隐式锁和显式锁进行一下对比:
出身,层次不同
从synchronized和lock的出身(原始的构成)来看看两者的不同:
- synchronized : Java中的关键字,是由JVM来维护的。是JVM层面的锁。
- Lock:是JDK5以后才出现的具体的类。使用lock是调用对应的API。是API层面的锁。
synchronized 是底层是通过monitorenter进行加锁(底层是通过monitor对象来完成的,其中的wait/notify等方法也是依赖于monitor对象的。只有在同步块或者是同步方法中才可以调用wait/notify等方法的。因为只有在同步块或者是同步方法中,JVM才会调用monitory对象的);通过monitorexit来退出锁的。
而lock是通过调用对应的API方法来获取锁和释放锁的。
通过反编译的结果,我们可以看出synchronized和lock的区别。
用总结漫威电影的一句话来概述这两个锁的区别:穷人靠变异,富人装备。synchronized自打娘胎里(JVM级别)就拥有不俗的实例,而lock则是在后期不断通过装备(类级别的封装)展现出更多姿多彩的能力。
使用方式不同
这个在我们文章的头部已经介绍了,此处再给出以下用法的对比:
切记使用lock的时候,加锁放在try外面,解锁放在finally中。
换到漫威电影里,我们可以类比为:蜘蛛侠(synchronized)每次行动前,带个头套就出去干了。而钢铁侠(lock)一定需要后期频繁的给装甲进行充电,保养等护理事宜。
等待是否可以被打断
首先,synchronized是不可中断的(网上常说的一个不怎么规范的说法)。除非抛出异常或者正常运行完成。
这里可能有些容易混淆。我们的线程Thread类不是提供了一个interrupt方法来中断线程么?凭啥说synchronized是不可中断的?
其实就是这个说法容易误导我们,实际上是synchronized在阻塞状态中是不可被打断的。
我们知道,当多个线程去访问同一个synchronized对象锁资源时,一次只能有一个线程获取到锁,其他的线程此时就会进入阻塞状态,直到抢到锁资源的线程执行完毕时,这些阻塞中的线程才会被唤醒,去重新争抢锁。而这些正在阻塞中,尚未被唤醒的锁资源,我们是无法将它们从阻塞中进行打断的。
而lock可以中断,也是针对这些等待锁资源的线程而言的。中断的方式有以下两种:
- 调用设置超时方法tryLock(long timeout ,timeUnit unit)
- 调用lockInterruptibly()放到代码块中,然后调用interrupt()方法可以中断
这里就不拿漫威的例子来讲了。我们的大人常说我们谈恋爱时不要在一棵树上吊死就是这个打断机制的原理。synchronized锁在爱上了一个女孩儿,但这个女孩儿嫁给了别的男人,于是它选择终其一生去等待她。而lock锁就比较听大人的话,更加的灵活。知道等待一段时间无法让心爱的女孩儿回心转意,就放弃了这一棵小树,回过头来就能发现一片森林。
当然,并不是说lock就比synchronized好了。具体在程序中,用哪种锁来处理问题,其实各有千秋。我们作为开发人员要根据不同的场景,选择最适合处理这个业务场景的锁。
公平与非公平的选择权利不同
- synchronized:非公平锁。
- lock:可以主动选择公平和非公平。可以在其构造方法中进行设置。默认是非公平的(false),可以主动设置为公平的(true)。
这个继续用漫威电影来讲。蜘蛛侠从小出身贫苦,因此出去和大家吃饭只能抢着吃。而钢铁侠成天和蜘蛛侠混一起,体验平民生活,因此也默认是抢着吃饭。但同时他也可以选择去上流环境中,大家井然有序的排队打饭。
当然,也可以看出,非公平(抢占式)锁往往伴随更高的效率(具体原因我们放在AQS中细讲),而公平(非抢占式)锁往往会降低效率,却可以保证线程根据抢锁的时间进行排序执行。
唤醒线程的粒度不同
- synchronized
不能精确唤醒线程。要么随机唤醒一个线程;要么是唤醒所有等待的线程。 - Lock
用(condition)来实现分组唤醒需要唤醒的线程,可以精确的唤醒。
性能比较
JDK1.5(lock强于synchronized)
synchronized是托管给JVM执行的,而lock是java写的控制锁的代码。在Java1.5中,synchronized的性能是低效的,因为这是一个重量级锁,需要调用操作接口,导致了有可能加锁消耗的系统时间比加锁之外的操作还多,相比之下,使用Java提供的Lock对象,性能就会高一些。
JDK1.6以及之后(官方推荐synchronized)
到了Java1.6之后,发生了变化。synchronized在语义很清晰并进行了许多优化。有适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等等,导致Java1.6中synchronized的性能并不比lock差。官方也表示,他们更支持使用synchronized,在未来的版本中还有优化余地。
因此,现在大家最常用的可能都是Java1.8或者更高级的版本了,lock现在可以被我们当做一个具有更多功能的一个锁。当业务不需要这些功能实现的时候,我们就尽量的选择synchronized来加锁。
Lock的常用API
void lock();
拿不到锁就不罢休,不然一直block(阻塞)。和synchronized一样的效果。而之后的方法都可以看作是对synchronized锁的增强
void lockInterruptibly();
可中断地获取锁,和lock()方法的不同之处在于该方法会响应中断,即在锁在还未获取到,阻塞等待中也可以中断当前线程。
boolean tryLock();
立刻判断是否能拿到锁,拿到返回true,否则返回false。
比较洒脱,跟渣男一样,和女孩说一句我爱你,要么在一起,要么就拜拜。
boolean tryLock(long time,TimeUnit unit);
超时获取锁,当线程在以下三种情况会返回:
- 当前线程在超时时间内获取了锁
- 当前线程在超时时间内被中断
- 超时时间结束,返回false
void unlock();
释放锁
Lock的重要实现类
我们得Lock只是一个接口,要想具体干活儿,我们还得分析它旗下的子类们:
ReentrantLock(可重入锁)
锁的可重入
ReentrantLock翻译过来为可重入锁,它的可重入性表现在同一个线程可以多次获得锁,而不同线程依然不可多次获得锁,以线程作为锁的分界线。最常见的场景就是递归,一个递归频繁的调用一个带锁的方法,此时作为可重复锁,对于该线程是可以重复的累积获取锁的。当然,在递归结束后,我们也需要再次循环的将锁释放依次释放掉,以达到我们所说的效果。具体的流程我们依旧放在AQS中讨论。
锁的公平与非公平
ReentrantLock还分为公平锁和非公平锁(好家伙,这一个类占了俩名儿),公平锁保证等待时间最长的线程将优先获得锁,而非公平锁并不会保证多个线程获得锁的顺序,但是非公平锁的并发性能表现更好,至于性能的分析问题,我们还是放在AQS中去说。ReentrantLock默认使用非公平锁。
ReentrantReadWriteLock(读写锁)
之前提到的锁(synchronized和ReentrantLock)都是排他锁,这些锁在同一时刻只运行一个线程进行访问读写锁维护了两把锁,一个读锁和一个写锁。通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。
- 当读线程抢到锁:同一时刻可以允许多个读线程访问,并阻塞写线程。
- 当写线程抢到锁:写锁就是一个排它锁,所有读线程和其他写线程均被阻塞。
读写锁比互斥锁允许对于共享数据更大程度的并发。每次只能有一个写线程,但是同时可以有多个线程并发地读数据。ReadWriteLock适用于读多写少的并发情况。
读写锁造成的写线程饥饿
其实,博主一直认为读写锁是一个很矛盾的锁。因为他的设计原理(维护了两把锁),导致它的应用场景就是读多写少的场景。而读多写少的场景必然会造成另一个问题,就是写线程饥饿。
什么是线程饥饿呢?我们来举一个例子。
假设我们现在有A,B两个线程读,一个C线程写。假设A线程在快执行完毕的时候,B线程继续开始进行读操作。当B线程快执行完毕时,A线程又继续开始读操作。我们发现,只要在所有读线程结束前,任何一个新出现的读线程就可以继续维持读锁的使用权。这种概率伴随着读线程的增多而增大,因此,读多写少的情况下适合读写锁是勿用质疑的,但是写线程饥饿可能让我们的读线程长时间无法获取到最新的数据。
那么,有什么方法可以解决我们的线程饥饿呢?
公平锁处理线程饥饿
之前说到公平锁的执行效率会比非公平锁低下,也许读者就会思考,那么有什么场景下会宁可牺牲效率,也要使用公平锁呢?瞧,这里就是一个应用场景。
公平锁的实现原理就是让根据线程的访问先后顺序,对它们进行排序。再加上读线程之间是不互斥的,因此读线程过多的场景下并不会让我们的维持的队列堵塞,当遇到一个写线程时,队列才会堵塞,等待最后一个读线程执行完毕后,就可以开始执行我们的写线程了。这个优化就保证我们的写线程的执行时效性问题。
但是公平锁天然的就会比非公平锁效率底下很多(具体原因在AQS中详细说明),因此此策略是以牺牲系统吞吐量为代价的。
StampedLock处理线程饥饿(简单理解)
StampedLock是Java8引入的一种新的所机制,简单的理解,可以认为它是读写锁的一个改进版本,它提供了一种乐观的读策略。
StampedLock 的乐观读允许一个写线程获取写锁,所以不会导致所有写线程阻塞,也就是当读多写少的时候,写线程有机会获取写锁,减少了线程饥饿的问题,吞吐量大大提高。
这里可能你就会有疑问,竟然同时允许多个乐观读和一个先线程同时进入临界资源操作,那读取的数据可能是错的怎么办?
是的,乐观读不能保证读取到的数据是最新的,所以将数据读取到局部变量的时候需要通过 lock.validate(stamp) 这个标志位校验是否被写线程修改过,若是修改过则需要上悲观读锁,再重新读取数据到局部变量。可以看到其实就是一个CAS操作。
同时由于乐观读并不是锁,所以没有线程唤醒与阻塞导致的上下文切换,性能更好。
读写锁之锁降级(写锁可降级)
锁降级是指把持住当前拥有的写锁的同时,再获取到读锁,随后释放写锁的过程。
注意:如果当前线程拥有写锁,然后将其释放,最后再获取到读锁,这种分段完成的过程不能称之为锁降级。
很多人对这个概念都会忽略,即时略有了解锁降级的朋友,也可能光知道上面的概念而已。本文让你彻底悟透这个技术。
首先,写锁降级为读锁,并不是我们的写锁自带的功能!!!重要的事儿说三遍,因为博主自己在这个点上误解了很久。就像打游戏一样,写锁降级完全是一个主动释放技能,而不是人家写锁自带的被动技能!那么写锁降级技术解决了什么问题呢?那就是为了防止脏读,因此设置一个读锁在当前线程彻底执行结束前,阻塞其他线程获取写锁修改数据。
纯语言描述可能不易理解,我们来看一下下面的例子,理解脏读产生的原因:
我们发现,只加写锁,并且作用域不够大的话,就可能会出现我们描述的这种情况。对于对数据精准度较高的系统而言,这种情况就不大友好了。那么写锁降级是怎么解决这个脏读的问题的呢?
首先先用代码揭开一下锁降级的真面目。
我们可以看到,所谓的锁降级,并不是真正意义的降级,而是在保持写锁的过程中,可以继续获取读锁,当释放写锁时,读锁依旧保持着,以达到"表面降级"的目的。
我们来看看锁降级具体是如何操作的:
可以看到,实际上还是灵活运用了读写锁的机制,让其他写线程一直阻塞到本线程结束后再继续拥有争夺锁的资格,以解决脏读的问题。
当然,这种解决方案会极大的影响业务的更新数据的时效性。
那你可能会想到,如果是可见性问题,那么voliate关键字是否可以解决可见性问题呢?这里千万不能混淆,因为这里依旧会出现数据原子性的问题。如果本身不用降级会只会让我们读到旧版的数据,那么想用voliate替代锁降级,直接会造成数据错乱的问题。
锁降级的好处
绕这么大半天,我们直接把锁的作用域加大,不也可以保证数据的强一致性?但是用了锁降级技术,可以在我们上述代码等待10s的过程中不阻塞那些需要获取读锁的线程。因此锁降级技术是一个非常好用却又容易被我们忽略的技术。
Lock的分组唤醒机制Condition
回忆 synchronized 关键字,它配合 Object 的 wait()、notify() 系列方法可以实现等待/通知模式。对于 Lock,通过 Condition 也可以实现等待/通知模式。Condition是在java 1.5中才出现的,它用来替代传统的Object的wait()、notify()实现线程间的协作,相比使用Object的wait()、notify(),使用Condition的await()、signal()这种方式实现线程间协作更加安全和高效。因此通常来说比较推荐使用Condition,阻塞队列实际上是使用了Condition来模拟线程间协作。
Condition的API介绍
Condition是个接口,基本的方法就是await()和signal()方法;
Condition依赖于Lock接口,生成一个Condition的基本代码是lock.newCondition();
调用Condition的await()和signal()方法,都必须在lock保护之内,就是说必须在lock.lock()和lock.unlock之间才可以使用。下面先列举三个最常用的方法:
- Conditon中的await()对应Object的wait();
- Condition中的signal()对应Object的notify();
- Condition中的signalAll()对应Object的notifyAll()。
同时,我们的根据我们的java线程的超时等待状态,共提供了以下这些方法:
- await() :造成当前线程在接到信号或被中断之前一直处于等待状态。
- await(long time, TimeUnit unit) :造成当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态
- awaitNanos(long nanosTimeout) :造成当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态。返回值表示剩余时间,如果在nanosTimesout之前唤醒,那么返回值 = nanosTimeout - 消耗时间,如果返回值 <= 0 ,则可以认定它已经超时了。
- awaitUninterruptibly() :造成当前线程在接到信号之前一直处于等待状态。【注意:该方法对中断不敏感】。
- awaitUntil(Date deadline) :造成当前线程在接到信号、被中断或到达指定最后期限之前一直处于等待状态。如果没有到指定时间就被通知,则返回true,否则表示到了指定时间,返回返回false。
- signal() :唤醒一个等待线程。该线程从等待方法返回前必须获得与Condition相关的锁。
- signal()All :唤醒所有等待线程。能够从等待方法返回的线程必须获得与Condition相关的锁。
分组唤醒Condition如何高效
synchronized实现生产消费模式的弊端
我们来说说,我们直接用原本synchronized自带的等待唤醒机制去实现生产消费模式存在什么弊端。
首先来看看一个生产者与消费者多对多的例子:
存值模板
取值模板
线程任务类
调用者
消费,生产线程各创建5个。
问题分析
稍微看一下我们的逻辑,可知每当消费者没有数据就挂起,唤醒生产者。而生产者仅仅生产一个数据就进行挂起,唤醒消费者进行消费。但是仅仅这种简单的单数据生产消费者模式,我们依旧需要使用notifyAll()唤醒所有线程,这是为什么?答案是:我们的存值线程与取值线程用的都是同一把锁。如果仅仅唤醒一个线程,我们无法保证消费完了,下次唤醒的一定是生产者。生产者生产完了,下次唤醒的一定是消费者。
运气不好的情况下,假设我们生产线程生产完毕,连续随机又唤醒10次生产线程,那么此时这10次生产线程必定都是无法生产的,只能经过判断重新挂起,直到消费线程抢占到锁,程序才能继续正常执行。儿这中间就进行了10次无意义的上下文切换,是非常低效的。
我们来看一下运行结果:
从运行结果中可知,我画的红线部分的线程都是随机唤醒后,无法成功执行任务的线程。在while循环中判断不符合执行条件后,重新进入等待状态。这些步骤完全是浪费时间的。
Condition保证生产消费模式的高效
成员变量
存值模板
取值模板
线程任务类
调用者
消费,生产线程各创建5个。
结果分析
结果已经变得很井井有条了,固定的消费一个会生产一个,生产一个消费一个。
我们发现,我们将消费者和生产者进行了分流。在我们的产品固定只有一个(list.size()=1)的情况下,我们甚至可以使用signal()来随机唤醒一个消费/生产线程来执行任务。
当然,如果我们产品数大于一个(list.size()>1)的情况下,我们依旧需要使用signalAll()来批量唤醒线程来抢占执行任务。但是在需要生产的情况下,依旧会排除掉所有的消费者。需要消费的时候也会排除掉所有的生产者,从而减小上下文切换的概率,以达到提升效率的目的。
一个锁两个Condition能否被两把锁替代
可能这个点是作者的奇思妙想。但在此处也提一下:
还是之前的例子。做以下修改:
成员遍变量
生成两个锁,并且分别持有一个消费者Condition和生产者Condition。
存值模板
注意,在生产者锁范围内,我加了一个消费者的Condition唤醒。
取值模板
与上面相反,在消费者锁范围内,我加了一个生产者的Condition唤醒。
线程任务类
调用者
依旧创建5个线程运行,我们来看看结果
结果分析
结果发生了异常,在网上查到了该异常的解释:
抛出该异常表明某一线程已经试图等待对象的监视器,或者试图通知其他正在等待对象的监视器,然而本身没有指定的监视器的线程。
也就是说,我们一把锁内只能使用本锁内的Condition。
更简单灵活的等待唤醒工具类LockSupport
LockSupport是什么
LockSupport是一个编程工具类,主要是为了阻塞和唤醒线程用的。它所有的方法都是静态方法,可以让线程在任意位置阻塞,也可以在任意位置唤醒。
它的内部其实两类主要的方法:park(停车阻塞线程)和unpark(启动唤醒线程)。
注意上面的123方法,都有一个blocker,这个blocker是用来记录线程被阻塞时被谁阻塞的。用于线程监控和分析工具来定位原因的。
现在我们知道了LockSupport是用来阻塞和唤醒线程的,而且之前相信我们都知道wait/notify也是用来阻塞和唤醒线程的,那么它相比,LockSupport有什么优点呢?
与wait/notify对比
wait/notify机制是基于锁机制的等待唤醒。那么我们这个工具类能强大到什么地步呢?
上面的代码中,MyThread线程中,不需要加锁,便可以实现线程的中断。而当其他线程需要对其进行唤醒时,仅仅需要将该线程对象作为参数,调用LockSupport.unpark(线程对象),即可让程序继续运行。
与wait/notfy的区别具体有以下两点:
- wait和notify都是Object中的方法,在调用这两个方法前必须先获得锁对象,但是park不需要获取某个对象的锁就可以锁住线程。
notify只能随机选择一个线程唤醒,无法唤醒指定的线程,unpark却可以唤醒一个指定的线程