引子
Java是支持多线程的语言,因此在很多二方包、基础库中为了保证代码在多线程的情况下也能正常运行,也就是我们常说的线程安全,都会加入如synchronized
这样的同步语义。但是在应用在实际运行时,很可能只有一个线程会调用相关同步方法。比如下面这个demo:
1 | import java.util.ArrayList; |
在这个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种的锁也是能降级的,只不过条件很苛刻,不在我们讨论范围之内。