深入理解Java虚拟机:(十)JVM是如何实现锁优化的?

一、线程安全

在如今多核操作系统盛行的环境下,我们如何将我们的程序在计算机中正确且高效的运行?对于多核中出现高效并发的问题,我们如何保证并发的正确性和如何实现线程安全说起。

这里引用下《Java Concurrency In Practice》的作者 Brian Goetz 对 “线程安全” 有一个比较恰当的定义:“当多个线程访问同一个对象时,如果不用考虑线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的”。

简而言之,造成线程安全的问题主要原因有以下两点:

  • 存在共享数据(也称临界资源)。
  • 存在多条线程,共同操作共享数据。

这个定义比较严谨,它要求线程安全的代码都必须具备一个特征:代码本身封装了所有必要的正确性保障手段(如互斥同步等)。令调用者无须关心多线程的问题,更无须自己采取任何措施来保证多线程的正确调用。

二、应用方式

synchronized 是解决Java并发最常见的一种方法,也是最简单的一种方法。关键字 synchronized 可以保证在同一时刻,只有一个线程可以访问某个方法或者某个代码块。同时 synchronized 也可以保证一个线程的变化,被另一个线程看到(保证了可见性)。

synchronized 的作用主要有三个:

  • 确保线程互斥的访问代码
  • 保证共享变量的修改能够及时可见(可见性)
  • 可以阻止JVM的指令重排序

synchronized 主要有三种应用方式:

  • 普通同步方法,锁的是当前实例的对象。
  • 静态同步方法,锁的是静态方法所在的类对象。
  • 同步代码块,锁的是括号里的对象。(此处的可以是实例对象,也可以是类的class对象。)

三、原理详解

Java虚拟机中的同步(Synchronization)都是基于进入和退出 Monitor 对象实现,无论是显示同步(同步代码块)还是隐式同步(同步方法)都是如此。

1、同步代码块

当声明 synchronized 代码块时,编译而成的字节码将包含 monitorenter 和 monitorexit 指令。这两种指令均会消耗操作数栈上的一个引用类型的元素(也就是 synchronized 关键字括号里的引用),作为所要加锁解锁的锁对象。

public void foo(Object lock) {
  synchronized (lock) {
    lock.hashCode();
  }
}
// 上面的Java代码将编译为下面的字节码
public void foo(java.lang.Object);
  Code:
     0: aload_1
     1: dup
     2: astore_2
     3: monitorenter
     4: aload_1
     5: invokevirtual java/lang/Object.hashCode:()I
     8: pop
     9: aload_2
    10: monitorexit
    11: goto          19
    14: astore_3
    15: aload_2
    16: monitorexit
    17: aload_3
    18: athrow
    19: return
  Exception table:
     from    to  target type
         4    11    14   any
        14    17    14   any

上述的 Java 代码编译成字节码后,你可能注意到了,上面的字节码中包含一个 monitorenter 指令以及多个 monitorexit 指令。这是因为 Java 虚拟机需要确保所获得的锁在正常执行路径,以及异常执行路径上都能够被解锁。

2、同步方法

当用 synchronized 标记方法时,你会看到字节码中方法的访问标记包括 ACC_SYNCHRONIZED。该标记表示在进入该方法时,Java 虚拟机需要进行 monitorenter 操作。而在退出该方法时,不管是正常返回,还是向调用者抛异常,Java 虚拟机均需要进行 monitorexit 操作。

public synchronized void foo(Object lock) {
  lock.hashCode();
}
// 上面的Java代码将编译为下面的字节码
public synchronized void foo(java.lang.Object);
  descriptor: (Ljava/lang/Object;)V
  flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
  Code:
    stack=1, locals=2, args_size=2
       0: aload_1
       1: invokevirtual java/lang/Object.hashCode:()I
       4: pop
       5: return

这里 monitorenter 和 monitorexit 操作所对应的锁对象是隐式的。对于实例方法来说,这两个操作对应的锁对象是 this;对于静态方法来说,这两个操作对应的锁对象则是所在类的 Class 实例。

3、底层原理

要理解底层实现,就需要理解两个重要的概念 Monitor 和 Mark Word。

(1)、Java对象头

synchronized 用到的锁,是存储在对象头中的。(这也是Java所有对象都可以上锁的根本原因)
HotSpot虚拟机中,对象头包括两部分信息:Mark Word(对象头)Klass Pointer(类型指针)

  • 其中类型指针,是对象指向它的类元素的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
  • 对象头又分为两部分:第一部分存储对象自身的运行时数据,例如哈希码,GC分代年龄,线程持有的锁,偏向时间戳等。这一部分的长度是不固定的。第二部分是末尾两位,存储锁标志位,表示当前锁的级别。

下图是对象头运行时的变化状态:
锁标志位是否偏向锁 确定唯一的锁状态
其中 轻量级锁 和 偏向锁 是JDK1.6之后新加的,用于对 synchronized 优化。稍后讲到

深入理解Java虚拟机:(十)JVM是如何实现锁优化的?_第1张图片

(2)、Monitor

Monitor是 synchronized 重量级锁 的实现关键。锁的标识位为 10 。当然 synchronized作为一个重量锁是非常消耗性能的,所以在 JDK1.6 以后做了部分优化,接下来的部分是讲作为重量锁的实现。

Monitor 是线程私有的数据结构,每一个对象都有一个 monitor 与之关联。每一个线程都有一个可用 monitor record 列表(当前线程中所有对象的 monitor),同时还有一个全局可用列表(全局对象 monitor)。每一个被锁住的对象,都会和一个 monitor 关联。

当一个monitor被某个线程持有后,它便处于锁定状态。此时,对象头中 MarkWord的 指向互斥量的指针,就是指向锁对象的 monitor 起始地址
monitor是由 ObjectMonitor 实现的,其主要数据结构如下:(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)。

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

object monitor 有两个队列 _EntryList_WaitSet ,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter对象)_owner 指向持有 objectMonitor的线程。

当多个线程同时访问一个同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的 monitor 后,会进入_owner 区域,然后把 monitor 中的 _owner 变量修改为当前线程,同时 monitor 中的计数器_count 会加1。

根据虚拟机规范的要求,在执行monitorenter指令时,会尝试获取对象的锁。如果对象没有被锁定(获取锁),获取对象已经被该线程锁定(锁重入)。则把计数器加1(_count 加1)。相应的,在执行monitorexit指令时,会讲计数器减1。当计数器为0时,_owner指向Null,锁就被释放。

如果线程调用 wait() 方法,将释放当前持有的 monitor,_owner变量恢复为null_count变量减1,同时该线程进入_WaitSet 等待被唤醒。

四、锁优化

在早期的 Java 版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(Monitor)是依赖于低层的操作系统的 Mutex Lock 来实现的。
而操作系统实现线程中的切换时,需要用用户态切换到核心态,这是一个非常重的操作,时间成本较高。这也是早期 synchronized 效率低下的原因。

1、重量级锁

重量级锁是 Java 虚拟机中最为基础的锁实现。在这种状态下,Java 虚拟机会阻塞加锁失败的线程,并且在目标锁被释放的时候,唤醒这些线程。

Java 线程的阻塞以及唤醒,都是依靠操作系统来完成的。举例来说,对于符合 posix 接口的操作系统(如 macOS 和绝大部分的 Linux),上述操作是通过 pthread 的互斥锁(mutex)来实现的。此外,这些操作将涉及系统调用,需要从操作系统的用户态切换至内核态,其开销非常之大。

2、自旋锁与自适应自旋

为了尽量避免昂贵的线程阻塞、唤醒操作,Java 虚拟机会在线程进入阻塞状态之前,以及被唤醒后竞争不到锁的情况下,进入自旋状态,在处理器上空跑并且轮询锁是否被释放。如果此时锁恰好被释放了,那么当前线程便无须进入阻塞状态,而是直接获得这把锁。

与线程阻塞相比,自旋状态可能会浪费大量的处理器资源。这是因为当前线程仍处于运行状况,只不过跑的是无用指令。它期望在运行无用指令的过程中,锁能够被释放出来。

自旋状态还带来另外一个副作用,那便是不公平的锁机制。处于阻塞状态的线程,并没有办法立刻竞争被释放的锁。然而,处于自旋状态的线程,则很有可能优先获得这把锁。

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

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

3、锁消除

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

虽然大部分程序员可以判断哪些操作是单线程的不必要加锁,但我们在使用Java的内置 API时,部分操作会隐性的包含锁操作。例如StringBuffer、HashTable的操作。

4、锁粗化

我们知道,在使用锁的时候,需要让同步的作用范围尽可能的小——仅在共享数据的操作中才进行。这样做的目的,是为了让同步操作的数量尽可能小,如果存在锁竞争,那么也能尽快的拿到锁。

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

例如,对Vector的循环add操作,每次add都需要加锁,那么JVM会检测到这一系列操作,然后将锁移到循环外。

5、轻量级锁

轻量级锁时 JDK 1.6 之中加入的新型锁机制,它名字中的 “轻量级” 是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就称为 “重量级” 锁。需要强调的是,轻量级锁并不是用来代替重量级锁的。它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

获取轻量锁:

(1)、判断当前对象是否处于无锁状态(偏向锁标记=0,无锁状态=01),如果是,则JVM会首先将当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储当前对象的Mark Word拷贝。(官方称为Displaced Mark Word)。接下来执行第2步。如果对象处于有锁状态,则执行第3步

(2)、JVM利用CAS操作,尝试将对象的Mark Word更新为指向Lock Record的指针。如果成功,则表示竞争到锁。将锁标志位变为00(表示此对象处于轻量级锁的状态),执行同步代码块。如果CAS操作失败,则执行第3步。

(3)、判断当前对象的Mark Word 是否指向当前线程的栈帧,如果是,则表示当前线程已经持有当前对象的锁,直接执行同步代码块。否则,说明该锁对象已经被其他对象抢占,此后为了不让线程阻塞,还会进入一个自旋锁的状态,如在一定的自旋周期内尝试重新获取锁,如果自旋失败,则轻量锁需要膨胀为重量锁(重点),锁标志位变为10,后面等待的线程将会进入阻塞状态。

深入理解Java虚拟机:(十)JVM是如何实现锁优化的?_第2张图片

释放轻量级锁:

轻量级锁的释放操作,也是通过CAS操作来执行的,步骤如下:

(1)、取出在获取轻量级锁时,存储在栈帧中的 Displaced Mard Word 数据。

(2)、用CAS操作,将取出的数据替换到对象的Mark Word中,如果成功,则说明释放锁成功,如果失败,则执行第3步。

(3)、如果CAS操作失败,说明有其他线程在尝试获取该锁,则要在释放锁的同时唤醒被挂起的线程。

6、偏向锁

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

因为经过研究发现,在大部分情况下,锁并不存在多线程竞争,而且总是由一个线程多次获得锁。因此为了减少同一线程获取锁(会涉及到一些耗时的CAS操作)的代价而引入。
如果一个线程获取到了锁,那么该锁就进入偏向锁模式,当这个线程再次请求锁时无需做任何同步操作,直接获取到锁。这样就省去了大量有关锁申请的操作,提升了程序性能。

获取偏向锁:

(1)、检查Mark Word 是否为可偏向状态,即是否为偏向锁=1,锁标志位=01。

(2)、若为可偏向状态,则检查 线程ID 是否为当前对象头中的线程ID,如果是,则获取锁,执行同步代码块。如果不是,进入第3步。

(3)、如果线程ID不是当前线程ID,则通过CAS操作竞争锁,如果竞争成功。则将Mark Word中的线程ID替换为当前线程ID,获取锁,执行同步代码块。如果没成功,进入第4步。

(4)、通过CAS竞争失败,则说明当前存在锁竞争。当执行到达全局安全点时,获得偏向锁的进程会被挂起,偏向锁膨胀为轻量级锁(重要),被阻塞在安全点的线程继续往下执行同步代码块。

释放偏向锁:

偏向锁的释放,采取了一种只有竞争才会释放锁的机制,线程不会主动去释放锁,需要等待其他线程来竞争。偏向锁的撤销需要等到全局安全点(这个时间点没有正在执行的代码),步骤如下:

(1)、暂停拥有偏向锁的线程,判断对象是否还处于被锁定的状态。

(2)、撤销偏向锁。恢复到无锁状态(01)或者 膨胀为轻量级锁

偏向锁可以提高带有同步但无竞争的程序性能。它并不一定总是对程序运行有利。如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。在具体问题具体分析的前提下,有时候使用参数 -XX:+UseBiasedLocking 来禁止偏向锁优化反而可以提升性能。

你可能感兴趣的:(JVM)