浅析 synchronized 底层实现与锁升级过程
在 Java 中,synchronized 关键字是用来控制线程同步的。就是在多线程的环境下,控制 synchronized 代码段不被多个线程同时执行。
那么 synchronized 具体是怎么做到线程同步的呢?还有锁升级过程的过程是怎样的的?我们来探讨一下。
一、synchronized 实现细节
1.1 Java 代码实现
我们先来了看下如果多线程间竞争共享资源,不采取措施会出现什么情况:
线程 2 将 count 减到了 97,线程 3、线程 1 在某一刻也做了 count--,但是结果却也是 97,说明他们在做 count--的时候并不知道有别的线程也操作了 count。
这个问题,相信大家都知道加 synchronized 可以解决。
对 run 方法作如下修改:
执行 count--有条不紊,不会出现不安全的问题。
因此,在代码层面,加关键字 synchronized 能解决上述线程安全问题。
1.2 字节码层面如何实现 synchronized
如果使用 IDEA 的话,这里推荐安装一个 jclasslib Bytecode viewer,这个插件可以很方便的看程序字节码执行指令:
我们来看一下刚才的程序字节码指令:
实际上 synchronized 的实现从字节码层面来看,就是 monitorenter 和 monitorexit 指令,这两个就可以实现 synchronized 了。
「monitorenter」:
Java 对象天生就是一个 Monitor,当 monitor 被占用,它就处于锁定的状态。
每个对象都与一个监视器关联。且只有在有线程持有的情况下,监视器才被锁定。
执行 monitorenter 的线程尝试获得 monitor 的所有权:
如果与 objectref 关联的监视器的条目计数为 0,则线程进入监视器,并将其条目计数设置为 1。然后,该线程是 monitor 的所有者。
如果线程已经拥有与 objectref 关联的监视器,则它将重新进入监视器,从而增加其条目计数。这个就是锁重入。
如果另一个线程已经拥有与 objectref 关联的监视器,则该线程将阻塞,直到该监视器的条目计数为零为止,然后再次尝试获取所有权。
「monitorexit」:
一个或多个 MonitorExit 指令可与 Monitorenter 指令一起使用,它们共同实现同步语句。
尽管可以将 monitorenter 和 monitorexit 指令用于提供等效的锁定语义,但它们并未用于同步方法的实现中。
JVM 在完成 monitorexit 时的处理方式分为正常退出和出现异常时退出:
常规同步方法完成时监视器退出由 Java 虚拟机的返回指令处理。也就是说程序正常执行完毕的时候,JVM 有一个指令会隐式的完成 monitor 的退出---monitorexit,这个指令是 athrow。
如果同步语句出现了异常时,JVM 的异常处理机制也能 monitorexit。
简单的加锁解锁过程
因此,执行同步代码块后首先要执行 monitorenter 指令,退出的时候 monitorexit 指令。
1.3 JVM 层实现
执行结果:
没有加 synchronized 的时候,对象头信息的值为 01 00 00 00,加了锁之后,对象头变了 08 f3 7f 02,说明 synchronized 会修改对象的头新信息,对象头在 Hotspot 里面叫做 markword。
一个对象的 markword 里面有非常重要的信息,其中最重要的就是锁 synchronized。(markword 里还有 GC 的信息,还有 hashcode 的信息。)
「Hotspot 实现的 JVM 在 64 位机的 markword 信息」:
二、锁升级过程
2.1 升级过程
在 JDK 早期的时候,synchronized 的底层实现是重量级的,所谓重量级,就是它直接去找操作系统去申请锁,它的效率是很低的。
JDK 后来对 synchronized 锁进行了优化,这样才有了锁升级的概念。
锁升级的过程大致是这样的:
new -> 「偏向锁」 -> 「轻量级锁 (自旋锁)」-> 「重量级锁」
synchronized 优化的过程和 markword 息息相关。
用 markword 中最低的三位代表锁状态,其中 1 位是偏向锁位,最后两位是普通锁位。
Object o = new Object()
锁 = 0 01 无锁态
注意:如果偏向锁打开,默认是匿名偏向状态
hashCode()
001 + hashcode
默认 synchronized(o)
00 -> 轻量级锁
默认情况,偏向锁有个时延,默认是 4 秒
why? 因为 JVM 虚拟机自己有一些默认启动的线程,里面有好多 sync 代码,这些 sync 代码启动时就知道肯定会有竞争,如果使用偏向锁,就会造成偏向锁不断的进行锁撤销和锁升级的操作,效率较低。
可以用 BiasedLockingStartupDelay 参数设置是否启动偏向锁(=0,立即启动偏向锁):
如果启动了偏向锁
锁升级过程:new Object () - > 101 偏向锁 ->线程 ID 为 0 -> Anonymous BiasedLock
打开偏向锁,new 出来的对象,默认就是一个可偏向匿名对象 101
如果有线程上锁
上偏向锁,指的就是,把 markword 的线程 ID 改为自己线程 ID 的过程。
偏向锁不可重偏向、批量偏向、批量撤销
如果有线程竞争
撤销偏向锁,升级为轻量级锁
线程在自己的线程栈生成 LockRecord ,用 CAS 操作将 markword 设置为指向自己这个线程的 LR 的指针,设置成功者得到锁
如果竞争加剧
竞争加剧:有线程超过 10 次自旋, (-XX:PreBlockSpin 参数可调),或者自旋线程数超过 CPU 核数的一半, JDK 1.6 之后,加入自适应自旋 Adapative Self Spinning ,JVM 自己控制。
升级重量级锁:向操作系统申请资源,linux mutex , CPU 从 3 级-0 级系统调用,线程挂起,进入等待队列,等待操作系统的调度,然后再映射回用户空间。
总结一下,锁升级的过程大概是这样的:
2.2 为什么有自旋锁了还需要重量级锁
自旋是消耗 CPU 资源的,如果锁的时间长,或者自旋线程多,CPU 会被大量消耗。
重量级锁有等待队列,所有拿不到锁的进入等待队列,不需要消耗 CPU 资源
2.3 偏向锁是否一定比自旋锁效率高?
不一定,在明确知道会有多线程竞争的情况下,偏向锁肯定会涉及锁撤销,这时候直接使用自旋锁。
JVM 启动过程,会有很多线程竞争(明确),所以默认情况启动时不打开偏向锁,过一段儿时间再打开。
2.4 synchronized 最底层实现
在硬件层面,锁其实是执行了 lock cmpxchg xx 指令。
synchronized 在字节码层面:
如果锁的是方法,jvm 会加一个 synchronized 修饰符;
如果是同步代码快,就是用 monitorenter 和 monitorexit 指令。
当 jvm 看到了 synchronized 修饰符或者 monitorenter 和 monitorexit 的时候,对应的就是 C++调用操作系统提供的同步机制。
CPU 级别是使用 lock 指令来实现的。
比如,我们要在 synchronized 某一块内存上设置一个数 i,把 i 的值从 0 变成 1,这个过程放在 CPU 执行可能会有好几条指令或者不能同步(速度太快),所以需要有个 lock 指令。
cmpxchg 前面如果加了一个 lock 的话,后面的指令执行过程中对这块区域进行锁定,只有这条指令可以修改,其他指令是不能操作的。
三、小结
Java 对象头 「markword」 在 Hotspot 虚拟机中,对象在内存中的布局分为三块区域: 对象头实例数据对齐填充
Java 对象头是实现 synchronized 的锁对象的基础,一般而言,synchronized 使用的锁对象是存储在 Java 对象头里。它是轻量级锁和偏向锁的关键。
「monitor」
一个同步工具,也可以描述为一种同步机制。
为什么每个对象都可以成为锁呢? 因为每个 Java Object 在 JVM 内部都有一个 native 的 C++ 对象 oop/oopDesc 与之对应,而对应的 oop/oopDesc 都会存在一个 markOop 对象头,而这个对象头是存储锁的位置,里面还有对象监视器,即 ObjectMonitor,所以这也是为什么每个对象都能成为锁的原因之一。
「锁升级(优化)过程」
synchronized 的锁是进行过优化的,引入了偏向锁、轻量级锁;锁的级别从低到高逐步升级, 无锁->偏向锁->轻量级锁->重量级锁。
「偏向锁」
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需简单地测试一下对象头的 markword 里是否存储着指向当前线程的偏向锁。
开启:「-XX:BiasedLockingStartupDelay=0」
「自旋锁」
自旋等待的时间或者次数是有一个限度的,如果自旋超过了定义的时间仍然没有获取到锁,则该线程应该被挂起。
JDK1.6 中-XX:+UseSpinning 开启; -XX:PreBlockSpin=10 为自旋次数; JDK1.7 后,去掉此参数,由 jvm 控制。
「重量级锁」
重量级锁通过对象内部的监视器(monitor)实现,其中 monitor 的本质是依赖于底层操作系统的 Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。
作者:行百里 er
链接:https://juejin.im/post/6888112467747176456
来源:掘金
评论