synchronized的四种锁状态

Java 多线程的锁都是基于对象的,Java 中的每一个对象都可以作为一个锁。

类锁,其实就是 Class 对象的锁。

  • Class 对象是一种特殊的 Java 对象,代表了程序中的类和接口。Java 中的每个类型(包括类、接口、数组以及基础类型)在 JVM 中都有一个唯一的 Class 对象与之对应。这个 Class 对象被创建的时机是在 JVM 加载类时,由 JVM 自动完成。

  • Class 对象中包含了与类相关的很多信息,如类的名称、类的父类、类实现的接口、类的构造方法、类的方法、类的字段等等。这些信息通常被称为元数据(metadata)。

  • 可以通过 Class 对象来获取类的元数据,甚至动态地创建类的实例、调用类的方法、访问类的字段等。这就是Java 的反射(Reflection)机制。

锁的基本用法

// 关键字在实例方法上,锁为当前实例
public synchronized void instanceLock() {
    // code
}

// 关键字在静态方法上,锁为当前Class对象
public static synchronized void classLock() {
    // code
}

// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
    Object o = new Object();
    synchronized (o) {
        // code
    }
}

两种等价的使用:

// 关键字在实例方法上,锁为当前实例
public synchronized void instanceLock() {
    // code
}

// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
    synchronized (this) {
        // code
    }
}
// 关键字在静态方法上,锁为当前Class对象
public static synchronized void classLock() {
    // code
}

// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
    synchronized (this.getClass()) {
        // code
    }
}

锁的四种状态及锁降级

在 JDK 1.6 以前,所有的锁都是”重量级“锁,因为使用的是操作系统的互斥锁,当一个线程持有锁时,其他试图进入synchronized块的线程将被阻塞,直到锁被释放。涉及到了线程上下文切换和用户态与内核态的切换,因此效率较低。

那为了减少获得锁和释放锁带来的性能消耗,JDK 1.6 引入了“偏向锁”和“轻量级锁” 的概念,对 synchronized 做了一次重大的升级,升级后的 synchronized 性能可以说上了一个新台阶。

在 JDK 1.6 及其以后,一个对象其实有四种锁状态,它们级别由低到高依次是:

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

无锁就是没有对资源进行锁定,任何线程都可以尝试去修改它,很好理解。

几种锁会随着竞争情况逐渐升级,锁的升级很容易发生,但是锁降级发生的条件就比较苛刻了,锁降级发生在 Stop The World 期间,当 JVM 进入安全点的时候,会检查是否有闲置的锁,然后进行降级。降级对象为仅仅能被 VMThread 访问而没有其他 JavaThread 访问的对象。

各种锁的优缺点对比(来自《Java 并发编程的艺术》):

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 适用于只有一个线程访问同步块场景。
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度。 如果始终得不到锁竞争的线程使用自旋会消耗 CPU。 追求响应时间。同步块执行速度非常快。
重量级锁 线程竞争不使用自旋,不会消耗 CPU。 线程阻塞,响应时间缓慢。 追求吞吐量。同步块执行时间较长。

对象的锁存放位置

每个 Java 对象都有一个对象头。如果是非数组类型,则用 2 个字宽来存储对象头,如果是数组,则会用 3 个字宽来存储对象头。在 32 位处理器中,一个字宽是 32 位;在 64 位虚拟机中,一个字宽是 64 位。对象头的内容如下表所示:

长度 内容 说明
32/64bit Mark Word 存储对象的 hashCode 或锁信息等
32/64bit Class Metadata Address 存储到对象类型数据的指针
32/64bit Array length 数组的长度(如果是数组)

Mark Word 的格式:

锁状态 29 bit 或 61 bit 1 bit 是否是偏向锁? 2 bit 锁标志位
无锁 0 01
偏向锁 线程 ID 1 01
轻量级锁 指向栈中锁记录的指针 此时这一位不用于标识偏向锁 00
重量级锁 指向互斥量(重量级锁)的指针 此时这一位不用于标识偏向锁 10
GC 标记 此时这一位不用于标识偏向锁 11

当对象状态为偏向锁时,Mark Word存储的是偏向的线程 ID;当状态为轻量级锁时,Mark Word存储的是指向线程栈中Lock Record的指针;当状态为重量级锁时,Mark Word为指向堆中的 monitor(监视器)对象的指针。

在 Java 中,监视器(monitor)是一种同步工具,用于保护共享数据,避免多线程并发访问导致数据不一致。在 Java 中,每个对象都有一个内置的监视器。

监视器包括两个重要部分,一个是锁,一个是等待/通知机制,后者是通过 Object 类中的wait(), notify(), notifyAll()等方法实现的。

偏向锁

Hotspot 的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,于是引入了偏向锁。

偏向锁会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。也就是说,偏向锁在资源无竞争情况下消除了同步语句,连 CAS 操作都不做了,着极大地提高了程序的运行性能。

偏向锁的实现原理

一个线程在第一次进入同步块时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID。当下次该线程进入这个同步块时,会去检查锁的 Mark Word 里面是不是放的自己的线程 ID。

如果是,表明该线程已经获得了锁,以后该线程在进入和退出同步块时不需要花费 CAS 操作来加锁和解锁;如果不是,就代表有另一个线程来竞争这个偏向锁。这个时候会尝试使用 CAS 来替换 Mark Word 里面的线程 ID 为新线程的 ID,这个时候要分两种情况:

  • 成功,表示之前的线程不存在了, Mark Word 里面的线程 ID 为新线程的 ID,锁不会升级,仍然为偏向锁;
  • 失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为 0,并设置锁标志位为 00,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。

CAS 是比较并设置的意思,用于在硬件层面上提供原子性操作。在 在某些处理器架构(如x86)中,比较并交换通过指令 CMPXCHG 实现((Compare and Exchange),一种原子指令),通过比较是否和给定的数值一致,如果一致则修改,不一致则不修改。

图中涉及到了 lock record 指针指向当前堆栈中的最近一个 lock record,是轻量级锁按照先来先服务的模式进行了轻量级锁的加锁。

撤销偏向锁

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

偏向锁升级成轻量级锁时,会暂停拥有偏向锁的线程,重置偏向锁标识,这个过程看起来容易,实则开销还是很大的,大概的过程如下:

  1. 在一个安全点(在这个时间点上没有字节码正在执行)停止拥有锁的线程。
  2. 遍历线程栈,如果存在锁记录的话,需要修复锁记录和 Mark Word,使其变成无锁状态。
  3. 唤醒被停止的线程,将当前锁升级成轻量级锁。

所以,如果应用程序里所有的锁通常处于竞争状态,那么偏向锁就会是一种累赘,对于这种情况,我们可以一开始就把偏向锁这个默认功能给关闭:

-XX:UseBiasedLocking=false

轻量级锁

多个线程在不同时段获取同一把锁,即不存在锁竞争的情况,也就没有线程阻塞。针对这种情况,JVM 采用轻量级锁来避免线程的阻塞与唤醒。

JVM 会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,我们称为 Displaced Mark Word。如果一个线程获得锁的时候发现是轻量级锁,会把锁的 Mark Word 复制到自己的 Displaced Mark Word 里面。

然后线程尝试用 CAS 将锁的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示 Mark Word 已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。

自旋是需要消耗 CPU 的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费 CPU 资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环 10 次,如果还没获取到锁就进入阻塞状态。

但是 JDK 采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。

自旋也不是一直进行下去的,如果自旋到一定程度(和 JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁

轻量级锁的释放

在释放锁时,当前线程会使用 CAS 操作将 Displaced Mark Word 的内容复制回锁的 Mark Word 里面。如果没有发生竞争,那么这个复制的操作会成功。如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么 CAS 操作会失败,此时会释放锁并唤醒被阻塞的线程。

重量级锁

重量级锁依赖于操作系统的互斥锁(mutex,用于保证任何给定时间内,只有一个线程可以执行某一段特定的代码段) 实现,而操作系统中线程间状态的转换需要相对较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗 CPU。

前面说到,每一个对象都可以当做一个锁,当多个线程同时请求某个对象锁时,对象锁会设置几种状态用来区分请求的线程:

  • Contention List:所有请求锁的线程将被首先放置到该竞争队列
  • Entry List:Contention List 中那些有资格成为候选人的线程被移到 Entry List
  • Wait Set:那些调用 wait 方法被阻塞的线程被放置到 Wait Set
  • OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为 OnDeck
  • Owner:获得锁的线程称为 Owner
  • !Owner:释放锁的线程

当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个ObjectWaiter对象插入到 Contention List 队列的队首,然后调用park 方法挂起当前线程。

当线程释放锁时,会从 Contention List 或 EntryList 中挑选一个线程唤醒,被选中的线程叫做Heir presumptive即假定继承人,假定继承人被唤醒后会尝试获得锁,但synchronized非公平的,所以假定继承人不一定能获得锁。

这是因为对于重量级锁,如果线程尝试获取锁失败,它会直接进入阻塞状态,等待操作系统的调度。

如果线程获得锁后调用Object.wait方法,则会将线程加入到 WaitSet 中,当被Object.notify唤醒后,会将线程从 WaitSet 移动到 Contention List 或 EntryList 中去。需要注意的是,当调用一个锁对象的waitnotify方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁

锁的升级流程

每一个线程在准备获取共享资源时:
第一步,检查 MarkWord 里面是不是放的自己的 ThreadId ,如果是,表示当前线程是处于 “偏向锁” 。

第二步,如果 MarkWord 不是自己的 ThreadId,锁升级,这时候,用 CAS 来执行切换,新的线程根据 MarkWord 里面现有的 ThreadId,通知之前线程暂停,之前线程将 Markword 的内容置为空。

第三步,两个线程都把锁对象的 HashCode 复制到自己新建的用于存储锁的记录空间,接着开始通过 CAS 操作,
把锁对象的 MarKword 的内容修改为自己新建的记录空间的地址的方式竞争 MarkWord。

第四步,第三步中成功执行 CAS 的获得资源,失败的则进入自旋 。

第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态。

第六步,如果自旋失败 ,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。

posted @ 2024-07-19 15:41  n1ce2cv  阅读(52)  评论(0编辑  收藏  举报