JVM系列之synchronized锁优化

引言

每年“金三银四、金九银十”都是投简历的好时机,博主会在这些阶段去投递简历,看看市面上的互联网公司的要求,再调整自己技术栈学习的顺序,在最近的投递的互联网公司(包含一线、超一线、中小型)对于java开发岗位的要求基本离不开“分布式/微服务开发”、“精通java”、“jvm”、“sql调优”、“常见中间件”、“springboot开发”、“算法和数据结构”、“计算机网络”。看了这些个要求,内心浮现出了几个大字:“我顶你个肺”

生活还是要继续,抱怨完就业情形,我嘎嘎就更新了jvm系列文章

在这里插入图片描述
参考书籍:“深入理解Java虚拟机”

个人java知识分享项目——gitee地址

个人java知识分享项目——github地址

synchronized锁优化

在上篇文章就提到synchronized锁优化,这篇文章好好学习一下HotSpot虚拟机开发团队对synchronized的优化手段,如适应性自旋(Adaptive Spinning)、锁消除 (Lock Elimination)、锁粗化(Lock Coarsening)、轻量级锁(Lightweight Locking)和偏向锁(Biased Locking)等。

自旋锁与自适应自旋

以往的synchronized锁是通过直接锁住对象让共享数据在同一时间内只能有一个线程去访问,在此期间想要访问此对象的其他的线程就会阻塞,这种设计对于高并发的场景肯定是不那么合适的,举个例子:有个A线程访问被获取了被synchronized修饰的ObjectA锁对象(业务只会持续很短的一段时间),在此期间B线程也访问ObjectA,但是锁已经被A线程占用了,那么此时B线程只能进行阻塞等待锁的释放,然后B线程获取ObjectA锁对象。

在这里插入图片描述

这个过程会涉及到挂起和恢复线程,挂起线程和恢复线程的操作都需要转入内核态中完成,而线程上下文从内核态到用户态又称之为“上下文切换”,这个过程是非常非常耗资源的,这些操作给系统的并发性能带来了很大的 压力。而且,在案例中(大多数的实际场景也是如此),共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。所以HotSpot虚拟机开发团队有人就提出了“如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一 下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁”。通过这种想法,我们上面提的例子就有了一点改变:“A线程访问被获取了被synchronized修饰的ObjectA锁对象(业务只会持续很短的一段时间),在此期间B线程也访问ObjectA,但是锁已经被A线程占用了,那么此时B线程并不会立马阻塞而是执行一个忙循环(自旋)去判断ObjectA锁对象是否被释放(这个循环次数是可以通过参数-XX:PreBlockSpin去控制,默认是10次,次数的限度设置是为了降低开销),如果在这段时间内A线程方式了锁,那么B线程就可以直接获取锁并不需要进行上下文的切换”。这项技术就是所谓的自旋锁。

在这里插入图片描述

自旋锁在JDK 1.4.2中就已经引入,只不过默认是关闭的,可以使用-XX:+UseSpinning 参数来开启,在JDK 1.6中改为默认开启。并且在JDK 1.6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虚拟机就会变得越来越“聪明”了。

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。

而变量是否逃逸,程序在开发的时候一般都能确定,但是有些同步措施并不是程序员自己加入的,举个例子:

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

在jdk1.5之前反编译的结果:

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(); }

在jdk1.8反编译的结果:

 public java.lang.String concatString(java.lang.String, java.lang.String, java.lang.String);
    Code:
       0: new           #4                  // class java/lang/StringBuilder
       3: dup
       4: invokespecial #5                  // Method java/lang/StringBuilder."<init>":()V
       7: aload_1
       8: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      11: aload_2
      12: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      15: aload_3
      16: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      19: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      22: areturn

SringBuffer是一个相对线程安全的类(方法被synchronized修饰),这里就涉及到了同步的问题,那么锁清除在这里就可以起作用了,如果你在jdk1.5之前操作操作没有涉及到同步的字符串拼接的操作时,虚拟机会观察变量sb,很快就会发现它的动态作用域被限制在 concatString()方法内部。也就是说,sb的所有引用永远不会“逃逸”到concatString()方法 之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。

客观地说,既然谈到锁消除与逃逸分析,那虚拟机就不可能是JDK 1.5之前的版本,但是这里只是为了证明Java对象中同步的普遍性,大家知道有这个东西存在就行。

锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。

大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。就像锁消除的案例中的append()方法就属于这类情况,那么在这种情况下,虚拟机会把加锁同步的范围扩展(粗化)到整个操作序列的外部,就是扩展到第一个append()操作之前直至最后一个append() 操作之后,这样只需要加锁一次就可以了。

轻量级锁

轻量级锁是JDK 1.6之中加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就称为“重量级”锁。首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗(带着这个目的去看下面的内容,就更加容易get到轻量级锁的设计的巧妙).

而实现轻量级锁需要借助对象头中的mark word这块区域,对于对象头,之前的对象深度解析这篇文章中有说明,为了更好的去讲解轻量级锁,在此贴上mark word 中 存储锁标志位 的状态图。

在这里插入图片描述
在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),这时候线程堆栈与对象头的状态如下图所示:
在这里插入图片描述
然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后2bit)将转变为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图
在这里插入图片描述
如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果对象的Mark Word的指针指向的是线程栈帧中的Displaced Mark Word,说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

解锁过程也是通过CAS操作来进行的,如果对象的Mark Word仍然指向着线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。

整个加解锁过程其实就是一个新旧数据比对的过程(通过CAS),根据数据的变化来确定同步代码块是否多个线程抢占共享变量,如果在某段时间内没有多个线程访问这块同步代码那就可以不需要去指向操作系统互斥量(这个对象的内存相比较2bit的锁标志位是要大很多的,同时还能减少传统的重量级锁使用操作系统互斥量产生的性能消耗),如果是出现了多个线程进行抢占共享变量,就会使用操作系统互斥量达到互斥同步。也可以理解成如果同步代码块没有多个出现并发问题的话,我们就应该尽可能的减少实现同步互斥所带来的一系列损耗。

偏向锁

偏向锁也是JDK 1.6中引入的一项锁优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。

偏向锁的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同(这个解释说的真好!!)

偏向锁的枷锁过程:当锁对象第一次被线程获取的时候, 虚拟机将会把对象头中的标志位设为“01”,即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如Locking、Unlocking 及对Mark Word的Update等),当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向(Revoke Bias)后恢复到未锁定(标志位为“01”)或轻量级锁定 (标志位为“00”)的状态,后续的同步操作就如上面介绍的轻量级锁那样执行。
在这里插入图片描述

而偏向锁可通过用户去灵活通过-XX: +UseBiasedLocking参数去开启,毕竟相比较轻量级锁,偏向锁的做法更加极端,从偏向锁的状态恢复到未锁定的状态是有一定的效率损耗的(虽然比较小就是了),但是对于实际的场景中是否开启需要开发人员去仔细的考量的。

想了解更多可以去看看下面这几篇文章:

synchronized锁升级过程

Synchronized锁的升级过程

猜你喜欢

转载自blog.csdn.net/a_ittle_pan/article/details/126335576