Synchronized的锁优化

       高效并发是从JDK1.5到JDK1.6的一个重要改进,HotSpot虚拟机开发团队在这个版本上花费了大量的精力去实现各种锁优化技术,如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等,这些技术都是为了在线程之间更高效的共享数据,以及解决竞争问题,从而提高程序的执行效率。

自旋锁与自适应自旋

       我们在讨论互斥同步的时候,提到了互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成。这些操作给系统的并发性能带来了很大的压力。同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是自旋锁。

       自旋锁在JDK1.4.2中就已经引入,只不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在JDK1.6中就已经改为默认开启了。自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的操作,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。因此,自旋等待的时间必然要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10次,用户可以使用参数-XX:PreBlockSpin来更改。

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

锁消除

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

我们来看下面的代码。

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

       我们知道,由于String是一个不可变的类,对于字符串的连接操作总是通过生成新的String对象来进行的,因此Javac编译器会对String连接做自动优化。在JDK1.5之前,会转化为StringBuffer对象的连续append()操作,在JDK1.5及以后的版本中,会转化为StringBuilder对象的连续append()操作,即如下面的代码所示。

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的所有引用永远不会“逃逸”到concatString()方法之外,其他的线程无法访问到它,因此这里有锁,但是可以被安全地消除掉,。

锁粗化

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

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

       上面连续的append()方法就属于这类情况,如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,以上述代码为例,就是扩展到第一个append()操作之前直至最后一个append()操作之后,这样只需要加锁一次就可以了。

轻量级锁

       轻量级锁是JDK1.6之中加入的新型锁机制,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

       要理解轻量级锁,必须从HotSpot虚拟机的对象(对象头部分)的内存布局开始介绍。HotSpot虚拟机的对象头分为两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称他为“Mark Word”,它是实现轻量级锁和偏向锁的关键。另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象的话,还有有一个额外的部分用于存储数组的长度。

       对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多信息。他会根据对象的状态复用自己的存储空间。例如,在32位的HotSpot虚拟机中对象未被锁定的状态下,Mark Word的32bit空间中的25bit用于存储对象的哈希码,4bit用于存储对象分代年龄。2bit用于存储锁标志位,1bit固定为0,在其他状态(轻量级锁定、重量级锁定、GC标志、可偏向)下对象的存储内容见表。

Synchronized的锁优化_第1张图片

     我们现在讲述轻量级锁的执行过程。在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),这时候线程堆栈与对象头的状态如下。

Synchronized的锁优化_第2张图片

       然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后2bit)将转变为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如下:

 

Synchronized的锁优化_第3张图片

       如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果指向说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态了。

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

       在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

偏向锁

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

       偏向锁的“偏”是指这个锁会偏向于第一个获得他的锁,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

       如果理解前面轻量级锁中关于Mark Word与线程之间的操作过程,那偏向锁的原理理解起来就会很简单。假设当前虚拟机启用了偏向锁(启用参数-XX:+UseBiasedLocking,这是JDK1.6的默认值)那么,当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01”,即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如Locking、Unlocking以及对Mark Word的Update等)。

       当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”),后续的同步操作就如上面介绍的轻量级锁那样执行。

Synchronized的锁优化_第4张图片

       如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。

以上都是《深入理解java虚拟机》的原话,但我感觉不好理解,偏向锁、轻量级锁和重量级锁的转化,现在贴上我在网上找的别人的回答,可能会比较有帮助。

 

作者:Doing
链接:https://www.zhihu.com/question/53826114/answer/236363126
来源:知乎

偏向所锁,轻量级锁都是乐观锁,重量级锁是悲观锁。
一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个
线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将
对象头中的ThreadID改成自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作。
一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了,检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程,如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的)。如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。
轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。

 

 

 

 

偏向锁获取过程:

  (1)访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01——确认为可偏向状态。

  (2)如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤(5),否则进入步骤(3)。

  (3)如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行(5);如果竞争失败,执行(4)。

  (4)如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。

  (5)执行同步代码。

2、偏向锁的释放:

  偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

 

     现在我自己的理解来解释一下,如果哪有不对可以联系我,一定要给我说一下。微信:liukun184685454,请说明是为了这个问题。

Synchronized的锁优化_第5张图片

      我刚开始比较困惑的地方是:这张图的1处,分出两个分支以后,原持有偏向锁的线程还没有从当前同步块中出来,当前线程怎么可以获取锁并执行同步块?现在问了一些人和自己理解了一下后,原来是原持有偏向锁的线程在未退出同步代码块时,当前线程在做完(2)(3)操作后,一直在自旋尝试获取锁,只不过当前线程在原持有偏向锁线程未退出同步代码块时,当前线程一直在失败,再尝试而已,当达到一定的次数后,轻量级锁就要升级为重量级锁,然后当原持有偏向锁的线程还没有执行完同步块时,当前线程就不再自旋了,而是进入阻塞状态。直到原持有偏向锁的线程执行完同步块后,唤醒包括当前线程的那些被阻塞的线程,来抢夺锁。

你可能感兴趣的:(并发知识)