接下来再看看调用 wait 的方法
没啥花头,就是将当前线程加入到 _waitSet 这个双向链表中,然后再执行 ObjectMonitor::exit
方法来释放锁。
接下来再看看调用 notify 的方法
也没啥花头,就是从 _waitSet 头部拿节点,然后根据策略选择是放在 cxq 还是 EntryList 的头部或者尾部,并且进行唤醒。
至于 notifyAll 我就不分析了,一样的,无非就是做了个循环,全部唤醒。
至此 synchronized 的几个操作都齐活了,出去可以说自己深入研究过 synchronized 了。
现在再来看下这个图,应该心里很有数了。
为什么会有_cxq 和 _EntryList 两个列表来放线程?
因为会有多个线程会同时竞争锁,所以搞了个 _cxq 这个单向链表基于 CAS 来 hold 住这些并发,然后另外搞一个 _EntryList 这个双向链表,来在每次唤醒的时候搬迁一些线程节点,降低 _cxq 的尾部竞争。
引入自旋
synchronized 的原理大致应该都清晰了,我们也知道了底层会用到系统调用,会有较大的开销,那思考一下该如何优化?
从小标题就已经知道了,方案就是自旋,文章开头就已经说了,这里再提一提。
自旋其实就是空转 CPU,执行一些无意义的指令,目的就是不让出 CPU 等待锁的释放。
正常情况下锁获取失败就应该阻塞入队,但是有时候可能刚一阻塞,别的线程就释放锁了,然后再唤醒刚刚阻塞的线程,这就没必要了。
所以在线程竞争不是很激烈的时候,稍微自旋一会儿,指不定不需要阻塞线程就能直接获取锁,这样就避免了不必要的开销,提高了锁的性能。
但是自旋的次数又是一个难点,在竞争很激烈的情况,自旋就是在浪费 CPU,因为结果肯定是自旋一会让之后阻塞。
所以 Java 引入的是自适应自旋,根据上次自旋次数,来动态调整自旋的次数,这就叫结合历史经验做事。
注意这是重量级锁的步骤,别忘了文章开头说的~。
至此,synchronized 重量级锁的原理应该就很清晰了吧? 小结一下
synchronized 底层是利用 monitor 对象,CAS 和 mutex 互斥锁来实现的,内部会有等待队列(cxq 和 EntryList)和条件等待队列(waitSet)来存放相应阻塞的线程。
未竞争到锁的线程存储到等待队列中,获得锁的线程调用 wait 后便存放在条件等待队列中,解锁和 notify 都会唤醒相应队列中的等待线程来争抢锁。
然后由于阻塞和唤醒依赖于底层的操作系统实现,系统调用存在用户态与内核态之间的切换,所以有较高的开销,因此称之为重量级锁。
所以又引入了自适应自旋机制,来提高锁的性能。
现在要引入轻量级锁了
我们再思考一下,是否有这样的场景:多个线程都是在不同的时间段来请求同一把锁,此时根本就用不需要阻塞线程,连 monitor 对象都不需要,所以就引入了轻量级锁这个概念,避免了系统调用,减少了开销。
在锁竞争不激烈的情况下,这种场景还是很常见的,可能是常态,所以轻量级锁的引入很有必要。
在介绍轻量级锁的原理之前,再看看之前 MarkWord 图。
轻量级锁操作的就是对象头的 MarkWord 。
如果判断当前处于无锁状态,会在当前线程栈的当前栈帧中划出一块叫 LockRecord 的区域,然后把锁对象的 MarkWord 拷贝一份到 LockRecord 中称之为 dhw(就是那个set_displaced_header 方法执行的)里。
然后通过 CAS 把锁对象头指向这个 LockRecord 。
轻量级锁的加锁过程:
如果当前是有锁状态,并且是当前线程持有的,则将 null 放到 dhw 中,这是重入锁的逻辑。
我们再看下轻量级锁解锁的逻辑:
逻辑还是很简单的,就是要把当前栈帧中 LockRecord 存储的 markword (dhw)通过 CAS 换回到对象头中。
如果获取到的 dhw 是 null 说明此时是重入的,所以直接返回即可,否则就是利用 CAS 换,如果 CAS 失败说明此时有竞争,那么就膨胀!
关于这个轻量级加锁我再多说几句。
每次加锁肯定是在一个方法调用中,而方法调用就是有栈帧入栈,如果是轻量级锁重入的话那么此时入栈的栈帧里面的 dhw 就是 null,否则就是锁对象的 markword。
这样在解锁的时候就能通过 dhw 的值来判断此时是否是重入的。
现在要引入偏向锁
我们再思考一下,是否有这样的场景:一开始一直只有一个线程持有这个锁,也不会有其他线程来竞争,此时频繁的 CAS 是没有必要的,CAS 也是有开销的。
所以 JVM 研究者们就搞了个偏向锁,就是偏向一个线程,那么这个线程就可以直接获得锁。
我们再看看这个图,偏向锁在第二行。
原理也不难,如果当前锁对象支持偏向锁,那么就会通过 CAS 操作:将当前线程的地址(也当做唯一ID)记录到 markword 中,并且将标记字段的最后三位设置为 101。
之后有线程请求这把锁,只需要判断 markword 最后三位是否为 101,是否指向的是当前线程的地址。
还有一个可能很多文章会漏的点,就是还需要判断 epoch 值是否和锁对象的类中的 epoch 值相同。
如果都满足,那么说明当前线程持有该偏向锁,就可以直接返回。
这 epoch 干啥用的?
可以理解为是第几代偏向锁。
偏向锁在有竞争的时候是要执行撤销操作的,其实就是要升级成轻量级锁。
而当一类对象撤销的次数过多,比如有个 Yes 类的对象作为偏向锁,经常被撤销,次数到了一定阈值(XX:BiasedLockingBulkRebiasThreshold,默认为 20 )就会把当代的偏向锁废弃,把类的 epoch 加一。
所以当类对象和锁对象的 epoch 值不等的时候,当前线程可以将该锁重偏向至自己,因为前一代偏向锁已经废弃了。
不过为保证正在执行的持有锁的线程不能因为这个而丢失了锁,偏向锁撤销需要所有线程处于安全点,然后遍历所有线程的 Java 栈,找出该类已加锁的实例,并且将它们标记字段中的 epoch 值加 1。
当撤销次数超过另一个阈值(XX:BiasedLockingBulkRevokeThreshold,默认值为 40),则废弃此类的偏向功能,也就是说这个类都无法偏向了。
至此整个 Synchronized 的流程应该都比较清楚了。
我是反着来讲锁升级的过程的,因为事实上是先有的重量级锁,然后根据实际分析优化得到的偏向锁和轻量级锁。
包括期间的一些细节应该也较为清楚了,我觉得对于 Synchronized 了解到这份上差不多了。
我再搞了张 openjdk wiki 上的图,看看是不是很清晰了:
最后
之所以分析源码,是因为看了资料,但是很多细节不清晰,然后很难受,所以没办法只能硬着头皮上了。
对于我这个 c++ 基本上不会的人来说,这个确实有点难度....断断续续写了一个星期。
其实没打算写这么多的,就只是想写自旋那一部分的...搞着搞着就停不下来了。
还有,如果有什么错误,赶紧联系我。
这文章代码有点多,不知道有多少人可以耐着性子看到这里...
我觉得看到这里的都是高手啊!能不能扣个 1 给我看看?