Java 知识 -- synchronize 关键字原理

本文最后更新于:3 天前

预备知识1:对象头的标记字 MarkWord

对象头

预备知识2:轻量级锁的实现机制:锁记录 Lock Record

锁记录的位置:栈的栈帧中的一部分

synchronized-lock-record-position

将锁对象的 Mark Word 复制到锁记录中,这个复制过来的记录叫做 Displaced Mark Word。具体来讲,是将 Mark Word 放到锁记录的 displaced_header 属性中。

synchronize-lock-record

当虚拟机使用 CAS 操作尝试将锁对象的 Mark Word 更新为指向锁记录的指针。如果更新成功,这个线程就真正获得了该对象的锁:

synchronize-light-weight-lock-acquire

重入的情况:

synchronize-light-weight-lock-reentrant

为什么JVM选择在线程栈中添加 Displaced Mark word 为 null 的 Lock Record 来表示重入计数呢?

首先,锁重入次数是一定要记录下来的,因为每次解锁都需要对应一次加锁,解锁次数等于加锁次数时,该锁才真正的被释放,也就是在解锁时需要用到说锁重入次数的。

一个简单的方案是将锁重入次数记录在对象头的 Mark Word 中,但 JDK 很明显没有这么做,原因是对象头的空间有限,轻量级锁已经占了62位了,没法存储重入次数。

另一个方案是只创建一个 Lock Record 并在其中记录重入次数,Hotspot没有这样做的原因可能是考虑到效率的因素:每次重入获得锁都需要遍历该线程的栈找到对应的Lock Record,然后修改它的值,效率太低。所以 JDK 就使用每次增加 Lock Record 的方式了

锁的几种状态

  1. 无锁可偏向

  2. 无锁不可偏向

  3. 偏向锁

  4. 轻量级锁

  5. 重量锁

偏向锁

  • 作用:如果一个线程连续不停的获取锁,那么获取的过程中如果没有发生竞态,那么可以跳过繁重的同步过程,直接就获得锁执行,这样可以大大提高一个对象在一段很长的时间内都只被一个线程用做锁对象场景下的性能

    因为轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。

  • 锁特性:不用实际加锁,偏向某一个线程

轻量级锁

  • 作用:在并发不高的情况下,存在多个线程交替执行,只有少量竞争,通过自旋和 CAS 处理加锁部分,防止出现系统调用而影响效率

    偏向锁是在只有一个线程执行同步块时提高性能,而轻量级锁是为了在线程交替执行同步块时提高性能

  • 特性:获取锁的线程执行,等待的线程通过 CAS + 自旋的方式等待

重量级锁

  • 作用:通过系统调用阻塞等待线程

  • 特性:通过 mutex 函数进行系统调用

传统的方式就是重量级锁,说是重量级的原因是同步的方式是一种悲观锁,会导致线程的状态切换,而线程状态的切换是一个很影响效率的操作。

具体流程

synchronize-over-all

无锁可偏向到有锁已偏向

首先一个新的对象处于无锁可偏向状态(101),这个时候如果有个线程 A 来获取这个对象锁,那么就通过 CAS 设置这个线程的线程 id,如果成功则锁对象进入有锁已偏向状态(001),下次当这个线程 A 在此获取这个锁的时候,只要状态没有发生变化,所需要的开销就是一次指针的比较运算,而这个运算是非常轻量的。而如果 CAS 获取锁失败,说明存在多线程竞争,那么会进一步进行锁升级为轻量级锁。

而这个线程 A 会再次进入同步块,在经过一些额外的检查之后,会往当前线程栈中添加一条 Displaced Mark Word 为 null 的 Lock Record,然后程序继续向下执行。

如果这个线程执行结束,并不会主动进行偏向锁的释放

偏向锁到轻量级锁

如果存在另外一个线程 B 也来加锁,那么锁的偏向状态会结束,触发偏向锁的撤销并升级的逻辑,这个时候可以根据线程 A 的执行情况分为如下两种(持有锁的线程是线程 A,竞争的是线程 B):

  1. 线程 B 到达的时候,线程 A 已经结束

  2. 线程 B 到达的时候,线程 A 还没有终止

无论是哪种情况,由于偏向锁不会主动释放,此时锁对象都处于有锁已偏向(101)的状态

由于对象的状态是有锁已偏向,但是又不是偏向的 B 线程,所以都会经历一个锁升级过程,但是还有不同的的地方:JVM 会在安全点 safe point 检查偏向的线程是否存活。

  1. 如果线程 A 还存活,那么线程 A 会进行偏向锁的撤销以及升级为轻量级锁的流程。

    偏向锁撤销时会将线程栈中最近一条 Lock Record 的 obj 字段设置为 null,但是不会修改锁对象头的线程 id 为 0

    此时 JVM 会在线程的栈空间新建一个 Lock Record (Displaced Mark Word) 的空间,用于存储对象之前的 mark word,然后将使用 CAS 操作尝试将对象的 Mark Word 更新指向原线程 A 栈中的 Lock Record 空间(注意,此时 JVM 还是会偏向线程 A),此时这个线程 A 就继续拥有了该对象的轻量级锁,并且将锁对象的 Mark Word 的锁标志位改为轻量级锁状态(?00)。

  2. 如果线程 A 不存活,那么线程 B 同样也会进入轻量级锁的升级过程,而不会通过 CAS 获取偏向锁。

    之所以线程 B 会进入轻量级锁而不重新获取偏向锁,其直接原因是:上面说过的偏向锁并不会主动撤销,此时锁状态是有锁已偏向状态(101)

    本质原因则是:

    最开始使用偏向锁是因为在很多场景下,加了synchronized的方法或代码块在运行过程中其实还是单线程使用(比如一些工具类为了保证其提供方法的线程安全会加synchronized,但在我们的应用中可能就是单线程使用),所以JVM为了提高性能加入了偏向锁机制。

    而线程 B 需要加锁时,Mark Word 已经置为有锁已偏向状态(001),就代表该锁对象已经被多个线程使用,已经不满足偏向锁的适用场景了。

    如果下次获得锁的时候还设置为无锁可偏向状态(101),那可能会有频繁的锁状态在不同的有锁已偏向偏向状态切换,导致性可能比重量级锁还低。

综上:偏向锁升级的时机为:当锁已经偏向某个线程 A 后,只要有另一个线程 B 尝试获取锁,则该偏向锁就会升级成轻量级锁。当然这个说法并不绝对,因为还有批量重偏向这一机制。

轻量级锁的升级或者降级

synchronize-light-weight-lock

加锁流程

  1. 在线程栈中创建一个 Lock Record,将其 obj(即上图的Object reference)字段指向锁对象。

  2. JVM 将锁对象的对象头的 Mark Word 复制到 Lock Record 的 Displaced Mark Word 中,其中由于是线程私有的栈帧,所以没有用到 CAS

  3. JVM 使用 CAS 操作尝试将锁对象的 Mark Word 更新为指向锁记录的指针。如果更新成功,这个线程就获得了该对象的锁;如果失败,会进入下方 4 判断是否重入。其中进行 CAS 的旧值是 Mark Word 的值,预期值是 Lock Record 的地址

  4. 如果是当前线程已经持有该锁了,代表这是一次锁重入。JVM 会新增一个 Lock Record 并设置 Displaced Mark Word 为 null,obj 指向锁对象,这样做起到了一个重入计数器的作用。

  5. 如果当前线程没有持有该锁,那么加锁失败,而加锁失败会膨胀,然后会升级成重量级锁,然后再尝试 CAS +自旋加锁,失败超过一定次数后,再进入阻塞队列等待(所以其实这个 CAS + 自旋的过程是重量级锁而不是轻量级锁的情况下的策略)

解锁流程

当有其他线程尝试获得轻量级锁时,是根据遍历偏向线程的 Lock Record 来确定该线程是否还在执行同步块中的代码。

  1. 遍历线程栈,找到所有 obj 字段等于当前锁对象的 Lock Record。

  2. 如果 Lock Record 的 Displaced Mark Word 为 null,代表这是一次重入,通过 CAS 将 obj 设置为 null 即完成解锁。

  3. 如果 Lock Record 的 Displaced Mark Word 不为 null,则通过 CAS 将 Displaced Mark Word 复制到锁对象的对象头的 Mark Word。如果成功,则继续执行下一条命令,否则膨胀为重量级锁。

问题1——为什么解锁的时候会替换失败呢?

因为解锁的时候可能有另一个线程来到,此时另一个线程会获取不到锁,膨胀成为重量级锁,改变了对象头?

问题2——为什么将重入的 Lock Record 保留呢?

使用

锁升级完成后,此时根据线程 B 的执行情况的后续处理会不同:

  1. 线程 A 退出同步区释放锁,此时 B 能够获取到锁,也就说明 B 处于轻量级锁自旋的时间没有太长,这说明并发程度不大,此后 JVM 会将锁状态置为无锁不可偏向状态(001),如果还有线程出现,也会立马升级成为轻量级锁

  2. 如果 B 经过一定时间的自旋没有获取到锁,此时会进行锁膨胀的过程,升级为重量级锁

其他

  • 重偏向:轻量级锁加锁时,如果 epoch 不等于 class 中的 epoch,会尝试重偏向,如果重偏向失败也会升级

  • 批量重偏向:如果一个类的大量对象被一个线程T1执行了同步操作,也就是大量对象先偏向了T1,T1同步结束后,另一个线程也将这些对象作为锁对象进行操作,会导偏向锁重偏向的操作。

  • 批量撤销:当一个偏向锁如果撤销次数到达40的时候就认为这个对象设计的有问题;那么JVM会把这个对象所对应的类所有的对象都撤销偏向锁;并且新实例化的对象也是不可偏向的。

博客

博客