本文来自网易云社区html
做者:马进
程序员
这里咱们来聊聊synchronized,以及wait(),notify()的实现原理。数组
在深刻介绍synchronized原理以前,先介绍两种不一样的锁实现。性能优化
咱们平时说的锁都是经过阻塞线程来实现的:当出现锁竞争时,只有得到锁的线程可以继续执行,竞争失败的线程会由running状态进入blocking状态,并被登记在目标锁相关的一个等待队列中,当前一个线程退出临界区,释放锁后,会将等待队列中的一个阻塞线程唤醒(按FIFO原则唤醒),令其从新参与到锁竞争中。多线程
这里要区别一下公平锁和非公平锁,顾名思义,公平锁就是得到锁的顺序按照先到先得的原则,从实现上说,要求当一个线程竞争某个对象锁时,只要这个锁的等待队列非空,就必须把这个线程阻塞并塞入队尾(插入队尾通常经过一个CAS保持插入过程当中没有锁释放)。相对的,非公平锁场景下,每一个线程都先要竞争锁,在竞争失败或当前已被加锁的前提下才会被塞入等待队列,在这种实现下,后到的线程有可能无需进入等待队列直接竞争到锁。并发
非公平锁虽然可能致使活锁(所谓的饥饿),可是锁的吞吐率是公平锁的5-10倍,synchronized是一个典型的非公平锁,没法经过配置或其余手段将synchronized变为公平锁,在JDK1.5后,提供了一个ReentrantLock能够代替synchronized实现阻塞锁,而且能够选择公平仍是非公平。
app
线程的阻塞和唤醒须要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来讲是一件负担很重的工做。同时咱们能够发现,不少对象锁的锁定状态只会持续很短的一段时间,例如整数的自加操做,在很短的时间内阻塞并唤醒线程显然不值得,为此引入了自旋锁。ide
所谓“自旋”,就是让线程去执行一个无心义的循环,循环结束后再去从新竞争锁,若是竞争不到继续循环,循环过程当中线程会一直处于running状态,可是基于JVM的线程调度,会出让时间片,因此其余线程依旧有申请锁和释放锁的机会。工具
自旋锁省去了阻塞锁的时间空间(队列的维护等)开销,可是长时间自旋就变成了“忙式等待”,忙式等待显然还不如阻塞锁。因此自旋的次数通常控制在一个范围内,例如10,100等,在超出这个范围后,自旋锁会升级为阻塞锁。布局
所谓自适应自旋锁,是经过JVM在运行时收集的统计信息,动态调整自旋锁的自旋上界,使锁的总体代价达到最优。
介绍了自旋锁和阻塞锁这两种基本的锁实现以后,咱们来聊一聊synchronized背后的锁实现。
synchronized锁在运行过程当中可能通过N次升级变化,首先能够想到的是:
自适应自旋锁是JDK1.6中引入的,自旋锁的自旋上界由同一个锁上的自旋时间统计和锁的持有者状态共同决定。当自旋超过上界后,自旋锁就升级为阻塞锁。就像C中的Mutex,阻塞锁的空间和时间开销都比较大(毕竟有个队列),为此在阻塞锁中,synchronized又进一步进行了优化细分。阻塞锁升级变化过程以下:
重量锁就是带着队列的锁,开销最大,它的实现和Mutex很像,可是多了一个waiting的队列,这部分实现最后介绍,咱们先来看看轻量锁和偏向锁是什么玩意。
在进一步介绍锁实现以前,咱们须要先了解一下JVM中对象的内存布局,JVM中每一个对象都有一个对象头(Object header),普通对象头的长度为两个字,数组对象头的长度为三个字(JVM内存字长等于虚拟机位数,32位虚拟机即32位一字,64位亦然),其构成以下所示:
图1. JAVA对象头结构
ClassAddress是指向方法区中对象所属类对象的地址指针,ArrayLength标志了数组长度, MarkWord用于存储对象的各类标志信息,为了在极小的空间存储尽可能多的信息,MarkWord会根据对象状态复用空间。MarkWord中有2位用于标志对象状态,在不一样状态下MarkWord中存储的信息含义分别为:
图2. MarkValue结构
看到这个表格多少会让人有些眼花缭乱,不急,咱们在讲解下面几种锁的过程当中会分别介绍这几种状态。
首先须要明确的是,不管是轻量锁仍是偏向锁,都不能代替重量锁,二者的本意都是在没有多线程竞争的前提下,减小重量锁产生的性能消耗。一旦出现了多线程竞争锁,轻量锁和偏向锁都会当即升级为重量锁。进一步讲,轻量锁和偏向锁都是重量锁的乐观并发优化。
对对象加轻量锁的条件是该对象当前没有被任何其余线程锁住。
先从对象O没有锁竞争的状态提及,这时候MarkWord中Tag状态为01,其余位分别记录了对象的hashcode,4位的对象年龄信息(新建对象年龄为0,以后每次在新生代拷贝一次就年龄+1,当年龄超过一个阈值以后,就会被丢入老年代,GC原理不是本文的主题,但至少咱们如今知道了,这个阈值<=15),以及1位的偏向信息用于记录这个对象是否可用偏向锁。 当一个线程A在对象O上申请锁时,它首先检查对象O的Tag,若发现是01且偏向信息为0,代表当前对象还未加锁,或加过偏向锁(加过,注意是加过偏向锁的对象只能被一样的线程加锁,若是不一样的线程想要获取锁,须要先将偏向锁升级为轻量锁,稍后会讲到),在判断对当前对象确实没有被任何其余线程锁住后(Tag为01或偏向线程不具备该对象锁),便可以在该对象上加轻量锁。
在判断能够加轻量锁以后,加轻量锁的过程为两步:
1. 在当前线程的栈(stack frame)中生成一个锁记录(lock record),这个锁记录并非咱们一般意义上说的锁对象(包含队列的那个),而仅仅是对象头MarkValue的一个拷贝,官方称之为displayed mark value。如图3所示:
图3. 加轻量锁以前
2. 经过CAS操做将上一步生成的lock record地址赋给目标对象的MarkValue中(Tag同时改成00),保证在给MarkValue赋值时Tag不会动态修改,若是CAS成功,代表轻量锁申请成果,若是CAS不成功,且Tag变为00,则查看MarkValue中lock record address是否指向当前线程栈中的锁记录,如果,则代表是一样的线程锁重入,也算锁申请成果。如图4所示: 在第二步中,若不知足加锁成功的两种状况,说明目标锁已经被其余线程持有,这时再也不知足加轻量锁条件,须要将当前对象上的锁状态升级为重量锁:将Tag状态改成10,并生成一个Monitor对象(重量锁对象),再将MarkValue值改成该Monitor对象的地址。最后将当前线程塞入该Monitor的等待队列中。
图4.加轻量锁以后
轻量锁的解锁过程也依赖CAS操做: 经过CAS将lock record中的Object原MarkValue赋还给Object的MarkValue,若替换成功,则解锁完成,若替换不成功,表示在当前线程持有锁的这段时间内,其余线程也竞争过锁,而且发生了锁升级为重量锁,这时须要去Monitor的等待队列中唤醒一个线程去从新竞争锁。
当发生锁重入时,会对一个Object在线程栈中生成多个lock record,每当退出一个synchronized代码块便解锁一次,并弹出一个lock record。
一言以蔽之,轻量锁经过CAS检测锁冲突,在没有锁冲突的前提下,避免采用重量锁的一种优化手段。
加轻量锁的代价是数个指令外加一个CAS操做,虽然轻量锁的代价已经足够小,它依然有优化空间。 细心的人应该发现,轻量锁的每次锁重入都要进行一次CAS操做,而这个操做是能够避免的,这即是偏向锁的优化手段了。
所谓偏向,就是偏袒的意思,偏向锁的初衷是在某个线程得到锁以后,消除这个线程锁重入(CAS)的开销,看起来让这个线程获得了偏护。
偏向锁和轻量锁的加锁过程很相似,不一样的是在第二步CAS中,set的值是申请锁的线程ID,Tag置为01(就初始状态来讲,是不变),这点能够从图2中开出。当发生锁重入时,只须要检查MarkValue中的ThreadID是否与当前线程ID相同便可,相同便可直接重入,不相同说明有不一样线程竞争锁,这时候要先将偏向锁撤销(revoke)为轻量锁,再升级为重量锁。 由于偏向锁的MarkValue为线程ID,能够直接定位到持有锁的线程,偏向锁撤销为轻量锁的过程,须要将持有锁的线程中与目标对象相关的最老的lock record地址替换到当前的MarkValue中,并将Tag置为00。
偏向锁的释放不须要作任何事情,这也就意味着加过偏向锁的MarkValue会一直保留偏向锁的状态,所以即使同一个线程持续不断地加锁解锁,也是没有开销的。 另外一方面,偏向锁比轻量锁更容易被终结,轻量锁是在有锁竞争出现时升级为重量锁,而通常偏向锁是在有不一样线程申请锁时升级为轻量锁,这也就意味着假如一个对象先被线程1加锁解锁,再被线程2加锁解锁,这过程当中没有锁冲突,也同样会发生偏向锁失效,不一样的是这回要先退化为无锁的状态,再加轻量锁,如图5:
图5. 偏向锁,以及锁升级
回到图2,咱们发现出了Tag外还有一个01标志位,上文中提到,这位表示偏向信息,0表示偏向不可用,1表示偏向可用,这位信息一样记录在对象的类对象中,当JVM发现一类对象频繁发生锁升级,而锁升级自己须要必定的开销,这种状况下偏向锁反而成为一种负担,尤为在生产者消费者这类常态竞争锁的场景中,偏向锁是彻底无心义的,当JVM搜集到足够的“证据”证实偏向锁不该当存在后,它就会将类对象中的相关标志置0,以后每次生成新对象其偏向信息都是0,都不会再加偏向锁。官网上称之为Bulk revokation。
另外,JVM对那种会有多线程加锁,但不存在锁竞争的状况也作了优化,听起来比较拗口,但在现实应用中确实是可能出现这种状况,由于线程以前除了互斥以外也可能发生同步关系,被同步的两个线程(一前一后)对共享对象锁的竞争极可能是没有冲突的。对这种状况,JVM用一个epoch表示一个偏向锁的时间戳(真实地生成一个时间戳代价仍是蛮大的,所以这里应当理解为一种相似时间戳的identifier),对epoch,官方是这么解释的:
A similar mechanism, called bulk rebiasing, optimizes situations in which objects of a class are locked and unlocked by different threads but never concurrently. It invalidates the bias of all instances of a class without disabling biased locking. An epoch value in the class acts as a timestamp that indicates the validity of the bias. This value is copied into the header word upon object allocation. Bulk rebiasing can then efficiently be implemented as an increment of the epoch in the appropriate class. The next time an instance of this class is going to be locked, the code detects a different value in the header word and rebiases the object towards the current thread.
再次一言以蔽之,偏向锁是在轻量锁的基础上减小了减小了锁重入的开销。
重量锁在JVM中又叫对象监视器(Monitor),它很像C中的Mutex,除了具有Mutex互斥的功能,它还负责实现了Semaphore的功能,也就是说它至少包含一个竞争锁的队列,和一个信号阻塞队列(wait队列),前者负责作互斥,后一个用于作线程同步。
这两天在网上找资料,发现一篇对重量锁不错的介绍,虽然我的以为里面对轻量锁,偏向锁介绍的有点少,另外在锁的变化升级上有点含糊。不妨碍它在Monitor描述上的优质。为了尊重原做者,这里贴出它的博客连接:
http://blog.csdn.net/chen77716/article/details/6618779
从这篇博文中咱们能够看到,在重量锁的调度过程当中,可能有不一样线程访问Monitor的队列,因此Monitor的队列必然都是并发队列,而并发队列的操做须要并发控制,是否是发现这又要依赖synchronized?哈哈,固然这种循环依赖是不可能出现的,由于Monitor中的队列都是经过CAS来保证其并发的正确性的。
写到这里,我本身都不禁惊叹CAS的神奇,任何阅读到这里的读者都会发现,synchronized的实现中处处都有CAS的身影。那么CAS的代价到底有多大呢? 关于CAS的介绍推荐两篇介绍,和一个答疑:这里还须要说明一下自旋锁与阻塞锁三个过程之间的关系:自旋锁是在发生锁竞争时自旋等待,那么自旋锁的前提是发生锁竞争,而轻量锁,偏向锁的前提都是没有锁竞争,因此加自旋锁应当发生在加剧量锁以前,准确地说,是在线程进入Monitor等待队列以前,先自旋一会,从新竞争,若是还竞争不到,才会进入Monitor等待队列。加锁顺序为:
CAS具体的代价在不一样硬件上有所区别,但从指令复杂度考虑,必然比普通赋值指令多不少时钟周期,可是在CAS和synchronized之间作选择时,依旧倾向CAS,由于synchronized背后布满了CAS,若是你对本身的coding有足够自信,那尝试本身CAS或许能有不错的收获。
最后回答咱们最初提出的几个问题:
Q1: synchronized到底有多大开销?与CAS这样的乐观并发控制相好比何?
从上述四个锁的原理以及加速顺序咱们不难发现,synchronzied在没有锁冲突的前提下最小开销为一个CAS+栈变量维护(lock record)+一个赋值指令,有锁冲突时须要维护一个Montor对象,经过Moinitor对象维护锁队列,这种状况下涉及到线程阻塞和唤醒,开销很大。
Synchronized大多数状况下没有CAS高效,由于synchronized的最小开销也至少包含一个CAS操做。CAS和synchronized实现的多线程自加操做性能对比见上一篇博客。
Q2:怎样使用synchronized更加高效?
使用synchronized要听从上篇博客中提到的三个原则,另外若是业务场景容许使用CAS,倾向使用CAS,或者JDK提供的一些乐观并发容器(如ConcurrentLinkedQueue等),也能够先用synchronized将业务逻辑实现,以后作针对性的性能优化。
Q3:与ReentrantLock(JDK1.5以后提供的锁对象)一类的锁相比有什么优劣?
ReentrantLock表明了JDK1.5以后由JAVA语言实现的一系列锁的工具类,而synchronized做为JAVA中的关键字,是由native(根据平台有所不一样,通常是C)语言实现的。ReentrantLock虽然也实现了 synchronized中的几种锁优化技术,但与synchronized相比,性能未必好,毕竟JAVA语言效率和native语言效率比大多数状况总有不如。ReentrantLock的优点在于为程序员提供了更多的选择和更好地扩展性,好比公平性锁和非公平性锁,读写锁,CountLatch等。
细心地人会发现,JDK1.6中的并发容器大多数都是用ReentrantLock一类的锁对象实现。例如LinkedBlockingQueue这样的生产者消费者队列,虽然也能够用synchronized实现,可是这种队列中存在若干个互斥和同步逻辑,用synchronized容易使逻辑变得混乱,难以阅读和维护。
总结一点,在业务并发简单清晰的状况下推荐synchronized,在业务逻辑并发复杂,或对使用锁的扩展性要求较高时,推荐使用ReentrantLock这类锁。
Q5:能够对synchronized作哪些优化?
经过介绍synchronized的背后实现,不难看出synchronized自己已经通过了高度优化,并且除了JVM运行时的锁优化外,JAVA编译器还会对synchronized代码块作一些额外优化,例如对确定不会发生锁竞争的synchronized进行锁消除,或频繁对一个对象进行synchronized时能够锁粗化(如synchronzied写在for循环内时,能够优化到外面),所以程序员在使用synchronized时须要注意的就是上篇博客中提到的三点原则,尤为是控制synchronzied的代码量,将无需互斥执行的代码尽可能移到synchronzed以外。
本文来自网易云社区,经做者马进受权发布
相关文章:
【推荐】 搜索凑单页大促显示延迟方案设计
【推荐】 知物由学 | AI时代,那些黑客正在如何打磨他们的“利器”?