Synchronized原理(偏向锁篇)

Synchronized原理(偏向锁篇)

传统的锁机制

传统的锁依赖于系统的同步函数,在linux上使用mutex互斥锁,最底层实现依赖于futex,这些同步函数都涉及到用户态和内核态的切换、进程的上下文切换。这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长,这种依赖于操作系统Mutex Lock所实现的锁我们称之为**“重量级锁”**。

对于加了synchronized关键字但运行时并没有多线程竞争,或两个线程接近于交替执行的情况,使用传统锁机制无疑效率是会比较低的。

在JDK 1.6之前,synchronized只有传统的锁机制,因此给开发者留下了synchronized关键字相比于其他同步机制性能不好的印象。

在JDK 1.6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。

目前锁一共有4种状态,级别从低到高依次是:无锁偏向锁轻量级锁重量级锁

在看这几种锁机制的实现前,我们先来了解下oop-klass模型。

oop-klass

简介

简单地说,一个Java类在JVM中被拆分为了两个部分:数据和描述信息,分别对应OOP和Klass。OOP表示java对象应该承载的数据,而Klass表示描述对象有多大,函数地址,对象大小,静态区域大小。

在这里插入图片描述

oop

Ordinary Object Pointer (普通对象指针),它用来表示对象的实例信息,看起来像个指针实际上是藏在指针里的对象。Klass是在class文件在加载过程中创建的,OOP则是在Java程序运行过程中new对象时创建的。

一个OOP对象包含以下几个部分:
在这里插入图片描述

考虑到虚拟器的储存效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。

下图表示32位机器中的对象头在不同情况下的结构

在这里插入图片描述

对象头的另外一部分是类型指针,即是对象指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

Klass

Klass包含元数据和方法信息,用来描述Java类。

Klass主要有两个功能:

  • 实现语言层面的Java类
  • 实现Java对象的分发功能

一般jvm在加载class文件时,会在方法区创建InstanceKlass,表示其元数据,包括常量池、字段、方法等。

在这里插入图片描述

这里着重介绍InstanceKlass的两个字段:

_prototype_header:原型头,用于用于标识Mark Word原型,在对象被创建出来以后,会从_prototype_header拷贝数据到对象头的Mark Word中。

revocation_count:撤销计数器,每次该class的对象发生偏向锁撤销操作时,计数器会自增1,当达到批量重偏向阈值(默认20)时,会执行批量重偏向;当达到批量撤销的阈值(默认40)时,会执行批量撤销。

代码示例

class Model
{
    public static int a = 1;
    public int b;

    public Model(int b) {
        this.b = b;
    }
}

public static void main(String[] args) {
    int c = 10;
    Model modelA = new Model(2);
    Model modelB = new Model(3);
}

上述代码的OOP-Klass模型如下所示

在这里插入图片描述

锁升级模型

复杂版(参考熬丙大佬的图做了些修改)

在这里插入图片描述

简易版

在这里插入图片描述

偏向锁

Java是支持多线程的语言,因此在很多二方包、基础库中为了保证代码在多线程的情况下也能正常运行,也就是我们常说的线程安全,都会加入如synchronized这样的同步语义。但是在应用在实际运行时,很可能只有一个线程会调用相关同步方法。比如下面这个demo:

import java.util.ArrayList;
import java.util.List;

public class SyncDemo1 {

    public static void main(String[] args) {
        SyncDemo1 syncDemo1 = new SyncDemo1();
        for (int i = 0; i < 100; i++) {
            syncDemo1.addString("test:" + i);
        }
    }

    private List<String> list = new ArrayList<>();

    public synchronized void addString(String s) {
        list.add(s);
    }

}

在这个demo中为了保证对list操纵时线程安全,对addString方法加了synchronized的修饰,但实际使用时却只有一个线程调用到该方法,对于轻量级锁而言,每次调用addString时,加锁解锁都有一个CAS操作;对于重量级锁而言,加锁也会有一个或多个CAS操作(这里的’一个‘、’多个‘数量词只是针对该demo,并不适用于所有场景)。

在JDK1.6中为了提高一个对象在一段很长的时间内都只被一个线程用做锁对象场景下的性能,引入了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只会执行几个简单的命令,而不是开销相对较大的CAS命令。

引入偏向锁的目的

在没有多线程竞争的情况下,尽量减少不必要的轻量级锁的执行。轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只依赖一次CAS原子指令。但在多线程竞争时,需要进行偏向锁撤销步骤,因此其撤销的开销必须小于节省下来的CAS开销,否则偏向锁并不能带来收益。JDK 1.6中默认开启偏向锁,可以通过-XX:-UseBiasedLocking来禁用偏向锁。

优点

加锁解锁无需额外的消耗,和非同步方法时间相差纳秒级别。

缺点

如果竞争的线程多,那么会带来额外的锁撤销的消耗(撤销时会暂停原所有者线程)。

适用场景

锁不存在竞争关系,或者说线程总是能有序的获取到锁(线程A执行完同步代码块后线程B才尝试去获取锁)。


工作流程

在这里插入图片描述

对象创建

当JVM启用了偏向锁模式(1.6以上默认开启),当新创建一个对象的时候,如果该对象所属的class没有关闭偏向锁模式(什么时候会关闭一个class的偏向模式下文会说,默认所有class的偏向模式都是是开启的),那新创建对象的mark word将是可偏向状态,此时mark word中的thread id(参见上文偏向锁状态下的mark word格式)为0,表示未偏向任何线程,也叫做匿名偏向(anonymously biased)。

需要注意的是,即使模式默认开启,出于性能(启动时间)的原因,在JVM启动后的的头4秒钟这个feature是被禁止的。这也意味着在此期间,_prototype_header会将它的locked_bias位设置为0,以禁止实例化的对象被偏向。4秒钟之后,所有的_prototype_headerlocked_bias位会被重设为1,如此新的对象就可以被偏向锁定了。

初始状态

当对象头的locked_bias为0时,此对象处于未锁定不可偏向的状态。

在此状态下,如果有线程尝试获取此锁,会升级为轻量级锁。如果有多个线程尝试获取此锁,会升级为重量级锁。

在这里插入图片描述

注:对象的hashCode并不是一创建就计算好的,而是在调用hasCode方法后,储存在对象头中的。且一旦被偏向的对象进行hashcode计算时,不管该对象有没有被锁定,都会触发偏向锁撤销。

此状态出现的可能:

  • 计算hashcode
  • 偏向锁被禁用
  • 偏向锁被撤销(什么情况下撤销下文会说)

当对象头的locked_bias为1时,此对象处于以下三种状态:

  • 匿名偏向(Anonymously biased)
    在此状态下Thread Id为NULL(0),意味着还没有线程偏向于这个锁对象。第一个试图获取该锁的线程将会面临这个情况,使用原子CAS指令可将该锁对象绑定于当前线程。这是允许偏向锁的类对象的初始状态。

    在这里插入图片描述

  • 可重偏向(Rebiasable)
    在此状态下,偏向锁的epoch字段是无效的(与锁对象对应InstanceKlass_prototype_headerepoch值不匹配)。下一个试图获取锁对象的线程将会面临这个情况,使用原子CAS指令可将该锁对象绑定于当前线程。在批量重偏向的操作中,未被持有的锁对象都被至于这个状态,以便允许被快速重偏向。

    在这里插入图片描述

  • 已偏向(Biased)
    在此状态下,Thread Id非空,且epoch为有效值——意味着其他线程正在使用这个锁对象。

    在这里插入图片描述

加锁过程

偏向锁获取可以分为4个步骤:

  1. 验证对象Mark Wordlocked_bias

    如果是0,则该对象不可偏向,走轻量级锁逻辑;如果是1,继续下一步操作。

  2. 验证对象所属InstanceKlass_prototype_headerlocked_bias

    确认_prototype_headerlocked_bias位是否为0,如果是0,则该类所有对象全部不允许被偏向锁定,并且该类所有对象的locked_bias位都需要被重置,使用轻量级锁替换;如果是1,继续下一步操作。

  3. 比对对象和原型的epoch

    校验对象的Mark Wordepoch位是否与该对象所属InstanceKlass_prototype_headerepoch匹配。如果不匹配,则表明偏向已过期,继续下一步操作,尝试重入锁或者重偏向;如果匹配,继续下一步操作,尝试重入锁或升级为轻量级锁定。

  4. 校检onwer线程

    比较偏向线程ID与当前线程ID。如果匹配,则表明当前线程已经获得了偏向,可以安全返回。如果不匹配,对象锁被假定为匿名偏向状态,当前线程应该尝试使用CAS指令获得偏向。如果失败的话,就尝试撤销(很可能引入安全点),然后回退到轻量级锁;如果成功,当前线程成功获得偏向,可直接返回。

    这里提个问题,为什么在偏向线程ID与当前线程ID不匹配的情况下还需要尝试使用CAS指令获取偏向呢?

    因为上一步存在对象头和原型头的epoch位不相等的情况,即允许重偏向。假如这次CAS成功,则此对象锁可以重新偏向于获取锁的线程;如果失败,则代表获取的时候产生了竞争,需要升级为轻量级锁定或重量级锁定。

    偏向锁获取图

    在这里插入图片描述

    我们来分解一下这个图

    case 1:当该对象第一次被线程获得锁的时候,发现是匿名偏向状态,则会用CAS指令,将mark word中的thread id由0改成当前线程Id。如果成功,则代表获得了偏向锁,继续执行同步块中的代码。否则,将偏向锁撤销,升级为轻量级锁。

    case 2:当被偏向的线程再次进入同步块时,发现锁对象偏向的就是当前线程,在通过一些额外的检查后,会往当前线程的栈中添加一条Displaced Mark Word为空的Lock Record中,然后继续执行同步块的代码,因为操纵的是线程私有的栈,因此不需要用到CAS指令;由此可见偏向锁模式下,当被偏向的线程再次尝试获得锁时,仅仅进行几个简单的操作就可以了,在这种情况下,synchronized关键字带来的性能开销基本可以忽略。

    case 3:当其他线程进入同步块时,发现已经有偏向的线程了,则会进入到撤销偏向锁的逻辑里,一般来说,会在safepoint中去查看偏向的线程是否还存活,如果存活且还在同步块中则将锁升级为轻量级锁,原偏向的线程继续拥有锁,当前线程则走入到锁升级的逻辑里;如果偏向的线程已经不存活或者不在同步块中,则将对象头的mark word改为无锁状态(unlocked),之后再升级为轻量级锁。

    由此可见,偏向锁升级的时机为:当锁已经发生偏向后,只要有另一个线程尝试获得偏向锁,则该偏向锁就会升级成轻量级锁。当然这个说法不绝对,因为还有批量重偏向这一机制(下文会讲到)。

解锁过程

当偏向锁被一个线程获取到时,会往所有者线程的栈中添加一条Displaced Mark Word为空的Lock Record

在这里插入图片描述

当有其他线程尝试获得锁时,根据遍历偏向线程的lock record来确定该线程是否还在执行同步块中的代码。因此偏向锁的解锁很简单,仅仅将栈中的最近一条lock recordobj字段设置为null。需要注意的是,偏向锁的解锁步骤中并不会修改对象头中的thread id。

在这里插入图片描述

锁撤销

一般来说,以下三种情况会触发锁撤销:

  • 被偏向的对象进行hashcode计算时,不管该对象有没有被锁定,都会触发偏向锁撤销,通过CAS将计算好的hashcode存入Mark Word中。

  • 当前的对象是已偏向未锁定状态,即所有者线程已经退出同步代码块,此时有其它的线程尝试获取偏向锁;在允许重偏向的情况下,原所有者线程会触发解锁,将对象恢复成匿名可偏向的状态;如果不允许重偏向,则会触发偏向锁撤销,将对象设置为未锁定且不可偏向的状态,竞争者线程按轻量级锁的逻辑去获取锁。

  • 当前的对象是已偏向已锁定的状态,即所有者线程正在执行同步代码块,此时有其它的线程尝试获取偏向锁,由于所有者线程仍需要持有这把锁,此时产生了锁竞争,偏向锁不适合处理这种有竞争的场景,即会触发偏向锁撤销,原偏向锁持有者线程会升级为轻量级锁定状态,竞争者线程按轻量级锁的逻辑去获取锁。

总结来说就是

  1. 计算hashcode。
  2. 锁定状态到禁用状态。
  3. 锁升级。

需要注意的是,锁撤销和解锁是两个不同的概念。撤销是指在获取偏向锁的过程因为不满足条件导致要将锁对象改为非偏向锁状态,即低三位变为010;解锁是指退出同步块的过程,即移除最近的锁记录。

批量重偏向与批量撤销
思考

上文我们说到当持有偏向锁的线程已经执行完同步代码块,此时其它线程尝试获取偏向锁会触发原所有者线程的解锁操作,那解锁成功后是不是意味着此偏向锁可以被其它线程锁获取呢?

大部分情况下,当偏向锁已经偏向于一个线程时,即使所有者线程不再占用此锁,也很难偏向于新的对象。如果有其它的线程试图获取此偏向锁,则会撤销偏向锁,进入锁升级的流程。需要注意的是,在执行撤销操作的时候,会等待线程进入safe point,然后暂停线程。当该class衍生出的多个对象都执行偏向锁撤销的话,也是一笔不小的性能开销。

成因

从偏向锁的加锁解锁过程中可看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时,再将偏向锁撤销为无锁状态或升级为轻量级,会消耗一定的性能,所以在多线程竞争频繁的情况下,偏向锁不仅不能提高性能,还会导致性能下降。
于是,就有了批量重偏向与批量撤销的机制。

解决场景
批量重偏向(bulk rebias)

避免短时间内大量偏向锁的撤销。例如一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作。当执行批量重偏向后,如果原偏向锁持有者线程不再执行同步块,则锁可以偏向于新线程。

批量撤销(bulk revoke)

在明显多线程竞争剧烈的场景下使用偏向锁是不合适的,例如生产生-消费者模式,会有多个线程参与竞争。当执行批量撤销后,会直接把class中的locked_bias字段置0,该class已经是偏向锁模式的实例会批量撤销偏向锁,该class新分配的对象的mark word则是无锁模式。

原理

以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。每个class对象会有一个对应的epoch字段,每个处于偏向锁状态对象的mark word中也有该字段,其初始值为创建该对象时,class中的epoch的值。每次发生批量重偏向时,就将该值+1,同时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁,将其epoch字段改为新值。下次获得锁时,发现当前对象的epoch值和class的epoch不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其mark word的Thread Id 改成当前线程Id。

当达到重偏向阈值后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。

图解

在这里插入图片描述


在这里插入图片描述


在这里插入图片描述


在这里插入图片描述


在这里插入图片描述


在这里插入图片描述


在这里插入图片描述


在这里插入图片描述


在这里插入图片描述


在这里插入图片描述


在这里插入图片描述


归纳总结
偏向锁的适用场景

在一个时间段内每一个时刻都是只有一个线程使用同一个对象,但是不是每一时刻都是同一个线程。

如何关闭偏向锁和延迟

偏向锁在Java 6Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

怎样确定偏向锁有没有被占用

如果锁对象属于匿名可偏向的状态,即线程ID=0,那么线程可以直接获取到偏向锁;如果锁对象属于已偏向的状态,那么就从记录的线程ID去查找锁记录,如果未找到,则说明此对象锁属于已偏向未锁定状态,此时会先判断是否允许重偏向来决定获取偏向锁还是走锁升级的逻辑;如果找到了锁记录,则说明此对象锁属锁属于已偏向已锁定状态,此时会直接走锁升级逻辑的判断(依旧存在重偏向的可能)。

什么情况下会允许重偏向,什么情况下不允许重偏向

当偏向锁处于已偏向未锁定状态状态时,通过比对对象头和原型头的epoch,如果不相等,则代表已经达到批量重偏向的阈值,允许进行重偏向。其它情况下,只要该锁已经偏向于线程,则不允许重偏向。

重偏向的时候需要先撤销偏向锁吗

不需要,重偏向仅仅需要通过CAS更新线程的id,如果成功对象锁会重偏向于新的线程,如果失败代表发生了竞争,此时才会撤销偏向锁,走锁升级的逻辑。

偏向锁怎样支持锁重入的,重入的流程是什么

当偏向锁重入时,会先去遍历栈中的Lock Record空间,从低位往高位找到第一个可用的Lock Record(即obj指向为空),并将obj字段指向当前锁对象;当偏向锁解锁时,也会遍历栈中Lock Record空间,从低位开始找到第一个和当前锁对象相关的Lock Record移除掉。当未遍历到和此锁对象有关联的Lock Record时,代表原偏向锁持有者线程已经执行完该对象锁定的同步代码。

End

Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。当条件不满足时,锁会按偏向锁->轻量级锁->重量级锁 的顺序升级。JVM种的锁也是能降级的,只不过条件很苛刻,不在我们讨论范围之内。该篇文章主要是对Java的偏向锁做介绍,下一篇文章会继续分析轻量级锁。

参考文献

死磕Synchronized底层实现–偏向锁 · Issue #13 · farmerjohngit/myblog (github.com)

不可不说的Java“锁”事 - 美团技术团队 (meituan.com)

  • 26
    点赞
  • 60
    收藏
    觉得还不错? 一键收藏
  • 14
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 14
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值