蜀国有一个皇帝叫蜀道难,他比较难伺候,别的皇帝早朝都是在大殿上同时接见所有大臣,共商国是。他不一样,他说早朝你们不要有事没事都跑过来叽叽喳喳,有事则来,无事则该干啥干啥去,然后安排太监每天早上在大门口守着,每次只允许一个大臣进来汇报情况。
“你敢多放进来一个就砍脑袋的干活。”
太监赶紧下跪,说“谪!”。
第一天,太监传话钦天监求见,皇帝允了,钦天监上殿报曰:“臣禀报,昨日我司夜观星象,西方忽现王星忽明忽暗,恐戎狄那边有乱。”
“朕知道了,退下吧”。一日无事。
第二天,太监传话钦天监求见,皇帝允了。一日无事。
第三天,太监传话钦天监求见……一日无事。
第四天,钦天监……一日无事。
第五天,皇帝不耐烦了,和贾太监说,钦天监这老家伙整天是不是闲着没事,以后他来了不用给我禀报,直接放他上殿讲,讲完让他走吧。
国泰民安的日子依旧过着,每天只有钦天监一个人来报告,贾太监每次看到是钦天监来了,也懒得搭理了,直接放他进去了。(这就是偏向锁,稍后我细细道来)
又一日,钦天监如往常进殿报道,贾太监站在门口打着盹,忽然耳边传来一个声音:
“贾太监,帮我禀告圣上,工部李尚书求见。”
“emmm…进去吧…嗯?等等,尚书大人你先等等,钦天监在里面,你等会再来求见吧。”太监一阵后怕,寻思着钦天监还在里面呢,这要是放进去了,我这脑袋可就没了,果然嗜睡误事。
过了一会儿,李尚书回来询问求见,被告知钦天监还没走,只好又离去。
又过了一会儿,李尚书又回来询问求见,正巧钦天监走了,太监进殿传话说工部李尚书求见,皇帝宣觐见,李尚书进殿上报了一番东南连连大雨,已派人去监察水利,修缮河堤。(这就是轻量级锁)
忽一日,西戎狄和北匈奴同时对帝国西方和北方发难,前线战事消息如片片雪花纷纷涌入京城,瞬间殿外来了一群大臣有要事禀告。
一会儿这个来问贾公公我可以进去了吗?一会儿那个来问贾公公我可以进去了吗?
把贾太监累的哟,一天下来光说“稍后再来”都把嘴皮子磨破了,没几日,贾太监就跪在皇帝面前哭泣道:“圣上啊,快想想办法呀,奴才这身子骨就要交代在门口了。”
皇帝一听,说你傻啊,叫他们一个个在门外排队啊,谁叫你要他们稍后来求见的。
贾太监细思大喜,觉得有理,次日在门口竖起一个牌子“禀报要事者,这边排队”,贾太监再也不用一个人对着一群人反复回话,只需要每次出来一个,然后传话放进去一个,就可以了。(这就是重量级锁)
上面这个故事,分别讲述了synchronized内部四种级别的状态,分别是:无锁状态,偏向锁状态,轻量级锁状态,重量级锁状态。
重量级锁状态
我们首先从重量级锁开始讲,重量级锁是通过互斥量(Mutex)来实现的,即一个线程进入了synchronized同步块,在未完成任务时,会阻塞后面的所有线程。
就像上面的故事所讲的,要禀告要事的大臣只能在大殿门口外一个接一个的阻塞排队。
之所以称它为重量级锁,是因为Java线程是映射到操作系统的原生线程上的,如果要阻塞或唤醒一个线程,都需要依靠操作系统从当前用户态转换到核心态中,这种状态转换需要耗费处理器很多时间,对于简单同步块,可能状态转换时间比用户代码执行时间还要长,导致实际业务处理所占比偏小,性能损失较大。
当然这个在虚拟机层面进行了一些比如自旋等待,锁粗化等等的优化,避免陷入频繁的切换状态。在这里我就不细讲了,有兴趣的可以关注我,我后续再和各位看官讲上一讲。
轻量级锁状态
轻量级锁是JDK6引入的,它的轻量是相较于通过系统互斥量实现的传统锁,轻量锁并不是用来取代重量级锁的,而是在没有大量线程竞争的情况下,减少系统互斥量的使用,降低性能的损耗。
轻量级锁是通过CAS(Compare And Swap)机制实现的,即如果锁被其他线程所占用,当前线程会通过自旋来获取锁,从而避免用户态与核心态的转换。
就像上面故事所说的,大殿中钦天监在汇报工作,工部尚书要求见,并不需要贾太监每次都进去问一下皇帝,惹得皇帝龙颜大怒,而是大臣自己隔一段时间便来询问贾太监能不能进去,不能就稍后再来问,直到可以进去为止。
偏向锁状态
偏向锁也是JDK6引入的,它存在的依据是“大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得”。它是通过记录第一次进入同步块的线程id来实现的,如果下一个要进入同步块的线程与记录的线程id相同,则说明这个锁由此线程占有,可以直接进入到同步块,不用执行CAS。
就像故事中的,如果每天只有钦天监一个人来的话,就不用贾太监禀告了,贾太监每次一看到钦天监,寻思着,哟,钦天监呢,您自个儿直接进去吧,说完自个儿出来吧。
如果说轻量锁是为了消除系统互斥量带来的性能损耗,那么偏向锁就是为了消除CAS带来的性能损耗,使之在无竞争的情况下消除整个同步,性能无限接近非同步。
如何通过这四种状态实现性能大幅度提升的
Java对象头
要说这个问题,我们需要先讲一下Java对象头,每个对象都会有一个对象头,它分为三个部分:
从表格可见,synchronized锁的信息是存在对象头里一个叫Mark Word的区域里的,考虑到虚拟机的空间效率,Mark Word被设计成非固定的数据结构,会根据对象的状态复用存储空间来存储不同的内容:
锁的升级
当JVM启用了偏向锁模式(JDK6以上默认开启),新创建对象的Mark Word是未锁定,未偏向但可偏向状态,此时Mark Word中的Thread id为0,表示未偏向任何线程,也叫做匿名偏向(anonymously biased)。
偏向锁状态--->无锁不可偏向状态/轻量级锁状态
当第一个线程尝试进入同步块时,发现Mark Word中线程ID为0,则会使用CAS将自己的线程ID设置到Mark Word中,并且,在当前线程栈中由高到低顺序找到可用的Lock Record,将线程ID记录下。完成这些,此线程就获取了锁对象的偏向锁。
当该偏向线程再次进入同步块时,发现锁对象偏向的就是当前线程,会往当前线程的栈中添加一条Displaced Mark Word为空的Lock Record中,用来统计重入的次数,然后继续执行同步块代码,因为线程栈是私有的,不需要CAS指令进行操作,所以在偏向锁模式下,同一个线程,只会执行一个CAS,之后获取释放锁只需要对Lock Record做操作,性能损耗基本可以忽略。
当另外一个线程试图进入同步块时,发现Mark Word中线程ID与自己不相符,这个时候就会引发偏向锁的撤销,变成无锁不可偏向状态或轻量级锁状态,当然,这只是宏观上的描述,严格意义上讲是不准确的,因为里面还存在重偏向机制,这里就不过于深入,在后续的文章中,我会专门出一篇文章,给各位看官详细介绍偏向锁到底是怎么回事。
无锁不可偏向状态--->轻量级锁状态
当锁对象变成无锁不可偏向状态时,多个线程运行到同步块以后,会检查锁对象状态值标志是否加锁,如果没有锁,就把锁对象的Mark Word信息拷贝存储到当前线程栈桢中Lock Record里,然后通过CAS尝试把对象的Mark Word的值改变成一个指向自己线程的指针。如果成功,则当前线程获得锁对象的轻量级锁,其他线程的CAS就会失败,因为锁对象的Mark Word已经变成一个新的指针了,必须等待线程释放锁,此时其他线程则通过自旋来竞争锁。当获取锁的线程执行完毕释放锁的时候,会将Lock Record里面之前拷贝的值还原到锁对象的Mark Word中。
轻量级锁状态--->重量级锁状态
当自旋次数超过JVM预期上限,会影响性能,所以竞争的线程就会把锁对象的Mark Word指向重锁,所谓的重锁,实际上就是一个堆上的monitor对象,即,重量级锁的状态下,对象的Mark Word为指向一个堆中monitor对象的指针。
然后所有的竞争线程放弃自旋,逐个插入到monitor对象里的一个队列尾部,进入阻塞状态。
当成功获取轻量级锁的线程执行完毕,尝试通过CAS释放锁时,因为Mark Word已经指向重锁,导致轻量级锁释放失败,这时线程就会知道锁已经升级为重量级锁, 它不仅要释放当前锁,还要唤醒其他阻塞的线程来重新竞争锁。
大概流程如下图所示:
这里有一点需注意的是:锁只能升级,不能降级。
锁的对比
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会堵塞,提高了程序的响音速度 | 始终得不到锁的线程,使用自旋会消耗CPU | 追求响应时间,同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较慢 |
synchronized的底层实现
synchronized无非以下两种:
1.对象锁:修饰非静态方法,修饰代码块
2.类锁:修饰静态方法,修饰代码块
其中按照修饰类型来分,又可以分为代码块同步和方法同步
代码块同步
代码块同步锁的是对象,使用monitorenter和monitorexit指令实现的。虽然我知道多一行代码少一位看官的定理,但是这里还是必须贴一张代码图,来证明我没有瞎说,是有理有据的“理据服”。
想要降服妖怪,就得先将其打回原形,所以我们先对一段简单的代码进行反编译,得到它的字节码。
final Object lock = new Object();
public int subtr(int i){
synchronized (lock){
return i-1;
}
}
字节码:
对象锁反编译
可以看出,monitorenter指令是在编译后插入到同步代码块的开始位置,monitorexit插入到同步代码块结束的地方,正常情况下monitorenter和monitorexit是一对一的匹配,而后面又出现了一个monitorexit,是因为那里是异常处,用来保证方法执行异常的时候,可以自动释放锁,而不会造成死锁。
方法同步
方法同步的实现官方没有透露,我们尝试对一个方法同步的代码进行反编译。
public synchronized int add(int i){
return i+1;
}
字节码:
同步方法反编译
从字节码里也看不到monitorenter和monitorexit,只能发现flags那里,多了一个ACC_SYNCHRONIZED的标示,没什么头绪。不过我猜想,底层应该是锁方法所属的对象或类。
这就是synchronized的大致原理,打回原形之后来看,是不是就觉得也不过如此?