Java多线程(2)synchronized详解

上一篇:Java多线程(1)线程与进程

synchronized详解

简介

可能在很多人眼里,在java中提到 锁、安全性、同步,首先想到的则是java提供的大佬(synchronized)。那么为什么在多线程下,单单靠一个关键字修饰代码块就可以实现所谓的安全性呢?可以说是对初学者而言及神奇又强大的存在。也成了大多数初学者百试不爽的良药

但是在逐渐对java认知的深入,我们认识到synchronized对于jvm来说是一个重量级的锁。其笨重无比,在如今人们对速度和性能极致要求的现在,现在此时并不能满足性能上的要求

诚然SUN公司也认识到了这一点,在Java SE 1.6对synchronized进行了各种优化后,有些情况下它就并不那么笨重了。在Java SE 1.6中为了减少获得锁和释放锁带来的性能开销而引入偏向锁和轻量级锁

一. 实现锁的方法

Java中每一个对象都可以作为锁。具体有如下三种形式:

  • 对于普通同步方法,锁是当前实例对象
  • 对于静态同步方法,锁是当前类的Class对象
  • 对于同步代码块,锁是synchronized括号里配置的对象
1. 三种同步方式
public class SynchronizedTest {
    /**
     * 同步修饰普通方法
     */
    public synchronized void test01() {
        // 同步修饰代码块
        synchronized (this) {
            System.out.println("hello synchronized");
        }
    }
    /**
     * 同步修饰静态方法
     */
    public synchronized static void test02() {

    }
}

使用javap 查看生成的class 文件:

javap -verbose SynchronizedTest.class

在这里插入图片描述

JVM会在monitorenter监视器入口处获取锁,然后执行完对应操作后,在monitorexit监视器出口释放锁。在class文件中synchronizedACC_SYNCHRONIZED标记,表明该方法为同步方法。
​ 从JVM规范中可以看到Synchronized在JVM里的实现原理,JVM基于进入和推出monitor对象来实现方法同步和代码块同步的,但两者的实现细节不一样。

代码块同步: 是使用monitorentermonitorexit指令实现的。而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明。但是,方法的同步同样可以使用这两个指令来实现
静态同步方法: 使用javap 可以看出synchronized被编译为普通的命令invokevirtualareturn字节码指令。在JVM层面并没有任何特别的指令来实现被synchronized修饰的方法,而是在Class文件的方法表中将该方法的access_flags字段中的synchronized标志位置1,表示该方法是同步方法并使用调用该方法的对象或该方法所属的Class在JVM的内部对象表示Klass作为锁对象
在这里插入图片描述
静态同步方法

2. monitorenter指令

monitorenter 指令时在编译后插入到同步代码块的开始位置的。而monitorexit是插入到方法的结束处和异常处,JVM要保证每个monitorenter必须都有对应的monitorexit与之对应。任何对象都有一个monitor对象与之关联,并且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor所有权,即尝试获取对象的锁。

3. Java对象头

synchronized用的锁是存在Java对象头里的。所以这里对Java对象头做详细介绍:

3.1 对象的内存布局

HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:

  • 对象头(Header)
    在这里插入图片描述
  • 实例数据(Instance Data)
  • 对齐填充(Padding)

HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别位32bit和64bit,官方称它位“Mark Word” (标记字段)。对象需要存储的运行时数据很多,其实已经超出了定义的位数。

Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如在32位HotSpot虚拟机中,如果对象处于被锁定状态下,那么Mark Word的32bit空间中的25bit用于存储对象哈希码,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定位0,而在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容见表:

存储内容 标志位 状态
对象哈希码、对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀(重量级锁定)
空,不需要记录信息 11 GC标记
偏向线程ID、偏向时间戳、对象分代年龄 01 可偏向

​ 对象头的另外一部分是类型指针(Klass Pointer)。即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身,此外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数据长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的原数组中却无法确定数组的大小。

​ 在运行期间,Mark Word标记字段里存储的数据会随着锁的标志位的变化而变化:

Mark Word的状态变化

无锁状态下 Mark Word : 对象的hashCode + 对象分代年龄 +(是否位偏向锁)0 +(所标志位)01
在这里插入图片描述

3.2 Monitor Record

Monitor 从字面意义上理解为监控、监视的意思。在Java中可以把它看作为一个同步工具,相当于操作系统中的互斥量,即值为1的信号量。它内置与每一个对象。在java世界里,每一个对象天生都拥有一把内置锁(Monitor)。这相当于一个许可证,只有你拿到许可证之后才可以进行操作,没有拿到则需要进行阻塞等待。

Monitor Record从字面意义上理解为:监视器记录。Monitor Record是线程私有的数据结构,每一个线程都有一个可用Monitor Record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor record关联 (对象头的MarkWord中的LockWord指向monitor record的起始地址),同时monitor record中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用

如下图所示为Monitor Record的内部结构:

Monitor Record
Owner 初始时为NULL表示当前没有任何线程拥有该Monitor Record当线程成功拥有该锁后,记录该线程ID作为唯一标识,当锁被释放时又设置成NULL
EntryQ 关联一个系统互斥锁(semaphore信号量),阻塞所有试图锁住Monitor Recoed失败的线程
RcThis 表示blocked或者waiting在该Monitor Record上所有的线程的个数
Nest 用来实现重入锁的计数
HashCode 保存从对象头拷贝过来的HashCode值(可能还包含GC分代年龄)
Candidate 用来避免不必要的阻塞或者等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,那么当执行线程结束任务释放锁后,如果唤醒所有等待的线程,会造成不必要的上下文切换(影响性能,因为在所有唤醒的线程中,只有一个能够真正的获取到锁,所以其他的线程在从阻塞到就绪到因为竞争锁失败又被阻塞,这中间都是一些不必要的资源浪费)。所以Candidate只提供了两种可能,0表示当前没有需要唤醒的线程。1表示在阻塞的线程中,唤醒一个继任线程来竞争锁
4. 什么是多线程中的上下文切换?

在上下文切换过程中,CPU 会停止处理当前运行的程序,并保存当前程序运行的具体位置以便之后继续运行。从这个角度来看,上下文切换有点像我们同时阅读几本书,在来回切换书本的同时我们需要记住每本书当前读到的页码。在程序中,上下文切换过程中的“页码”信息是保存在进程控制块(PCB)中的。PCB 还经常被称作“切换桢”(switchframe)。“页码”信息会一直保存到 CPU 的内存中,直到他们被再次使用。
上下文切换是存储和恢复 CPU 状态的过程,它使得线程执行能够从中断点恢复执行。上下文切换是多任务操作系统和多线程环境的基本特征

二. 锁优化

高效并发是从JDK 1.5 到 JDK 1.6的一个重要改进,HotSpot虚拟机在这个版本上花费了大量精力去实现各种锁优化技术,如:适应性自旋(Adaptive Spinning)、锁消除(Lock Eliminate)、锁粗化(Lock Coarsening)、轻量级锁(Lightweight Locking)和偏向锁(Biased Locking)等,这些技术都是为了在先咸亨之间更搞笑地共享数据,以及解决竞争问题,从而提高程序的执行效率。

1. 锁的类型

​ 在Java SE 1.6里Synchronied同步锁,一共有四种状态:无锁、偏向锁、轻量级所、重量级锁,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率

锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)
2. 自旋锁与自适应自旋锁
2.1 自旋锁

引入背景:大家都知道,在没有加入锁优化时,大佬Synchronized时一个非常“胖大”的家伙。在多线程竞争锁时,当一个线程获取锁时,它会阻塞所有正在竞争的线程,这样对性能带来了极大的影响。在挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作个i系统的并发性能带来了很大的压力。同时HotSpot团队注意到在很多情况下,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和回复阻塞线程并不值得。在如今多处理器环境下,完全可以让另一个没有获取到锁的线程在门外等待一会(自旋),但不放弃CPU的执行时间。等待持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这便是自旋锁由来的原因

​ 自旋锁早在JDK1.4 中就引入了,只是当时默认时关闭的。在JDK 1.6后默认为开启状态。自旋锁本质上与阻塞并不相同,先不考虑其对多处理器的要求,如果锁占用的时间非常的短,那么自旋锁的新能会非常的好,相反,其会带来更多的性能开销(因为在线程自旋时,始终会占用CPU的时间片,如果锁占用的时间太长,那么自旋的线程会白白消耗掉CPU资源)。因此自旋等待的时间必须要有一定的限度,如果自选超过了限定的次数仍然没有成功获取到锁,就应该使用传统的方式去挂起线程了,在JDK定义中,自旋锁默认的自旋次数为10次,用户可以使用参数-XX:PreBlockSpin来更改

​ 可是现在又出现了一个问题:如果线程锁在线程自旋刚结束就释放掉了锁,那么是不是有点得不偿失。所以这时候我们需要更加聪明的锁来实现更加灵活的自旋。来提高并发的性能。(这里则需要自适应自旋锁!)

2.2 自适应自旋锁

在JDK 1.6中引入了自适应自旋锁。这就意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋 时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100此循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM对程序的锁的状态预测会越来越准备,JVM也会越来越聪明

2.3 锁消除

​锁消除时指虚拟机即时编译器再运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持。意思就是:JVM会判断再一段程序中的同步明显不会逃逸出去从而被其他线程访问到,那JVM就把它们当作栈上数据对待,认为这些数据时线程独有的,不需要加同步。此时就会进行锁消除。

​ 当然在实际开发中,我们很清楚的知道那些地方时线程独有的,不需要加同步锁,但是在Java API中有很多方法都是加了同步的,那么此时JVM会判断这段代码是否需要加锁。如果数据并不会逃逸,则会进行锁消除。比如如下操作:在操作String类型数据时,由于String是一个不可变类,对字符串的连接操作总是通过生成的新的String对象来进行的。因此Javac编译器会对String连接做自动优化。在JDK 1.5之前会使用StringBuffer对象的连续appen()操作,在JDK 1.5及以后的版本中,会转化为StringBuidler对象的连续append()操作。

    public static String test03(String s1, String s2, String s3) {
        String s = s1 + s2 + s3;
        return s;
    }

上述代码使用javap 编译结果
众所周知,StringBuffer是安全同步的。但是在上述代码中,JVM判断该段代码并不会逃逸,则将该代码带默认为线程独有的资源,则并不需要同步,所以执行了锁消除操作。(还有Vector中的各种操作也可实现锁消除。在没有逃逸出数据安全防卫内)

2.4 锁粗化

原则上,我们都知道在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁)。

​ 大部分上述情况是完美正确的,但是如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要地性能操作。

​ 这里贴上根据上述Javap 编译地情况编写地实例java类

    public static String test04(String s1, String s2, String s3) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        sb.append(s3);
        return sb.toString();
    }

在上述地连续append()操作中就属于这类情况。JVM会检测到这样一连串地操作都是对同一个对象加锁,那么JVM会将加锁同步地范围扩展(粗化)到整个一系列操作的 外部,使整个一连串地append()操作只需要加锁一次就可以了

3. 偏向锁
1. 无锁 -> 偏向锁

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

  • 首先读取目标对象的 MarkWord, 判断是否处于可偏向的状态(如下图)
    在这里插入图片描述
    下面是 Open Jdk/ JDK 8 源码 中检测一个对象是否处于可偏向状态的源码

      // Indicates that the mark has the bias bit set but that it has not
      // yet been biased toward a particular thread
      bool is_biased_anonymously() const {
        return (has_bias_pattern() && (biased_locker() == NULL));
      }
    
  • 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), 此时间点所有的工作线程都停止了字节码的执行
2. 偏向锁的撤销

偏向锁使用了一种等待竞争出现才会释放锁的机制。所以当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放锁。但是偏向锁的撤销需要等到全局安全点(就是当前线程没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,让你后检查持有偏向锁的线程是否活着。如果线程不处于活动状态,直接将对象头设置为无锁状态。如果线程活着,JVM会遍历栈帧中的锁记录,栈帧中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁
在这里插入图片描述

  • 在偏向锁 CAS 更新操作失败以后, 等待到达全局安全点。
    • 通过 MarkWord 中已经存在的 Thread Id 找到成功获取了偏向锁的那个线程, 然后在该线程的栈帧中补充上轻量级加锁时, 会保存的锁记录(Lock Record), 然后将被获取了偏向锁对象的 MarkWord 更新为指向这条锁记录的指针。
    • 至此, 锁撤销操作完成, 阻塞在安全点的线程可以继续执行
3. 偏向锁的批量再偏向(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
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()) { 
      // 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;
        }
      }
    }
  }

4. 轻量级锁

在JDK 1.6之后引入的轻量级锁,需要注意的是轻量级锁并不是替代重量级锁的,而是对在大多数情况下同步块并不会有竞争出现提出的一种优化。它可以减少重量级锁对线程的阻塞带来地线程开销。从而提高并发性能。
轻量级锁提升程序同步性能的依据是:对于绝大部分的锁,在整个同步周期内都是不存在竞争的(区别于偏向锁)。这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁比传统的重量级锁更慢。

​如果要理解轻量级锁,那么必须先要了解HotSpot虚拟机中对象头地内存布局。上面介绍Java对象头也详细介绍过。在对象头中(Object Header)存在两部分。第一部分用于存储对象自身的运行时数据,HashCode、GC Age、锁标记位、是否为偏向锁等。一般为32位或者64位(视操作系统位数定)。官方称之为Mark Word,它是实现轻量级锁和偏向锁的关键。 另外一部分存储的是指向方法区对象类型数据的指针(Klass Point),如果对象是数组的话,还会有一个额外的部分用于存储数据的长度。

3.1 偏向锁 -> 轻量级锁

有竞争且竞争不强烈时,JVM就会由偏向锁膨胀为轻量级锁,考虑到线程的阻塞和唤醒需要CPU从用户态转为核心态(增加CPU负担),而这种转换对CPU来说是一件负担很重的操作,因此没有获取到锁的线程不会进入阻塞状态,而是通过自旋的方式一直尝试获取锁,处于一种忙等状态,所以这种处理竞争的方式比较浪费CPU,但是响应速度很快。

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

  • 一种是不可偏向的无锁状态, 之所以不允许偏向, 是因为已经检测到了多于一个线程的竞争, 升级到了轻量级锁的机制
  • 另一种是不可偏向的已锁 ( 轻量级锁) 状态

之所以会出现上述两种状态, 是因为偏向锁不存在解锁的操作, 只有撤销操作。 触发撤销操作时:

  • 原来已经获取了偏向锁的线程可能已经执行完了同步代码块, 使得对象处于 “闲置状态”,相当于原有的偏向锁已经过期无效了。此时该对象就应该被直接转换为不可偏向的无锁状态。
  • 原来已经获取了偏向锁的线程也可能尚未执行完同步代码块, 偏向锁依旧有效, 此时对象就应该被转换为被轻量级加锁的状态
3.2 轻量级加锁过程:

在线程执行同步块之前,JVM会先在当前线程的栈帧中创建一个名为锁记录Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(JVM会将对象头中的Mark Word拷贝到锁记录中,官方称为Displaced Mark Ward)这个时候线程堆栈与对象头的状态如图:
轻量级锁CAS操作之前堆栈与对象的状态
如上图所示:如果当前对象没有被锁定,那么锁标志位位01状态,JVM在执行当前线程时,首先会在当前线程栈帧中创建锁记录Lock Record的空间用于存储锁对象目前的Mark Word的拷贝。
然后,虚拟机使用CAS操作将标记字段Mark Word拷贝到锁记录中,并且将Mark Word更新位指向Lock Record的指针。如果更新成功了,那么这个线程就有用了该对象的锁,并且对象Mark Word的锁标志位更新位(Mark Word中最后的2bit)00,即表示此对象处于轻量级锁定状态,如图:
轻量级锁CAS操作之后堆栈与对象的状态

如果这个更新操作失败(锁的标记位不是01),JVM会检查当前的Mark Word中是否存在指向当前线程的栈帧的指针,如果有,说明该锁已经被获取,可以直接调用。如果没有,则说明该锁被其他线程抢占了,如果有两条以上的线程竞争同一个锁,那轻量级锁就不再有效,直接膨胀位重量级锁,没有获得锁的线程会被阻塞。此时,锁的标志位为10.Mark Word中存储的时指向重量级锁的指针。

​ 轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头中,如果成功,则表示没有发生竞争关系。如果失败,表示当前锁存在竞争关系。锁就会膨胀成重量级锁(先自旋获取轻量锁)。两个线程同时争夺锁,导致锁膨胀的流程图如下:

轻量级锁及膨胀流程图

3.3 轻量级锁的释放
  1. 通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word
  2. 如果替换成功,整个同步过程就完成了
  3. 如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程

释放锁线程视角:由轻量锁切换到重量锁,是发生在轻量锁释放锁的期间,之前在获取锁的时候它拷贝了锁对象头的markword,在释放锁的时候如果它发现在它持有锁的期间有其他线程来尝试获取锁了,并且该线程对markword做了修改,两者比对发现不一致,则切换到重量锁。
因为重量级锁被修改了,所有display mark word和原来的markword不一样了。
怎么补救,就是进入mutex前,compare一下obj的markword状态。确认该markword是否被其他线程持有。
此时如果线程已经释放了markword,那么通过CAS后就可以直接进入线程,无需进入mutex,就这个作用

尝试获取锁线程视角:如果线程尝试获取锁的时候,轻量锁正被其他线程占有,那么它就会修改markword,修改重量级锁,表示该进入重量锁了。

还有一个注意点:等待轻量锁的线程不会阻塞,它会一直自旋等待锁,并如上所说修改markword。
这就是自旋锁,尝试获取锁的线程,在没有获得锁的时候,不被挂起,而转而去执行一个空循环,即自旋。在若干个自旋后,如果还没有获得锁,则才被挂起,获得锁,则执行代码。

5. 重量级锁

重量级锁:依赖于底层操作系统的Mutex Lock,线程会被阻塞住
缺点:加锁和解锁需要从用户态切换到内核态,性能消耗较大
重量级锁的特点:其他线程试图获取锁时,都会被阻塞,只有持有锁的线程释放锁之后才会唤醒这些线程
重量级锁(互斥锁)也称为阻塞同步、悲观锁
Java中的重量级锁是通过ObjectMonitor实现的。接下来简单分析下ObjectMonitor的实现逻辑。

ObjectMonitor:

在这里插入图片描述

Objectmonitor中的关键词

  • EntryList (锁候选者列表)
    EntryList是一个双向链表。当EntryList为空,cxq不为空,Owener会在unlock时,将cxq中的数据移动到EntryList。并指定EntryList列表头的第一个线程为OnDeck线程

    • EntryList跟cxq的区别
      在cxq中的队列可以继续自旋等待锁,若达到自旋的阈值仍未获取到锁则会调用park方法挂起。而EntryList中的线程都是被挂起的线程
  • WaitList
    WatiList是Owner线程地调用wait()方法后进入的线程。进入WaitList中的线程在notify()/notifyAll()调用后会被加入到EntryList

  • cxq(ContentionList) 竞争列表
    cxq是一个单向链表。被挂起线程等待重新竞争锁的链表, monitor 通过CAS将包装成ObjectWaiter写入到列表的头部。为了避免插入和取出元素的竞争,所以Owner会从列表尾部取元素
    在这里插入图片描述

  • Owner 当前锁持有者

  • OnDeckThread
    可进行锁竞争的线程。若一个线程被设置为OnDeck,则表明其可以进行tryLock操作,若获取锁成功,则变为Owner,否则仍将其回插到EntryList头部

    • OnDeckThread竞争锁失败的原因
      cxq中的线程可以进行自旋竞争锁,所以OnDeckThread若碰上自旋线程就需要和他们竞争
  • recursions 重入计数器
    用来表示某个线程进入该锁的次数

5.1 轻量级锁->重量级锁

轻量级锁膨胀之后,就升级为重量级锁了。重量级锁是依赖对象内部的monitor锁来实现的,而monitor又依赖操作系统的MutexLock(互斥锁)来实现的,所以重量级锁也被成为互斥锁。
当轻量级所经过锁撤销等步骤升级为重量级锁之后,它的Markword部分数据大体如下:

bit fields 是否偏向锁 锁标志位
hash 0 01

升级过程图:
在这里插入图片描述

上图简单描述多线程获取锁的过程,当多个线程同时访问一段同步代码时,首先会进入 Entry Set当线程获取到对象的monitor 后进入 The Owner 区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)

由此看来,monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因

5.2 重量级加锁的过程

获取monitor

  1. 线程首先通过CAS尝试将monitor的owner设置为自己
  2. 若执行成功,则判断该线程是不是重入。若是重入,则执行recursions + 1,否则执行recursions = 1
  3. 若失败,则将自己封装为ObjectWaiter,并通过CAS加入到cxq中

释放monitor

  1. 判断是否为重量级锁,是则继续流程。
  2. recursions - 1
  3. 根据不同的策略设置一个OnDeckThread
6. 锁的优缺点对比
优点 缺点 使用场景
偏向锁 加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步快的场景
轻量级锁 竞争的线程不会阻塞,提高了响应速度 如线程成始终得不到锁竞争的线程,使用自旋会消耗CPU性能 追求响应时间,同步快执行速度非常快
重量级锁 线程竞争不适用自旋,不会消耗CPU 线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗 追求吞吐量,同步快执行速度较长

猜你喜欢

转载自blog.csdn.net/haiyanghan/article/details/109228597