synchronized的四种锁状态

在 Java 中,synchronized 关键字是一个非常重要的同步机制,用于确保在多线程环境下,同一时刻只有一个线程可以执行某个方法或代码块。本文将详细介绍 synchronized 关键字的使用、锁的类型、锁的升级过程以及锁的实现原理。

1 synchronized 的基本用法

synchronized 关键字主要有以下三种应用方式:

  1. 同步方法:为当前对象(this)加锁,进入同步代码前要获得当前对象的锁。
  2. 同步静态方法:为当前类加锁(锁的是 Class 对象),进入同步代码前要获得当前类的锁。
  3. 同步代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
// 关键字在实例方法上,锁为当前实例
public synchronized void instanceLock() {
    // code
}

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

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

2 锁的四种状态及锁升级

在 JDK 1.6 以前,所有的锁都是“重量级”锁,使用的是操作系统的互斥锁,效率较低。为了减少获得锁和释放锁带来的性能消耗,JDK 1.6 引入了“偏向锁”和“轻量级锁”的概念,对 synchronized 进行了重大升级。

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

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

锁会随着竞争情况逐渐升级,但锁降级发生的条件比较苛刻,通常发生在 Stop The World(STW)期间,当 JVM 进入安全点的时候,会检查是否有闲置的锁,然后进行降级。

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

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

3 对象的锁存储位置

每个 Java 对象都有一个对象头(Object Header),其中包含了 Mark WordMark Word 存储了对象的 hashCode 或锁信息等。
对象头的内容如下表所示:

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

Mark Word 的具体格式如下:

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

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

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

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

4 偏向锁

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

偏向锁的实现原理

  1. 线程在第一次进入同步块时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID。
  2. 下次该线程进入这个同步块时,会去检查锁的 Mark Word 里面是不是放的自己的线程 ID。
  3. 如果是,表明该线程已经获得了锁,以后该线程在进入和退出同步块时不需要花费 CAS 操作来加锁和解锁。
  4. 如果不是,就代表有另一个线程来竞争这个偏向锁。这个时候会尝试使用 CAS 来替换 Mark Word 里面的线程 ID 为新线程的 ID。这个时候要分两种情况:
    • 成功,表示之前的线程不存在了,Mark Word 里面的线程 ID 为新线程的 ID,锁不会升级,仍然为偏向锁;
    • 失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为 0,并设置锁标志位为 00,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。

撤销偏向锁

  1. 偏向锁使用了一种等到竞争出现才释放锁的机制

  2. 当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

  3. 撤销偏向锁的过程包括暂停拥有偏向锁的线程,重置偏向锁标识,这个过程开销较大。

  4. 撤销偏向锁的大概过程:

    • 在一个安全点(在这个时间点上没有字节码正在执行)停止拥有锁的线程。
    • 遍历线程栈,如果存在锁记录的话,需要修复锁记录和 Mark Word,使其变成无锁状态。
    • 唤醒被停止的线程,将当前锁升级成轻量级锁。
  5. 如果应用程序里所有的锁通常处于竞争状态,那么偏向锁就会是一种累赘,对于这种情况,我们可以一开始就把偏向锁这个默认功能给关闭:

    -XX:UseBiasedLocking=false
    

5 轻量级锁

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

轻量级锁的实现原理

  1. JVM 会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,称为 Displaced Mark Word。如果一个线程获得锁的时候发现是轻量级锁,会把锁的 Mark Word 复制到自己的 Displaced Mark Word 里面。
  2. 线程尝试用 CAS 将锁的 Mark Word 替换为指向锁记录的指针。
  3. 如果成功,当前线程获得锁;如果失败,表示 Mark Word 已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。

自旋

  1. 自旋是需要消耗 CPU 的,如果一直获取不到锁,那该线程就一直处在自旋状态,白白浪费 CPU 资源。
  2. JDK 采用了适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多;如果自旋失败了,则自旋的次数就会减少。

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

轻量级锁的释放

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

6 重量级锁

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

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

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

重量级锁的实现原理

  1. 当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个 ObjectWaiter 对象插入到 Contention List 队列的队首,然后调用 park 方法挂起当前线程。
  2. 当线程释放锁时,会从 Contention ListEntryList 中挑选一个线程唤醒,被选中的线程叫做 Heir presumptive 即假定继承人,假定继承人被唤醒后会尝试获得锁,但 synchronized 是非公平的,所以假定继承人不一定能获得锁。

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

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

7 锁的升级流程

  1. 检查 Mark Word 里面是不是放的自己的 ThreadId,如果是,表示当前线程是处于“偏向锁”。
  2. 如果 Mark Word 不是自己的 ThreadId,锁升级,用 CAS 来执行切换,新的线程根据 Mark Word 里面现有的 ThreadId,通知之前线程暂停,之前线程将 Mark Word 的内容置为空。
  3. 两个线程都把锁对象的 HashCode 复制到自己新建的用于存储锁的记录空间,接着开始通过 CAS 操作,把锁对象的 Mark Word 的内容修改为自己新建的记录空间的地址的方式竞争 Mark Word
  4. 第三步中成功执行 CAS 的获得资源,失败的则进入自旋。
  5. 自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于轻量级锁的状态,如果自旋失败。
  6. 进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。

8 总结

  1. Java 中的每一个对象都可以作为一个锁,Java 中的锁都是基于对象的。
  2. synchronized 关键字可以用来修饰方法和代码块,它可以保证在同一时刻最多只有一个线程执行该段代码。
  3. synchronized 关键字在修饰方法时,锁为当前实例对象;在修饰静态方法时,锁为当前 Class 对象;在修饰代码块时,锁为括号里面的对象。
  4. Java 6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。在 Java 6 以前,所有的锁都是“重量级”锁。所以在 Java 6 及其以后,一个对象其实有四种锁状态,它们级别由低到高依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。
  5. 偏向锁会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。也就是说,偏向锁在资源无竞争情况下消除了同步语句,连 CAS 操作都不做了,提高了程序的运行性能。
  6. 轻量级锁是通过 CAS 操作和自旋来实现的,如果自旋失败,则会升级为重量级锁。
  7. 重量级锁依赖于操作系统的互斥量(mutex)实现的,而操作系统中线程间状态的转换需要相对较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗 CPU。

通过理解 synchronized 关键字的工作原理和锁的升级过程,可以更好地掌握 Java 并发编程的核心概念,确保在多线程环境下程序的正确性和性能。

9 思维导图

在这里插入图片描述

10 参考链接

synchronized到底锁的什么?偏向锁、轻量级锁、重量级锁到底是什么?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值