Java锁系列——3、JVM 对 Synchronized 锁优化

概述

在上篇博客中,我们提到轻量级锁、偏向锁、重量级锁等概念。在早期的 java 虚拟机中,synchronized 锁基于 monitor 管程对象实现,而 monitor 对象又基于底层操作系统互斥量来保证同步。这就意味着,所有线程切换时需要从 用户态 转化为 核心态,而线程的转化过程比较缓慢,这也是早期 synchronized 锁效率低下的主要原因。在 jdk6 之后,jvm 对 syncrhonized 锁进行了一系列优化。本篇博客我们就来整理一下 jvm 都对 synchronized 锁进行了哪些优化。


JVM 对 Synchronized 的优化

本篇博客分以下六个模块展开:

  1. 偏向锁
  2. 轻量级锁
  3. 锁转化关系图
  4. 自旋锁
  5. 锁消除
  6. 锁粗化

1、偏向锁

偏向锁是 jdk6 新引入的一种锁优化。其中它的“偏”就是指偏向某个线程。翻译过来也就是说,当某个线程被锁 偏向 后:在后序的执行过程中,如果没有其他线程获取该锁,那么锁对象永远偏向该线程,当该线程获取偏向锁时,无须进行任何同步操作。

也就是说,偏向锁优化的原理就是消除数据在无竞争情况下的同步操作,进而提高效率

一般情况下,偏向锁总是偏向第一次获取锁的线程。当锁对象第一次被线程获取后,虚拟机将该同步对象 Mark Word 区域的锁标识设置为“01”,即偏向模式,同时使用 CAS 将获取锁的线程 ID 保存到 Mark Word 之中。如果设置成功,后序该线程获取锁对象时,无须进行任何同步操作。

当另一个线程尝试获取锁资源时,偏向模式宣告结束。

  • 如果当前锁对象处于锁定状态,通过 CAS 将该锁标识置位“00”,即升级为“轻量级锁”。

  • 如果当前锁对象处于未锁定状态,则撤销偏向,将偏向标识置位0,即非偏向锁阶段。

偏向锁可以提高同步、但无竞争的程序性能。如果程序中锁总是被不同的线程获取,那偏向模式是多余的,如果程序中的锁总是被某一个线程获取,那它可以大幅提高程序性能。


2、轻量级锁

轻量级锁是 JDK6 中引入的新型锁机制。它的本意不是为了替代重量级锁,而是为了在没有多线程竞争的场景下,减少重量级锁使用操作系统互斥量产生的性能损耗

和重量级锁相同,轻量级锁也基于 HotSpot 虚拟机对象头的 Mark Word 模块实现。之前提到锁标识为“10” 时表示当前锁为重量级锁,而轻量级锁的锁标识为“00”。

下面我简单描述一下轻量级锁的加锁过程:

  1. 在代码进入同步块时,如果当前同步对象还没有被 锁定(锁标志为“01”,即无锁状态),虚拟机首先在线程的栈帧中创建锁记录,用于存储同步对象的 Mark Word 拷贝。

  2. 虚拟机尝试通过 CAS 操作将同步对象的 Mark Word 更新指向上一步栈帧中的锁记录。如果更新成功,那么该线程就拥有了同步对象的锁,并将栈帧中的锁标识修改为“00”,标识此对象处于轻量级锁状态。

  3. 如果操作失败,虚拟机首先检查同步对象的 Mark Word 是否指向当前线程的锁记录。如果是,说明当前线程已经拥有了该对象锁,继续向下执行。否则说明锁已经被其他线程抢占了。如果有两个线程争抢同一个锁,那轻量级锁就不再生效,膨胀为重量级锁。

轻量级锁提升程序性能的依据是:对于绝大多数锁,在整个同步周期内是不存在竞争的,这是一个经验数据。如果不存在竞争,轻量级锁 CAS 操作避免了使用互斥量的开销。但一旦存在竞争,除了互斥量的开销外,还额外进行了 CAS 操作。因此在有竞争的情况下,轻量级锁的性能不如重量级锁


3、锁转化关系图

有了偏向锁、轻量级锁,重量级锁(上篇博客所有原理都是针对重量级锁)的描述,我们以同步对象Mark Word 区域的变化为基础,描述各种锁之间的转化关系。具体如下图所示:
Java锁系列——3、JVM 对 Synchronized 锁优化_第1张图片
通过上图我们可以总结出以下几点:

  • 偏向锁在非偏向线程访问,并此时处于非锁定状态时撤销偏向,恢复为未锁定状态
  • 偏向锁在非偏向线程访问,并此时锁正处于锁定状态时升级为轻量级锁
  • 轻量级锁在存在竞争时膨胀为重量级锁
  • synchronized 通常由偏向锁开始,撤销偏后升级到 轻量级锁,轻量级锁 膨胀为 重量级锁
  • 整个锁升级的过程是不可逆的

我认为(个人理解,不一定正确)偏向锁和轻量级锁其实差不多,都会在线程竞争时升级。主要区别有以下几点:

  • 偏向锁通过 CAS 记录偏向线程,后序偏向线程访问时省去同步操作、轻量级锁通过 CAS 让同步对象 Mark Word 指向栈帧中的锁记录,省去操作系统互斥量(也就是重量级锁处理方式)所带来的消耗。
  • 偏向锁一劳永逸,只在第一次线程访问时记录,而轻量级锁每次访问都需要 CAS 操作尝试修改通过对象 Mark Word 指向。

也就是说两者可以提升效率的场景是类似的,都是同步且没有竞争。只是轻量级锁还支持线程间的交替执行,偏向锁更像轻量级锁的一种特殊情况。


4、自旋锁

当多线程场景下出现多个线程同时获取同一锁资源时,synchronized 锁从轻量级锁膨胀为重量级锁。此时等待锁的线程不会立即切换到核心态阻塞,而是先尝试 “忙循环”(自旋) 一段时间,在自旋的过程中请求锁资源。如果获取到锁,就继续相下执行,否则切换到内核态阻塞。我们把这种通过“自旋”一段时间避免线程切换到内核态阻塞的处理方式称为自旋锁

自旋锁的背景:早期的 java 虚拟机开发者发现:绝大多数情况下线程占用锁的时间很短,为了等待这段很短的时间让 java 线程 切换到内核态挂起很不划算。如果物理机有一个以上的处理器,线程A 获取到锁在处理器a 上执行。线程B 获取锁失败时,可以先在处理器b 上自旋一段时间等待锁资源。如果此时线程A 释放锁资源,线程B 就可以获取锁向下执行,省去切换为核心态线程挂起操作所带的损耗。

自旋锁的缺陷:自旋锁从 jdk4 就已经引入,不过当时默认是关闭的,可以通过配置参数开启,当时自旋锁有以下缺陷:

  • 首先是处理器数量,自旋锁在单处理器模式下会降低性能:因为获取到锁的线程得不到执行,没有获取锁的线程抢占 CPU 资源执行自旋操作,这种处理方式反而会影响同步操作的效率。

  • 其次是自旋时间的设定:自旋时间过短时,效果不明显,绝大多数线程还是会切换到核心态挂起等待唤醒。自旋时间过长时,因为大量的线程执行自旋操作浪费CPU资源,当CPU自旋浪费过多时,可能还不如切换到内核态挂起等待唤醒,让获取到锁的线程优先执行。

自旋锁的优化:从 JDK6 开始,自旋锁设为默认开启状态并引入自适应模式。自适应模式意味着线程的自旋不再是固定的,而是根据前一个在同一个锁上自旋时间及锁的拥有者来决定:

  • 如果一个线程在一段时间自旋后成功获取锁资源,那么我们就让等待该锁资源的线程自旋比稍微长一段时间,可能是100次循环,可能更多。虚拟机认为此次资源会极有可能获取到锁资源

  • 如果对于某个锁,自旋经常失败,那么我们认为这个锁很难通过自旋获取到锁资源,在以后获取该锁的过程中,虚拟机会尝试省略掉自旋过程,直接切换到内核态挂起。

有了自适应的改造,自旋获取到锁资源的可能也越来越多,对整体性能的优化也越来越大。


5、锁消除

锁消除是指 java 虚拟机即时编译期间,对一些代码层面要求同步,但被检测到不可能存在共享竞争的锁进行消除

锁消除的主要判断依据源于数据是否会逃逸到其他线程被访问:如果判断在一段代码内,堆上所有数据都不会逃逸出去被其他线程访问,那么我们可以将这部分数据视为栈上数据,认为他们是线程私有的,因此也不需要同步处理。

在实际开发过程中,用到锁消除的场景特别多,下面我举一个简单的例子:

public String connectString(String s1, String s2, String s3) {
    return s1 + s2 + s3;
}

我们知道 java String 类是不可变类,对字符串的操作都会创建新的 String 对象来完成,因此编译器在编译上述代码时会自动优化:在 JDK 1.5 之前该段代码会被优化为一个 StringBuffer 对象连续调用 append() 方法。在JDK 1.5之后的版本被优化为 一个 StringBuilder 对象连续调用 append() 方法。也就是说上述方法中的代码很有可能变成这样:

public String connectString(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
	sb.append(s1);
	sb.append(s2);
	sb.append(s3);
	return sb.toString();
}

而 StringBuffer 的 append() 方法默认是被同步修饰的。从这里也就可以看出,绝大多数同步操作都是虚拟机在编译期间自动生成的,实际应用中的同步处理比我们代码中显示编写的同步处理多很多。

现在我们回到上述方法,append() 方法以调用对象为锁对象,也就是说上述 append() 方法每次调用都需要获取对象 sb 的锁对象。而 sb 对象从头到尾都被限制在方法 connectString() 中,也就是说该对象不会逃逸出去被其他线程获取,因此虽然这里有锁,也会在即时编译阶段被消除。


6、锁粗化

原则上,我们在编写代码时,锁的作用范围越小越好,最好控制到只在共享数据可能出现线程安全问题的代码处加锁。这样做的好处特别明显:获取到锁的线程只需要执行较少的代码就可以释放锁,等待锁的线程可以尽快获取锁资源,提升整体效率。

上述原则在绝大多数情况下都是成立的。但如果出现连续多个小同步代码块,或者说甚至将同步操作写在循环中时,频繁的加解锁操作又会给系统带来额外的性能损耗。

就拿上述锁消除中的代码来说,线程执行完该方法需要频繁的获取释放锁。虚拟机在检测到有这样一串零碎的操作都需要获取同一个锁对象时,将会把加锁同步的范围扩大至整个同步区域外部。也就是说上述代码只需要在第一次 append() 方法处加锁,最后一次 append() 方法处解锁即可。

我们把这种通过扩展锁范围,降低加解锁次数的优化方式称为锁粗化


参考
https://blog.csdn.net/javazejian/article/details/72828483
深入理解 Java 虚拟机

你可能感兴趣的:(JAVA锁)