JUC-偏向锁

引子

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.util.ArrayList;
import java.util.List;

public class SyncDemo {

public static void main(String[] args) {
SyncDemo1 syncDemo = new SyncDemo();
for (int i = 0; i < 100; i++) {
syncDemo.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_header的locked_bias位会被重设为1,如此新的对象就可以被偏向锁定了

初始状态

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

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

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

此状态出现的可能:

  • 计算hashcode

  • 偏向锁被禁用

  • 偏向锁被撤销(什么情况下撤销下文会说)

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

匿名偏向(Anonymously biased)

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

可重偏向(Rebiasable)

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

已偏向(Biased)

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

加锁过程

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

验证对象Mark Word的locked_bias位。

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

验证对象所属InstanceKlass的_prototype_header的locked_bias位。

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

比对对象和原型的epoch位。

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

校检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 record的obj字段设置为null。需要注意的是,偏向锁的解锁步骤中并不会修改对象头中的thread id

锁撤销

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

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

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

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

总结来说就是

  • 计算hashcode
  • 锁定状态到禁用状态
  • 锁升级

需要注意的是,锁撤销和解锁是两个不同的概念。撤销是指在获取偏向锁的过程因为不满足条件导致要将锁对象改为非偏向锁状态,即低三位变为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 6和Java 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种的锁也是能降级的,只不过条件很苛刻,不在我们讨论范围之内。


JUC-轻量级锁

介绍

轻量级锁是JDK1.6之中加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就称为“重量级”锁。首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

引入轻量级锁的目的

轻量级锁的目标是,减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。

顾名思义,轻量级锁是相对于重量级锁而言的。使用轻量级锁时,不需要申请互斥量,仅仅将Mark Word中的部分字节CAS更新指向线程栈中的Lock Record,如果更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁

优点

竞争的线程不会阻塞,使用自选,提高程序响应速度。

缺点

如果一直不能获取到锁,长时间的自旋会造成CPU消耗。

适用场景

适用于少量线程竞争锁对象,且线程持有锁的时间不长,追求响应速度的场景。

工作流程

入口

轻量级锁的进入方式有三种

  • 对象处于未锁定不可偏状态。此状态下对象不能进入偏向锁模式,当有线程尝试获取锁时,会通过轻量级锁的方式获取锁。

  • 对象锁已经偏向于线程(不考虑重偏向情况)。当锁已经偏向于线程,且线程处于锁定状态或处于未锁定但不允许重偏向的情况下,其它的线程尝试获取锁时,会触发偏向锁撤销,然后升级为轻量级或重量级锁定。

  • 对象已被轻量级锁定。

当对象已经被轻量级锁定的时候,会判断是否是锁重入,如果是重入的话,会记录一条Displaced Mark Word为空的Lock Record。如果不是重入,会膨胀为重量级锁。需要注意的是,即使膨胀为重量级锁,没有获取到锁的线程也不会马上阻塞,而是通过适应性自旋尝试获取锁,当自旋次数达到临界值后,才会阻塞未获取到的线程。JVM认为获取到锁的线程大概率会很快的释放锁,这样做是为了尽可能的避免用户态到内核态的切换。

加锁过程

  1. 判断对象是否是无锁状态(低三位 = 001),如果是,执行 2,如果不是,执行 4。

  2. 在栈中建立一个Lock Record,将无锁状态的Mark Word拷贝到锁记录的Displaced Mark Word中,将owner指向当前对象。

  3. 尝试通过CAS 将锁对象的 Mark Word 更新为指向Lock Record的指针,如果更新成功,该线程获取到轻量级锁,并且需要把对象头的Mark Word的低两位改成10(注意这里修改的是对象头的Mark Word,Lock Record中记录的还是无锁状态的Mark Word);如果更新失败,执行 4。

  4. 对象是轻量级锁定状态,判断对象头的 Mark Word是否指向当前线程的栈帧。如果是,则这次为锁重入,将刚刚建立的Lock Record中的Displaced Mark Word设置为null,记录重入,该线程重入轻量级锁。如果不是,执行 5。

  5. 线程获取轻量级锁失败,锁膨胀为重量级锁,对象头的Mark Word改为指向重量级锁monitor的指针。获取失败的线程不会立即阻塞,先适应性自旋,尝试获取锁。到达临界值后,阻塞该线程,直到被唤醒。

关于适应性自旋

自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

图解

无锁状态时获取锁的流程

线程执行到同步块时,同步对象处于无锁状态,锁标志位为01,偏向标志位为0,偏向锁被禁用,对象处于无锁态。

在加锁前,虚拟机需要在当前线程的栈帧中建立锁记录(Lock Record)的空间。Lock Record 中包含一个 _displaced_header 属性,用于存储锁对象的 Mark Word 的拷贝。

将锁对象的 Mark Word 复制到锁记录中,这个复制过来的记录叫做 Displaced Mark Word。具体来讲,是将 mark word 放到锁记录的 _displaced_header 属性中,将Owner指向当前对象。

虚拟机使用 CAS 操作尝试将锁对象的 Mark Word 更新为指向锁记录的指针。如果更新成功,这个线程就获得了该对象的锁。

更新成功后,需要修改原对象头Mark Word中的锁状态标志位为00,目的是告诉其它线程此对象已经被轻量级锁定。

重入锁流程

当对象处于加锁状态时,会去检验Mark Word是否指向当前线程的栈帧,如果是则将刚刚建立的Lock Record中的Displaced Mark Word设置为null,记录线程重入锁。

非重入锁

如果指向的不是当前线程的栈帧则会触发锁膨胀,膨胀为重量级锁。


解锁过程

轻量级锁加锁时有锁重入的可能,同样的,在解锁时也需要判断是否是锁重入解锁。

  1. 检索当前线程栈中的锁记录空间,从低位往高位找到第一条和此对象有关的Lock Record。加锁时,如果是锁重入,会将 Displaced Mark Word 设置为 null,相应的,在解锁时需要判断Displaced Mark Word是否为 null,如果是,则说明是锁重入解锁,移除onwer的指向,不做替换操作;如果不是,执行 2。

  2. 通过CAS把当前线程栈帧Lock Record中的Displaced Mark Word替换到对象头的Mark Word中去,如果替换成功,则轻量级解锁成功;如果替换失败,则说明发生了锁膨胀,对象现在是重量级锁定状态,执行 3。

  3. 执行重量级锁释放流程,释放重量级锁,同时唤醒被阻塞的线程去获取锁。

图解

非重入释放锁

解锁的思路是使用 CAS 操作把当前线程的栈帧中的 Displaced Mark Word 替换回锁对象中去,如果替换成功,则解锁成功。

CAS成功

CAS失败


轻量级锁未释放前被其它线程尝试获取,此时Mark Word指针已经被替换为指向Monitor,释放锁时CAS会失败,此时需要走重量级解锁流程。

重入锁释放

加锁时,如果是锁重入,会将 Displaced Mark Word 设置为 null。相对应地,解锁时,如果判断 Displaced Mark Word 为 null 则说明是锁重入,不做替换操作。

归纳总结

轻量级锁的适用场景

少量的线程竞争锁,且所有者线程占用锁的事件补偿,追求响应速度的场景。

什么时候会升级为轻量级锁

当对象的偏向模式被关闭、对象处于已偏向已锁定、已偏向未锁定但不支持重偏向的场景下,就会升级为轻量级锁。

什么时候会升级为重量级锁

当竞争产生时就会升级为重量级锁,比如,两个线程同时获取锁,成功的线程会获取到轻量级锁,失败的线程会执行锁膨胀,升级为重量级锁。

轻量级锁怎样实现锁重入

当轻量级锁已经被线程持有,且对象头的Mark Word指向的是当前线程的栈帧时,会把本条Lock Record的Displaced Mark Word 设置为 null,实现锁重入。当重入解锁时,只需要修改所有者onwer的指向。

轻量级锁是否会自旋

轻量级锁流程不会自旋,自旋发生在产生竞争后,获取失败的线程将锁膨胀为重量级锁。失败的线程不会立刻阻塞,而是先尝试适应性自旋,等待所有者释放锁,当到达临界值后再阻塞。

End

轻量级锁可以看作是偏向锁重偏向的升级版,加入了有锁到无锁的状态转换,即使当竞争产生时升级到重量级锁,也不会马上阻塞线程,而是通过适应性自旋来决定是否阻塞,提高了性能。轻量级锁相较于偏向锁来说,简单一些,下一篇会着重介绍重量级锁,以及自旋的线程是怎样进入重量级锁的等待队列的。


JUC-CAS和自旋到底是一个概念吗

问题:

CAS是compare and swap ,就是一个比较工作内存和主内存的值是否相同,相同的话,就用新值来替换这么一个操作。

但是,为什么好多地方都说这是自旋呢?

我的理解是,比较一次的话,成功就返回true了,失败,那么就返回false。如果没有for循环的话,这就是一次操作啊,它本身不会去重试吧?只有在for死循环里边,才可以重试啊,很多底层都是这样做的。

所以,仅仅是CAS的话,就是一次操作,为什么叫做自旋呢。自旋的概念到底就是说是CAS,还是说有for循环的才叫CAS。

回答:

你的理解大体上是没问题的,稍微还有一点不完美。

自旋和CAS不是一回事儿,只是再CAS的时候可以利用自旋机制来不断重试

自旋是一种锁优化机制,所以锁优化中会有『自旋锁』的概念(线程空转重试获取锁),自旋不一定是用在CAS场景,其他锁场景也是能用的(比如互斥锁)

CAS是一种更新的原子操作,是实现乐观锁的机制,CAS可以不用自旋机制,失败也可以直接返回false。只是一般应用场景下,CAS都会带有重试机制(while和for实现空转,不断尝试)