Java中的偏向锁,轻量级锁, 重量级锁解析

文章目录

参考文章

Java 中的锁

在 Java 中主要2种加锁机制:

  • synchronized 关键字
  • java.util.concurrent.LockLock是一个接口,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)的指针
  • 非数组类型的对象头的结构如下图
    《Java中的偏向锁,轻量级锁, 重量级锁解析》

先修知识 2: CAS 指令

  • CAS (Compare And Swap) 指令是一个CPU层级的原子性操作指令。 在 Intel 处理器中, 其汇编指令为 cmpxchg。
  • 该指令概念上存在 3 个参数, 第一个参数【目标地址】, 第二个参数【值1】, 第三个参数【值2】, 指令会比较【目标地址存储的内容】和 【值1】 是否一致, 如果一致, 则将【值 2】 填写到【目标地址】, 其语义可以用如下的伪代码表示。
function cas(p , old , new ) returns bool {
    if *p ≠ old { // *p 表示指针p所指向的内存地址
        return false
    }
    *p ← new
    return true
}
  • 注意: 该指令是是原子性的, 也就是说 CPU 执行该指令时, 是不会被中断执行其他指令的

先修知识 3: “CAS”实现的”无锁“算法常见误区

  • 误区一: 通过简单应用 “比较后再赋值” 的操作即可轻松实现很多无锁算法
    • CAS 指令的一个不可忽略的特征是原子性。 在 CPU 层面, CAS 指令的执行是有原子性语义保证的, 如果 CAS 操作放在应用层面来实现, 则需要我们自行保证其原子性。 否则就会发生如下描述的问题:
// 下列的函数如果不是线程互斥的
function cas( p , old , new) returns bool {
    if *p ≠ old { // 此处的比较操作进行时, 可以同时有多个线程通过该判断
        return false
    }
    *p ← new // 多个线程的赋值操作会相互覆盖, 造成程序逻辑的错误
    return true
}
  • 误区二: 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) 的概念

  • 这个概念涉及的内容较多, 不便于展开叙述。 从理解下文的角度上来讲, 需要知道, 每个线程都有自己独立的内存空间, 栈帧就是其中的一部分。里面可以存储仅属于该线程的一些信息。
  • 需要深入了解的同学, 需要自行查阅 栈帧 相关的概念

synchronized 关键字之锁的升级(偏向锁->轻量级锁->重量级锁)

前面提到过, synchronized 代码块是由一对 monitorenter/moniterexit 字节码指令实现, monitor 是其同步实现的基础, Java SE1.6 为了改善性能, 使得 JVM 会根据竞争情况, 使用如下 3 种不同的锁机制

  • 偏向锁(Biased Lock )
  • 轻量级锁( Lightweight Lock)
  • 重量级锁(Heavyweight Lock)

上述这三种机制的切换是根据竞争激烈程度进行的, 在几乎无竞争的条件下, 会使用偏向锁, 在轻度竞争的条件下, 会由偏向锁升级为轻量级锁, 在重度竞争的情况下, 会升级到重量级锁。

注意 JVM 提供了关闭偏向锁的机制, JVM 启动命令指定如下参数即可

-XX:-UseBiasedLocking

下图展现了一个对象在创建(allocate) 后, 根据偏斜锁机制是否打开, 对象 MarkWord 状态以不同方式转换的过程

《Java中的偏向锁,轻量级锁, 重量级锁解析》

无锁 -> 偏向锁

从上图可以看到 , 偏向锁的获取方式是将对象头的 MarkWord 部分中, 标记上线程ID, 以表示哪一个线程获得了偏向锁。 具体的赋值逻辑如下:

  • 首先读取目标对象的 MarkWord, 判断是否处于可偏向的状态(如下图)
    《Java中的偏向锁,轻量级锁, 重量级锁解析》

    • (实际 JDK 代码中,其实是通过标志位结合 epoch 值去判断是否处于可偏向的状态, 而不是根据 ThreadId 为 0 来判断可以尝试偏向锁获取的, 这一点从文章后续的源码解析中看到)
  • 如果为可偏向状态, 则尝试用 CAS 操作, 将自己的线程 ID 写入MarkWord

    • 如果 CAS 操作成功(状态转变为下图), 则认为已经获取到该对象的偏向锁, 执行同步块代码
      《Java中的偏向锁,轻量级锁, 重量级锁解析》
    • 如果 CAS 操作失败, 则说明, 有另外一个线程抢先获取了偏向锁, 此时需要撤销偏向锁,使目标对象进入轻量级锁的状态。 该操作需要等待全局安全点 JVM safepoint ( 此时间点, 没有线程在执行字节码) 。
  • 如果是已偏向状态, 则检测 MarkWord 中存储的 thread ID 是否等于当前 thread ID 。

    • 如果相等, 则证明本线程已经获取到偏向锁, 可以直接继续执行同步代码块
    • 如果不等, 则证明该对象目前偏向于其他线程, 需要撤销偏向锁

从上面的偏向锁机制描述中,可以注意到

  • 偏向锁的 撤销(revoke) 是一个很特殊的操作, 为了执行撤销操作, 需要等待全局安全点(Safe Point), 此时间点所有的工作线程都停止了字节码的执行。
  • 一个线程在执行完同步代码块以后, 并不会尝试将 MarkWord 中的 thread ID 赋回原值 。如果该线程需要再次加锁时, 会发现之前已经获得偏向锁, 无须修改对象头的任何内容, 最小化开销。

偏向锁的批量再偏向(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 值相等, 如果不相等, 则说明该对象的偏向锁已经失效了, 可以直接通过 CAS 操作尝试再次将该对象再次偏向于请求获得锁的线程。

上述的逻辑可以在 JDK 源码中得到验证。

sharedRuntime.cpp

在 sharedRuntime.cpp 中, 下面代码是 synchronized 的主要逻辑

Handle h_obj(THREAD, obj);
  if (UseBiasedLocking) {
    // Retry fast entry if bias is revoked to avoid unnecessary inflation
    ObjectSynchronizer::fast_enter(h_obj, lock, true, CHECK);
  } else {
    ObjectSynchronizer::slow_enter(h_obj, lock, CHECK);
  }
  • UseBiasedLocking 是 JVM 启动时, 偏斜锁是否启用的标志。
  • fast_enter 中包含了偏斜锁的相关逻辑
  • slow_enter 中绕过偏斜锁, 直接进入轻量级锁获取
void ObjectSynchronizer::fast_enter(Handle obj, BasicLock* lock,
                                    bool attempt_rebias, TRAPS) {
  if (UseBiasedLocking) {
    if (!SafepointSynchronize::is_at_safepoint()) {
      BiasedLocking::Condition cond = BiasedLocking::revoke_and_rebias(obj, attempt_rebias, THREAD);
      if (cond == BiasedLocking::BIAS_REVOKED_AND_REBIASED) {
        return;
      }
    } else {
      assert(!attempt_rebias, "can not rebias toward VM thread");
      BiasedLocking::revoke_at_safepoint(obj);
    }
    assert(!obj->mark()->has_bias_pattern(), "biases should be revoked by now");
  }

  slow_enter(obj, lock, THREAD);
}

  • 该函数中再次保险性地做了偏斜锁是否开启的检查(UseBiasedLocking)
  • 当系统不处于安全点时, 代码通过方法 revoke_and_rebias 这个函数尝试获取偏斜锁, 如果获取成功就可以直接返回了, 如果不成功则进入轻量级锁的获取过程
  • revoke_and_rebias 这个函数名称就很有意思, 说明该函数中包含了 revoke 的操作也包含了 rebias 的操作
    • revoke 不是只应该在安全点时刻才发生吗? 答案: 有一些特殊情形, 不需要安全点也可以执行 revoke 操作
    • 此处为什么只有 rebias 操作, 没有初次的 bias 操作?答案: 首次的 bias 操作也被当成了 rebias 操作的一个特例

revoke_and_rebias 函数的定义在 biasedLocking.cpp

BiasedLocking::Condition BiasedLocking::revoke_and_rebias(Handle obj, bool attempt_rebias, TRAPS) {
  assert(!SafepointSynchronize::is_at_safepoint(), "must not be called while at safepoint");

  // We can revoke the biases of anonymously-biased objects
  // efficiently enough that we should not cause these revocations to
  // update the heuristics because doing so may cause unwanted bulk
  // revocations (which are expensive) to occur.
  markOop mark = obj->mark();
  if (mark->is_biased_anonymously() && !attempt_rebias) {
      /* 
		    进一步查看源码可得知, is_biased_anonymously() 为 true 的条件是对象处于可偏向状态, 
		    且 线程ID  为空, 表示尚未偏向于任意一个线程。 
		    此分支是进行对象的 hashCode 计算时会进入的, 根据 markWord 结构可以看到, 
		    当一个对象处于可偏向状态时, markWord 中 hashCode 的存储空间是被占用的
		    所以需要 revoke 可偏向状态, 以提供存储 hashCode 的空间
		 */
    
    // We are probably trying to revoke the bias of this object due to
    // an identity hash code computation. Try to revoke the bias
    // without a safepoint. This is possible if we can successfully
    // compare-and-exchange an unbiased header into the mark word of
    // the object, meaning that no other thread has raced to acquire
    // the bias of the object.
    markOop biased_value       = mark;
    markOop unbiased_prototype = markOopDesc::prototype()->set_age(mark->age());
    markOop res_mark = obj->cas_set_mark(unbiased_prototype, mark);
    if (res_mark == biased_value) {
      return BIAS_REVOKED;
    }
  } else if (mark->has_bias_pattern()) {
    Klass* k = obj->klass();
    markOop prototype_header = k->prototype_header();
    if (!prototype_header->has_bias_pattern()) {
      // This object has a stale bias from before the bulk revocation
      // for this data type occurred. It's pointless to update the
      // heuristics at this point so simply update the header with a
      // CAS. If we fail this race, the object's bias has been revoked
      // by another thread so we simply return and let the caller deal
      // with it.
      markOop biased_value       = mark;
      markOop res_mark = obj->cas_set_mark(prototype_header, mark);
      assert(!obj->mark()->has_bias_pattern(), "even if we raced, should still be revoked");
      return BIAS_REVOKED;
    } else if (prototype_header->bias_epoch() != mark->bias_epoch()) { 
	    /* 
		    这个分支就是首次获取偏向锁会进入的分支
			笔者根据此处的代码推断, 一个对象在刚刚分配出来的时候, 
			其对象头中存储的 epoch 值和 class 中存储的 epoch 值是不一样的,
			以体现对象从未被偏向过
		 */
    
      // The epoch of this biasing has expired indicating that the
      // object is effectively unbiased. Depending on whether we need
      // to rebias or revoke the bias of this object we can do it
      // efficiently enough with a CAS that we shouldn't update the
      // heuristics. This is normally done in the assembly code but we
      // can reach this point due to various points in the runtime
      // needing to revoke biases.
      if (attempt_rebias) {
	    /*
			下面的代码就是尝试通过 CAS 操作, 将本线程的 ThreadID 尝试写入对象头中
		*/
        assert(THREAD->is_Java_thread(), "");
        markOop biased_value       = mark;
        markOop rebiased_prototype = markOopDesc::encode((JavaThread*) THREAD, mark->age(), prototype_header->bias_epoch());
        markOop res_mark = obj->cas_set_mark(rebiased_prototype, mark);
        if (res_mark == biased_value) {
          return BIAS_REVOKED_AND_REBIASED;
        }
      } else {
        markOop biased_value       = mark;
        markOop unbiased_prototype = markOopDesc::prototype()->set_age(mark->age());
        markOop res_mark = obj->cas_set_mark(unbiased_prototype, mark);
        if (res_mark == biased_value) {
          return BIAS_REVOKED;
        }
      }
    }
  }

偏向锁 -> 轻量级锁

从之前的描述中可以看到, 存在超过一个线程竞争某一个对象时, 会发生偏向锁的撤销操作。 有趣的是, 偏向锁撤销后, 对象可能处于两种状态。

  • 一种是不可偏向的无锁状态, 如下图(之所以不允许偏向, 是因为已经检测到了多于一个线程的竞争, 升级到了轻量级锁的机制)
    《Java中的偏向锁,轻量级锁, 重量级锁解析》

  • 另一种是不可偏向的已锁 ( 轻量级锁) 状态
    《Java中的偏向锁,轻量级锁, 重量级锁解析》

之所以会出现上述两种状态, 是因为偏向锁不存在解锁的操作, 只有撤销操作。 触发撤销操作时, 对象既有可能处于“被占用状态”, 也有可能处于 “闲置状态”, 如果是被占用状态,则对象就应该被转换为已加锁状态。

轻量级加锁过程:

  • 首先根据标志位判断出对象状态处于不可偏向的无锁状态( 如下图)
    《Java中的偏向锁,轻量级锁, 重量级锁解析》
  • 在当前线程的栈桢(Stack Frame)中创建用于存储锁记录(lock record)的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。如果在此过程中发现,
  • 然后线程尝试使用 CAS 操作将对象头中的 Mark Word 替换为指向锁记录的指针。
    • 如果成功,当前线程获得锁
    • 如果失败,表示该对象已经被加锁了, 先进行自旋操作, 再次尝试 CAS 争抢, 如果仍未争抢到, 则进一步升级锁至重量级锁。

下图引用自博文 聊聊并发(二)Java SE1.6中的Synchronized展示了两个线程竞争锁, 最终导致锁膨胀为重量级锁的过程。
《Java中的偏向锁,轻量级锁, 重量级锁解析》

重量级锁

重量级锁依赖于操作系统的互斥量(mutex) 实现, 其具体的详细机制此处暂不展开, 日后可能补充。 此处暂时只需要了解该操作会导致进程从用户态与内核态之间的切换, 是一个开销较大的操作。

存疑的问题

  1. 在锁膨胀的图例中, 线程 2 在线程 1 尚未释放锁时, 即将对象头修改为指向重量级锁的状态, 这个操作具体如何完成, 是否需要等待全局安全点?笔者尚未细究

  2. 轻量级锁的第一次获取时, 如果 CAS 操作失败, 按照 聊聊并发(二)Java SE1.6中的Synchronized 的描述, 会进行自旋的尝试。 但按照 Synchronization and Object Locking 的描述, 会去检测已加的锁是归属于自身线程, 没有提到自旋操作。 具体哪一种是正确的行为, 有待研究源码。

  3. biasedLocking.cpp中的方法 revoke_and_rebias 存在 4 个条件分支, 其中笔者添加了注释的两个分支其主要逻辑已经清晰, 但未添加注释的两个分支具体逻辑笔者尚不清楚, 有待进一步研究

    原文作者:java锁
    原文地址: https://blog.csdn.net/lengxiao1993/article/details/81568130
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞