深入理解JVM(③)Java的锁优化

时间:2024-01-27 10:13:20

前言

从JDK5到JDK6HotSpot虚拟机开发团队花费了大量的资源实现了各种锁优化技术,如适应性自旋(Adaptive Spinning)锁消除(Lock Elimination)锁膨胀(Lock Coarsening)轻量级锁(LightEight Locking)偏向锁(Biased Locking)等,这些技术都是胃了在线程之间更高效地共享数据及解决竞争问题,从而提供程序的执行效率。

自旋锁与自适应锁

在Java中锁起到的作用是互斥同步,而互斥同步对性的影响最大的是阻塞,阻塞是通过挂起线程和恢复线程来实现的,这个操作是很昂贵的,消耗的服务器资源比较大。针对于此虚拟机开发团队发明了自旋锁,因为在共享数据的锁定状态只会持续很短一段时间,为了这段时间去挂起和恢复线程很不值得。所以在一个线程获得锁的同时另一个线程可以先“稍等一会儿”,但并不放弃处理器执行时间,为了让线程等待,只须让线程执行一个忙循环(自旋),这就是自旋锁。

那么这个自旋锁的自旋时间多久比较合适呢?

如自旋时间太短那就起不到自旋的作用了,太长又会占用过多的处理器资源。所以在JDK1.4.2中引入自旋锁的时候,就提供了自旋次数为10默认值以及可以自行配置的参数-XXPreBlockSpin。

在JDK1.6中对自旋锁进行了优化,引入了自适应自旋。它可以根据前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果上一次获得了锁,那么下一次就会被认为也会获得锁,进而自旋时间会加长;如果这个锁很少被成功获得,那么有可能就直接省略掉自旋锁,避免处理器资源浪费。

锁消除

锁消除是指:虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。
锁消除是虚拟机自行判断的,开发人员,在编写代码的时候并不用刻意的去规避这些问题,因为有些同步措施都是Java本身自己实现的。
例如如下代码:

public String concatString(String str1,String str2,String str3){
    return str1 + str2 + str3;
}

因为String是被final修饰的类,所以每次变动都是会产生新的String对象来进行的,因此在编译时会对String连接做自动优化。在JDK5之前会转成StringBuffer对象进行append()操作,在JDK5以后会转为StringBuilder对象进行append()操作。
这样JDK5之前编译器就会把代码变成如下形式:

public String concatString(String str1,String str2,String str3){
    StringBuffer sb = new StringBuffer();
    sb.append(str1);
    sb.append(str2);
    sb.append(str3);
    return sb.toString();
}

因为StringBuffer::append()方法就涉及到同步块,锁的就是sb对象。所以发现sb的动态作用域在concatString()方法内部,其他线程又无法访问到它,因此这里的锁就可以被安全的消除。

锁粗化

我们在编写代码的时候,一般会遵循一个原则,就是尽量将同步块的作用范围限制的最小,只在共享数据的实际作用域中才进行同步,这样同步操作数量会变得更少,即使存锁竞争,等待锁的线程也能尽可能快地拿到锁。
但是实际情况,在一系列连续操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
上面的代码中concatString()方法就是频繁的堆sb对象进行加锁,虚拟机会探测到这种情况,将锁的范围扩展到整个系列操作的外部。就是在第一个append()操作之前到最后一个append()操作之后,只需要加一次锁就可以了。
总结一下锁粗化:虚拟机探测到有一系列零碎的操作都对同一个对象加锁,将会加锁的同步范围扩展(粗化)到整个系列的操作外部。

轻量级锁

轻量级锁是相对于操作系统互斥量来实现的“重量级”锁而言的,但是轻量级锁并不用来替代重量级锁的,它是指在没有多线程竞争的前提下,减少重量级锁使用操作系统互斥量产生的性能消耗。

要理解轻量级锁,必须要对虚拟机对象的内存布局(尤其是对象头部分)。

HotSpot虚拟机的对象头分为两部分:
  • 第一部门用户存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GC Age)等。这部分数据的长度咋32位和64位的虚拟机中分别会占用32个或64个比特,官方称它为“Mark Word”,它是实现轻量级锁和偏向锁的关键
  • 第二部分是用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分用户存储数组长度

由于对象头信息是与对象自身定义的数据无关的额外存储成本,Mark Word被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。
Mark Word会根据对象的状态复用自己的存储空间。下面是对象的状态对应的对象头的存储内容表

HotSpot虚拟机对象头Mark Word

轻量级锁工作过程

轻量级锁加锁
  1. 在代码即将进入同步块的时候,如果此同步对象没有被锁定(标志位“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。
  2. 然后,虚拟机将使用CAS操作尘世把对象的Mark Word 更新为执行Lock Record 的指针。
  3. 如果这个更新操作成功了,即代表线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。
  4. 如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。

上面说了轻量级锁的加锁过程了,它的解锁过程也同样是通过CAS操作来进行的。

  1. 如果对象的Mark Word 仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Wrod和线程中复制的Displaced Mark Word替换回来。
  2. 加入能够替换,那整个同步过程就顺利完成了;
  3. 如果替换失败,则说明有其他线程尝试过滤获取该锁,就要在释放锁的同时,唤醒被挂起的线程。

轻量级锁总结:
轻量级锁能提升新恒信性能的依据是:“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”。
如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。

偏向锁

偏向锁的意义:

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

偏向锁的定义:

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

偏向锁加锁过程

  1. 当虚拟机启动了偏向锁,那么当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。
  2. 同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Word之中。
  3. 如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块是,虚拟机都可以不再进行任何同步操作。

偏向锁解锁过程

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

感悟:

《深入理解Java虚拟机(第三版)》这本书,差不多算是看完了,笔记也都记录下来了,还是有几章个人觉得并不重要的章节给忽略掉了,所以就没有做笔记。这次的感觉比第一次读第二版的时候有了更深的理解,也接触到了新的知识,例如以前没研究过Java9的模块化的知识。
这次陆陆续续算上在博客上做笔记也是大概用了一个多月将近2个月,感觉比读第二版的时候,速度快了些。
每次准备换工作的时候,都是第一个想起来要看一遍这本书。但是最近两年的情况好像有所不同了,现在各大互联网公司面试必备的条件就是手撕算法,所以我也深刻的意识到了,想要出去面试光看这本书是远远不够的,因此后续的时间里,我将要开启算法的学习历程了,大家也一起加油吧!
还是那句话,我们只需努力,剩下的交给时间就好了。