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

接上篇《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:

// 随便创建一个对象
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 虚拟机源码中定义数据结构如下:

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 对象的执行流程图如下:
JVM技术总结之六——JVM的锁优化_第1张图片

  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 中。

你可能感兴趣的:(JVM,算法,Java,java,多线程)