前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >JVM技术总结之六——JVM的锁优化

JVM技术总结之六——JVM的锁优化

作者头像
剑影啸清寒
发布2020-07-20 10:28:30
4770
发布2020-07-20 10:28:30
举报
文章被收录于专栏:琦小虾的Binary琦小虾的Binary

接上篇《JVM技术总结之五——JVM逃逸分析》

六. JVM 的锁优化

参考地址: 《java 中的锁 – 偏向锁、轻量级锁、自旋锁、重量级锁》 《彻底搞懂synchronized(从偏向锁到重量级锁)》 《synchronized实现原理》

在介绍 JVM 锁优化之前,首先明确几个概念,用于后续的介绍。

6.1 线程状态切换

由于需要限制不同的程序之间的访问能力,防止他们获取别的程序的内存数据,或者获取外围设备的数据,CPU 划分出两个权限等级:用户态内核态

  • 内核态:CPU 可以访问内存所有数据,可以访问硬盘、网卡等外围设备;也可以将自己从一个程序切换到另一个程序;
  • 用户态:只能访问受限的内存,不允许访问外围设备。占用 CPU 的能力被剥夺,CPU 资源可以被其他程序获取;

Java 程序运行时,在若干线程抢夺 synchronized 的锁时,只有一个线程抢夺成功,其他线程会进入阻塞状态,等待锁的释放。但 Java 线程是映射到操作系统的原生线程上的阻塞唤醒一个 Java 线程,需要操作系统的介入,也就是**用户态与内核态的切换 线程状态的切换会消耗大量的资源,因为用户态、内核态都有自己专用的资源(内存空间、寄存器等),从用户态切换到内核态时,用户态需要向内核态传递很多信息,同时内核态又要保存好自己运行时所需的信息,用于状态切换回内核态之后正常的后续工作。 所以理解 Java 线程切换代价,是理解 Java 中各种锁的优缺点的基础之一**。在我们使用锁的时候,需要估算代码执行的时间以及线程状态切换是否频繁。如果代码执行时间较短,或者线程状态切换很频繁,那么比较未获取锁的线程挂起消耗的时间,以及获取锁线程的代码执行时间,前者比后者消耗时间更长,这样的同步策略是很糟糕的。 synchronized 会导致争用不到锁的线程进入阻塞状态,所以它是 Java 语言中的重量级同步操作。为了优化上述性能问题,Java 从 1.5 版本之后引入偏向锁轻量锁(包括自旋锁),默认开启自旋锁

注:轻量级锁与重量级锁:

  • 轻量级锁:自旋锁、偏向锁、轻量锁
  • 重量级锁:synchronized

6.2 markword

markword 是所有 Java 对象数据结构的一部分,它是所有 Java 对象锁信息的表示,此处对其进行简要介绍。markword 是一个 32/64 bit 长度的数据,其中最后两位是锁状态标志位

状态

标志位

存储内容

未锁定偏向锁

01

对象 HashCode、对象分代年龄(偏向锁标志为 0 时)

可偏向

01

偏向线程ID、偏向时间戳、对象分代年龄(偏向锁标志为 1 时)

轻量级锁定

00

指向锁记录的指针

重量级锁定

10

执行重量级锁定的指针

GC 标志

11

空(不需要记录信息)

上述基本内容说明完毕后,可以进行后续关于锁优化的说明。 对于 synchronized 这个关键字,可能之前大家有听过,他是一个重量级锁,开销很大,建议大家少用。但到了 JDK 1.6 之后,该关键字被进行了很多的优化,建议大家多使用。因为优化之后的 synchronized 关键字并非一开始就在对象上加了重量级锁,而是从偏向锁 -> 轻量级锁(自旋锁)-> 重量级锁逐步升级的过程。

6.3 偏向锁

偏向锁是 JDK 1.6 引入的一项锁优化,其中的“偏”是偏心的偏。偏向锁会偏向于第一个获得它的线程,在接下来的执行过程中,假如该锁没有其他线程来竞争该锁,也没有被其他线程获取,那么持有偏向锁的线程将永远不需要进行同步操作。

前面的介绍中可以了解到,每个对象都有锁信息,对象关于锁的信息是存到 markword 中的。当我们创建一个锁对象,并命名为 lockObject:

代码语言:javascript
复制
// 随便创建一个对象
Object lockObject = new Object();
synchronized(lockObject) {
    // ......
}

上述代码我们可以分为主要的两步:创建对象、对象加锁第一步,创建对象:在我们创建这个 lockObject 对象时,该对象的 markword 关键数据如下:

bit fields

锁标志位

是否偏向锁

Hash

01

0

表中数据可知,锁标志位为 01,说明当前锁状态为偏向锁;【是否偏向锁】的状态为 0,说明当前对象还没有被加上偏向锁。这里也说明了,所有对象在被创建了之后,都是可偏向的,但是刚刚被创建出来的时候,锁信息【是否偏向锁】的状态都为 0,即创建对象的偏向锁还没有生效。

第二步,对象加锁:当线程执行到临界区时,执行操作:

  1. 使用 CAS 操作将线程 ID 插入到 Markword 中;
  2. 修改偏向锁标志位;

此时 markword 结构信息如下:

bit fields

锁标志位

是否偏向锁

thread ID

epoch

01

1

此时该对象偏向锁的【是否偏向锁】标志置为 1,说明偏向锁生效了,同时线程 ID 也存入了 markword 中。 该线程在之后的执行过程中,如果再次进入相同的同步代码段中,并不需要进行 synchronized 关键字通常需要做的加锁、解锁的操作,而是进行如下步骤:

  1. 判断线程 ID:比较当前线程 ID 与该对象 markword 的线程 ID 是否一致;
    • 如果一致,说明该线程已经成功获取了锁,继续正常执行同步代码块中的代码;
    • 如果不一致,进入下一步;
  2. 检查对象【是否偏向锁】状态
    • 如果为 0,这是前面第一次获取锁的操作,执行前面说的工作,使用 CAS 操作竞争锁;
    • 如果为 1,而且偏向的不是自己(markword 中线程 ID 与当前线程不同),说明锁存在竞争,进入下一步;
  3. 执行锁膨胀锁撤销
    • 锁膨胀:偏向锁失效,锁升级为轻量级锁;
    • 锁撤销:锁升级后,将该锁撤销;(该步骤消耗较大)
      • (1) 在一个安全点停止拥有锁的线程;(安全点会导致 stop the world,性能下降严重)
      • (2) 遍历该线程的线程栈,如果存在锁记录,需要修复所有 markword,变成无锁状态;
      • (3) 唤醒当前线程,将当前偏向锁升级为轻量级锁

所以如果大部分同步代码块都是由两个及以上线程竞争,那么偏向锁本身就是一种累赘,这种情况下我们可以在程序运行之前设置 JVM 参数 -XX:-UseBiasedLocking将偏向锁默认功能关闭。

6.4 轻量锁

锁撤销升级为轻量锁后,锁对象的 markword 会进行相应的变化,线程中所有栈帧创建锁记录 LockRecord,修改所有与锁对象相关的栈帧信息。 修改后的锁对象的 markword 改为:

bit fields

锁标志位

指向 LockRecord 的指针

00

轻量级锁主要分为两种:自旋锁自适应自旋锁

6.5 自旋锁

自旋锁主要目的是为了避免用户线程与内核切换引起的消耗。如果持有锁的线程能在很短的时间内释放锁资源,那么等待锁释放的线程暂时不用做线程状态切换(即线程不用进入阻塞挂起状态),只需要让 CPU 等一等,也就是进入自旋状态,等待锁释放后立即获取锁,这样就避免了线程状态切换引起的消耗。 但是线程的自旋需要消耗 CPU,自旋状态下 CPU 是处于无效运行状态的。所以需要设定一个自旋等待的最长时间,如果在这段时间内一直获取不到锁,那么就进入阻塞状态。 在 JDK 1.5 版本下,自旋周期是定死的,1.6 版本下引入了自适应自旋锁,通常情况下认为一个线程上下文切换所需时间是一个比较好的值(默认情况下自旋次数为 10 次)。JVM 针对当前 CPU 负荷情况做了一定的优化,具体策略此处不细讲。如果线程自旋次数超过了这个值,自旋的方式就不适合了,这时候锁再次膨胀,升级为重量级锁

自旋锁的优缺点:

  • 优点:对于锁竞争不激烈,或者占用锁时间短的代码,这两种情况下,自旋的消耗远小于切换线程状态的消耗,所以性能会有大幅度的提升;
  • 缺点:不适合锁竞争激烈执行时间长的同步代码块,这两种情况下自旋时间较长,经常会超过最长等待时间进入阻塞状态,这样会浪费很多 CPU 资源。对于这种情况,应该关闭自旋锁。

注:轻量级锁也被称为非阻塞同步锁乐观锁,因为这个过程并没有把线程阻塞挂起,而是让线程空循环等待。

6.6 重量级锁

参考地址: 《synchronized实现原理》 《深入理解Java并发之synchronized实现原理》

升级重量级锁完毕后,markword 部分数据为:

bit fields

锁标志位

指向 Mutex 的指针

10

前面说过,重量级锁性能消耗最大的地方在于用户态向内核态的转换。重量级锁也被称为互斥锁、悲观锁、阻塞同步锁,是依赖对象内部的 monitor 锁实现的,monitor 是依赖操作系统的 MutexLock,即互斥锁实现的。在 Java 虚拟机中,monitor 是由 ObjectMonitor 实现的,在 HotSpot 虚拟机源码中定义数据结构如下:

代码语言:javascript
复制
ObjectMonitor() {
    _header       = NULL;
    _count        = 0; // 记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL; // 记录持有当前 ObjectMonitor 对象的线程
    _WaitSet      = NULL; // 处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 处于等待锁 block 状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

ObjectMonitor 中有两个队列 EntryListWaitSet,用来保存 ObjectWaiter 对象列表,ObjectWaiter 对象用来封装每个等待该 monitor 的线程。owner 指针指向 ObjectMonitor 对象的线程。ObjectMonitor 对象的执行流程图如下:

Java重量级锁ObjectMonitor执行流程
Java重量级锁ObjectMonitor执行流程
  1. 多个线程同时访问某段同步代码,首先进入 EntryList(即图中的 EntrySet);
  2. 在 EntryList 与 WaitSet 中的线程争抢进入 owner 中,成功进入到 owner 的线程使 ObjectMonitor 对象的 count 值 +1;
  3. 如果线程调用 wait() 方法,则该线程会放弃争取该 ObjectMonitor 的权利,进入 WaitSet 线程等待室中,等待被唤醒(通过notify() / notifyAll() 方法唤醒)
    • 如果当前线程在 owner 中,则释放当前 monitor,owner 指针置为 NULL,count 减 1,从 owner 中转移到 WaitSet 中;
    • 如果当前线程在 EntryList 中,则转移到 WaitSet 中;
  4. 如果 owner 中的当前线程执行完毕,释放 monitor 并复位变量的值,其他在 EntryList 与 WaitSet 中的所有线程重新争抢进入 Owner 中。
本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2020-07-16 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 六. JVM 的锁优化
    • 6.1 线程状态切换
      • 6.2 markword
        • 6.3 偏向锁
          • 6.4 轻量锁
            • 6.5 自旋锁
              • 6.6 重量级锁
              领券
              问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档