且听我一个故事讲透一个锁原理之synchronized

7次阅读

共计 4440 个字符,预计需要花费 12 分钟才能阅读完成。

微信公众号:IT 一刻钟大型现实非严肃主义现场一刻钟与你分享优质技术架构与见闻,做一个有剧情的程序员关注可第一时间了解更多精彩内容,定期有福利相送哟。

故事从这里展开
蜀国有一个皇帝叫蜀道难,他比较难伺候,别的皇帝早朝都是在大殿上同时接见所有大臣,共商国是。他不一样,他说早朝你们不要有事没事都跑过来叽叽喳喳,有事则来,无事则该干啥干啥去,然后安排太监每天早上在大门口守着,每次只允许一个大臣进来汇报情况。“你敢多放进来一个就砍脑袋的干活。“太监赶紧下跪,说“谪!“。第一天,太监传话钦天监求见,皇帝允了,钦天监上殿报曰:”臣禀报,昨日我司夜观星象,西方忽现王星忽明忽暗,恐戎狄那边有乱。““朕知道了,退下吧”。一日无事。第二天,太监传话钦天监求见,皇帝允了。一日无事。第三天,太监传话钦天监求见 …… 一日无事。第四天,钦天监 …… 一日无事。第五天,皇帝不耐烦了,和贾太监说,钦天监这老家伙整天是不是闲着没事,以后他来了不用给我禀报,直接放他上殿讲,讲完让他走吧。国泰民安的日子依旧过着,每天只有钦天监一个人来报告,贾太监每次看到是钦天监来了,也懒得搭理了,直接放他进去了。(这就是偏向锁,稍后我细细道来)又一日,钦天监如往常进殿报道,贾太监站在门口打着盹,忽然耳边传来一个声音:“贾太监,帮我禀告圣上,工部李尚书求见。”“emmm… 进去吧 … 嗯?等等,尚书大人你先等等,钦天监在里面,你等会再来求见吧。”太监一阵后怕,寻思着钦天监还在里面呢,这要是放进去了,我这脑袋可就没了,果然嗜睡误事。过了一会儿,李尚书回来询问求见,被告知钦天监还没走,只好又离去。又过了一会儿,李尚书又回来询问求见,正巧钦天监走了,太监进殿传话说工部李尚书求见,皇帝宣觐见,李尚书进殿上报了一番东南连连大雨,已派人去监察水利,修缮河堤。(这就是轻量级锁)忽一日,西戎狄和北匈奴同时对帝国西方和北方发难,前线战事消息如片片雪花纷纷涌入京城,瞬间殿外来了一群大臣有要事禀告。一会儿这个来问贾公公我可以进去了吗?一会儿那个来问贾公公我可以进去了吗?把贾太监累的哟,一天下来光说“稍后再来”都把嘴皮子磨破了,没几日,贾太监就跪在皇帝面前哭泣道:“圣上啊,快想想办法呀,奴才这身子骨就要交代在门口了。”皇帝一听,说你傻啊,叫他们一个个在门外排队啊,谁叫你要他们稍后来求见的。贾太监细思大喜,觉得有理,次日在门口竖起一个牌子“禀报要事者,这边排队”,贾太监再也不用一个人对着一群人反复回话,只需要每次出来一个,然后传话放进去一个,就可以了。(这就是重量级锁)上面这个故事,分别讲述了 synchronized 内部四种级别的状态,分别是:无锁状态,偏向锁状态,轻量级锁状态,重量级锁状态。
重量级锁状态
我们首先从重量级锁开始讲,重量级锁是通过互斥量(Mutex)来实现的,即一个线程进入了 synchronized 同步块,在未完成任务时,会阻塞后面的所有线程。就像上面的故事所讲的,要禀告要事的大臣只能在大殿门口外一个接一个的阻塞排队。之所以称它为重量级锁,是因为 Java 线程是映射到操作系统的原生线程上的,如果要阻塞或唤醒一个线程,都需要依靠操作系统从当前用户态转换到核心态中,这种状态转换需要耗费处理器很多时间,对于简单同步块,可能状态转换时间比用户代码执行时间还要长,导致实际业务处理所占比偏小,性能损失较大。当然这个在虚拟机层面进行了一些比如自旋等待,锁粗化等等的优化,避免陷入频繁的切换状态。在这里我就不细讲了,有兴趣的可以关注我,我后续再和各位看官讲上一讲。
轻量级锁状态
轻量级锁是 JDK6 引入的,它的轻量是相较于通过系统互斥量实现的传统锁,轻量锁并不是用来取代重量级锁的,而是在没有大量线程竞争的情况下,减少系统互斥量的使用,降低性能的损耗。轻量级锁是通过 CAS(Compare And Swap)机制实现的,即如果锁被其他线程所占用,当前线程会通过自旋来获取锁,从而避免用户态与核心态的转换。就像上面故事所说的,大殿中钦天监在汇报工作,工部尚书要求见,并不需要贾太监每次都进去问一下皇帝,惹得皇帝龙颜大怒,而是大臣自己隔一段时间便来询问贾太监能不能进去,不能就稍后再来问,直到可以进去为止。
偏向锁状态
偏向锁也是 JDK6 引入的,它存在的依据是“大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得”。它是通过记录第一次进入同步块的线程 id 来实现的,如果下一个要进入同步块的线程与记录的线程 id 相同,则说明这个锁由此线程占有,可以直接进入到同步块,不用执行 CAS。就像故事中的,如果每天只有钦天监一个人来的话,就不用贾太监禀告了,贾太监每次一看到钦天监,寻思着,哟,钦天监呢,您自个儿直接进去吧,说完自个儿出来吧。如果说轻量锁是为了消除系统互斥量带来的性能损耗,那么偏向锁就是为了消除 CAS 带来的性能损耗,使之在无竞争的情况下消除整个同步,性能无限接近非同步。
如何通过这四种状态实现性能大幅度提升的
Java 对象头
要说这个问题,我们需要先讲一下 Java 对象头,每个对象都会有一个对象头,它分为三个部分:

内容
说明

Mark Word
存储对象的 hashcode 或锁信息

Class Metadata Address
存储到对象类型数据的指针

Array length
数组的长度(如果当前对象是数组)

从表格可见,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 的大致原理,打回原形之后来看,是不是就觉得也不过如此?有什么疑问或更好的解读,可以在下方留言,我们进行愉快友好的磋商交流。如果觉得有用,记得分享~

正文完
 0