Java线程锁笔记
Java中的偏向锁,轻量级锁, 重量级锁解析
参考文章
- Java并发编程:Synchronized底层优化(偏向锁、轻量级锁)
- 聊聊并发(二)Java SE1.6中的Synchronized
- Lock Lock Lock: Enter!
- 5 Things You Didn’t Know About Synchronization in Java and Scala
- Synchronization and Object Locking
- Eliminating Synchronization-Related Atomic Operations with Biased Locking and Bulk Rebiasing
Java 中的锁
在 Java 中主要2种加锁机制:
synchronized
关键字java.util.concurrent.Lock
(Lock
是一个接口,ReentrantLock
是该接口一个很常用的实现)
这两种机制的底层原理存在一定的差别
synchronized
关键字通过一对字节码指令monitorenter/monitorexit
实现, 这对指令被 JVM 规范所描述。java.util.concurrent.Lock
通过 Java 代码搭配sun.misc.Unsafe
中的本地调用实现的
一些先修知识
先修知识 1: Java 对象头
-
字宽(Word): 内存大小的单位概念, 对于 32 位处理器 1 Word = 4 Bytes, 64 位处理器 1 Word = 8 Bytes
-
每一个 Java 对象都至少占用 2 个字宽的内存(数组类型占用3个字宽)。
- 第一个字宽也被称为对象头Mark Word。 对象头包含了多种不同的信息, 其中就包含对象锁相关的信息。
- 第二个字宽是指向定义该对象类信息(class metadata)的指针
-
非数组类型的对象头的结构如下图
-
说明:
- MarkWord 中包含对象 hashCode 的那种无锁状态是偏向机制被禁用时, 分配出来的无锁对象MarkWord 起始状态
- 偏向机制被启用时,分配出来的对象状态是 ThreadId|Epoch|age|1|01, ThreadId 为空时标识对象尚未偏向于任何一个线程, ThreadId 不为空时, 对象既可能处于偏向特定线程的状态, 也有可能处于已经被特定线程占用完毕释放的状态, 需结合 Epoch 和其他信息判断对象是否允许再偏向(rebias)。
下面的图片来自参考论文 Eliminating Synchronization-Related Atomic Operations with Biased Locking and Bulk Rebiasing , 可以与上面的表格进行比对参照, 更为清晰, 可以看出来, 标志位(tag bits)可以直接确定唯一的一种锁状态
先修知识 2: CAS 指令
- CAS (Compare And Swap) 指令是一个CPU层级的原子性操作指令。 在 Intel 处理器中, 其汇编指令为 cmpxchg。
- 该指令概念上存在 3 个参数, 第一个参数【目标地址】, 第二个参数【值1】, 第三个参数【值2】, 指令会比较【目标地址存储的内容】和 【值1】 是否一致, 如果一致, 则将【值 2】 填写到【目标地址】, 其语义可以用如下的伪代码表示。
|
|
- 注意: 该指令是是原子性的, 也就是说 CPU 执行该指令时, 是不会被中断执行其他指令的
先修知识 3: “CAS”实现的"无锁“算法常见误区
-
误区一: 通过简单应用 “
比较后再赋值
” 的操作即可轻松实现很多无锁算法
- CAS 指令的一个不可忽略的特征是原子性。 在 CPU 层面, CAS 指令的执行是有原子性语义保证的, 如果 CAS 操作放在应用层面来实现, 则需要我们自行保证其原子性。 否则就会发生如下描述的问题:
|
|
- 误区二: CAS 操作的 ABA 问题
- 大部分网络博文对 ABA 问题的常见描述是: 应用 CAS 操作时, 目标地址的值刚开始为 A, 工作线程/进程 读取后, 进行了一系列运算, 计算得出了新值 C, 在此期间, 目标地址的值被其他线程已经进行了不止一次修改, 其值已经从 A 被改为 B , 又改回 A, 此时便会发生同步问题。
- 上面的描述是其实是错误的, 思考一下就会发现, 如果工作线程的操作目的是将目标地址的值从 A 改为 C, 那么即便在这期间目标地址的值经过了其他线程或进程的多次修改, 其语义依旧是正确的。
- 例如目前要将某银行账号的余额扣除 50, 通过 CAS 保证同步 :
- 首先读取原有余额为 100 ,
- 计算余额应该赋值为 100 - 50 = 50
- 此时该线程被挂起, 该账户同时又发生了转入 150 和转出 150 的操作, 余额经历了 100 -》250 -》100 的变动
- 线程被唤醒, 进行 CAS 赋值操作 cas(p, 100, 50) , 正常得以执行。
- 该账户的余额依旧是正确的
- 通过上述例子就可以发现, ABA 的问题并不在于多次修改。 查阅一下 CAS 的 wiki 解释, 就会发现, ABA 真正的问题是, 假如目标地址的内容被多次修改以后, 虽然从二进制上来看是依旧是 A, 但是其语义已经不是 A 。例如, 发生了整数溢出, 内存回收等等。
先修知识 4: 栈帧(Stack Frame) 的概念
- 这个概念涉及的内容较多, 不便于展开叙述。 从理解下文的角度上来讲, 需要知道, 每个线程都有自己独立的内存空间, 栈帧就是其中的一部分。里面可以存储仅属于该线程的一些信息。
- 需要深入了解的同学, 需要自行查阅 栈帧 相关的概念
先修知识 5: 轻量级加锁的过程
轻量级加锁的过程在参考文章一中有较为的描述以及配图, 这里直接将其摘抄过来, 做轻微整理和调整
- (1)在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态”, 是否可偏向的标志位是 “1”), 也就是如下状态
,然后, 虚拟机会首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。
-
(2)拷贝对象头中的Mark Word复制到锁记录中。这时候线程堆栈与对象头的状态如图2.1所示
-
图 2.1
-
(3)拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤(4),否则执行步骤(5)。
-
(4)如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图2.2所示。
-
图2.2
-
5)如果这个更新操作失败了,说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁
先修知识 6: 重量级加锁的过程
- 轻量级锁在向重量级锁膨胀的过程中, 一个操作系统的互斥量(mutex)和条件变量( condition variable )会和这个被锁的对象关联起来。
- 具体而言, 在锁膨胀时, 被锁对象的 markword 会被通过 CAS 操作尝试更新为一个数据结构的指针, 这个数据结构中进一步包含了指向操作系统互斥量(mutex) 和 条件变量(condition variable) 的指针
先修知识7:理解各锁基本定义作用。
1.偏向锁
偏向锁是JDK1.6提出来的一种锁优化的机制。其核心的思想是,如果程序没有竞争,则取消之前已经取得锁的线程同步操作。也就是说,若某一锁被线程获取后,便进入偏向模式,当线程再次请求这个锁时,就无需再进行相关的同步操作了,从而节约了操作时间,如果在此之间有其他的线程进行了锁请求,则锁退出偏向模式。在JVM中使用-XX:+UseBiasedLocking
|
|
初始化一个Vector,往里面添加10000000个Integer对象,然后输出时间差。以此来测试偏向锁的性能。至于为什么要使用Vector而不使用ArrayList呢?
因为ArrayList是线程不安全的,Vector是线程安全的。这样说可能还不够具体,可以翻看一下源码吧。
Vector中的几乎所有操作是带有sychronized的,而ArrayList是没有的,所以Vector是线程安全的。
接下来我们来测试一下,开启偏向锁和不开启偏向锁对程序性能的影响有多大。
配置JVM启动(开启偏向锁)参数为:
配置JVM启动(关闭偏向锁)参数为:
Perfect!开启偏向锁的程序运行时间明显较短,开启偏向锁比不开启偏向锁,在单个线程中操作一个对象的同步方法,是有一定的优势的。其实也可以这样理解,当只有一个线程操作带有同步方法的Vector对象的时候,此时对Vector的操作就转变成了对ArrayList的操作。
偏向锁在锁竞争激烈的场合没有太强的优化效果,因为大量的竞争会导致持有锁的线程不停地切换,锁也很难保持在偏向模式,此时,使用偏向锁不仅得不到性能的优化,反而有可能降低系统的性能,因此,在激烈竞争的场合,可以尝试使用
-XX:-UseBiastedLocking
参数禁用偏向锁。
2.轻量级锁
如果偏向锁失败,Java虚拟机就会让线程申请轻量级锁,轻量级锁在虚拟机内部,使用一个成为BasicObjectLock的对象实现的,这个对象内部由一个BasicLock对象和一个持有该锁的Java对象指针组成。BasicObjectLock对象放置在Java栈帧中。在BasicLock对象内部还维护着displaced_header字段,用于备份对象头部的Mark Word.
当一个线程持有一个对象的锁的时候,对象头部Mark Word信息如下
|
|
末尾的两位比特为00,整个Mark Word为指向BasicLock对象的指针。由于BasicObjectLock对象在线程栈中,因此该指针必然指向持有该锁的线程栈空间。当需要判断一个线程是否持有该对象时,只需要简单地判断对象头的指针是否在当前线程的栈地址范围即可。同时,BasicLock对象的displaced_header,备份了原对象的Mark word内容,BasicObjectLock对象的obj字段则指向持有锁的对象头部。
3.重量级锁
当轻量级锁失败,虚拟机就会使用重量级锁。在使用重量级锁的时,对象的Mark Word如下:
|
|
重量级锁在操作过程中,线程可能会被操作系统层面挂起,如果是这样,线程间的切换和调用成本就会大大提高。
4.自旋锁
自旋锁可以使线程在没有取得锁的时候,不被挂起,而转去执行一个空循环,(即所谓的自旋,就是自己执行空循环),若在若干个空循环后,线程如果可以获得锁,则继续执行。若线程依然不能获得锁,才会被挂起。
使用自旋锁后,线程被挂起的几率相对减少,线程执行的连贯性相对加强。因此,对于那些锁竞争不是很激烈,锁占用时间很短的并发线程,具有一定的积极意义,但对于锁竞争激烈,单线程锁占用很长时间的并发程序,自旋锁在自旋等待后,往往毅然无法获得对应的锁,不仅仅白白浪费了CPU时间,最终还是免不了被挂起的操作 ,反而浪费了系统的资源。
在JDK1.6中,Java虚拟机提供-XX:+UseSpinning
参数来开启自旋锁,使用-XX:PreBlockSpin
参数来设置自旋锁等待的次数。
在JDK1.7开始,自旋锁的参数被取消,虚拟机不再支持由用户配置自旋锁,自旋锁总是会执行,自旋锁次数也由虚拟机自动调整。
synchronized 关键字之锁的升级(偏向锁->轻量级锁->重量级锁)
前面提到过, synchronized 代码块是由一对 monitorenter/moniterexit 字节码指令实现, monitor 是其同步实现的基础, Java SE1.6 为了改善性能, 使得 JVM 会根据竞争情况, 使用如下 3 种不同的锁机制
- 偏向锁(Biased Lock )
- 轻量级锁( Lightweight Lock)
- 重量级锁(Heavyweight Lock)
上述这三种机制的切换是根据竞争激烈程度进行的, 在几乎无竞争的条件下, 会使用偏向锁, 在轻度竞争的条件下, 会由偏向锁升级为轻量级锁, 在重度竞争的情况下, 会升级到重量级锁。
注意 JVM 提供了关闭偏向锁的机制, JVM 启动命令指定如下参数即可
|
|
下图展现了一个对象在创建(allocate) 后, 根据偏斜锁机制是否打开, 对象 MarkWord 状态以不同方式转换的过程
上图在参考文章一中的中文翻译对照图如下
无锁 -> 偏向锁
从上图可以看到 , 偏向锁的获取方式是将对象头的 MarkWord 部分中, 标记上线程ID, 以表示哪一个线程获得了偏向锁。 具体的赋值逻辑如下:
-
首先读取目标对象的 MarkWord, 判断是否处于可偏向的状态(如下图)
-
下面是 Open Jdk/ JDK 8 源码 中检测一个对象是否处于可偏向状态的源码
|
|
应评论区一位朋友的提问, 进一步摘抄一下markOop.hpp中的方法定义
|
|
-
has_bias_pattern()
返回 true 时代表 markword 的可偏向标志 bit 位为 1 ,且对象头末尾标志为 01。 -
biased_locker() == NULL
返回 true 时代表对象 Mark Word 中 bit field 域存储的 Thread Id 为空。 -
如果为可偏向状态, 则尝试用 CAS 操作, 将自己的线程 ID 写入MarkWord
-
如果 CAS 操作成功(状态转变为下图), 则认为已经获取到该对象的偏向锁, 执行同步块代码 。 注意, age 后面的标志位中的值并没有变化, 这点之后会用到
-
补充: 一个线程在执行完同步代码块以后, 并不会尝试将 MarkWord 中的 thread ID 赋回原值 。这样做的好处是: 如果该线程需要再次对这个对象加锁,而这个对象之前一直没有被其他线程尝试获取过锁,依旧停留在可偏向的状态下, 即可在不修改对象头的情况下, 直接认为偏向成功。
-
如果 CAS 操作失败, 则说明, 有另外一个线程 Thread B 抢先获取了偏向锁。 这种状态说明该对象的竞争比较激烈, 此时需要撤销 Thread B 获得的偏向锁,将 Thread B 持有的锁升级为轻量级锁。 该操作需要等待全局安全点 JVM safepoint ( 此时间点, 没有线程在执行字节码) 。
-
-
如果是已偏向状态, 则检测 MarkWord 中存储的 thread ID 是否等于当前 thread ID 。
- 如果相等, 则证明本线程已经获取到偏向锁, 可以直接继续执行同步代码块
- 如果不等, 则证明该对象目前偏向于其他线程, 需要撤销偏向锁
从上面的偏向锁机制描述中,可以注意到
- 偏向锁的 撤销(revoke) 是一个很特殊的操作, 为了执行撤销操作, 需要等待全局安全点(Safe Point), 此时间点所有的工作线程都停止了字节码的执行。
偏向锁的撤销(Revoke)
如上文提到的, 偏向锁的撤销(Revoke) 操作并不是将对象恢复到无锁可偏向的状态, 而是在偏向锁的获取过程中, 发现了竞争时, 直接将一个被偏向的对象“升级到” 被加了轻量级锁的状态。 这个操作的具体完成方式如下:
- 在偏向锁 CAS 更新操作失败以后, 等待到达全局安全点。
- 通过 MarkWord 中已经存在的 Thread Id 找到成功获取了偏向锁的那个线程, 然后在该线程的栈帧中补充上轻量级加锁时, 会保存的锁记录(Lock Record), 然后将被获取了偏向锁对象的 MarkWord 更新为指向这条锁记录的指针。
- 至此, 锁撤销操作完成, 阻塞在安全点的线程可以继续执行。
偏向锁的批量再偏向(Bulk Rebias)机制
偏向锁这个机制很特殊, 别的锁在执行完同步代码块后, 都会有释放锁的操作, 而偏向锁并没有直观意义上的“释放锁”操作。
那么作为开发人员, 很自然会产生的一个问题就是, 如果一个对象先偏向于某个线程, 执行完同步代码后, 另一个线程就不能直接重新获得偏向锁吗? 答案是可以, JVM 提供了批量再偏向机制(Bulk Rebias)机制
该机制的主要工作原理如下:
- 引入一个概念 epoch, 其本质是一个时间戳 , 代表了偏向锁的有效性
- 从前文描述的对象头结构中可以看到, epoch 存储在可偏向对象的 MarkWord 中。
- 除了对象中的 epoch, 对象所属的类 class 信息中, 也会保存一个 epoch 值
- 每当遇到一个全局安全点时, 如果要对 class C 进行批量再偏向, 则首先对 class C 中保存的 epoch 进行增加操作, 得到一个新的 epoch_new
- 然后扫描所有持有 class C 实例的线程栈, 根据线程栈的信息判断出该线程是否锁定了该对象, 仅将 epoch_new 的值赋给被锁定的对象中。
- 退出安全点后, 当有线程需要尝试获取偏向锁时, 直接检查 class C 中存储的 epoch 值是否与目标对象中存储的 epoch 值相等, 如果不相等, 则说明该对象的偏向锁已经无效了, 可以尝试对此对象重新进行偏向操作。
上述的逻辑可以在 JDK 源码中得到验证。
在 sharedRuntime.cpp 中, 下面代码是 synchronized 的主要逻辑
|
|
- UseBiasedLocking 是 JVM 启动时, 偏斜锁是否启用的标志。
- fast_enter 中包含了偏斜锁的相关逻辑
- slow_enter 中绕过偏斜锁, 直接进入轻量级锁获取
|
|
-
该函数中再次保险性地做了偏斜锁是否开启的检查(UseBiasedLocking)
-
当系统不处于安全点时, 代码通过方法
revoke_and_rebias
这个函数尝试获取偏斜锁, 如果获取成功就可以直接返回了, 如果不成功则进入轻量级锁的获取过程 -
1
revoke_and_rebias
这个函数名称就很有意思, 说明该函数中包含了 revoke 的操作也包含了 rebias 的操作
- revoke 不是只应该在安全点时刻才发生吗? 答案: 有一些特殊情形, 不需要安全点也可以执行 revoke 操作
- 此处为什么只有 rebias 操作, 没有初次的 bias 操作?答案: 首次的 bias 操作也被当成了 rebias 操作的一个特例
revoke_and_rebias 函数的定义在 biasedLocking.cpp
|
|
偏向锁 -> 轻量级锁
从之前的描述中可以看到, 存在超过一个线程竞争某一个对象时, 会发生偏向锁的撤销操作。 有趣的是, 偏向锁撤销后, 对象可能处于两种状态。
-
一种是不可偏向的无锁状态, 如下图(之所以不允许偏向, 是因为已经检测到了多于一个线程的竞争, 升级到了轻量级锁的机制)
-
另一种是不可偏向的已锁 ( 轻量级锁) 状态
之所以会出现上述两种状态, 是因为偏向锁不存在解锁的操作, 只有撤销操作。 触发撤销操作时:
- 原来已经获取了偏向锁的线程可能已经执行完了同步代码块, 使得对象处于 “闲置状态”,相当于原有的偏向锁已经过期无效了。此时该对象就应该被直接转换为不可偏向的无锁状态。
- 原来已经获取了偏向锁的线程也可能尚未执行完同步代码块, 偏向锁依旧有效, 此时对象就应该被转换为被轻量级加锁的状态
轻量级加锁过程:
-
首先根据标志位判断出对象状态处于不可偏向的无锁状态( 如下图)
-
在当前线程的栈桢(Stack Frame)中创建用于存储锁记录(lock record)的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。如果在此过程中发现,
-
然后线程尝试使用 CAS 操作将对象头中的 Mark Word 替换为指向锁记录的指针。
- 如果成功,当前线程获得锁
- 如果失败,表示该对象已经被加锁了, 先进行自旋操作, 再次尝试 CAS 争抢, 如果仍未争抢到, 则进一步升级锁至重量级锁。
下图引用自博文 聊聊并发(二)Java SE1.6中的Synchronized展示了两个线程竞争锁, 最终导致锁膨胀为重量级锁的过程。
**注意: 下图中第一个标绿 MarkWord 的起始状态是HashCode|age|0|01
是偏向锁未被启用时, 分配对象后的状态, 所以在图中并没有偏向锁这一流程的体现, 是直接从无锁状态进入了轻量级锁的状态
重量级锁
重量级锁依赖于操作系统的互斥量(mutex) 实现, 其具体的详细机制此处暂不展开, 日后可能补充。 此处暂时只需要了解该操作会导致进程从用户态与内核态之间的切换, 是一个开销较大的操作。
存疑的问题
- 在锁膨胀的图例中, 线程 2 在线程 1 尚未释放锁时, 即将对象头修改为指向重量级锁的状态, 这个操作具体如何完成, 是否需要等待全局安全点?笔者尚未细究
- 轻量级锁的第一次获取时, 如果 CAS 操作失败, 按照 聊聊并发(二)Java SE1.6中的Synchronized 的描述, 会进行自旋的尝试。 但按照 Synchronization and Object Locking 的描述, 会去检测已加的锁是归属于自身线程, 没有提到自旋操作。 具体哪一种是正确的行为, 有待研究源码。
- biasedLocking.cpp中的方法
revoke_and_rebias
存在 4 个条件分支, 其中笔者添加了注释的两个分支其主要逻辑已经清晰, 但未添加注释的两个分支具体逻辑笔者尚不清楚, 有待进一步研究