Java 多线程并发【4】虚拟机锁优化方案

在线程安全章节说明了线程安全的定义,以及线程安全实现的方案。

  • 线程安全分为不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
  • 线程安全的实现又划分为互斥同步、非阻塞同步和无锁方案。

Java 给我们提供了很多实现线程安全的工具,包括 synchronized 关键字、 java.util.concurrent 包中提供的实现和一些线程安全的数据结构等等。虽然工具完善,但是面对不同的实际场景,还是有很多优化空间的。

锁优化技术

锁优化技术包括适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等,这些技术都是为了提高线程之间共享数据的效率,以及解决竞争问题从而提高程序等运行效率。

自旋锁与自适应自旋

互斥同步存在的问题

互斥同步对性能最大的影响是导致线程阻塞,并且挂起和恢复线程的操作都需要转入内核态中完成,浪费系统资源。而另一方面,在很多应用中共享数据的锁定状态只会持续很短的时间,为了这么短时间的线程安全去挂起和恢复线程并不划算。

解决方案:自旋锁

自旋锁就是为了解决这个问题出现的(定义):当一个锁已被线程 A 持有,B 线程请求持有锁时等待一会儿,但并不让出 CPU 时间片,看看 A 线程能否很快释放锁,为了让 B 线程进行等待,一般是通过一个循环来实现,这个循环也就是所谓的自旋。

自旋锁的缺点及解决办法

自旋等待并不能代替阻塞,自旋操作虽然避免了线程切换的开销,但是它会占用 CPU 时间片,因此,如果锁被占用时间很短,自旋锁的效果会非常显著,反之,如果锁被占用时间很长,那么自旋的线程只会白白浪费处理器资源。因此自旋的等待时间必须要有一定的限度,如果自旋次数超过了最大限定次数仍没有获得锁,就应该使用传统的方式挂起线程了。

自适应自旋

自适应自旋意味着自旋的时间不是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果对于同一个锁,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么 JVM 就会认为这次自旋也很有可能再次成功,从而允许他将自旋等待的时间相对设置为更长的时间;如果对于一个锁,自旋很少成功获取到锁,那么在以后获取这个锁时将可能省略掉自旋过程,避免浪费 CPU 资源。

编译器对同步的优化

锁消除

锁消除是指 JVM 即时编译器在运行时,对一些要求同步到代码,但检测到不可能存在同步问题的锁进行消除。

锁消除的主要判断依据源于逃逸分析的数据支持,如果在一段代码中,堆上所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当作栈上的数据对待,认为它们是线程私有的,加锁操作也就无需进行。

举例说明

String 是一个不可变的类,字符串的拼接是通过生成新的 String 对象来完成的,因此 Javac 编译器会对 String 拼接做自动优化。

JDK 1.5 版本之前,会转换为 StringBuffer 对象,连续执行 append 操作。

JDK 1.5 后续版本,会转换为 StringBuilder 对象,连续执行 append 操作。

锁粗化

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

大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

JVM 探测到存在连续的零碎的对同一个对象的加锁操作时,将会把加锁的范围扩展到整个操作序列的外部。

JVM 中的锁优化技术

在介绍 JVM 对同步操作的优化之前,我们需要先了解 Java 对象的对象头结构。

对象头分为两部分信息:

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

对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。

对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如在32位的虚拟机中,对象不同状态的存储信息是不同的:

  • 未锁定状态:Mark Word的 32bit 空间中的 25bit 用于存储对象哈希码(HashCode),4bit 用于存储对象分代年龄,2bit 用于存储锁标志位,1bit 固定为 0。
  • 偏向锁状态:23bit 用于存储偏向锁的线程 id,2bit 用于存储 Epoch,4bit用于存储对象分代年龄,1bit 固定为 1,2bit 存储锁标志位。
  • 轻量级锁状态:30bit 用于存储指向栈中锁记录的指针,2bit 用于存储锁标志位。
  • 重量级锁状态:30bit 用于存储指向重量级锁的指针,2bit 用于存储锁标志位。
Java 多线程并发【4】虚拟机锁优化方案
1460000022904670.png

以此类推 64 为结构如下:

Java 多线程并发【4】虚拟机锁优化方案
1460000022904668.png

介绍完对象头的内存结构后,我们就可以进一步探索不同状态的概念了。

偏向锁

偏向锁被设计出来的目的是消除数据在无竞争情况下的同步原语(操作系统提供的一种同步保障的操作),进一步提高程序的运行性能。

偏向锁就是设置了偏向线程,在无竞争的情况下,优先偏向线程先获取到锁,而不进行任何额外的同步保障逻辑。

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

  • 锁对象第一次被线程获取时,JVM 会把对象的锁标志位设置为 01 ,进入偏向锁模式。

  • 使用 CAS 操作记录当前线程 ID。

  • 如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步代码块时,JVM 都可以不再进行任何同步操作。

  • 此时如果存在另一个线程尝试获取这个锁时(注意这里不是争用),偏向锁模式就结束了。根据锁对象目前是否处于被锁定的状态

    • 撤销偏向后恢复到未锁定(标志位为 01)
    • 进入轻量级锁状态(标志位为 00 ),后续进行轻量级锁相关的操作。

偏向锁可以提高带有同步但无竞争的程序性能。它同样是一个带有效益权衡(Trade Off)性质的优化,也就是说,它并不一定总是对程序运行有利,如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。

轻量级锁

“轻量级“ 是相对于互斥同步中的实现方案而言的。轻量级锁并不能代替重量级锁,它被设计出来的意义是,在没有多线程竞争的前提下,减少传统的重量级锁进行系统调用产生的性能消耗。轻量级锁一般是在无竞争的情况下使用 CAS 操作 + 自旋去消除同步互斥的性能消耗。

轻量级锁一般的实现就是自旋锁,通过自旋尝试获取锁,以避免线程阻塞。

轻量级锁的加锁过程:

  • 对象没有被锁定,锁标志位为 01。

  • JVM 在当前线程的栈帧中创建一个锁记录空间(Lock Record )。

  • 然后将对象的对象头数据 copy 到这个线程的 Lock Record 中。

  • JVM 使用 CAS 操作尝试将对象头的数据更新为指向 Lock Record 的指针,如果成功,就代表当前线程拥有了对象的锁。

  • 同时将对象的锁标志位更新成 00 ,表示此对象处于轻量级锁状态。

  • 如果更新锁标志位失败,JVM 检查对象的对象头是否指向当前线程的栈帧

    • 若指向,代表当前线程已拥有了这个对象的锁
    • 若没有,代表被其他线程持有了锁,进行自旋继续尝试获取锁
  • 如果多个线程争用同一个锁(这里是争用),那么轻量级锁就失效了,需要膨胀为重量级锁,锁标标志位更新为 10。

轻量级锁的解锁过程也是通过 CAS 操作进行的:

  • 对象的对象头仍指向线程的 Lock Record ,用 CAS 操作将线程中的对象头数据更新到对象本身。如果成功,同步过程就完成了,如果失败,说明有其他线程尝试获取过该锁,那就要在释放锁的同时,唤起被挂起的线程。

轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了 CAS 操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

重量级锁

重量级锁会在持有锁时阻塞其他线程的执行,也就是互斥同步,实现方式就是使用 synchronized 关键字。

锁的升级过程

由无锁过程到重量级锁,是一个逐渐加强同步保障的过程:

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

锁的升级是在 JVM 编译时,对加锁的处理:

  1. 锁对象在未加锁时处于无锁状态,锁标志位为 01。

  2. 锁对象第一次被线程获取, JVM 记录该线程的 ID 为偏向锁的线程ID ,进入偏向锁模式,此时标志位仍是 01。

  3. 当有另一个线程尝试访问这个锁对象时,偏向锁模式结束,根据锁对象当前的状态是否仍处于锁定来判断。

    • 未锁定,恢复到无锁状态,标志位为 01。
    • 仍锁定,进入轻量级锁模式,标志位更新为 00。
  4. 轻量级锁状态下,JVM 在当前线程的栈帧中创建一个锁记录空间(Lock Record ) ,然后将对象的对象头数据 copy 到这个线程的 Lock Record 中,然后使用 CAS 操作尝试将对象头中的轻量级锁指针更新为指向这个线程的 Lock Record 的指针。

    • 如果更新指针成功,说明当前线程持有了锁对象。
    • 若更新失败了,说明锁对象已被其他线程持有,通过自旋的方式继续尝试去获取锁。
  5. 如果自旋超过限定次数后仍未获取到锁,就要升级为重量级锁模式。

  6. 进入重量级锁状态,锁标志位更新为 10 ,阻塞线程等待获取锁对象。


原文始发于微信公众号(八千里路山与海):Java 多线程并发【4】虚拟机锁优化方案

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由半码博客整理,本文链接:https://www.bmabk.com/index.php/post/85128.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
半码博客——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!