9.Java 对象锁


9. 锁

Java中的锁都是对象锁,Class 类是特殊的 Java 对象,所以类锁也是对象锁。

  • 每个类只有一个 Class 对象,类锁就是 Class 对象的锁。

为什么任意一个 Java 对象都能成为锁对象?

对象派生自 Object,每个 Java 对象都会对应一个监视器锁 Monitor,这个 Monitor 底层利用了操作系统的 Mutex Lock 互斥锁,并维护了线程阻塞与唤醒的 API。

synchronized 对对象加锁时,就是通过对象内置的 Monitor 来加锁的。

Monitor

Monitor 是线程私有的数据结构,每一个线程都有一个可用 monitor record 列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个 monitor 关联,同时 monitor 中有一个 Owner 字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

9.1 临界区

临界区就是一块互斥的代码区域,它在同一时刻只能由一个线程执行。

9.2 锁的分类

Java 6 之前,所有的 synchronized 锁都是重量级锁,是利用了对象的监视器 Monitor,本质是利用了操作系统的互斥量 Mutex;Java 6 之后,对 synchronized 锁进行了优化,有四种对象锁状态:

  1. 无锁
  2. 偏向锁
  3. 轻量级锁
  4. 重量级锁

偏向锁 和 轻量级锁 是对 synchronized 的优化,但是锁竞争激烈时,反而会降低执行效率。

锁会随着锁竞争发生锁升级。锁状态只能升级,不能降级。

美团技术团队-不可不说的锁事

9.2.1 Java 对象头

Java 对象头的组成:

  1. Mark Word
  2. 指向类信息的指针 Klass Pointer
  3. 数组的长度

对象头信息

Mark Word

Mark Word 在 32 位 JVM 中的长度是 32 bit,在 64 位 JVM 中长度是 64bit。以 32 位为例:

  • 使用 1 bit 来指示 是否为偏向锁
  • 使用 2 bit 来标志 锁的状态

Mark Word 用来存放对象信息或锁信息。Mark Word 被设计成可以复用的形式,会随着锁标志位的变化而发生变化。

  • 当对象是无锁态时,Mark Word 默认记录对象的 hashCode,锁标志位是 01,是否为偏向锁位为 0;
  • 当对象锁为偏向锁时,锁标志位依然是 01,是否为偏向锁位为 1,Mark Word 的前 23 位标志占有偏向锁的线程 id;
  • 当对象锁为轻量级锁时,锁标志位是 00,前 30 位指向栈帧中锁记录的指针
  • 当对象锁位重量级锁时,锁标志位是 10,前 30 位为指向重量级锁的指针

Klass Pointer

对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

9.2.2 偏向锁

线程在第一次进入同步块的时候,会在对象头和栈帧中记录偏向锁的线程 id,当下一次该线程再次进入同步块的时候,会检查 Mark Word 中的线程 id 是否为当前线程,如果是当前线程,则直接可以执行。

引入偏向锁,是为了在无多线程竞争的情况下,尽量减少不必要的资源占用。

  • 偏向锁会在资源无竞争的情况下消除同步语句
  • 偏向的意思是:偏向锁总会偏向与第一个访问锁的线程
  • 偏向锁会在 对象头 中 记录 线程 ID
  • 偏向锁不会主动去释放锁,在有线程竞争锁的时候才会释放锁。
1. 偏向锁的加锁与撤销过程

线程在访问同步代码块时,会先检查对象头中的 Mark Word,如果是 01,代表目前处于无锁或偏向锁状态;然后再检查对象头,具体是无锁还是偏向锁:

  1. 如果是无锁状态,则尝试使用 CAS 操作来来尝试修改 Mark Word 中的线程 id 为当前线程的 id:
    1. 如果修改成功,则说明是第一次上锁,当前线程则会获得该偏向锁,从而在 Mark Word 中记录自己线程 ID 和获取锁的时间 epoch 等信息,然后执行同步代码块。
    2. 如果修改失败了,则说明有别的线程在此时抢先拿到了偏向锁,此时锁状态为偏向锁状态。执行偏向锁的流程。
  2. 如果是偏向锁状态,先判断 Mark Word 中的线程 ID 是不是自己的 ID:
    1. 如果是,则说明该线程之前已经获得了该偏向锁,且没被撤销,于是直接访问该同步资源;
    2. 如果不是,则说明目前该同步资源之前被其他线程访问过,偏向锁状态还没撤消(因为偏向锁不会在线程退出同步代码块后主动撤消)。此时会执行锁撤消,需要等原持有偏向锁的线程到达全局安全点(Safe Point)时将其暂停,然后会检查其是否已经退出同步:
      1. 如果退出了,则把锁状态设置为无锁态(包括修改线程 ID 为空,偏向锁状态为 0),然后让其他线程去竞争该同步资源,之后会唤醒该撤销了锁的线程(如果它还活着),因为该线程目前不占用该同步资源了。
      2. 如果没有退出同步,则锁需要升级为轻量级锁:把锁标志位设置为 00,然后唤醒被暂停的线程。同时之后对该同步资源的锁竞争会进入轻量级锁状态。
2. 偏向锁撤销的细节

偏向锁的释放总是在有线程竞争该偏向锁的时候。

偏向锁进行锁升级时,会在全局安全点暂停之前占有偏向锁的线程,这个开销很大

  • 若进程中锁经常处于竞争状态,则不应设置偏向锁,因为反而会降低效率。
  • JVM 中,偏向锁默认开启,关闭偏向锁的方式:-XX:-UseBiasedLocking=false,关闭后默认直接进入轻量级锁状态。
  • 全局安全点:在该时间节点上,没有字节码正在执行

3. 锁升级的过程

当锁存在竞争时,之前持有偏向锁的线程会被在全局安全点暂停,然后判断是否执行完了同步代码块,如果没执行完,则锁会升级为轻量级锁。

  1. 遍历线程栈,如果存在锁记录的话,需要修复锁记录和 Mark Word,使其变成无锁状态。
  2. 偏向锁成功升级成轻量级锁,唤醒之前被暂停但没退出同步代码块的线程来持有该轻量级锁,并从 Safa Point 处继续往下执行,其他线程则通过轻量级锁的方式来进行锁竞争。

负责锁撤消和锁升级的是其他的线程。

9.2.3 轻量级锁

轻量级锁通过自旋来尝试获取轻量级锁,不会发生线程的阻塞与唤醒,从而减少重量级锁带来的性能消耗。

  • 轻量级锁通过自旋的方式来尝试获取锁,不会阻塞线程,因此性能比重量级锁更高;
  • 轻量级锁对象的对象头中 Mark Word 指向线程栈的栈帧中的锁记录 Lock Record
栈帧中的锁记录 Lock Record
栈帧中的锁记录

Lock Record 的结构:

  • Displaced Mark Word, 存放对象头中 Mark Word 的拷贝;
  • owner 指针,用于指向对象头的 Mark Word

锁被重入时,会在线程栈中继续添加一个新的 Lock Record,该 Lock Record 中的 Displaced Mark Wordnull,用所有的 Lock Record 来进行重入计数。

轻量级锁的加锁与释放
轻量级锁的加锁

在代码进入同步块时,如果同步对象锁状态为无锁状态(锁标志位为 01 状态,是否为偏向锁为 0):

  1. 虚拟机首先将在当前线程的栈帧中建立一个名为锁记录Lock Record的空间;
  2. 然后拷贝对象头中的 Mark WordLock Record 作为 Displaced Mark Word
    • 到这为止的修改都是对当前线程进行的修改,接下来尝试对同步资源的对象头进行修改,尝试获取锁。
  3. 拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock Record 里的 owner 指针指向对象的 Mark Word
    1. 如果更新成功了,则该线程就成功获得了对象锁。
    2. 若替换失败,则表示 Mark Word 已经被替换成其他线程的锁记录指针,说明存在竞争锁的线程,则当前线程自旋请求锁。

如果同步对象是有锁状态,则先判断对象的 Mark Word 是否指向本线程的栈帧范围:

  1. 如果是,则说明是锁的重入,不需要锁竞争;
    • 此时会再次创建新的 Lock Record,来表示锁重入。
  2. 如果不是,则触发锁升级。
自旋

自旋只会发生在只有一个额外的线程来竞争锁资源时,即,如果当前同步资源正被某个线程占用,而且等待该资源的线程只有一个,则会通过自旋来等待。如果等待的线程超过一个,则轻量级锁会升级为重量级锁。

适应性自旋

自旋来请求锁会一直占用 CPU,JDK 采用 适应性自旋 来避免 CPU 一直被占用:

  • 若线程自旋请求锁成功,则下次竞争锁时的可自旋次数增加
  • 若线程自旋请求锁失败,则下次竞争锁时的可自旋次数减少
轻量级锁升级

轻量级锁升级的两种情况:

  1. 当线程自旋到一定程度,依然没有获取到锁,则自旋失败阻塞该线程。同时该轻量级锁升级为重量级锁。
  2. 等待锁的线程超过了 1 个,则也会触发锁的升级。
轻量级锁的释放

线程使用完同步资源后,会释放轻量级锁,当前持有锁的线程会进行 CAS 操作,将 Displaced Mark Word 的内容复制回 Mark Word中:

  • 如果没有锁竞争,那么这个复制的操作会成功;
  • 如果之前有其他线程自旋失败导致轻量级锁升级成了重量级锁,那么 CAS 操作会失败,此时当前线程会直接释放锁并唤醒被阻塞的线程(自旋失败的线程)。
    • 被唤醒的线程会重新争夺锁

9.2.4 重量级锁

重量级锁依赖于操作系统的互斥量(mutex) 实现的,而操作系统中线程间状态的转换需要相对比较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗 CPU。

  • 当对象锁为重量级锁时,Mark Word 中存储的是指向重量级锁(互斥量)的指针
  • 等待重量级锁的线程都会进入阻塞状态
  • 当调用一个锁对象的 notify()wait() 方法时,首先会将锁膨胀成重量级锁

9.2.5 锁的升级流程

当线程想要获取共享资源时:

  1. 首先检查 Mark Word 中的线程 id 是不是自身,如果是,则对象锁为偏向锁;
  2. 如果不是自己的线程 id,则尝试使用 CAS 操作来把 Mark Word 中的线程 id 修改为本线程的id,如果修改失败,则说明当前存在锁竞争,此时暂停正在占有锁的线程,并修改线程 id 为空,偏向锁升级为轻量级锁。之后恢复被暂停的线程。
  3. 轻量级锁的线程,会在栈帧中新建锁记录,线程把锁对象的 hashcode 复制到对应的锁记录中,然后通过 CAS 操作把 Mark Word 修改为指向锁记录的指针。
  4. 成功修改 Mark Word 的线程会获得轻量级锁,失败的线程自旋。
  5. 若自旋的线程在自旋中成功获得了锁,则轻量级锁不会升级;
  6. 如果自旋失败,则轻量级锁升级为重量级锁。此时,未获得锁的线程会阻塞,等待持有锁的线程在同步块内执行完成,并唤醒自己。从而重新进行锁竞争。

9.2.6 各种锁的对比

  • 偏向锁 是通过对比 Mark Word 来避免执行 CAS 操作
  • 轻量级锁 是通过 CAS 操作和自旋来避免线程阻塞和唤醒,而影响性能
  • 重量级锁 是将除了拥有锁的线程以外的其他线程直接阻塞

9.3 乐观锁与悲观锁

9.3.1 乐观锁

乐观锁总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁,也无需等待。

一旦多个线程发生冲突,则是使用 CAS 来解决线程安全性问题。

乐观锁默认不加锁,所以不会出现死锁。

乐观锁适用于 “多读少写” 的情况,避免冲突时频繁加锁,影响性能。

9.3.2 悲观锁

悲观锁总是假设访问共享资源会发生冲突,所以每次都会加锁,以保证临界区同时只有一个线程在执行。

  • synchronizedLock 的实现类 都是悲观锁。

悲观锁适用于 “多写少读” 的情况,避免频繁的失败和重试影响性能。

Reference

偏向锁的状态转移原理


文章作者: Yu Yang
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Yu Yang !
 上一篇
Java 锁分类 Java 锁分类
Java 中的锁 Java 中的并发包: java.util.concurrent , 又叫 JUC。 1. 乐观锁 与 悲观锁 1.1 乐观锁乐观锁总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁,也无需等待。 一旦多个线程
下一篇 
4.线程间通信 4.线程间通信
4. 线程间通信4.1 锁与同步JAVA 中的锁都是对象锁,是基于对象的。 4.1.1 线程同步线程同步是指约束线程按照一定的顺序执行。 线程同步可以通过锁 synchronized 来实现: public class ObjectLock
  目录