介绍 — Java 6 中的线程优化
Sun、IBM、BEA 和其他公司在各自实现的 Java 6 虚拟机上都花费了大量的精力优化锁的管理和同步。诸如偏向锁(biased locking)、锁粗化(lock coarsening)、由逸出(escape)分析产生的锁省略、自适应自旋锁(adaptive spinning)这些特性,都是通过在应用程序线程之间更高效地共享数据,从而提高并发效率。尽管这些特性都是成熟且有趣的,但是问题在于:它们的承诺真的能实现么?在这篇由两部分组成的文章里,我将逐一探究这些特性,并尝试在单一线程基准的协助下,回答关于性能的问题。
悲观锁模型
Java 支持的锁模型绝对是悲观锁(其实,大多数线程库都是如此)。如果有两个或者更多线程使用数据时会彼此干扰,这种极小的风险也会强迫我们采用非常严厉的手段防止这种情况的发生——使用锁。然而研究表明,锁很少被占用。也就是说,一个访问锁的线程很少必须等待来获取它。但是请求锁的动作将会触发一系列的动作,这可能导致严重的系统开销,这是不可避免的。
我们的确还有其他的选择。举例来说,考虑一下线程安全的 StringBuffer 的用法。问问你自己:是否你曾经明知道它只能被一个线程安全地访问,还是坚持使用 StringBuffer,为什么不用 StringBuilder 代替呢?
知道大多数的锁都不存在竞争,或者很少存在竞争的事实对我们作用并不大,因为即使是两个线程访问相同数据的概率非常低,也会强迫我们使用锁,通过同步来保护被访问的数据。“我们真的需要锁么?”这个问题只有在我们将锁放在运行时环境的上下文中观察之后,才能最终给出答案。为了找到问题的答案,JVM 的开发者已经开始在 HotSpot 和 JIT 上进行了很多的实验性的工作。现在,我们已经从这些工作中获得了自适应自旋锁、偏向锁和以及两种方式的锁消除(lock elimination)——锁粗化和锁省略(lock elision)。在我们开始进行基准测试以前,先来花些时间回顾一下这些特性,这样有助于理解它们是如何工作的。
逸出分析 — 简析锁省略(Escape analysis - lock elision explained)
逸出分析是对运行中的应用程序中的全部引用的范围所做的分析。逸出分析是 HotSpot 分析工作的一个组成部分。如果 HotSpot(通过逸出分析)能够判断出指向某个对象的多个引用被限制在局部空间内,并且所有这些引用都不能“逸出”到这个空间以外的地方,那么 HotSpot 会要求 JIT 进行一系列的运行时优化。其中一种优化就是锁省略(lock elision)。如果锁的引用限制在局部空间中,说明只有创建这个锁的线程才会访问该锁。在这种条件下,同步块中的值永远不会存在竞争。这意味这我们永远不可能真的需要这把锁,它可以被安全地忽略掉。考虑下面的方法:
<span color="#7f0055"> public</span>String concatBuffer(String s1, String s2, String s3) {, StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); sb.append(s3); <span color="#7f0055">return</span> sb.toString(); }
图 1. 使用局部的 StringBuffer 连接字符串
如果我们观察变量 sb,很快就会发现它仅仅被限制在 concatBuffer 方法内部了。进一步说,到 sb 的所有引用永远不会“逸出”到 concatBuffer 方法之外,即声明它的那个方法。因此其他线程无法访问当前线程的 sb 副本。根据我们刚介绍的知识,我们知道用于保护 sb 的锁可以忽略掉。
从表面上看,锁省略似乎可以允许我们不必忍受同步带来的负担,就可以编写线程安全的代码了,前提是在同步的确是多余的情况下。锁省略是否真的能发挥作用呢?这是我们在后面的基准测试中将要回答的问题。
简析偏向锁(Biased locking explained)
大多数锁,在它们的生命周期中,从来不会被多于一个线程所访问。即使在极少数情况下,多个线程真的共享数据了,锁也不会发生竞争。为了理解偏向锁的优势,我们首先需要回顾一下如何获取锁(监视器)。
获取锁的过程分为两部分。首先,你需要获得一份契约. 一旦你获得了这份契约,就可以自由地拿到锁了。为了获得这份契约,线程必须执行一个代价昂贵的原子指令。释放锁同时就要释放契约。根据我们的观察,我们似乎需要对一些锁的访问进行优化,比如线程执行的同步块代码在一个循环体中。优化的方法之一就是将锁粗化,以包含整个循环。这样,线程只访问一次锁,而不必每次进入循环时都进行访问了。但是,这并非一个很好的解决方案,因为它可能会妨碍其他线程合法的访问。还有一个更合理的方案,即将锁偏向给执行循环的线程。
将锁偏向于一个线程,意味着该线程不需要释放锁的契约。因此,随后获取锁的时候可以不那么昂贵。如果另一个线程在尝试获取锁,那么循环线程只需要释放契约就可以了。Java 6 的 HotSpot/JIT 默认情况下实现了偏向锁的优化。
简析锁粗化(Lock coarsening explained)
另一种线程优化方式是锁粗化(或合并,merging)。当多个彼此靠近的同步块可以合并到一起,形成一个同步块的时候,就会进行锁粗化。该方法还有一种变体,可以把多个同步方法合并为一个方法。如果所有方法都用一个锁对象,就可以尝试这种方法。考虑图 2 中的实例。
<span color="#7f0055"> public static</span> String concatToBuffer(StringBuffer sb, String s1, String s2, String s3) { sb.append(s1); sb.append(s2); sb.append(s3); <span color="#7f0055">return</span> }
图 2. 使用非局部的 StringBuffer 连接字符串
在这个例子中,StringBuffer 的作用域是非局部的,可以被多个线程访问。所以逸出分析会判断出 StringBuffer 的锁不能安全地被忽略。如果锁刚好只被一个线程访问,则可以使用偏向锁。有趣的是,是否进行锁粗化,与竞争锁的线程数量是无关的。在上面的例子中,锁的实例会被请求四次:前三次是执行 append 方法,最后一次是执行 toString 方法,紧接着前一个。首先要做的是将这种方法进行内联。然后我们只需执行一次获取锁的操作(为整个方法),而不必像以前一样获取四次锁了。
这种做法带来的真正效果是我们获得了一个更长的临界区,它可能导致其他线程受到拖延从而降低吞吐量。正因为这些原因,一个处于循环内部的锁是不会被粗化到包含整个循环体的。
线程挂起 vs. 自旋(Thread suspending versus spinning)
在一个线程等待另外一个线程释放某个锁的时候,它通常会被操作系统挂起。操作在挂起一个线程的时候需要将它换出 CPU,而通常此时线程的时间片还没有使用完。当拥有锁的线程离开临界区的时候,挂起的线程需要被重新唤醒,然后重新被调用,并交换上下文,回到 CPU 调度中。所有这些动作都会给 JVM、OS 和硬件带来更大的压力。
在这个例子中,如果注意到下面的事实会很有帮助:锁通常只会被占有很短的一段时间。这就是说,如果能够等上一会儿,我们可以避免挂起线程的开销。为了让线程等待,我们只需将线程执行一个忙循环(自旋)。这项技术就是所谓的自旋锁。
当锁被占有的时间很短时,自旋锁的效果非常好。另一方面,如果锁被占有很长时间,那么自旋的线程只会消耗 CPU 而不做任何有用的工作,因此带来浪费。自从 JDK 1.4.2 中引入自旋锁以来,自旋锁被分为两个阶段,自旋十个循环(默认值),然后挂起线程。
自适应自旋锁(Adaptive spinning)
JDK 1.6 中引入了自适应自旋锁。自适应意味着自旋的时间不再固定了,而是取决于一个基于前一次在同一个锁上的自旋时间以及锁的拥有者的状态。如果在同一个锁对象上,自旋刚刚成功过,并且持有锁的线程正在运行中,那么自旋很有可能再次成功。进而它将被应用于相对更长的时间,比如 100 个循环。另一方面,如果自旋很少发生过,它将被遗弃,避免浪费任何 CPU 周期。
StringBuffer vs. StringBuilder 的基准测试
但是要想设计出一种方法来判断这些巧妙的优化方法到底多有效,这条路并不平坦。首要的问题就是如何设计基准测试。为了找到问题的答案,我决定去看看人们通常在代码中运用了哪些常见的技巧。我首先想到的是一个非常古老的问题:使用 StringBuffer 代替 String 可以减少多少开销?
一个类似的建议是,如果你希望字符串是可变的,就应该使用 StringBuffer。这个建议的缘由是非常明确的。String 是不可变的,但如果我们的工作需要字符串有很多变化,StringBuffer 将是一个开销较低的选择。有趣的是,在遇到 JDK 1.5 中的 StringBuilder(它是 StringBuffer 的非同步版本)后,这条建议就不灵了。由于 StringBuilder 与 StringBuffer 之间唯一的不同在于同步性,这似乎说明,测量两者之间性能差异的基准测试必须关注在同步的开销上。我们的探索从第一个问题开始,非竞争锁的开销如何?
这个基准测试的关键(如清单 1 所示)在于将大量的字符串拼接在一起。底层缓冲的初始容量足够大,可以包含三个待连接的字符串。这样我们可以将临界区内的工作最小化,进而重点测量同步的开销。
基准测试的结果
下图是测试结果,包括 EliminateLocks、UseBiasedLocking 和 DoEscapeAnalysis 的不同组合。
图 3. 基准测试的结果
关于结果的讨论
之所以使用非同步的 StringBuilder,是为了提供一个测量性能的基线。我也想了解一下各种优化是否真的能够影响 StringBuilder 的性能。正如我们所看到的,StringBuilder 的性能可以保持在一个不变的吞吐量水平上。因为这些技术的目标在于锁的优化,因此这个结果符合预期。在性能测试的另一栏中我们也可以看到,使用没有任何优化的同步的 StringBuffer,其运行效率比 StringBuilder 大概要慢三倍。
仔细观察图 3 的结果,我们可以注意到从左到右性能有一定的提高,这可以归功于 EliminateLocks。不过,这些性能的提升比起偏向锁来说又显得有些苍白。事实上,除了 C 列以外,每次运行时如果开启偏向锁最终都会提供大致相同的性能提升。但是,C 列是怎么回事呢?
在处理最初的数据的过程中,我注意到有一项测试在六个测试中要花费格外长的时间。由于结果的异常相当明显,因此基准测试似乎在报告两个完全不同的优化行为。经过一番考虑,我决定同时展示出高值和低值(B 列和 C 列)。由于没有更深入的研究,我只能猜测这里应用了一种以上的优化(很可能是两种),并且存在一些竞争条件,偏向锁大多时候会取胜,但不非总能取胜。如果另一种优化占优了,那么偏向锁的效果要么被抑制,要么就被延迟了。
这种奇怪的现象是逸出分析导致的。明确了这个基准测试的单线程化的本质后,我期待着逸出分析会消除锁,从而将 StringBuffer 的性能提到了与 StringBuilder 相同的水平。但是很明显,这并没有发生。还有另外一个问题;在我的机器上,每一次运行的时间片分配都不尽相同。更为复杂的是,我的几位同事在他们的机器上运行这些测试,得到的结果更混乱了。在有些时候,这些优化并没有将程序提速那么多。
前期的结论
尽管图 3 列出的结果比我所期望的要少,但确实可以从中看出各种优化能够除去锁产生的大部分开销。但是,我的同事在运行这些测试时产生了不同的结果,这似乎对测试结果的真实性提出了挑战。这个基准测试真的测量锁的开销了么?我们的结论成熟么?或者还有没有其他的情况?在本文的第二部分里,我们将会深入研究这个基准测试,力争回答这些问题。在这个过程中,我们会发现获取结果并不困难,困难的是判断出这些结果是否可以回答前面提出的问题。
<span color="#7f0055"><strong> public class</strong></span> LockTest { <span color="#7f0055"><strong>private static final int</strong></span> <span color="#2a00ff"><em>MAX</em></span> = 20000000; <span color="#808080">// 20 million</span> <span color="#7f0055"><strong>public static void</strong></span> main(String[] args) <span color="#7f0055"><strong>throws</strong></span> InterruptedException { <span color="#808080">// warm up the method cache</span> <span color="#7f0055"><strong>for (int</strong></span> i = 0; i < <span color="#2a00ff"><em>MAX</em></span>; i++) { concatBuffer(<span color="#2a00ff">"Josh", "James", "Duke"</span>); concatBuilder(<span color="#2a00ff">"Josh", "James", "Duke"</span>); } System.gc(); Thread.sleep(1000); System.<span color="#2a00ff"><em>out</em></span>.println(<span color="#2a00ff">"Starting test"</span>); <span color="#7f0055"><strong>long</strong></span> start = System.currentTimeMillis(); <span color="#7f0055"><strong>for (int</strong></span> i = 0; i < <span color="#2a00ff"><em>MAX</em></span>; i++) { concatBuffer(<span color="#2a00ff">"Josh", "James", "Duke"</span>); } <span color="#7f0055"><strong>long</strong></span> bufferCost = System.currentTimeMillis() - start; System.<span color="#2a00ff"><em>out</em></span>.println(<span color="#2a00ff">"StringBuffer: "</span> + bufferCost + <span color="#2a00ff">" ms."</span>); System.gc(); Thread.sleep(1000); start = System.currentTimeMillis(); <span color="#7f0055"><strong>for (int</strong></span> i = 0; i < <span color="#2a00ff"><em>MAX</em>;</span> i++) { concatBuilder(<span color="#2a00ff">"Josh", "James", "Duke"</span>); } <span color="#7f0055"><strong>long</strong></span> builderCost = System.currentTimeMillis() - start; System.<span color="#2a00ff"><em>out</em></span>.println(<span color="#2a00ff">"StringBuilder: "</span> + builderCost + " ms."); System.<span color="#2a00ff"><em>out</em></span>.println(<span color="#2a00ff">"Thread safety overhead of StringBuffer: "</span> + ((bufferCost * 10000 / (builderCost * 100)) - 100) + "%\n"); } <span color="#7f0055"><strong>public static</strong></span> String concatBuffer(String s1, String s2, String s3) { StringBuffer sb = <span color="#7f0055"><strong>new</strong></span> StringBuffer(); sb.append(s1); sb.append(s2); sb.append(s3); <span color="#7f0055"><strong>return</strong></span> sb.toString(); } <span color="#7f0055"><strong>public static</strong></span> String concatBuilder(String s1, String s2, String s3) { StringBuilder sb = <span color="#7f0055"><strong>new</strong></span> StringBuilder(); sb.append(s1); sb.append(s2); sb.append(s3); <span color="#7f0055"><strong>return</strong></span> sb.toString(); } }
运行基准测试
我运行这个测试的环境是:32 位的 Windows Vista 笔记本电脑,配有 Intel Core 2 Duo,使用 Java 1.6.0_04。请注意,所有的优化都是在 Server VM 上实现的。但这在我的平台上不是默认的 VM,它甚至不能在 JRE 中使用,只能在 JDK 中使用。为了确保我使用的是 Server VM,我需要在命令行上打开 -server 选项。其他的选项包括:
- -XX:+DoEscapeAnalysis, off by default
- -XX:+UseBiasedLocking, on by default
- -XX:+EliminateLocks, on by default
编译源代码,运行下面的命令,可以启动测试:
java-server -XX:+DoEscapeAnalysis LockTest
关于 Jeroen Borgers
Jeroen Borger 是 Xebia 的资深咨询师。Xebia 是一家国际 IT 咨询与项目组织公司,专注于企业级 Java 和敏捷开发。Jeroen 帮助他的客户攻克企业级 Java 系统的性能问题,他同时还是 Java 性能调试课程的讲师。他在从 1996 年开始就可以在不同的 Java 项目中工作,担任过开发者、架构师、团队 lead、质量负责人、顾问、审核员、性能测试和调试员。他从 2005 年开始专注于性能问题。
鸣谢
没有其他人的鼎力相助,是不会有这篇文章的。特别感谢下面的朋友:
Dr. Cliff Click,原 Sun 公司的 Server VM 主要架构师,现工作在 Azul System;他帮我分析,并提供了很多宝贵的资源。
Kirk Pepperdine,性能问题的权威,帮助我编辑文章。
David Dagastine,Sun JVM 性能组的 lead,他为我解释了很多问题,并把我引领到正确的方向。
我的很多 Xebia 的同事帮我进行了基准测试;
资源
Java concurrency in practice, Brian Goetz et all.
Java theory and practice: Synchronization optimizations in Mustang Did escape analysis escape from Java 6 Dave Dice’s Weblog Java SE 6 Performance White Paper
查看英文原文: Do Java 6 threading optimizations actually work? 。
志愿参与 InfoQ 中文站内容建设,请邮件至 editors@cn.infoq.com 。也欢迎大家到 InfoQ 中文站用户讨论组参与我们的线上讨论。
评论