线程安全与锁优化(2)——锁优化

接上篇文章:线程安全与锁优化(1)
写在最前,本篇文章基本上来源于 《深入理解Java虚拟机》 并发部分 的提炼,并附带自己的理解,主要是为了帮助自己理解,和用于复习。如果同时还能对其他人有所裨益,那就更好不过了。如果有谬误的地方,还请不吝指出。

自旋锁与自适应自旋


自旋锁
互斥同步在性能上的影响主要来源于阻塞,即挂起线程和恢复线程的操作都转入内核态实现。

然而,大多数情况下,共享数据的锁定只会持续很短的一段时间,为了这段时间进行大开销的工作并不值得。

如果机器拥有一个以上的处理器,能让两个或以上的线程并行执行,我们就可以使得后面请求锁的线程“稍等一会”,但不放弃处理器的执行时间,而是等待持有锁的线程是否很快就会释放锁。只需要让线程执行一个忙循环(自旋),也就是所谓的自旋锁。在JDK 6默认开启。

自旋等待并不能代替阻塞,除了对处理器数量的要求,自旋等待虽然避免了线程切换的开销,但却要占用处理器时间。所以如果占用时间很长,自旋的线程只会白白消耗处理器资源,从而带来性能的浪费。所以自旋的默认值为10次,也可以通过参数-XX: PreBlockSpin自行更改


自适应的自旋
不过无论是默认值,还是用户指定的自选次数,对于JVM中的所有锁都是相同的。在JDK6中,对于自旋锁的优化,加入了自适应的自旋。这意味着自旋的时间不再固定,而由前一次在同一个锁上的自旋时间及锁的拥有者状态决定:

如果在同一个锁对象上,自旋等待刚刚成果获得过锁,并且持有锁的线程正在运行,那么JVM就会认为这次自旋也很有可能成果,进而允许自旋等待持续更长时间。

相反,如果自旋很少成功获得某个锁,那么以后获取时,可能直接省略自旋过程,以免浪费处理器资源。

随着程序运行时间增长,以及性能监控信息的完善,虚拟机对于程序锁的状况预测会变得越来越精准。

锁消除

指当虚拟机即时编译器在运行时,对于一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。

其判定依据主要源于逃逸分析的数据支持。如果判断一段代码,堆上的所有数据都不会逃逸出去被其他线程访问,就可以把它们当做栈上数据对待,认为它们是线程私有的,自然无需进行同步加锁。

判断变量是否逃逸,需要进行复杂的过程间分析,如果程序员能自发地在不存在数据争用的情况下取消同步,是否就不需要这样复杂的过程呢?

不是的,同步措施在java程序出现得极为频繁,一段看起来没有同步的代码可能也运用了同步的机制。
代码示例:

public String concatString(String s1, String s2, String s3){
   return s1+s2+s3;
}

事实上,代码优化后可能会变成这样(假设是JDK5之前):

public String concatString(String s1, String s2, String s3){
 StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

每个StringBuffer::append()方法中都有一个同步块,锁就是sb对象。经过逃逸分析后,发现sb的动态作用域被限制在concatString()方法内部,也就是说sb的所有引用都不会逃逸到这个方法之外,其他线程无法访问到它。

所以这里虽然有锁,但可以被安全地消除掉。解释执行时依然会加锁,但经过即时编译器编译后,会忽略所有的同步措施而直接执行。

锁粗化

编写代码时,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中进行同步。

但如果一系列连续操作都对同一个对象反复加锁和解锁,甚至加锁操作都是出现在循环体中,即使没有线程竞争,频繁地互斥同步操作也会导致不必要的性能消耗。

上面的append()方法就属于这种情况,如果检测到一串操作都对同一个对象加锁,就会把锁同步的范围扩展(粗化)到整个序列的外部。这样只需要加锁一次就可以了。

轻量级锁

JDK 6时加入的新型机制。“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,即“重量级”锁。但轻量级锁并非用于代替重量级锁,而是减少重量级锁使用操作系统互斥量产生的性能消耗。

要理解轻量级锁,就得先理解HotSpot虚拟机对象的内存布局(尤其是对象头)。
对象头分为两部分:(在对象实例化内存布局与访问定位中有详细解释)
第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄等,是实现轻量级锁和偏向锁的关键,称为MarkWord
另一部分用于存储指向方法区对象类型数据的指针。如果是数组,则还会有一个部分存储数组长度。

由于对象头信息是与对象自身定义数据无关的额外存储成本,考虑到空间使用效率。MarkWord被设计为非固定的动态数据结构,使得在极小的空间内存储更多信息。
在这里插入图片描述

注意,图中表并非是一个对象所拥有的状态,而是在不同的锁状态下,会呈现不同的结构分布。

代码即将进入同步块时,如果同步对象没有被锁定(锁标志位为01),则JVM在当前线程的栈帧中建立一个名为锁记录的空间,用于存储锁对象目前的Mark Word拷贝,这个拷贝称为Displaced Mark Word。

然后,JVM尝试用CAS操作把Mark Word更新为指向lock record的指针,并在lock record里存放MW拷贝。如果更新成功,则代表线程拥有了对象的锁,且MW锁标志位变为了00,表示处于轻量级锁定状态
在这里插入图片描述

在这里插入图片描述
(图片来源于黑马JUC教程p79

如果更新失败,说明至少有一个线程与当前线程竞争该对象锁。JVM首先检查对象的MW是否指向当前线程的栈帧,

  • 如果是,说明当前线程已经拥有了这个对象,直接进入同步块执行即可(即锁重入,通过锁记录的数量可以看出加了几次锁);
  • 否则,说明锁对象已经被其它线程占用,如果出现2个线程以上争用同一个锁,轻量级锁就不再有效,必须膨胀为重量级锁,锁标志变为10。MW存储指向重量级锁的指针,后面等待锁的线程必须进入阻塞状态。

当退出synchronized代码块(解锁时),如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减一。
如果不为null,则使用CAS将MW恢复给对象头。如果失败,说明轻量级锁已经升级为重量级锁,进入重量级锁解锁流程。

轻量级锁提升同步性能的依据是,“对于绝大部分锁,同步周期内不存在竞争”。如果没有竞争,通过CAS操作避免了使用互斥量的开销

锁膨胀(不是优化措施)

尝试加轻量级锁过程中,CAS操作无法成功,可能是其它线程已经加上了轻量级锁,这时候就需要进行锁膨胀,将轻量级锁变为重量级锁。

即,为Object申请Monitor锁,让Object指向重量级锁地址。
然后自己进入Monitor的EntryList BLOCKED

在这里插入图片描述

当第一个线程退出同步块解锁时,使用CAS将MW恢复对象头失败,这时候会进入重量级解锁流程,即按照Monitor地址找到Monitor对象,设置Owner为null,唤醒EntryList中BLOCKED线程。

关于重量级锁:
当线程第一次获取锁时,会为对象关联Monitor,此时对象头存放Monitor的地址。当调用wait时,进入WaitSet并释放锁,此时EntryList中的某一线程获得锁。直到某一线程调用了notify(),WaitSet中的线程被唤醒,加入EntryList,等待当前线程释放锁后,与其他同处于EntryList中的线程一同竞争锁。

偏向锁

JDK6引入的锁优化措施,目的是清除数据在无竞争情况下的同步原语。
轻量级锁是在无竞争的情况下使用CAS操作消除同步使用的互斥量,偏向锁则是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。

当锁对象第一次被线程获取时,虚拟机会把对象头的标志位设为01,偏向模式设为1,表示进入偏向模式。同时使用CAS操作把获取到整个锁的线程ID记录在MW中,如果成功,则持有偏向锁的线程以后每次进入锁相关的同步块时,都不用进行任何同步操作。

撤销条件

一旦出现另一个线程尝试获取整个锁,偏向模式马上宣告结束(改为0)。
如果上一个线程已经结束对目标的使用,那么会转为轻量级锁。
否则,膨胀为重量级锁。

当调用hashcode时,也会撤销偏向锁,这是因为结构里没有额外的位置存放hashcode

调用wait/notify方法也会导致偏向锁撤销,因为这两个方法都是重量级锁才有的功能。

批量重偏向

如果撤销的次数达到了一个阈值20,就会换一个偏向的对象,而非撤销重偏向

批量撤销

如果撤销的次数达到了40,则会认为不应该撤销,这个类的所有新创建的对象都是不可偏向的

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值