Bootstrap

13.重量级锁原理及其实战

重量级锁原理及其实战

在JVM中,每个对象都关联一个监视器,这里面包含了Object实例以及Class实例。监视器是一个同步工具,相当于一个许可证,拿到许可证可以到进入临界区进行操作,没有拿到则需要阻塞等待,重量级锁通过这种方式保证任何时间只有一个线程通过监视器保护的临界区代码

1.重量级锁的核心原理

本质上JVM每个对象都会有一个监视器,监视器和对象一起创建、销毁。

监视器的主要特点

  • 同步:监视器所保护的临界区代码是互斥执行的。
  • 协作:监视器提供Signal机制,允许正持有许可证的线程暂时放弃许可,进入阻塞等待状态,等待其他线程发送Signal去唤醒。
  • 信号机制:监视器的协作机制基于信号的概念。当一个线程调用wait()方法时,它等待一个信号来唤醒它。其他线程可以通过调用notify()方法发送一个信号,唤醒一个等待的线程,或者通过调用notifyAll()方法发送信号,唤醒所有等待的线程。
  • 锁的获取与释放:线程在进入被监视器保护的临界区之前,必须先获取监视器的锁。如果锁已经被其他线程持有,那么线程将进入阻塞状态,直到锁被释放。当线程执行完临界区代码后,它会释放监视器的锁,以便其他线程可以获取锁并执行临界区代码。
  • 等待集合:监视器维护了一个等待集合,用于存放等待获取监视器锁的线程。当一个线程调用wait()方法时,它会被加入到等待集合中,直到被唤醒。

监视器是在JVM内部实现的,对于开发人员来说,可以通过关键字synchronized来使用监视器。synchronized关键字可以应用于方法或代码块,用于指定需要保护的临界区代码。在进入synchronized保护的临界区时,线程会自动获取监视器锁,执行完临界区后释放锁。

HotSpot在虚拟机中,监视器是由C++类 ObjectMonitor实现的,其构造器的大致如下

ObjectMonitor::ObjectMonitor(oop object) :
  _header(markWord::zero()),                     // 对象监视器的头部标记
  _object(_oop_storage, object),                 // 监视的对象
  _owner(nullptr),                               // 持有该监视器的线程
  _previous_owner_tid(0),                        // 上一个持有该监视器的线程ID
  _next_om(nullptr),                             // 下一个对象监视器
  _recursions(0),                                // 递归调用计数
  _EntryList(nullptr),                           // 进入监视器等待队列的线程列表
  _cxq(nullptr),                                 // 等待监视器的线程列表
  _succ(nullptr),                                // 监视器等待集合的后继监视器
  _Responsible(nullptr),                         // 创建该监视器的线程
  _SpinDuration(ObjectMonitor::Knob_SpinLimit),  // 自旋等待的时长限制
  _contentions(0),                               // 竞争次数
  _WaitSet(nullptr),                             // 等待集合,存放等待监视器的线程
  _waiters(0),                                   // 等待线程数量
  _WaitSetLock(0)                                // 等待集合的锁
{ }

其中 _owner _WaitSet _ cxq_EntryList 这几个属性比较关键, _WaitSet _ cxq_EntryList,这三个队列存放抢夺重量级锁的线程,而 _owner所指向的线程就是获得到锁的线程

  1. _cxq
    • 作用:_cxq是一个等待集合,用于存放等待获取该监视器锁的线程。
    • 在抢占锁的过程中,当一个线程尝试获取该监视器的锁但失败时,它会被放入_cxq中。_cxq是一个FIFO(先进先出)的队列,用于记录等待获取该监视器锁的线程。在锁被释放时,JVM会从_cxq中选择一个线程唤醒,使之有机会再次尝试获取锁。
  2. _WaitSet
    • 作用:_WaitSet是一个等待集合,用于存放等待获取该监视器锁的线程。
    • 当一个线程调用wait()方法后,它会被加入到_WaitSet中,表示它正在等待获取该监视器的锁。在抢占锁的过程中,如果当前持有锁的线程释放了锁,它会从_WaitSet中选择一个线程唤醒,使之有机会再次尝试获取锁。
  3. _EntryList
    • 作用:_EntryList是一个等待集合,用于存放进入监视器等待队列的线程列表。
    • 在抢占锁的过程中,当一个线程尝试获取该监视器的锁但失败时,它会被放入_EntryList中。_EntryList用于记录正在等待进入监视器的线程列表。这些线程还没有进入具体的等待集合(如_WaitSet_cxq),而是等待被监视器的所有者线程释放锁后再进入具体的等待集合。
  4. _owner
    • 作用:_owner变量用于记录当前持有该监视器的线程。
    • 在抢占锁的过程中,当一个线程成功获取了该监视器的锁时,它会将自己的线程标识设置为_owner,表示它是当前持有该监视器的线程。这样其他线程在尝试获取该监视器的锁时会知道当前持有锁的线程是谁,从而被阻塞等待。
  5. OnDeck
    • 作用:OnDeck的作用是记录在等待获取对象监视器锁的线程中,位于等待队列的下一个线程。它用于确定下一个线程在对象监视器锁释放后将被唤醒并有机会尝试获取锁。
    • 当一个线程尝试获取对象监视器锁但失败时,它会被放入等待队列中,等待其他线程释放锁后再次尝试获取。这些线程按照一定的顺序排队,形成一个先进先出(FIFO)的等待队列。
    • 在等待队列中,OnDeck指向下一个准备被唤醒的线程。当对象监视器锁被持有的线程释放后,JVM会从等待队列中选择OnDeck指向的线程唤醒,使其有机会再次尝试获取锁。
    • 通过使用OnDeck变量,JVM可以确定下一个将被唤醒的线程,从而实现线程之间的协调和同步。这种机制确保了等待获取对象监视器锁的线程按照一定的顺序被唤醒,并有机会竞争获取锁。

在这里插入图片描述

2.重量级锁的开销

EntryList WaitSet中的线程处于阻塞的状态,线程的阻塞或者唤醒都需要操作系统来进行帮忙,Linux内核下采用pthread_mutext_lock系统调用实现,进程需要从用户态 切换到 内核态

在操作系统中,Linux用户态(User Mode)和内核态(Kernel Mode)是两种不同的执行模式,用于区分应用程序代码和操作系统内核代码的执行环境。

  1. 用户态(User Mode):
    • 用户态是应用程序执行的一种模式,其中应用程序在受限的环境中运行,无法直接访问或执行核心系统资源和特权指令。
    • 在用户态下,应用程序执行的代码称为用户代码。用户代码运行在用户空间,它可以执行一般的计算任务、访问受限的系统资源(如文件、网络等)和调用系统调用接口以请求操作系统提供的服务。
    • 用户态的应用程序通常运行在较低的权限级别下,无法直接对硬件设备进行操作,也无法访问操作系统内核的关键数据结构和功能。
  2. 内核态(Kernel Mode):
    • 内核态是操作系统内核执行的一种模式,其中内核具有对系统资源和特权指令的完全访问权限。
    • 在内核态下,操作系统内核执行的代码称为内核代码。内核代码运行在内核空间,它可以直接访问和操作硬件设备、管理系统资源、执行特权指令和提供系统调用接口给用户态的应用程序。
    • 内核态的特权级别较高,内核代码可以执行敏感操作和进行关键的系统管理任务,如任务调度、内存管理、设备驱动等。

在这里插入图片描述

用户态 是应用程序的运行空间,为了能够访问到内核管理资源(例如CPU 内存 IO)可以通过内核态所提供的访问接口实现,这些接口就叫做系统调用。

pthread_mutex_lock系统调用,就是内核态为用户态进程提供Linux内核态下 互斥锁的访问机制,所以使用 pthread_mutex_lock系统调用时,进程需要从用户态切换到内核态,而这种切换是非常耗时的,有可能比用户执行代码的时间还要长。这也就是JVM重量级锁使用Linux内核态的互斥锁(Mutex)开销非常大的原因。

3.重量级锁代码演示

3.1.重量锁演示代码

package com.hrfan.java_se_base.base.thread.jol;

import com.hrfan.java_se_base.common.utils.SleepUtil;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.concurrent.CountDownLatch;

/**
 * 重量级锁测试
 */
public class InnerWeightLockTest {
    private static final Logger log = LoggerFactory.getLogger(InnerWeightLockTest.class);

    @Test
    @DisplayName("测试重量级锁的案例")
    public void test() {
        // 打印JVM信息
        log.error("JVM参数信息:{}", VM.current().details());

        SleepUtil.sleepMillis(5000);

        LightWeightObjectLock lock = new LightWeightObjectLock();

        // 打印抢占锁前 锁状态
        log.error("抢占锁前,lock状态");
        lock.printLockStatus();

        SleepUtil.sleepMillis(5000);
        // 创建3个计时器
        CountDownLatch latch = new CountDownLatch(3);

        Runnable runnable = () -> {
            for (int i = 0; i < 1000; i++) {
                synchronized (lock) {
                    lock.increase();
                    if (i == 1) {
                        log.error("第一个线程占有锁,lock状态!");
                        lock.printLockStatus();
                    }
                }
            }
            // 第一个线程执行完毕
            latch.countDown();
            // 线程虽然释放锁,但是一致存在死循环
            while (true) {
                // 每次循环等待1ms
                SleepUtil.sleepMillis(1);
            }
        };
        new Thread(runnable).start();


        // 等待1s
        SleepUtil.sleepMillis(1000);
        Runnable lightweightRunnable = () -> {
            for (int i = 0; i < 1000; i++) {
                synchronized (lock) {
                    lock.increase();
                    if (i == 0) {
                        log.error("抢占线程占有锁,lock状态!");
                        lock.printLockStatus();
                    }
                    // 每次循环等待1ms
                    SleepUtil.sleepMillis(1);
                }
            }
            // 循环执行完毕
            latch.countDown();
        };
        // 创建两个线程来进行竞争锁
        new Thread(lightweightRunnable,"抢锁线程1").start();
        SleepUtil.sleepMillis(100);
        new Thread(lightweightRunnable,"抢锁线程2").start();
        // 等待全部线程执行完毕
        try {
            latch.await();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        SleepUtil.sleepMillis(10000);
        log.error("释放锁后,lock状态!");
        lock.printLockStatus();
    }
}
class LightWeightObjectLock{
    private static final Logger log = LoggerFactory.getLogger(MyObjectLock.class);


    private int count = 0;

    /**
     * 打印当前对象的一个状态
     */
    public void printLockStatus(){
        log.error(ClassLayout.parseInstance(this).toPrintable());
    }

    /**
     * 将当前共享变量自增
     */
    public void increase(){
        this.count++;
    }
}

3.2.结果分析:

这里偏向锁和之前一样,区别不大
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

4.重量级锁特点

重量级锁是一种在并发编程中使用的锁实现,也被称为悲观锁。

  1. 实现机制:

    • 状态切换:重量级锁具有两个状态:锁定状态和解锁状态。当一个线程获得锁时,其他线程必须等待锁的释放才能获取锁。这涉及到线程之间的上下文切换,即从用户态切换到内核态,操作系统负责管理和调度线程的执行。
    • 系统调用:重量级锁的实现通常会依赖于操作系统提供的互斥量或信号量。当一个线程获得锁时,它会调用操作系统的相关系统调用来锁定资源,阻塞其他线程的访问;当线程释放锁时,它会调用系统调用来解锁资源,允许其他线程获取锁。
  2. 特点和适用场景:

    • 开销较大:由于涉及到系统调用和线程上下文切换,重量级锁的开销相对较大。因此,它适用于对性能要求不高但线程安全性要求较高的场景。
    • 长时间占有锁:当线程需要长时间占用锁资源时,例如执行复杂的计算或访问慢速设备,重量级锁可以提供有效的线程同步。
    • 线程饥饿问题:由于重量级锁涉及到线程的上下文切换和系统调用,如果存在过多的线程竞争同一个锁,某些线程可能会长时间等待,导致线程饥饿问题。

总结起来,重量级锁是一种较为常见的线程同步机制,适用于对线程安全性要求较高、竞争激烈或长时间占用锁的场景。然而,由于其开销较大,需要在性能和线程竞争之间做出权衡。在一些情况下,可以考虑使用其他更轻量级的锁实现来提高并发性能。

5.偏向锁、轻量级锁、重量级锁的对比

5.1.synchronized执行流程

总结一下synchronized的执行过程,大致如下

  1. 进入 synchronized 块:
    当线程要执行进入 synchronized 修饰的方法或代码块时,它首先需要获取对象的锁。如果锁是空闲的,线程可以立即获取锁并进入 synchronized 块;如果锁已被其他线程占用,线程则进入锁的等待队列,等待获取锁。
  2. 获取对象锁:
    一旦线程进入锁的等待队列,它将尝试获取对象锁。获取锁的过程根据锁的状态有所不同:
    • 偏向锁:如果对象的锁是偏向锁,并且持有偏向锁的线程正是当前线程,那么线程可以直接获取锁,执行 synchronized 块。
    • 轻量级锁:如果对象的锁是轻量级锁,并且未被其他线程获取,当前线程可以通过CAS(比较并交换)操作将锁升级为自己的轻量级锁,并执行 synchronized 块。
    • 重量级锁:如果对象的锁是重量级锁,当前线程将进入阻塞状态,等待操作系统调度来获取锁。
  3. 执行 synchronized 块:
    一旦线程成功获取对象锁,它可以执行 synchronized 块中的代码。在执行期间,其他线程无法获取相同对象的锁,它们将被阻塞,直到当前线程释放锁。
  4. 释放对象锁:
    当线程执行完 synchronized 块中的代码或遇到异常时,它将释放对象锁。释放锁的过程将导致锁的状态发生变化:
    • 偏向锁:如果对象的锁是偏向锁,当前线程会将锁的状态恢复为无锁状态,以便其他线程可以尝试获取偏向锁。
    • 轻量级锁:如果对象的锁是轻量级锁,并且当前线程是持有锁的线程,它会通过原子操作将锁的状态恢复为无锁状态,以便其他线程可以尝试获取锁。
    • 重量级锁:如果对象的锁是重量级锁,当前线程会释放锁,唤醒等待队列中的某个线程来获取锁。

5.2.偏向锁、轻量级锁和重量级锁区别

偏向锁、轻量级锁和重量级锁之间的区别以及锁膨胀的过程

特点偏向锁轻量级锁重量级锁
竞争状态无竞争状态短暂竞争状态激烈竞争状态
加锁过程获取锁时,将对象头中的Mark Word设置为指向当前线程的Thread ID获取锁时,尝试使用CAS操作将Mark Word修改为指向锁记录(Lock Record)的指针获取锁时,涉及到系统调用,例如操作系统提供的互斥量或信号量
解锁过程解锁时,检查对象头中的Mark Word是否指向当前线程的Thread ID解锁时,使用原子操作将Mark Word恢复为指向对象的原始HashCode解锁时,涉及到系统调用,例如操作系统提供的互斥量或信号量
锁膨胀过程当另一个线程尝试获取偏向锁时,偏向锁会自动升级为轻量级锁当另一个线程尝试获取轻量级锁时,轻量级锁会自动升级为重量级锁无锁膨胀过程
性能影响对于只有一个线程访问的场景,性能最佳对于短暂竞争状态的场景,性能较好适用于竞争激烈或长时间占有锁的场景,性能较差
适用场景适用于只有一个线程频繁访问临界区的场景适用于短暂竞争状态下的临界区访问适用于竞争激烈或长时间占有锁的场景

总结:

  • 偏向锁适用于只有一个线程频繁访问临界区的场景,性能最佳。
  • 轻量级锁适用于短暂竞争状态下的临界区访问,性能较好。
  • 重量级锁适用于竞争激烈或长时间占有锁的场景,性能较差。
  • 锁膨胀是指锁的升级过程,从偏向锁升级为轻量级锁,再升级为重量级锁。
  • 锁的选择应根据实际场景和竞争程度进行权衡,以获得最佳的性能和线程安全性。
;