随笔分类
锁升级
偏向锁
在大多数情况下,锁不仅不存在多线程竞争问题,而且总是由同一线程多次获得
为了让线程获得锁的代价更低,引入了偏向锁
锁会偏向于第一个获得它的线程,会在对象头存储锁偏向的线程ID,以后该线程进入和退出该同步代码块时只需要来检查是否为偏向锁、锁标志位以及线程ID即可
偏向锁在Java1.6之后是默认启用的,但是会在应用程序启动几秒钟之后才激活
可以使用 -XX:BiasedLockingStartupDelay=0
关闭延迟,如果确定应用程序中所有锁通常情况下都处于竞争的状态,可以使用-XX:-UseBiasedLocking-false
来关闭偏向锁
偏向锁是只有一个线程在执行同步代码块时进一步提高效率,适用于同一线程反复获得同一锁的情况.
偏向锁可以提高带有同步但无竞争的程序性能
获得
当线程第一次访问同步代码块并获取锁时,偏向锁处理流程如下:
- 虚拟机会将对象头中的锁标志位设置为 0 1 ,即偏向模式
- 同时使用CAS操作把获取这个锁的线程ID记录在对象的Mark Word中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何的同步操作,偏向锁的效率高
当发生锁竞争时,就会去撤销偏向锁
撤销
-
偏向锁的撤销必须得等到
全局安全点
(这也就意味着偏向锁的撤销是比较低效的) -
暂停拥有偏向锁的线程,去检查持有偏向锁的线程状态
-
遍历当前JVM的所有线程,
-
如果能找到偏向线程,说明其处于活动状态,再来判断偏向线程是否继续竞争锁
- 竞争,偏向锁升级为轻量级锁
- 不竞争,竞争的线程获得偏向锁,即将Mark Word偏向其它线程或者恢复为无锁或者标记该对象不适合作为偏向锁(当偏向锁的撤销次数
Epoch
> 40 时直接会升级为轻量级锁)
-
如果偏向线程不存活,将偏向锁设置为无锁,别的线程继续来竞争该锁(CAS)
-
轻量级锁
"轻量级"是相对于使用monitor的传统锁而言,传统的monitor是"重量级"的锁,轻量级锁的出现并非是要去替代重量级锁
引入轻量级锁的目的:在多线程交替执行同步代码块时,尽量避免重量级锁引起的性能消耗,但是如果多个线程在同一时刻进入临界区(同步代码块),会导致轻量级锁膨胀升级重量级锁,所以说轻量级锁的出现并非是要去替代重量级锁
加锁
当关闭偏向锁功能或者多个线程竞争偏向锁导致锁升级时,则会去尝试获取轻量级锁
- 当线程执行代码进入同步块时,如果Mark World为无锁状态,JVM会在当前线程的栈帧建立一个
Lock Record(锁记录)
,用于存储当前对象的Mark World的拷贝,官方称之为"Displaced Mark World",将对象的Mark World复制到栈帧中的Lock Record中,将Lock Record中的owner
指向当前对象 - 复制成功后,JVM将用CAS操作将对象的Mark World更新为指向栈帧中Lock Record的指针
- 更新成功,表示线程成功竞争到了锁,则将锁的标志位置为
00
,表示处于轻量级锁状态 - 失败,先去判断当前对象的Mark World是否指向当前线程的栈帧,如果是表示当前线程已经持有了当前对象的锁,则直接去执行同步代码块;如果不是,只能说明该锁对象已经被其它线程抢占了,此时轻量级锁将膨胀升级为重量级锁,锁标志位变成
11
,后面等待的线程将会进入阻塞状态
- 更新成功,表示线程成功竞争到了锁,则将锁的标志位置为
撤销
轻量级锁的释放也是通过CAS操作来进行的
- 取出轻量级锁保存在当前线程的栈帧
Lock Record
中的数据 - 通过CAS操作将取出的数据替换当前锁对象的
Mark World
中的对应数据,如果成功,则说明释放锁成功 - 操作失败(说明在当前线程持有锁期间,有别的线程尝试获取该锁,并对锁对象中Mark World进行了修改,二者对比发现不一样),说明当前有其他线程尝试获取该锁,则需要将轻量级锁膨胀升级为重量级锁
评价
对于轻量级锁,其性能优化的依据对于大部分的锁,在整个生命周期内都是不会存在竞争的
,即不会同时存在多个线程访问同步代码块,线程之间交替访问同步代码块
,如果打破了这一依据,除了有互斥的开销外,还会有额外的CAS操作,因此在多线程竞争的情况下,轻量级锁比重量级锁更慢
其原理在于将对象的Mark World复制到当前线程的栈帧中 Displaced Mark World
,以及将Mark World更新为指向Lock Record的指针
在线程交替执行同步代码块时,可以避免重量级锁引起的性能消耗(包括系统调用引起的内核态和用户态的切换、线程阻塞造成的线程切换等),使用轻量级锁时,不需要去申请互斥量
自旋锁
monitor是重量级锁
,其会阻塞和唤醒线程,线程的阻塞和唤醒需要CPU从用户态转为系统态
,频繁的阻塞和唤醒对CPU是一件负担比较重的任务,这些操作给系统的并发带来了比较大的压力
同时虚拟机的开发应用注意到在许多应用上,共享数据的锁定状态
只会持续较短的一段时间,为了这段时间而将线程阻塞和唤醒并不值得.
如果物理机器有一个以上的处理器,能让多个线程同时并行执行,我们就可以让后面请求锁的那个线程稍等一下
,但并不放弃处理器的执行时间,即抱着某种希望在这段时间内看看持有锁的线程是否很快机会释放锁.
为了让线程等待(非阻塞),仅需要让线程执行一个忙循环(自旋
)即可,这项技术就是所谓的自旋锁
自旋锁并不是最好的,它的依赖条件:
- 同步代码块执行时间短
- 硬件支持多线程并行执行
自旋锁在JDK6及其之后便是默认开启的,自旋等待并不能替代阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是会占用处理器资源的,因此,如果锁被占用的时间很短,自旋等待的效果就会比较好,反之,如果锁被占用的时间很长,那么自旋的线程只会白白地消耗处理器资源,而不会做任何有用的工作,即会带来性能上的浪费
因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数还没有成功获得锁,就应该去使用传统的方式去挂起线程.
自旋次数默认值是10次
,可以通过参数-XX:PreBlockSpin
来更改
适应性自旋锁
自适应的自旋锁,即自旋时间不固定,而是由前一次在同一个锁上自旋时间以及锁的拥有者状态来决定.如果在同一个锁对象上,上一个线程自旋等待刚刚成功获得锁,并且持有锁的线程正在运行中,那么JVM就会认为这次自旋也很有可能会再次成功,进而允许线程自旋等待持续相对较长的时间.另外,如果对于某个锁,自旋很少成功获得锁过,那在以后获取这个锁时将有可能省略掉这个自旋等待的过程,以避免浪费处理器资源
有了自适应自旋,随着程序运行
和性能监控信息
的不断完善,虚拟机对程序锁的状况预测会愈加准确,即JVM将会变得越来越聪明
src\share\vm\runtime\objectMonitor.cpp
void ATTR ObjectMonitor::EnterI (TRAPS) {
// We try one round of spinning *before* enqueueing Self.
//
// If the _owner is ready but OFFPROC we could use a YieldTo()
// operation to donate the remainder of this thread's quantum
// to the owner. This has subtle but beneficial affinity
// effects.
if (TrySpin (Self) > 0) { //这里就是自旋等待了
assert (_owner == Self , "invariant") ;
assert (_succ != Self , "invariant") ;
assert (_Responsible != Self , "invariant") ;
return ;
}
#define TrySpin TrySpin_VaryDuration //这是个宏定义,对应 TrySpin_VaryDuration函数
int ObjectMonitor::TrySpin_VaryDuration (Thread * Self) {
// Dumb, brutal spin. Good for comparative measurements against adaptive spinning.
int ctr = Knob_FixedSpin ;//获取自旋次数
if (ctr != 0) {
while (--ctr >= 0) { //开始自旋
if (TryLock (Self) > 0) return 1 ; //每一次自旋尝试去获取锁
SpinPause () ; //这是个等待函数
}
return 0 ;
}
for (ctr = Knob_PreSpin + 1; --ctr >= 0 ; ) { //这里体现的就是适应性自旋锁 static int Knob_PreSpin = 10 ; // 20-100 likely better
if (TryLock(Self) > 0) { //如果获得了锁
// Increase _SpinDuration ...
// Note that we don't clamp SpinDuration precisely at SpinLimit.
// Raising _SpurDuration to the poverty line is key.
int x = _SpinDuration ;
if (x < Knob_SpinLimit) { //去增加自旋等待时间,基于这次获得了锁,JVM预测下一此有很大几率会获得锁,因此会来增加自旋等待时间
if (x < Knob_Poverty) x = Knob_Poverty ;
_SpinDuration = x + Knob_BonusB ;
}
return 1 ;
}
SpinPause () ;
}
锁消除
虚拟机JIT在运行时,对一些代码要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除
锁消除的主要判定依据来源于逃逸分析
的数据支持.如果判断在一段代码中,堆上的所有数据都不会逃逸出去而被其他线程访问到,便可以将其视为栈上数据
来对待,即认为是线程私有的,这样同步加锁便无需进行
变量是否逃逸,对于JVM而言需要通过数据流分析来确定,但是程序员自己应该才是最清楚的,怎么会在明知道不存在数据争用的情况下要求同步呢?其实大对数情况下,许多同步的措施都不是程序员自己加入的,同步的代码在JAVA程序中的普遍程度超过了大部分读者的想象.
锁粗化
原则上,我们在编写代码时,总是推荐将同步代码块的作用范围限制得小一些,只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作尽可能少一些,如果存在锁竞争,那么竞争的锁也能很快的得到锁.大部分情况下,以上的原则是正确的,但是如果一系列的操作都对同一个对象反复加锁和解锁
,甚至加锁操作是出现在循环中,那即使没有现成竞争,频繁地进行互斥同步
操作也会导致不必要的性能损耗
即当JVM探测到一连串小的操作都是用同一个对象加锁,将同步代码块的范围放大,放到这串操作的外面,这样的话只需要进行一次加锁即可
//演示一种极端的情况
public void doIt() {
synchronized(lock){
//do some things
}
synchronized(lock){
//do other things
}
}
进行锁粗化优化后
//优化后
public void doIt() {
synchronized(lock){
//do some things
//do other thing
}
}