一、简介
本文主要讲解synchronize的底层实现原理以及虚拟机对synchronize的优化,包含锁优化如偏向锁、轻量级锁、自旋锁、重量级索以及锁消除和锁粗化。
本文主要参考:
zejian_ 博主的文章 深入理解Java并发之synchronized实现原理
郑学炜 博主的文章 15.多线程编程中锁的4种状态-无锁状态 偏向锁状态 轻量级锁状态 重量级锁状态
以及自己的部分个人整理。
尊重原创,转载声明。
二、底层原理
2.1 同步代码块的底层实现
- java中使用synchronize对同步代码块的底层是通过monitorenter 和 monitorexit 指令并搭配计数器来控制同步的。代码块的同步是显示的。其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置。 计数器每次值增一或减一,总是成对出现。
- 编译器会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。
2.2 同步方法的底层实现
- java中使用synchronize对方法的同步是通过方法常量池中方法表结构(method_info_structure)中的ACC_SYNCHRONIZED 这个访问标志位来判断是否是同步方法的。方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。
- 当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后在方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。
三、锁状态优化
3.1 理解java对象头和monitor
3.1.1 jvm中对象在内存中的区域划分图
- 实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
- 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。
- 对象头: 由Mark Word 和 类元数据地址 和 数组长度组成(如果是数组类型)。需要重点关注的是Mark Word。
3.2 虚拟机对锁的优化
3.2.1 四种锁状态优化
- 偏向锁、轻量级锁、自旋锁、重量级锁简介
- 锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁(synchronized);随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁;
- 锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级;
- 无锁状态
- 锁标志位为“01”状态即偏向锁状态的锁标志位,只是是否为偏向锁标志为“0”;
- 偏向锁
- jdk6加入的优化手段,大多数情况下锁不仅不存在多线程竞争,而且总是由统一线程多次获得。为了减少线程频繁获取锁这种没必要的操作引起的消耗而引入了偏向锁;
- 偏向锁的核心思想: 如果一个线程获得了锁即从无锁状态进入偏向锁状态,此时mark word会变为偏向锁结构,将偏向标志位置为1,并将线程ID指向为当前线程ID。这样下次请求过来的线程仍然为当前线程时,就可以直接获取锁而不必再进行CAS操作来加锁和解锁了。
- 偏向锁获取流程:
- ①访问Mark Word中偏向锁的标识是否为1,锁标志位是否为01来确认可偏向状态;
- ②如果是可偏向状态,则测试偏向线程ID是否指向当前线程,如果是,则进入步骤⑤,否则进入步骤③;
- ③如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中的偏向锁线程ID指向当前线程ID,然后执行⑤;如果竞争失败,执行④;
- ④如果CAS操作获取偏向锁失败,则表示有别的线程参与竞争。当该线程到达安全点时获取偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续执行同步代码;
- ⑤执行同步代码;
- 偏向锁释放流程:
- 在上面④操作提到:偏向锁只有在别的线程参与竞争偏向锁的时候,持有偏向锁的线程才会释放锁。线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。这个得看拥有该偏向锁是否还有需要用,如果该线程已经死了或者没用了,则恢复未锁定,再重新偏向即可,否则,则升级,并且偏向状态为0,此时已经不是偏向锁了。
- 在上面④操作提到:偏向锁只有在别的线程参与竞争偏向锁的时候,持有偏向锁的线程才会释放锁。线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。这个得看拥有该偏向锁是否还有需要用,如果该线程已经死了或者没用了,则恢复未锁定,再重新偏向即可,否则,则升级,并且偏向状态为0,此时已经不是偏向锁了。
- 偏向锁适合锁竞争很少出现的场合。如果锁竞争频繁出现,那么偏向锁就不适合,此时会先先升级为轻量级锁;
- 线程默认进入偏向锁状态,可以通过jvm的参数-XX:UseBiasedLocking=false来关闭偏向锁;
- 偏向锁的得锁和释放锁流程图TODO
- 轻量级锁
- jdk6加入的优化手段。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
- 轻量级锁获取流程:
- ①在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如图所示;
- ②拷贝对象头中的Mark Word复制到锁记录中;
- ③拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤④,否则执行步骤⑤;
- ④如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态;
- ⑤如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。
- 轻量级锁释放流程:
- 轻量级锁解锁时,把复制的对象头替换回去(cas)如果替换成功(就是要把无所的状态放回去给对象头,之后锁继续被拿还是轻量级锁,但是如果锁已经是重量级锁了,那么就失败,之后锁就是重量级的锁了),锁结束,之后别的线程来拿还是轻量级锁,如果失败,说明已有竞争,释放锁,此时把对象头设为重量级锁,并notify 唤醒其他等待线程。
- 轻量级锁解锁时,把复制的对象头替换回去(cas)如果替换成功(就是要把无所的状态放回去给对象头,之后锁继续被拿还是轻量级锁,但是如果锁已经是重量级锁了,那么就失败,之后锁就是重量级的锁了),锁结束,之后别的线程来拿还是轻量级锁,如果失败,说明已有竞争,释放锁,此时把对象头设为重量级锁,并notify 唤醒其他等待线程。
- 自旋锁
- 轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。
- jdk4的时候引入的优化操作。默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在JDK6中就已经改为默认开启了。自旋次数的默认值是10次,用户可以使用参数-XX:PreBlockSpin来更改。
- jdk6的时候引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。另一方面,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虚拟机就会变得越来越“聪明”了。
- 重量级锁
- 重量级锁:就是让争抢锁的线程从用户态转换成内核态。让cpu借助操作系统进行线程协调。
- 轻量级锁及膨胀流程图
- 四种状态锁的比较
3.2.2 锁消除优化
- 消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,是线程私有的,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。
3.2.3 锁粗化优化
-
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快地拿到锁。大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(锁粗化)到整个操作序列的外部。
四、多线程系列链接
- 多线程系列(一)—— 线程的状态及转换
- 多线程系列(二)—— 线程的创建方式
- 多线程系列(三)—— 线程常用方法
- 多线程系列(四)—— 线程优先级和守护线程和终止线程的方式
-
多线程系列(六)—— 生产者消费者案例
- 多线程系列(七)—— synchronized关键字简单使用以及可重入性
- 多线程系列(八)—— synchronized关键字原理以及锁优化
- volatile相关
- ThreadLocal相关
- 锁LOCK相关系列
- 原子类相关系列
- 并发集合相关系列
- 线程池相关系列
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/17748.html