【Java 多线程】5 - 7 深入了解监视器锁

§5-7 深入了解监视器锁

5-7.1 synchronized 关键字原理

synchronized 关键字所使用的锁称为监视器锁(monitor),作为一个修饰符,它可用于修饰方法和代码块。

下文内容来自:

这一次,彻底搞懂Java中的synchronized关键字 - 掘金 (juejin.cn)

要想了解 synchronized 的原理,得先了解 Java 对象头。

5-7.2 Java 对象头与 Monitor 对象

在 JVM 中,对象在内存中的存储布局可分为三个区域:

  • 实例数据:存储类的属性数据信息,含父类的属性信息,这部分内存按 4 字节对齐;
  • 填充数据:由于虚拟机要求对象起始地址必须是 8 字节的整数倍,这部分数据并不是必须存在的,仅仅是为了字节对齐;
  • 对象头:在 Hotspot 虚拟机中,对象头又分为两部分,一个是 Mark Word(标记字段),一个是 Class Pointer(类型指针);若是数组,则还会有数组长度(数组也当作对象处理);

(一)对象头

对象头的 Mark Word 主要存储了对象的运行时数据,Mark Word 也存储了对象和锁的有关信息。

当对象使用 synchronized 关键字当作同步锁时,和锁的一系列相关操作都与 Mark Word 有关。JDK 1.6 版本对 synchronized 关键字做了优化,引入了轻量级锁和偏向锁。Mark Word 在不同锁状态下的存储内容不相同。以 32 位 JVM 中对象头的存储内容为例,如下图所示:

image

可以看到,Mark Word 使用 2 bits 存储锁标记状态。

我们先讨论优化前的 synchronized 同步锁,也就是重量级锁。优化后的偏向锁和轻量级锁稍后讨论。

(二)Monitor 对象

可以看到,为重量级锁时,对象头存储了一个 Monitor 对象的指针。这时就必须先了解什么是 Monitor 对象。

Monitor 对象称为管程或监视器锁。在 Java 中,每一个对象实例都会关联一个 Monitor 对象,因此这也被称为对象锁。这个 Monitor 对象既可以和关联对象一起创建和销毁,也可以在线程试图获取锁对象时生成。当 Monitor 对象由线程持有时,它便处于锁定状态。

在 Hotspot 虚拟机中,Monitor 是由 ObjectMonitor 实现的,这是一个 C++ 的实现类,主要数据结构如下:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //记录个数
    _waiters      = 0,
    _recursions   = 0;  // 线程重入次数
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; // 调用wait方法后的线程会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ; // 阻塞队列,线程被唤醒后根据决策判读是放入cxq还是EntryList
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 没有抢到锁的线程会被放到这个队列
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
}

ObjectMonitor 主要有五个部分:

  • _owner:监视器锁的持有者线程,初始值为 NULL,表示无任何线程持有锁,当一个线程成功获取监视器锁后会保存线程 ID 标识,当锁被释放时,该值又会被重置为 NULL
  • _WaitSet:调用了监视器 wait 方法后的线程会加入到这个队列;
  • _cxq:阻塞队列,线程被唤醒后根据决策判读线程放入 _cxq 还是 _EntryList
  • _EntryList:没有抢到锁的线程会放到这个队列中;
  • _count:用于记录线程获取锁的次数,成功获取锁时该值 + 1,释放锁时该值 - 1;

若线程获取到对象的监视器,监视器的 _owner 会设为持有者线程的 ID,同时 _count 加 1;若调用对象的 wait 方法,线程会释放持有的监视器锁,并将 _owner 重置为 NULL,且 _count 减 1,此时线程会进入到 _WaitSet 队列中等待唤醒。

_WaitSet, _cxq_EntrySet 都是链表结构的队列,存放封装了线程的 ObjectWaiter 对象。这里需要深入虚拟机查看相关源码才能理解这些队列的作用,这里仅简单地介绍这些队列间的关系:

在多条线程竞争监视器锁时,所有没有抢到监视器锁的线程都会封装成一个 ObjectWaiter 对象并加入到 _EntryList 队列中。当一个进程获取到监视器锁时,调用 wait 方法,线程也会被封装成 ObjectWaiter 对象并加入到 _WaitSet 队列中。当调用锁对象的 notify 方法后,会根据不同的情况决定将 _WaitSet 中的线程放到 _cxq 还是 _EntryList 队列中。等到持有锁的线程释放锁后,又会根据条件执行 _EntryList 中的线程,或者将 _cxq 中的线程移到 _EntryList 中执行。

可见,位于 _WaitSet 中的线程处于 WAITING 状态,等待唤醒;位于 _EntryList 中的线程处于 BLOCKED 状态,等待锁释放。而 _cxq 队列是一个临时队列,最终线程还是要被转移到 _WaitSet_EntryList 中。

(三)同步代码块实现原理

先编写一个简单的类,类中有一个方法,方法中含有一个简单的同步代码块。

package com.multithreading;

public class SyncBlock {
    private static int i = 0;

    public void add() {
        synchronized (this) {
            i++;
        }
    }
}

编译后,使用 javap 命令对字节码文件进行反编译,通过命令 javap -v -c -l com.multithreading.SyncBlock 得到:

{
  public com.multithreading.SyncBlock();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0

  public void add();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter					  //同步代码块的入口
         4: getstatic     #7                  // Field i:I
         7: iconst_1
         8: iadd
         9: putstatic     #7                  // Field i:I
        12: aload_1
        13: monitorexit						  //同步代码块的出口
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit						  //同步代码块的出口
        20: aload_2
        21: athrow
        22: return
      Exception table:
         from    to  target type
             4    14    17   any
            17    20    17   any
}

可以看到,monitorenter 对应的就是同步代码块的入口,monitorexit 对应的就是同步代码块的出口。而第 4, 7, 8, 9 条指令对应的就是 i++ 的指令(可见其并不具备原子性)。当执行到 monitorenter 指令时,线程会尝试获取对象的监视器锁,获取该锁的所有权。

当该对象的监视器计数器 _count 为 0 时,线程会获取对象的监视器锁,_count 加 1,并将 _owner 设置为持有者线程的 ID。持有者线程可以重入该锁,对应地,每重入一次,计数器 _count 都会加 1。其他线程执行 monitorenter 指令时,由于监视器锁已被该线程持有,这些线程会进入 _EntryList 等待锁释放。

执行了多少次 monitorenter 就要对应有多少次 monitorexit。也就是说,多次重入锁的持有者线程必须对应执行相应重入次数的释放锁操作。每执行一次 monitorexit 指令,_count 都会减 1,当 _count 达到零时,该对象的锁被释放,_owner 重置为 0。此时其他线程才能有机会获取该对象的监视器锁。

(四)同步方法实现原理

同样地,先编写一个简单的类,类中含有一个执行简单操作的同步方法。

package com.multithreading;

public class SyncMethod {
    private static int i = 0;

    public synchronized void add() {
        i++;
    }
}

同样地,使用 javap 指令,反编译字节码文件,得到:

{
  public com.multithreading.SyncMethod();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0

  public synchronized void add();
    descriptor: ()V
    flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED	//同步方法具有 ACC_SYNCHRONIZED 标记
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #7                  // Field i:I
         3: iconst_1
         4: iadd
         5: putstatic     #7                  // Field i:I
         8: return
      LineNumberTable:
        line 7: 0
        line 8: 8
}

可以看到,同步方法中不再有 monitorentermonitorexit 指令,取而代之的是,方法具有 ACC_SYNCHRONIZED 标记,标记方法为同步方法。

由于同步方法全方法都是同步代码,因此不需要再通过 monitorentermonitorexit 指令标记同步代码的出入口。当线程执行到同步方法时,会判断该方法是否具有 ACC_SYNCHRONIZED 标志。如果有该标志,则说明该方法为同步方法,线程将会以同样的方式获取对象的监视器锁,此处不再赘述。

5-7.3 重量级锁的性能问题

在 Linux 操作系统中,应用程序运行在用户空间中,进入用户空间运行状态就是所谓的用户态。在用户态中可能会涉及到 I/O 调用,这时就需要进入内核运行,此时进程进入内核运行态,简称内核态。

  • 内核:本质上也是一种软件,控制计算机的硬件资源,并为上层应用程序提供运行环境;
  • 用户空间:上层应用程序活动的空间,应用程序必须运行在依托于内核所提供的资源,包括 CPU 资源、存储资源、I/O 资源;
  • 系统调用:为了使得上层应用程序能够访问内核控制的资源,内核必须为上层应用程序提供这些接口,称为系统调用;

前文提到,使用 synchronized 加锁是一种重量级锁的加锁方式,在 ObjectMonitor.cpp点击此处跳转)会涉及到 Atomic::cmpxchg_ptr, Atomic::inc_ptr 等内核函数。执行同步代码块,没有竞争到监视器锁的线程会由 park() 挂起,而竞争到锁的线程会由 unpark() 唤醒。这时候就会涉及到内核态与用户态之间的转换,这种转换会消耗大量的系统资源。若程序中存在大量的锁竞争,则会频繁引起内核态与用户态之间的切换,严重影响程序性能,这也是 synchronized 效率低的原因。

因此,自 JDK 1.6 起,引入了偏向锁和轻量级锁优化 synchronized

5-7.4 锁的不同状态

JDK 1.6 引入了偏向锁和轻量级锁后,synchronized 就具有四种不同状态:无锁状态、偏向锁、轻量级锁和重量级锁。这四种锁状态依次表示锁从低级锁向高级锁升级的过程。锁会根据线程竞争的激烈程度进行升级,锁升级的过程是单向不可逆的。一旦升级为重量级锁,锁就不能够降级。

接下来先来了解不同的锁状态。

(一)偏向锁

研究发现大多数情况下不仅不存在线程竞争关系,反而大多数情况是同一条线程多次反复获取同一把锁

因此,为了减少同一条线程反复获取同一把锁的代价,引入了偏向锁的概念。

核心思想:若一个线程获取了锁,则锁进入偏向模式,此时 Mark Word 结构转变为偏向锁(第 30 位偏向锁标识位改为 1),并且记录该偏向线程的 ID,以及偏向时间戳。当线程再次请求锁时,无需做任何同步操作,即可获取锁。这样省去了大量反复申请锁的操作,提升程序性能。

偏向锁在线程竞争较少的情况下具有很好的优化效果,这种情况下同一条线程反复获得同一把锁的可能性很高。但是,若线程竞争成都较为激烈,偏向锁就出现问题。面对这种情况,偏向锁会升级为轻量级锁。

(二)轻量级锁

优化依据对于大部分的锁,在整个同步生命周期内都不存在竞争。

当偏向锁升级为轻量级锁时,Mark Word 结构也会随之发生变化。JVM 会利用 CAS 算法尝试修改对象原本的 Mark Word,若成功修改,则表示锁升级成功;否则,修改失败,意味着锁升级失败。

锁升级成功时,对象的 Mark Word 会修改为 Lock Record 的指针。

适用场景:线程交替执行同步代码块的场合。若存在同一时间访问同一锁的情况,轻量级锁就会失效,进而膨胀为重量级锁。

(三)自旋锁

轻量级锁升级失败后,虚拟机为了防止线程真实地在操作系统层面上挂起,会进行自旋锁的优化手段,

自旋锁的条件:自旋锁基于在大多数情况下,持有者线程持有锁的时间不会太长。

基于这种假设,这时若直接将线程在操作系统层面上挂起,操作系统执行线程切换需要从用户态转换到核心态,时间成本相对较高。因此,虚拟机会假设在不久的将来,当前线程就能够获取锁,会让当前线程执行几个空循环(自旋),不断尝试获取锁。一般而言,空循环不会执行太多次。因为过多次的循环意味着等待时间过长,而过长的等待时间也就意味着空循环可能显著占用 CPU 资源,造成资源浪费。基于这种考虑,自旋时间不会太长,若经过若干次循环后,还是未能够获取锁,这时就会将自旋锁升级为重量级锁。

自旋锁也是一种轻量级的锁机制。

5-7.5 锁升级过程

了解了 JDK 1.6 所引入的这些新的锁机制后,接下来来看看 synchronized 是如何一步步升级锁的。

以 32 位 JVM 为例:

  1. 对象未被当成锁时,该对象就是一个普通的对象,对象头的 Mark Work 分别记录对象的 hashCode(25位)、GC 分代年龄(4 位)、一个偏向锁标志位(1 位,此时为 0)以及锁标志位(2 位,此时为 01);
  2. 当对象被当做同步锁并有一条线程 A 竞争到了这把锁,锁标志位不变,Mark Word 中对象的 hashCode 修改为持有者线程 ID 和 偏向时间戳,偏向锁标志位修改为 1;
  3. 此时锁处于偏向模式,这时若有线程 B 竞争这把锁,JVM 会采用 CAS 算法尝试修改对象原本的 Mark Word。注意到线程 A 一般不会自动释放锁,但若成功抢到锁,则会将锁的偏向线程 ID 修改为线程 B 的 ID,由线程 B 执行同步代码;
  4. 但若线程 B 竞争偏向锁失败,意味着线程竞争较为激烈,这时锁会升级为轻量级锁。JVM 会在当前线程的线程栈中开辟一块单独的空间,保存指向对象锁 Mark Word 的指针,同时会在对象锁的 Mark Word 中保存指向这片空间的指针。这两个保存操作均以 CAS 算法执行。若保存成功,则锁升级成功,并将对象的 Mark Word 中锁标志位修改为 00;否则,修改失败,锁升级失败,意味着线程竞争过于激烈;
  5. 若线程 B 竞争轻量级锁失败,这时会采用自旋锁机制,让当前线程执行几轮空循环,不断地尝试获取锁。自 JDK 1.7 开始,自旋锁默认启用,自旋次数由 JVM 决定。若枪锁成功,则会执行同步代码;否则,锁将升级为重量级锁;
  6. 若竞争自旋锁失败,则锁会升级为重量级锁,锁标志位修改为 10。这种情况下,未抢到锁的线程都将会被阻塞。

CAS 算法在上一节中已有提及,此处不再赘述。

5-7.6 小结

  1. 使用重量级锁的同步代码块,代码块入口会有 monitorenter 指令,代码块出口会有 monitorexit 指令;同步方法会有 ACC_SYNCHRONIZED 方法标记;
  2. 对象的监视器锁由 ObjectMonitor 类实现,这是一个 C++ 实现类。其中,_owner 记录持有者线程 ID,_count 表示重入次数,_WaitSet, _cxq_EntryList 都是列表结构的队列,存储封装了线程的 ObjectWaiter 对象。前者存储调用了 wait 方法进入等待状态(WAITING)的线程,_cxq 是一个临时队列,最终由决策判读线程放入 _WaitSet 还是 _EntryList 中;_EntryList 存储进入阻塞状态(BLOCKED)的线程,没有抢到锁的线程都会位于这个队列中;
  3. JDK 1.6 引入了多种锁机制优化 synchronized 重量级锁,以减少内核态与用户态转换所带来的性能消耗;
  4. 同步锁会逐步升级,升级过程单向不可逆,锁的升级根据线程的竞争激烈程度决定,一旦升级为重量级锁则不能降级;
  5. 对象在未当成锁时,是一个普通对象;一旦被某个线程视为锁并持有时,对象升级为偏向锁,对象的 Mark Word 记录偏向线程 ID 和时间戳;
  6. 存在另一条线程竞争偏向锁时,若竞争成功,则修改对象的偏向线程 ID;若失败,则升级为轻量级锁,JVM 使用 CAS 算法,在当前线程的线程栈中开辟一块独立空间,保存指向对象锁的 Mark Word 指针,同时会在对象锁的 Mark Word 保存指向这片空间的指针;
  7. 若竞争轻量级锁失败,则采用自旋锁机制,让当前线程执行几轮空循环,等待锁释放,避免由于内核态和用户态之间的切换导致性能下降、时间成本增高;若成功抢到锁,则执行同步代码;否则,则将锁升级为重量级锁;

synchronized 关键字的用法十分简单,但其原理却十分复杂,不容易弄清。有关内容仍需进一步查阅相关资料,才能够进一步了解其原理。

posted @ 2023-09-30 12:10  Zebt  阅读(245)  评论(0)    收藏  举报