synchronized原理和锁优化策略(偏向/轻量级/重量级)

1 前置知识点

要了解锁优化策略中的轻量级锁与偏向锁的原理和运作过程,需要先了解java的锁和Hotspot虚拟机的对象头部分的内存布局。

1.1 Java 2种主要加锁机制

  • synchronized 关键字
    1
    2
    3
    synchronized(lockObject){
    //代码
    }
    1
    2
    3
    public synchornized void test(){
    //代码
    }

    这里需要指出的是,无论是对一个对象进行加锁还是对一个方法进行加锁,实际上,都是对对象进行加锁。对于synchornized方法,实际上虚拟机会根据synchronized修饰的是实例方法还是静态方法,去取对应的实例对象或者Class对象来进行加锁。

  • java.util.concurrent.Lock(Lock是一个接口,ReentrantLock是该接口一个很常用的实现)

这两种机制的底层原理存在一定的差别

  • java.util.concurrent.Lock通过Java代码搭配sun.misc.Unsafe中的native调用实现的,即UNSAFE.park()和UNSAFE.unpark()
  • synchronized关键字通过一对字节码指令 monitorenter/monitorexit实现,这对指令被JVM规范所描述。
    • monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。
    • 任何对象都有一个 monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

1.2 字宽和对象头

  • 字宽(Word): 内存大小的单位概念, 对于32位处理器1 Word = 4 Bytes,64位处理器1 Word = 8 Bytes

  • 对象头信息是与对象自身定义的数据无关的额外存储成本,每一个Java对象都至少占用2个字宽的内存(数组类型占用3个字宽)。

    • 第一个字宽,也被称为对象头Mark Word,用来存储对象自身的运行时数据 如:哈希码(HashCode)、GC分代年龄(Generational GC Age)等。

    • 第二个字宽用于存储指向方法区对象类型数据的指针。

当偏向机制被禁用时,被分配出来的对象初始的MarkWord状态为无锁状态

偏向机制被启用时,分配出来的对象的初始状态是 ThreadId|Epoch|age|1|01, ThreadId 为空时标识对象尚未偏向于任何一个线程, ThreadId 不为空时, 对象既可能处于偏向特定线程的状态, 也有可能处于已经被特定线程占用完毕释放的状态, 需结合 Epoch 和其他信息判断对象是否允许再偏向(rebias)。

1.3 锁记录

锁记录lock Record,在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个栈,存放名为锁记录(Lock Record)的栈帧,锁记录用于存储锁记录目前的Mark Word的拷贝(称为Displaced Mark Word)以及记录锁对象的指针owner。

在代码进入同步块的时候Lock Record就会创建,所以偏向锁时也有Lock Record存在,只不过作用不大。Lock Record主要用于轻量级锁和重量级锁,

其数据结构如下

1
2
3
4
5
6
7
8
9
10
11
// A BasicObjectLock associates a specific Java object with a BasicLock.
// It is currently embedded in an interpreter frame.
class BasicObjectLock VALUE_OBJ_CLASS_SPEC {
private:
BasicLock _lock; // the lock, must be double word aligned
oop _obj; // object holds the lock;
};
class BasicLock VALUE_OBJ_CLASS_SPEC {
private:
volatile markOop _displaced_header;
};

《Eliminating Synchronization-Related Atomic Operations with Biased Locking and Bulk Rebiasing》一文的说法是:当字节码解释器执行monitorenter字节码轻度锁住一个对象时,就会在获取锁的线程的栈上显式或者隐式分配一个lock record。lock record在线程的Interpretered Frame上(解释帧)分配

2 synchronized关键字之锁的升级

synchronized代码块是由一对monitorenter/moniterexit字节码指令实现,monitor是其同步实现的基础,Java SE1.6为了改善性能,减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”,

所以在Java SE1.6里锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。在几乎无竞争的条件下, 会使用偏向锁, 在轻度竞争的条件下, 会由偏向锁升级为轻量级锁, 在重度竞争的情况下, 会升级到重量级锁。

锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。

这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率,下文会详细分析。

偏向锁——>轻量级锁——>重量级锁

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

2.1 偏向锁

Hotspot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

在没有实际竞争的情况下,还能够针对部分场景继续优化。如果不仅仅没有实际竞争,自始至终,使用锁的线程都只有一个,那么,维护轻量级锁都是浪费的。偏向锁的目标是,无竞争且只有一个线程使用锁的情况下,减少使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS。

2.1.1 偏向锁配置

偏向锁在Java 6和Java 7里是默认启用的,可以使用下列语句关闭偏向机制

  • -XX:-UseBiasedLocking=false 关闭偏向锁

偏向锁机制默认开启,但是它在应用程序启动几秒钟之后才会激活,如有必要可以使用JVM的BiasedLockingStartupDelay参数来关闭延迟

  • -XX:BiasedLockingStartupDelay = 0 关闭延迟

InstanceKlass

  • HotSpot为所有加载的类型,在class元数据——InstanceKlass中保留了一个MarkWord原型——mark_prototype。这个值的bias位域决定了该类型的对象是否允许被偏向锁定。

  • 与此同时,当前的epoch位(用来标识对象的偏向锁是否还有效,批量重偏向时细说,先知道它表示有效期即可)也被保留在prototype中。这意味着,对应class的新对象可以简单地直接拷贝这个原型值,而不必在后面进行修正。

  • 在批量重偏向(bulk rebias)的操作中,prototype的epoch位将会被更新;

  • 在批量撤销(bulk revoke)的操作中,prototype将会被置成不可偏向的状态——bias位被置0。

2.1.2偏向锁的获取

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

  1. 首先读取锁对象的MarkWord, 判断是否处于可偏向的状态

  2. 验证对象所属InstanceKlass的prototype的bias位和epoch位

    • 确认prototype的epoch位是否被设置为1。如果没有设置,则该类所有对象全部不允许被偏向锁定。
    • 确认prototype的bias位是否被设置为1。如果没有设置,则该类所有对象全部不允许被偏向锁定。
  3. 首先检查对象头Mark Word中记录的Thread Id是否是当前线程ID,并且epoch位和InstanceKlass对象的epoch位是否相等(表示偏向锁未过期)

    • 如果是,则表明当前线程已经获得对象锁,以后该线程进入同步块时,不需要CAS进行加锁。

    • 否则,如果偏向模式关闭,则尝试撤销偏向锁

    • 否则,如果epoch位和InstanceKlass对象的epoch位不相等(说明偏向锁已过期),并且jvm参数允许重偏向,那么

      • 重偏向,就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其mark word的Thread Id改成当前线程Id。
      • 如果这里失败,说明存在竞争,进行锁升级。
    • 再else一次,能走到这里,这里说明当前要么偏向别的线程,要么是匿名偏向(即没有偏向任何线程),这时候通过一个CAS(期望值为0)尝试将自己的ThreadID放置到Mark Word中Thread ID。

      • 如果CAS操作成功,则代表当前是匿名偏向,没有锁竞争,锁对象继续保持biasable可偏向状态,ThreadID字段被设置成了偏向锁所有者的ID(即当前线程ID),然后执行同步代码。

      • 如果CAS操作失败,表示在该锁对象上存在竞争并且这个时候有另外一个线程Thread B抢先获取了偏向锁,这种状态说明该对象的竞争比较激烈, 此时需要撤销Thread B获得的偏向锁,将Thread B持有的锁升级为轻量级锁。

        每次进入同步块(即执行monitorenter)的时候都会以从高往低的顺序在栈中找到第一个可用的Lock Record(判断Lock Record是否空闲的依据是其obj字段是否为null),并将其obj设置为偏向线程ID;每次解锁(即执行monitorexit)的时候都会从最低的一个Lock Record移除。先进后出,作为重入的计数器。所以如果能找到对应的Lock Record说明偏向的线程还在执行同步代码块中的代码。

偏向锁使用了一种等到竞争出现才释放锁的机制,即一个线程在执行完同步代码块以后,并不会尝试将MarkWord中的thread ID赋回原值。这样做的好处是:如果该线程需要再次对这个对象加锁,而这个对象之前一直没有被其他线程尝试获取过锁,依旧停留在可偏向的状态下,即可在不修改对象头的情况下,直接认为偏向成功。

2.1.3偏向锁的撤销

偏向锁的撤销(Revoke) 操作并不是将对象恢复到无锁可偏向的状态,而是指在获取偏向锁的过程因为不满足条件导致要将锁对象改为非偏向锁状态;释放是指退出同步块时的过程,即将内存最低的对应的lock Record的obj置为null,需要注意撤销与释放的区别。 偏向锁的撤销,是轻量级锁的前提。

我们之前说过偏向锁使用了一种等到竞争出现才释放锁的机制,所以偏向锁撤销很多时候发生在如下情况:偏向锁偏向的线程是线程B,线程A来争锁,发现有竞争,从而触发了。

  • 如果要撤销的锁偏向的是当前线程,则直接撤销偏向锁,否则会将该操作push到VM Thread中等到safe point的时候再执行。

    在JVM中有个专门的VM Thread,该线程会源源不断的从VMOperationQueue中取出请求,比如GC请求。对于需要safe point的操作(VM_Operationevaluate_at_safepoint返回true),必须要等到所有的Java线程进入到safe point才开始执行。

  • 当到达全局安全点(safe point,在这个时间点上没有字节码正在执行)时,首先将拥有偏向锁的线程挂起

  • 通过Mark Word拿到偏向的线程ID,查看该ID的线程是否存活,如果已经不存活了,则直接撤销偏向锁。

    JVM维护了一个集合存放所有存活的线程,通过遍历该集合判断是否有线程的ID等于偏向线程ID,有的话表示存活。

  • 偏向的线程是否还在同步块中,如果不在了,则撤销偏向锁,升级为轻量级锁,继续往下执行同步代码。

    我们回顾一下偏向锁的加锁流程:每次进入同步块(即执行monitorenter)的时候都会以从高往低的顺序在栈中找到第一个可用的Lock Record,将其obj字段指向锁对象。每次解锁(即执行monitorexit)的时候都会将最低的一个相关Lock Record移除掉。所以可以通过遍历线程栈中的Lock Record来判断线程是否还在同步块中

2.1.4批量重偏向和批量撤销

从上文偏向锁的加锁解锁过程中可以看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁。safe point这个词我们在GC中经常会提到,其代表了一个状态,在该状态下所有线程都是暂停的。总之,偏向锁的撤销是有一定成本的,如果说运行时的场景本身存在多线程竞争的,那偏向锁的存在不仅不能提高性能,而且会导致性能下降。因此,JVM中增加了一种批量重偏向/撤销的机制。

存在如下两种情况:

  1. 一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种case下,会导致大量的偏向锁撤销操作。

  2. 存在明显多线程竞争的场景下使用偏向锁是不合适的,例如生产者/消费者队列。

批量重偏向(bulk rebias)机制是为了解决第一种场景。批量撤销(bulk revoke)则是为了解决第二种场景。

其做法是:以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20,jvm参数BiasedLockingBulkRebiasThreshold控制)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。

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

BiasedLockingDecayTime是开启一次新的批量重偏向距离上次批量重偏向的后的延迟时间,默认25000。也就是开启批量重偏向后,如果经过了一段较长的时间(>=BiasedLockingDecayTime),撤销计数器才超过阈值,那我们会重置计数器。

2.1.4.1批量重偏向

介绍完偏向,我们发现如果锁先偏向了线程B,那么等另外任何一个线程来竞争的时候,都会导致进入偏向锁的撤销流程,在撤销流程里,才会判断线程B是否还活着,如果已经不活动了,则重偏向。

但偏向锁的撤销流程需要等到全局安全点,这是一个极大的消耗,为了能够让许多本应该重偏向的偏向锁无须等到全局安全点时才被重偏向,jvm引入了批量重偏向的逻辑。

该机制的主要工作原理如下:

  • 引入一个概念epoch,其本质是一个时间戳,代表了偏向锁的有效性,epoch存储在可偏向对象的MarkWord中。除了对象中的epoch,对象所属的类class信息中,也会保存一个epoch值
  • 每当遇到一个全局安全点时,比如要对class C 进行批量再偏向,则首先对 class C中保存的epoch进行增加操作,得到一个新的epoch_new
  • 然后扫描所有持有 class C 实例的线程栈,根据线程栈的信息判断出该线程是否锁定了该对象,仅将epoch_new的值赋给被锁定的对象中。(也就是现在偏向锁还在被使用的对象才会被赋值epoch_new)
  • 退出安全点后,当有线程需要尝试获取偏向锁时,直接检查 class C 中存储的 epoch 值是否与目标对象中存储的 epoch 值相等, 如果不相等,则说明该对象的偏向锁已经无效了,可以尝试对此对象重新进行偏向操作。

2.1.4.2批量撤销

  • 将类的偏向标记关闭,之后当该类已存在的实例获得锁时,就会升级为轻量级锁;该类新分配的对象的mark word则是无锁模式。
  • 处理当前正在被使用的锁对象,通过遍历所有存活线程的栈,找到所有正在使用的偏向锁对象,然后撤销偏向锁。

2.2 轻量级锁

JVM的开发者发现在很多情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此JVM引入了轻量级锁的概念。

轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;

2.2.2 加锁过程

  1. 当一个线程A进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(称为Displaced Mark Word)以及记录锁对象的指针(即obj(即下图的Object reference)字段指向锁对象)。

    • 下图右边的部分就是一个Lock Record。左边部分是锁对象。
  2. 线程A尝试获取这个锁,虚拟机将使用CAS操作尝试将锁对象的Mark Word更新为指向自己创建的Lock Record的指针

    • 如果这个更新动作成功了,那么这个线程A就拥有了该对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后2bit)将转变为“00”,即表示此对象处于轻量级锁定状态
    • 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否已经指向当前线程的栈帧。
      • 如果指向的不是自己线程的栈帧,说明这个锁对象已经被其他线程抢占了。那么
        • 它就会自旋等待锁,一定次数后仍未获得锁对象,说明发生了竞争,需要膨胀为重量级锁。
      • 如果已经指向当前线程的栈帧,说明当前线程已经拥有了这个对象的锁,现在是重入状态,那么
        • 设置Lock Record第一部分(Displaced Mark Word)为null,起到了一个重入计数器的作用。
        • 下图为重入三次时的lock record示意图,左边为锁对象,右边为当前线程的栈帧
        • 然后结束。就可以直接进入同步块继续执行。

为什么JVM选择在线程栈中添加Displaced Mark word为null的Lock Record来表示重入计数呢?首先锁重入次数是一定要记录下来的,因为每次解锁都需要对应一次加锁,解锁次数等于加锁次数时,该锁才真正的被释放,也就是在解锁时需要用到说锁重入次数的。

一个简单的方案是将锁重入次数记录在对象头的mark word中,但mark word的大小是有限的,已经存放不下该信息了。
另一个方案是只创建一个Lock Record并在其中记录重入次数,Hotspot没有这样做的原因我猜是考虑到效率有影响:每次重入获得锁都需要遍历该线程的栈找到对应的Lock Record,然后修改它的值。所以最终Hotspot选择每次获得锁都添加一个Lock Record来表示锁的重入。

2.2.3 解锁过程

  1. 遍历当前线程栈,找到所有obj字段等于当前锁对象的Lock Record。

  2. 如果Lock Record的Displaced Mark Word为null,代表这是一次重入,将obj设置为null后continue(即一次解锁结束)。

  3. 如果Lock Record的Displaced Mark Word不为null,则利用CAS指令将对象头的mark word恢复成为Displaced Mark Word。如果成功,则continue(即一次解锁结束),否则膨胀为重量级锁。

2.3 重量级锁

重量级锁是我们常说的传统意义上的锁,其利用操作系统底层的同步机制去实现Java中的线程同步。

重量级锁的状态下,锁对象的mark word为指向一个堆中monitor对象的指针。

一个monitor对象包括这么几个关键字段:cxq(下图中的ContentionList),EntryList ,WaitSet,owner。

其中cxq ,EntryList ,WaitSet都是由ObjectWaiter的链表结构,owner指向持有锁的线程。

2.3.1 加锁过程

2.3.1.1 轻量级锁膨胀为重量级锁

  1. 调用omAlloc分配一个ObjectMonitor对象(以下简称monitor),在omAlloc方法中会先从线程私有的monitor集合omFreeList中分配对象,如果omFreeList中已经没有monitor对象,则从JVM全局的gFreeList中分配一批monitor到omFreeList中。

  2. 初始化monitor对象

  3. 将状态设置为膨胀中(INFLATING)状态

  4. 设置monitor的header字段为displaced mark word,owner字段为Lock Record,obj字段为锁对象

  5. 设置锁对象头的mark word为重量级锁状态(锁标志位为10),并指向第一步分配的monitor对象

  6. 进入2.3.1.3 获取锁流程

2.3.1.2 无锁状态膨胀为重量级锁

  1. 调用omAlloc分配一个ObjectMonitor对象(以下简称monitor)

  2. 初始化monitor对象

  3. 设置monitor的header字段为mark word,owner字段为null,obj字段为锁对象

  4. 设置锁对象头的mark word为重量级锁状态,指向第一步分配的monitor对象

  5. 进入2.3.1.3 获取锁流程

2.3.1.3 获取锁流程

  1. 如果当前是无锁状态、或者锁重入、当前线程是之前持有轻量级锁的线程,那么则进行简单操作后返回。

  2. 否则,先自旋尝试获得锁,这样做的目的是为了减少执行操作系统同步操作带来的开销

  3. 当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个ObjectWaiter对象插入到cxq的队列的队首,然后调用park函数挂起当前线程。在linux系统上,park函数底层调用的是gclib库的pthread_cond_wait,JDK的ReentrantLock底层也是用该方法挂起线程的。

  4. 当线程释放锁时,会从cxq或EntryList中挑选一个线程唤醒,被选中的线程叫做Heir presumptive即假定继承人,就是图中的Ready Thread,假定继承人被唤醒后会尝试获得锁,但synchronized是非公平的,所以假定继承人不一定能获得锁(这也是它叫”假定”继承人的原因)。

  5. 如果线程获得锁后调用Object#wait方法,则会将线程加入到WaitSet中,当被Object#notify唤醒后,会将线程从WaitSet移动到cxq或EntryList中去。需要注意的是,当调用一个锁对象的wait或notify方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁。

synchronized的monitor锁机制和JDK的ReentrantLock与Condition是很相似的,ReentrantLock也有一个存放等待获取锁线程的链表,Condition也有一个类似WaitSet的集合用来存放调用了await的线程。如果你之前对ReentrantLock有深入了解,那理解起monitor应该是很简单。

2.3.2 释放锁过程

在进行必要的锁重入判断以及自旋优化后,

  1. 设置owner为null,即释放锁,这个时刻其他的线程能获取到锁。这里是一个非公平锁的优化;
  2. 如果当前没有等待的线程则直接返回就好了,因为不需要唤醒其他线程。
  3. 如果EntryList的首元素非空,就取出来调用ExitEpilog方法,该方法会唤醒ObjectWaiter对象的线程,然后立即返回;
  4. 如果EntryList的首元素为空,就将cxq的所有元素放入到EntryList中,然后再从EntryList中取出来队首元素执行ExitEpilog方法,然后立即返回;

参考文章

Evaluating and improving biased locking in the HotSpot virtual machine
锁机制-java面试
Java中的偏向锁,轻量级锁, 重量级锁解析
偏向锁到底是怎么个回事
死磕Synchronized底层实现–偏向锁
死磕Synchronized底层实现–重量级锁

0%