synchronized分析

该文章是本人学习记录总结的,有误请指出,感谢。

一开始学习Java时,介绍Java的同步机制那就必然是synchronized。但之后又了解到synchronized是一个重量级锁,所以应当尽量使用Lock。

之后又了解到Java1.6对synchronized进行了优化。

所以除非:

  • 业务需要获取锁可以被中断
  • 需要获取锁可以超时
  • 可以尝试着获取锁

的情况下使用Lock,应当尽量使用synchronized。代码更加简洁。

一、synchronized介绍

因为是Java语法提供的,也可以称为内置锁。

根据作为锁的对象不同,可分为

  • 对象锁
    • 声明在非静态方法上时,以当前类的实例作为锁。
    • 同步代码块中,以括号里的对象作为锁。
  • 类锁。
    • 声明在静态方法上时则以当前类的字节码对象作为锁也就是类锁。

二、synchronized实现

从上图可以看出synchronized代码块通过monitorenter和monitorexit指令实现,由JVM保证monitorenter保证有一个配对的monitorexit。

synchronized方法则没有特别的指令,而是在Class文件的方法表中将该方法的access_flags字段中的synchronized标志位置1,表示该方法是同步方法并使用调用该方法的对象或该方法所属的Class在JVM的内部对象表示Klass做为锁对象。

扫描二维码关注公众号,回复: 6778118 查看本文章

三、Java对象头和Monitor

先要了解一下Java对象头和monitor

1、对象头

对象头由三部分组成

1.1、Mark Word

Mark Word为一个字大小,即在32位JVM长度为32位,在64位JVM长度为64位。

因为Mark Word用于存储与对象自定义数据无关的数据,为了节省空间,会根据对象的状态不同存放不同的数据。

32位JVM存储格式:

状态(State) 25bit 4bit 1bit 2bit
23bit 2bit 是否偏向锁(biased_lock):1bit 锁标志位(lock):2bit
无锁(normal) 对象的散列值(identity_hashcode) 分代年龄(age) 0 01
偏向锁(Biased) 线程ID(threadID) 偏向时间戳(epoch) 分代年龄(age) 1 01
轻量级锁(Lightweight Locked) 指向栈中记录的指针(ptr_to_lock_record) 00
重量级锁(Heavyweight Locked) 指向管程的指针(ptr_to_heavyweight_monitor) 10
GC标记(Marked for GC) 空(null) 11

JDK1.6之后存在锁升级的概念,JVM对同步锁的处理随着竞争激烈,处理方式从偏向锁到轻量级锁再到重量级锁。

1.2、klass pointer

用于存储对象的类型指针,该指针指向它的元数据,大小为一个字。

1.3、array length(数组对象才有)

只有数组对象才有这部分数据

因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

参考:

Java对象头详解

Java的对象头和对象组成详解

2、Monitor Record

Monitor Record是线程私有的,每个线程都有一个Monitor Record列表,同时还有一个全局可用列表。每一个作为锁的对象都会与一个Monitor Record关联(对象头的MarkWord中的LockWord指向monitor record的起始地址)。

Owner
EntryQ
RcThis
Nest
HashCode
Candidate

Owner:初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;

EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。

RcThis:表示blocked或waiting在该monitor record上的所有线程的个数。

Nest:用来实现重入锁的计数。

HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。

Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程,1表示要唤醒一个继任线程来竞争锁。

四、synchronized的优化

锁粗化(Lock Coarsening)

也就是减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁。

锁消除(Lock Elimination)

通过运行时JIT编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本地Stack上进行对象空间的分配(同时还可以减少Heap上的垃圾收集开销)。

适应性自旋(Adaptive Spinning)

所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

偏向锁(Biased Locking)和轻量级锁(Lightweight Locking)

一开始我对于锁的了解就是拿到了就执行任务,拿不到就阻塞。

Java的线程时是映射到操作系统原生线程上的,线程的阻塞和唤醒都需要操作系统的介入,需要在用户态和核心态之间转换当,这种切换会消耗掉大量的系统资源(因为用户态和系统态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作 摘自:Java线程阻塞的代价)。

因此,JVM使用锁会逐步升级:无锁->偏向锁->轻量级锁->重量级锁

锁只能升级,不能降级

  1. ​ 初始没有线程使用锁,Mark Word为无锁状态

  2. 偏向锁:
    加锁
    • 测试Mark Word中是否指向当前线程,是的话表示当前线程已获取该锁,不是则判断是否是偏向锁。
    • 不是偏向锁则会CAS操作在对象头存储当前线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。
    • 若是偏向锁则表示当前锁有其它线程在使用,存在竞争,偏向锁会膨胀为轻量级锁。
    膨胀
    • 当前线程CAS获取锁失败,撤销偏向锁(不是释放的意思,偏向锁没有释放):发现锁存在竞争,

    等待全局安全点(此时间点,所有的工作线程都停了字节码的执行),通过ID找到已获得偏向锁的线程,挂起该线程,从该线程的Monitor Record列表获得一个空闲记录,并将锁对象的对象头设为轻量级锁状态,将Lock Record更新为指向该空闲记录的指针。到这里锁撤销完成,被挂起的线程继续运行。

    • 撤销完成后,对象可能处于两种状态,不可偏向的无锁状态和不可偏向的已锁状态。
      • 不可偏向的无锁状态:原本获取偏向锁的线程执行完了同步块。
      • 不可偏向的已锁状态:原本获取偏向锁的线程未执行完了同步块。此时对象应该被转换为轻量级加锁的状态。
    批量再偏向:

    ​ 偏向锁这个机制很特殊, 别的锁在执行完同步代码块后, 都会有释放锁的操作, 而偏向锁并没有直观意义上的“释放锁”操作。

    那么作为开发人员, 很自然会产生的一个问题就是, 如果一个对象先偏向于某个线程, 执行完同步代码后, 另一个线程就不能直接重新获得偏向锁吗? 答案是可以, JVM 提供了批量再偏向机制(Bulk Rebias)机制

    该机制的主要工作原理如下:

    • 引入一个概念 epoch, 其本质是一个时间戳 , 代表了偏向锁的有效性
    • 从前文描述的对象头结构中可以看到, epoch 存储在可偏向对象的 MarkWord 中。
    • 除了对象中的 epoch, 对象所属的类 class 信息中, 也会保存一个 epoch 值
    • 每当遇到一个全局安全点时, 如果要对 class C 进行批量再偏向, 则首先对 class C 中保存的 epoch 进行增加操作, 得到一个新的 epoch_new
    • 然后扫描所有持有 class C 实例的线程栈, 根据线程栈的信息判断出该线程是否锁定了该对象, 仅将 epoch_new 的值赋给被锁定的对象中。
    • 退出安全点后, 当有线程需要尝试获取偏向锁时, 直接检查 class C 中存储的 epoch 值是否与目标对象中存储的 epoch 值相等, 如果不相等, 则说明该对象的偏向锁已经无效了, 可以尝试对此对象重新进行偏向操作。
  3. 轻量级锁:
    加锁:
    1. 通过对象判断锁对象对象头是否是无锁状态,是则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储所对象的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),拷贝成功后,尝试CAS操作将Mark Word的Lock Record更新为指向moniter record的指针。若更新失败,则表示竞争很激烈,需要膨胀为重量级锁。
    2. 锁对象处于不可偏向无锁状态,多个线程CAS操作试图将Mark Word更新为指向自己的Monitor Word的指针,更新成功的获取到锁,失败的线程进入情况4。
    3. 锁对象处于不可偏向的已锁状态,同时Mark Word中是指向自己的Monitor Word,这就是重入(reentrant)锁的情况,只需要简单的将Nest加1即可。不需要任何原子操作,效率非常高。
    4. 锁对象处于不可偏向的已锁状态,同时Mark Word中不是指向自己的Monitor Word,线程自旋一定次数仍然获取失败后膨胀。
    释放锁:
    1. 首先检查该对象是否处于膨胀状态并且该线程是这个锁的拥有者,如果发现不对则抛出异常;
    2. 检查Nest字段是否大于1,如果大于1则简单的将Nest减1并继续拥有锁,如果等于1,则进入到第3步;
    3. 检查rfThis是否大于0,设置Owner为NULL然后唤醒一个正在阻塞或等待的线程再一次试图获取锁,如果等于0则进入到第4步
    4. 缩小(deflate)一个对象,通过将对象的LockWord置换回原来的HashCode值来解除和monitor record之间的关联来释放锁,同时将monitor record放回到线程是有的可用monitor record列表。
  4. 重量级锁:

    重量级锁依赖于操作系统的互斥量(mutex) 实现。

  5. 小结

    偏向锁、轻量级锁、重量级锁适用于不同的并发场景:

    • 偏向锁:无实际竞争,且将来只有第一个申请锁的线程会使用锁。
    • 轻量级锁:无实际竞争,多个线程交替使用锁;允许短时间的锁竞争。
    • 重量级锁:有实际竞争,且锁竞争时间长。

    另外,如果锁竞争时间短,可以使用自旋锁进一步优化轻量级锁、重量级锁的性能,减少线程切换。
    如果锁竞争程度逐渐提高(缓慢),那么从偏向锁逐步膨胀到重量锁,能够提高系统的整体性能。

参考:

Java中的偏向锁,轻量级锁, 重量级锁解析

Java中synchronized的实现原理与应用

Java的对象头和对象组成详解

【java并发编程实战4】偏向锁-轻量锁-重量锁的那点秘密(synchronize实现原理)

整篇参考:

JVM内部细节之一:synchronized关键字及实现细节(轻量级锁Lightweight Locking)

【死磕Java并发】—–深入分析synchronized的实现原理

猜你喜欢

转载自www.cnblogs.com/rynar/p/11145397.html