synchronized 中的锁升级

本文详细解析了Java内存模型中的偏向锁、轻量级锁和重量级锁的工作原理,包括它们的升级流程、引入原因以及JVM如何通过锁消除和锁粗化优化性能。重点讨论了synchronized锁的使用和内存管理机制。
摘要由CSDN通过智能技术生成

目录

偏向锁 

轻量级锁

重量级锁:

锁消除和锁粗化


synchronized 的锁升级流程:

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁   // 随着竞争的增加,只能锁升级,不能降级

偏向锁 

为什么要引入偏向锁?

因为经过HotSpot的作者大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。

偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程之后重新获取该锁不再需要同步操作:

  • 当锁对象第一次被线程获得的时候进入偏向状态,标记为 101,同时使用 CAS 操作将线程 ID 记录到 Mark Word。如果 CAS 操作成功,这个线程以后进入这个锁相关的同步块,查看这个线程 ID 是自己的就表示没有竞争,就不需要再进行任何同步操作

  • 当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定或轻量级锁状态

偏向锁的升级:

当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致。

如果一致(还是线程1获取锁对象),表示偏向锁是偏向于当前线程的,则无需使用CAS来加锁、解锁了,直接进入同步;
如果不一致,那么需要查看Java对象头中记录的线程1是否处于同步块中。
如果已经退出同步块,则将对象头设置成无锁状态并撤销偏向锁,重新偏向。
如果处于同步块中,它还没有执行完,其它线程来抢夺,该偏向锁会被取消掉并出现锁升级。此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁。
锁升级过程中Mark Word的改变。从无锁升级到偏向锁,Mark Word的后三位会从001变为101。并且Mark Word将执行持有偏向锁的线程id。

一个对象创建时:

  • 如果开启了偏向锁(默认开启),那么对象创建后,MarkWord 值为 0x05 即最后 3 位为 101,thread、epoch、age 都为 0

  • 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 JVM 参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟。(JDK 8 延迟 4s 开启偏向锁原因:在刚开始执行代码时,可能会有好多线程来抢锁,如果开偏向锁效率反而降低)

  • 当一个对象已经计算过 hashCode,就再也无法进入偏向状态了

  • 添加 VM 参数 -XX:-UseBiasedLocking 禁用偏向锁

撤销偏向锁的状态:

  • 调用对象的 hashCode:偏向锁的对象 MarkWord 中存储的是线程 id,调用 hashCode 导致偏向锁被撤销

  • 当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁

  • 调用 wait/notify,需要申请 Monitor,进入 WaitSet

批量撤销

  • 如果对象被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID
  • 批量重偏向:当撤销偏向锁阈值超过 20 次后,JVM 会觉得是不是偏向错了,于是在给这些对象加锁时重新偏向至加锁线程
  • 批量撤销:当撤销偏向锁阈值超过 40 次后,JVM 会觉得自己确实偏向错了,根本就不该偏向,于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的

当产生竞争时,偏向锁升级为轻量级锁

轻量级锁

轻量级锁的本质就是自旋锁

为什么要引入轻量级锁?

轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。

升级为轻量锁的时机 : 当关闭偏向锁功能或多线程竞争偏向锁会导致偏向锁升级为轻量级锁

轻量级锁的加锁过程:

  • 创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,存储锁定对象的 Mark Word

  • 让锁记录中 Object reference 指向锁住的对象,并尝试用 CAS 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录

  • 如果 CAS 替换成功,对象头中存储了锁记录地址和状态 00(轻量级锁) ,表示由该线程给对象加锁

  • 如果 CAS 失败,有两种情况:

    • 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进行自旋操作,如果自旋次数过多,进入锁膨胀过程

    • 如果是线程自己执行了 synchronized 锁重入,就添加一条 Lock Record 作为重入的计数

  • 当退出 synchronized 代码块(解锁时)

    • 如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减 1

    • 如果锁记录的值不为 null,这时使用 CAS 将 Mark Word 的值恢复给对象头

      • 成功,则解锁成功

      • 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

自适应自旋锁

JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

轻量级锁升级情况:

  • 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁

  • Thread-1 加轻量级锁失败,进入锁膨胀流程:为 Object 对象申请 Monitor 锁,通过 Object 对象头获取到持锁线程,将 Monitor 的 Owner 置为 Thread-0,将 Object 的对象头指向重量级锁地址,然后自己进入 Monitor 的 EntryList BLOCKED

  • 当 Thread-0 退出同步块解锁时,使用 CAS 将 Mark Word 的值恢复给对象头失败,这时进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程

重量级锁:

每个Java对象都可以关联一个操作系统的Monitor,如果使用synchronized给对象上锁(重量级),该对象头的MarkWord中就被设置为指向Monitor对象的指针。Monitor 也是 class,其实例存储在堆中,这就是重量级锁。

工作流程:

  • 开始时 Monitor 中 Owner 为 null

  • 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor 中只能有一个 Owner,obj 对象的 Mark Word 指向 Monitor,把对象原有的 MarkWord 存入线程栈中的锁记录

  • 在 Thread-2 上锁的过程,Thread-3、Thread-4、Thread-5 也执行 synchronized(obj),就会进入 EntryList BLOCKED(双向链表)

  • Thread-2 执行完同步代码块的内容,根据 obj 对象头中 Monitor 地址寻找,设置 Owner 为空,把线程栈的锁记录中的对象头的值设置回 MarkWord。

  • 唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的,如果这时有新的线程想要获取锁,可能直接就抢占到了,阻塞队列的线程就会继续阻塞

  • WaitSet 中的 Thread-0,是以前获得过锁,但条件不满足进入 WAITING 状态的线程(wait-notify 机制)

注意:

  • synchronized 必须是进入同一个对象的 Monitor 才有上述的效果

  • 不加 synchronized 的对象不会关联监视器,不遵从以上规则

字节码

代码:

public static void main(String[] args) {
    Object lock = new Object();
    synchronized (lock) {
        System.out.println("ok");
    }
}
0:  new             #2      // new Object
3:  dup
4:  invokespecial   #1      // invokespecial <init>:()V,非虚方法
7:  astore_1                // lock引用 -> lock
8:  aload_1                 // lock (synchronized开始)
9:  dup                     // 一份用来初始化,一份用来引用
10: astore_2                // lock引用 -> slot 2
11: monitorenter            // 【将 lock对象 MarkWord 置为 Monitor 指针】
12: getstatic       #3      // System.out
15: ldc             #4      // "ok"
17: invokevirtual   #5      // invokevirtual println:(Ljava/lang/String;)V
20: aload_2                 // slot 2(lock引用)
21: monitorexit             // 【将 lock对象 MarkWord 重置, 唤醒 EntryList】
22: goto 30
25: astore_3                // any -> slot 3
26: aload_2                 // slot 2(lock引用)
27: monitorexit             // 【将 lock对象 MarkWord 重置, 唤醒 EntryList】
28: aload_3
29: athrow
30: return
Exception table:
    from to target type
      12 22 25      any
      25 28 25      any
LineNumberTable: ...
LocalVariableTable:
    Start Length Slot Name Signature
        0   31      0 args [Ljava/lang/String;
        8   23      1 lock Ljava/lang/Object;

说明:

  • 通过异常 try-catch 机制,确保一定会被解锁

  • 方法级别的 synchronized 不会在字节码指令中有所体现

  • 当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。

  • 在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。
  • 在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

锁消除和锁粗化

锁消除

Java虚拟机在JIT(Just In Time Compiler,一般翻译为即时编译器)编译时(可以简单理解为当某段代码即将第一次被执行时进行编译),通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。

关闭锁消除:java -XX:-EliminateLocks -jar benchmarks.jar

锁粗化

按理来说,同步块的作用范围应该尽可能小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。 但是加锁解锁也需要消耗资源,如果存在一系列的连续加锁解锁操作,可能会导致不必要的性能损耗。
锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值