Skip to content

Synchronized 锁

对象结构和锁状态

Java中的 synchronized 关键字是其内置锁的实现。这个内置锁本质上是附加在任何Java对象上的。

一个Java对象在内存中通常由三部分组成:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。对象头是锁机制的关键,它包含一个名为“Mark Word”的字段。

在64位JVM中,Mark Word占64个比特(8字节),它的内容会根据对象的锁状态而变化,主要作用就是记录对象的哈希码、GC分代年龄以及锁信息。

Mark Word 结构 (64位虚拟机)

Mark Word的最后3个比特位是锁状态的标志:

  • lock (2 bit): 锁状态标记位。

  • biased_lock (1 bit): 是否启用了偏向锁的标记。为1表示启用,为0表示未启用。

这两个标记位的组合共同决定了对象的具体锁状态,如下表所示:

biased_lock lock 含义(锁状态)
0 01 无锁
1 01 偏向锁
0 00 轻量级锁
0 10 重量级锁
0 11 GC标记

锁升级机制

在JDK 1.6之前,synchronized 锁都是重量级锁,它依赖于操作系统的互斥量(Mutex),会导致CPU在用户态和核心态之间切换,性能开销大。为了优化性能,JDK 1.6引入了偏向锁和轻量级锁。

synchronized 的锁状态会随着线程竞争的激烈程度而升级,过程如下:

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

这个升级过程是单向的,即锁可以升级但不能降级(例如,轻量级锁不能再变回偏向锁)。这种策略旨在提高锁获取和释放的效率。

无锁状态

一个对象在没有任何线程尝试获取其锁时的初始状态就是无锁状态。

  • 内存结构: 此时Mark Word的最后3位是 001 (biased_lock=0, lock=01)。

  • 示例分析:

    • 通过JOL(Java Object Layout)工具可以查看对象内存布局。

    • 当创建一个新对象时,如果偏向锁机制尚未启动(JVM启动后有4秒的默认延迟),其Mark Word的末尾会是 ...001,表示它处于无锁且“不可偏向”(non-biasable)的状态。

偏向锁

1. 偏向锁核心原理

  • 目的: 优化“只有一个线程持续访问同步代码块”的场景。

  • 工作方式:

    1. 当第一个线程获取锁时,JVM会使用CAS(比较并交换)操作将该线程的ID记录到锁对象的Mark Word中,并将锁状态标志位改为 101 (biased_lock=1, lock=01)。

    2. 此后,当该线程再次进入同步代码块时,只需检查Mark Word中的线程ID是否是自己的ID,如果是,则无需任何同步操作(连CAS都不需要),直接执行代码。这极大地降低了无竞争情况下的锁获取开销。

  • 锁撤销与升级:

    • 一旦出现第二个线程尝试获取这个锁,偏向模式就会结束。

    • JVM会检查原持有偏向锁的线程是否还在执行同步代码。如果不在,就将锁对象的Mark Word重置为无锁状态或重偏向给新线程;如果在,就撤销原线程的偏向锁,并将锁升级为轻量级锁。

  • 缺点: 如果应用中充满了锁竞争,偏向锁的撤销过程会带来额外的性能开销,反而得不偿失。

2. 偏向锁案例分析

  • 单线程场景:

    • 抢占锁前:对象处于“可偏向”(biasable)状态,Mark Word末尾为 101,但线程ID字段为空。

    • 占有锁后:Mark Word中记录了当前线程的ID,状态变为“已偏向”(biased)。

    • 释放锁后:锁对象依然保持偏向状态,其Mark Word中的线程ID也不会被清除。这是因为JVM乐观地认为该线程很可能会再次获取该锁。

  • 多线程场景(无优化):

    • 线程A获取锁后,锁偏向于A。

    • 线程A结束后,线程B尝试获取同一个锁。此时会发生偏向锁撤销,锁直接升级为轻量级锁,而不是重偏向给线程B。这是因为默认情况下,单次的锁撤销不足以触发更复杂的优化。

3. 批量重偏向 (Bulk Re-biasing)

  • 问题背景: 当一个类的多个对象都被线程A偏向后,线程B又依次来获取这些锁,每次都升级为轻量级锁的开销较大。

  • 机制: JVM引入了批量重偏向机制来优化这种情况。

    • 由JVM参数 BiasedLockingBulkRebiasThreshold 控制,默认值为20。

    • JVM会为每个类维护一个偏向锁撤销计数器。当一个类的对象发生偏向锁撤销时,该计数器加1。

    • 当计数器达到阈值(如20)时,JVM认为该类的锁可能出现了所有权转移。它不会再将后续的锁升级为轻量级锁,而是会执行“批量重偏向”,将后续遇到该类对象的锁直接偏向于新的线程(如线程B)。

  • 示例验证: 线程B循环获取20个由线程A偏向的锁对象。前19次,锁会升级为轻量级锁;但在第20次,会触发批量重偏向,第20个锁对象会直接重偏向于线程B,后续新建的该类对象也会默认偏向B。

4. epoch 字段

  • 作用: epoch 是实现批量重偏向的关键。它是Mark Word在偏向锁状态下的一个2位字段。

  • 工作原理:

    1. 每个Class对象内部维护一个 epoch 值,初始为0。

    2. 当一个对象被创建并成为偏向锁时,其Mark Word中的 epoch 会被设置为其Class对象当时的 epoch 值。

    3. 当触发批量重偏向时,Class对象的 epoch 值会加1。

    4. 当一个线程尝试获取一个偏向锁时,会比较锁对象Mark Word中的 epoch 和其Class对象中的 epoch

      • 如果两者不相等,说明Class的 epoch 已经更新(发生了批量重偏向),意味着当前对象的偏向已“过时”。此时,该线程可以通过CAS操作将锁重偏向于自己。

      • 如果两者相等,说明仍处于同一“纪元”,此时发生竞争则必须走锁升级流程。

5. 批量撤销 (Bulk Revocation)

  • 问题背景: 如果一个类的对象锁竞争非常激烈,即使重偏向后,撤销次数依然持续增加,说明偏向锁对这个类完全不适用。

  • 机制:

    • 由JVM参数 BiasedLockingBulkRevokeThreshold 控制,默认值为40。

    • 当一个类的偏向锁撤销计数器达到这个更高的阈值时,JVM会执行“批量撤销”。

    • 后果:

      1. JVM会将该Class标记为“不可偏向”。

      2. 之后所有新创建的该Class的对象都将是无锁状态(不可偏向),当它们被加锁时会直接进入轻量级锁状态。

      3. JVM会遍历所有线程栈,将该Class所有现存的偏向锁实例全部撤销。

6. 批量撤销冷静期

  • 机制:

    • 由JVM参数 BiasedLockingDecayTime 控制,默认25000毫秒(25秒)。

    • 这是一个“再给一次机会”的机制。如果在距离上一次批量重偏向发生超过25秒后,撤销计数器才达到批量撤销阈值,那么JVM不会立即执行批量撤销,而是会重置计数器,让这个类从头再来。

    • 如果在25秒的“冷静期”内撤销次数迅速达到阈值,则会立即执行批量撤销。


四、轻量级锁

1. 轻量级锁核心原理

  • 目的: 在线程交替执行同步块,但竞争不激烈(即同一时刻只有一个线程竞争)的情况下,避免重量级锁的性能开销。它是一种基于CAS的自旋锁。

  • 加锁过程:

    1. 线程在自己的栈帧中创建一个名为“锁记录”(Lock Record)的空间。

    2. JVM将锁对象的Mark Word复制到这个锁记录中(称为 Displaced Mark Word)。

    3. 线程使用CAS操作,尝试将锁对象的Mark Word更新为指向这个锁记录的指针。

    4. 如果CAS成功,线程获得锁,锁对象的Mark Word状态变为 00

  • 解锁过程:

    1. 线程使用CAS操作,将保存在锁记录中的 Displaced Mark Word 恢复到对象头中。

    2. 如果CAS成功,解锁完成。如果失败,说明在持有锁期间有其他线程尝试获取锁,锁已经膨胀为了重量级锁,此时需要进入重量级锁的解锁流程。

2. 轻量级锁分类

轻量级锁的本质是自旋,即线程不被挂起,而是执行一个空循环等待锁释放。

  • 普通自旋锁: 循环固定的次数。在JDK 1.7后已被弃用。

  • 自适应自旋锁:

    • 这是当前JVM的实现方式。自旋的次数是动态调整的。

    • 如果之前在同一个锁上自旋成功过,JVM会认为这次也很可能成功,就会允许更长时间的自旋。

    • 如果对于某个锁,自旋很少成功,JVM会缩短甚至直接跳过自旋,以避免浪费CPU资源。


五、重量级锁

1. 重量级锁核心原理

  • 基础: 当轻量级锁的自旋失败或多个线程同时竞争锁时,锁会膨胀为重量级锁。它依赖于操作系统的互斥锁(Mutex Lock)实现。

  • 性能开销: 会导致线程的阻塞和唤醒,这需要在用户态和内核态之间进行切换,开销很大。

  • ObjectMonitor:

    • 每个Java对象都与一个ObjectMonitor关联。重量级锁的实现就是基于这个C++对象。

    • ObjectMonitor 内部有几个关键队列:

      • _owner: 指向当前持有锁的线程。

      • _cxq (Contention Queue): 竞争队列。所有新来的、请求锁的线程首先进入此队列。

      • _EntryList: 入口队列。_cxq 中有资格获取锁的线程会被移到这里。

      • _WaitSet: 等待队列。调用了 object.wait() 方法的线程会进入此队列,并释放锁。

  • 工作流程: 抢锁失败的线程会被封装成节点放入 _cxq,然后进入 _EntryList 成为候选者并被挂起(park)。当持有锁的线程释放锁时,会从 _EntryList 中唤醒(unpark)一个线程来获取锁。

2. 重量级锁案例分析

一个完整的锁升级过程可以这样触发:

  1. 线程A 首先执行,获取锁,此时锁变为偏向A的 偏向锁

  2. 线程B 在A结束后执行,尝试获取锁,发生偏向锁撤销,锁升级为 轻量级锁

  3. 线程B在持有轻量级锁时,如果长时间不释放(例如,通过 LockSupport.park() 暂停),此时 线程C 过来竞争。

  4. 线程C自旋尝试获取锁失败,于是锁膨胀为 重量级锁。线程C被阻塞。

  5. 此时,锁对象的Mark Word标志位变为 10,并指向 ObjectMonitor 的地址。

Mark Word 字段

首先要理解一个核心思想:Mark Word 的设计是为了在有限的空间内存储尽可能多的信息。它不像一个固定的结构体,它的分区和每个区域的含义会根据对象当前所处的锁状态而动态变化。可以把它看作是一个“多路复用”的字段,其最后几个比特位充当“状态标志”,决定了前面所有比特位的含义。以下介绍以主流的 64位HotSpot虚拟机 为例。在32位虚拟机中,位数减半,但分区思想是相同的。

1. 无锁状态 (Unlocked)

这是对象的默认初始状态(在偏向锁延迟启动期间)。

  • 状态标志: 最后3位为 001 (biased_lock=0, lock=01)。

  • 分区结构 (从高位到低位):

    |--------------------------------------------------|-----------|----|---|----| | unused (25 bits) | identity_hashcode (31 bits) | age (4 bits) | 0 | 01 | |--------------------------------------------------|-----------|----|---|----|

  • 字段说明:

    • unused: 未使用的空间。

    • identity_hashcode: 对象的“身份哈希码”。这是一个延迟加载(lazily loaded)的值。只有当程序第一次调用该对象的 System.identityHashCode()Object.hashCode() 方法时,JVM才会计算这个哈希码并将其存入这里。在此之前,这部分区域可能为空。

    • age: GC分代年龄(4 bits)。对象在新生代的Survivor区每熬过一次Minor GC,年龄就加1。当年龄达到阈值(默认为15)时,对象会被晋升到老年代。4个比特位最大能表示15。

    • biased_lock: 偏向锁标志位,此时为0,表示非偏向锁状态。

    • lock: 锁标志位,此时为01,与 biased_lock 组合表示“无锁”状态。

2. 偏向锁状态 (Biased Lock)

当对象启用了偏向锁,并且被第一个线程获取时,它会进入此状态。

  • 状态标志: 最后3位为 101 (biased_lock=1, lock=01)。

  • 分区结构:

    |--------------------------------|-----------|----|---|----| | Thread ID (54 bits) | epoch (2 bits) | age (4 bits) | 1 | 01 | |--------------------------------|-----------|----|---|----|

  • 字段说明:

    • Thread ID: 记录持有该偏向锁的线程的ID或指针。这是一个非常重要的优化,因为线程再次访问时,只需检查这个ID即可,无需更复杂的操作。

    • epoch: 用于批量重偏向和批量撤销优化的一个字段,可以理解为一个“纪元”或“版本号”,用于处理偏向锁的有效性。

    • age: GC分代年龄,与无锁状态时相同。

    • biased_lock: 偏向锁标志位,为1。

    • lock: 锁标志位,为01。

3. 轻量级锁状态 (Lightweight Lock)

当出现线程竞争,偏向锁被撤销后,锁会升级为轻量级锁。

  • 状态标志: 最后2位为 00

  • 分区结构:

    |------------------------------------------------------|----| | Pointer to Lock Record (62 bits) | 00 | |------------------------------------------------------|----|

  • 字段说明:

    • Pointer to Lock Record: 此时Mark Word不再存储对象的哈希码或GC年龄等信息。这些信息被复制并保存到了持有锁的线程栈帧中的一个叫做“锁记录(Lock Record)”的空间里。而Mark Word本身则变成一个指针,指向这个锁记录。通过这种方式,JVM可以判断锁是否被当前线程持有。

    • lock: 锁标志位,为00,表示轻量级锁状态。

4. 重量级锁状态 (Heavyweight Lock)

当线程自旋获取轻量级锁失败,竞争进一步加剧时,锁会膨胀为重量级锁。

  • 状态标志: 最后2位为 10

  • 分区结构:

    |------------------------------------------------------|----| | Pointer to Heavyweight Monitor (62 bits) | 10 | |------------------------------------------------------|----|

  • 字段说明:

    • Pointer to Heavyweight Monitor: 与轻量级锁类似,Mark Word变成一个指针,但此时它指向的是一个重量级的监视器对象(ObjectMonitor)。这个监视器对象是在C++层面实现的,包含了等待队列、持有者信息等,能够管理线程的阻塞和唤醒。

    • lock: 锁标志位,为10,表示重量级锁状态。

5. GC标记状态

在垃圾回收期间,Mark Word还有一种特殊状态。

  • 状态标志: 最后2位为 11

  • 分区结构:

    |------------------------------------------------------|----| | GC-specific data | 11 | |------------------------------------------------------|----|

  • 字段说明:

    • 此时Mark Word不包含任何锁信息,它被GC模块用于存储与垃圾回收相关的信息,例如标记对象是否存活。
锁状态 biased_lock lock 存储内容 (64位)
无锁 0 01 unused(25) + identity_hashcode(31) + age(4)
偏向锁 1 01 Thread ID(54) + epoch(2) + age(4)
轻量级锁 (N/A) 00 Pointer to Lock Record (62)
重量级锁 (N/A) 10 Pointer to Heavyweight Monitor (62)
GC标记 (N/A) 11 GC相关数据