深入理解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 优化。稍后讲到

在这里插入图片描述

(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,后面等待的线程将会进入阻塞状态。

在这里插入图片描述

释放轻量级锁:

轻量级锁的释放操作,也是通过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 来禁止偏向锁优化反而可以提升性能。

发布了337 篇原创文章 · 获赞 206 · 访问量 13万+

猜你喜欢

转载自blog.csdn.net/riemann_/article/details/104093841
今日推荐