写点什么

浅析 synchronized 底层实现与锁升级过程

  • 2023-03-16
    湖南
  • 本文字数:4392 字

    阅读完需:约 14 分钟

在 Java 中,synchronized 关键字是用来控制线程同步的。就是在多线程的环境下,控制 synchronized 代码段不被多个线程同时执行。


那么 synchronized 具体是怎么做到线程同步的呢?还有锁升级过程的过程是怎样的的?我们来探讨一下。

一、synchronized 实现细节

1.1 Java 代码实现

我们先来了看下如果多线程间竞争共享资源,不采取措施会出现什么情况:

public class TestSync implements Runnable {
private int count = 100;
public static void main(String[] args) { TestSync ts = new TestSync(); Thread t1 = new Thread(ts, "线程1"); Thread t2 = new Thread(ts, "线程2"); Thread t3 = new Thread(ts, "线程3");
t1.start(); t2.start(); t3.start(); }
@Override public void run() { while (true) { if (count > 0) { count--; System.out.println(Thread.currentThread().getName() + " count = " + count); } else { break; } } }}
复制代码


线程 2 将 count 减到了 97,线程 3、线程 1 在某一刻也做了 count--,但是结果却也是 97,说明他们在做 count--的时候并不知道有别的线程也操作了 count。


这个问题,相信大家都知道加 synchronized 可以解决。


对 run 方法作如下修改:

@Overridepublic void run() {    while (true) {        synchronized (this) {            if (count > 0) {                count--;                System.out.println(Thread.currentThread().getName() + " count = " + count);            } else {                break;            }        }    }}
复制代码


执行 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 层实现

public static void main(String[] args) {    Object o = new Object();    System.out.println(ClassLayout.parseInstance(o).toPrintable());
synchronized (o) { System.out.println(ClassLayout.parseInstance(o).toPrintable()); }}
复制代码

执行结果:

java.lang.Object object internals: OFFSET  SIZE   TYPE DESCRIPTION                               VALUE      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)      8     4        (object header)                           e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)     12     4        (loss due to the next object alignment)Instance size: 16 bytesSpace losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 08 f3 7f 02 (00001000 11110011 01111111 00000010) (41939720) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397) 12 4 (loss due to the next object alignment)Instance size: 16 bytesSpace losses: 0 bytes internal + 4 bytes external = 4 bytes total
复制代码

没有加 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 位是偏向锁位,最后两位是普通锁位。


  1. Object o = new Object()


锁 = 0 01 无锁态


注意:如果偏向锁打开,默认是匿名偏向状态


  1. hashCode()


001 + hashcode


  1. 默认 synchronized(o)


00 -> 轻量级锁


默认情况,偏向锁有个时延,默认是 4 秒


why? 因为 JVM 虚拟机自己有一些默认启动的线程,里面有好多 sync 代码,这些 sync 代码启动时就知道肯定会有竞争,如果使用偏向锁,就会造成偏向锁不断的进行锁撤销和锁升级的操作,效率较低。


可以用 BiasedLockingStartupDelay 参数设置是否启动偏向锁(=0,立即启动偏向锁):

-XX:BiasedLockingStartupDelay=0
复制代码
  1. 如果启动了偏向锁


锁升级过程:new Object () - > 101 偏向锁 ->线程 ID 为 0 -> Anonymous BiasedLock

打开偏向锁,new 出来的对象,默认就是一个可偏向匿名对象 101


  1. 如果有线程上锁


上偏向锁,指的就是,把 markword 的线程 ID 改为自己线程 ID 的过程。

偏向锁不可重偏向、批量偏向、批量撤销


  1. 如果有线程竞争


撤销偏向锁,升级为轻量级锁


线程在自己的线程栈生成 LockRecord ,用 CAS 操作将 markword 设置为指向自己这个线程的 LR 的指针,设置成功者得到锁


  1. 如果竞争加剧


竞争加剧:有线程超过 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

来源:掘金

用户头像

还未添加个人签名 2021-07-28 加入

公众号:该用户快成仙了

评论

发布
暂无评论
浅析synchronized底层实现与锁升级过程_Java_做梦都在改BUG_InfoQ写作社区