狠狠撸

狠狠撸Share a Scribd company logo
Java 6 中的线程优化真的有效么?
作者 Jeroen Borgers 译者 韩锴 发布于 2008 年 10 月 22 日 上午 1 时 31 分

社区 Java 主题 性能和可伸缩性 标签 并行计算, JVM, 多线程, 并发, Java SE


介绍 — Java 6 中的线程优化
Sun、IBM、BEA 和其他公司在各自实现的 Java 6 虚拟机上都花费了大量的精力优化锁的管理和同步。诸如偏向
锁(biased locking)、锁粗化(lock coarsening)、由逸出(escape)分析产生的锁省略、自适应自旋锁
(adaptive spinning)这些特性,都是通过在应用程序线程之间更高效地共享数据,从而提高并发效率。尽管
这些特性都是成熟且有趣的,但是问题在于:它们的承诺真的能实现么?在这篇由两部分组成的文章里,我将逐
一探究这些特性,并尝试在单一线程基准的协助下,回答对于性能的问题。

相关厂商内容

InfoQ 中文站电子杂志《架构师》试刊号发布

Java 6 中的线程优化真的有效么?

技术沙龙:Solaris 网络虚拟机构架和 Grails 框架分析(11.1 杭州)

SOY Framework:Java 富客户端快速开发框架

IDC:《软件商成长路线图》白皮书免费下载




相关赞助商

InfoQ 中文站 Java 社区,关注公司 Java 社区的变化与创新,通过新闻、文章、视频访谈和演讲以及迷你书等为
中国 Java 技术社区提供一流资讯。

悲观锁模型

 Java 支持的锁模型绝对是悲观锁(其实,大多数线程库都是如此)。如果有两个或者更多线程使用数据时会
 彼此干扰,这种极小的风险也会强迫我们采用非常严厉的手段防止这种情况的发生——使用锁。然而研究表
 明,锁很少被占用。也就是说,一个访问锁的线程很少必须等待来获取它。但是请求锁的动作将会触发一系列
 的动作,这可能导致严重的系统开销,这是不可避免的。


我们的确还有其他的选择。举例来说,考虑一下线程安全的 StringBuffer 的用法。问问你自己:是否你曾经明知
道它只能被一个线程安全地访问,还是坚持使用 StringBuffer,为什么不用 StringBuilder 代替呢?

知道大多数的锁都不存在竞争,或者很少存在竞争的事实对我们作用并不大,因为即使是两个线程访问相同数据
的概率非常低,也会强迫我们使用锁,通过同步来保护被访问的数据。“我们真的需要锁么?”这个问题只有在我
们将锁放在运行时环境的上下文中观察之后,才能最终给出答案。为了找到问题的答案,JVM 的开发者已经开始
在 HotSpot 和 JIT 上进行了很多的实验性的工作。现在,我们已经从这些工作中获得了自适应自旋锁、偏向锁和
以及两种方式的锁消除(lock elimination)——锁粗化和锁省略(lock elision)。在我们开始进行基准测试以
前,先来花些时间回顾一下这些特性,这样有助于理解它们是如何工作的。
逸出分析 — 简析锁省略(Escape analysis - lock elision
explained)
逸出分析是对运行中的应用程序中的全部引用的范围所做的分析。逸出分析是 HotSpot 分析工作的一个组成部
分。如果 HotSpot(通过逸出分析)能够判断出指向某个对象的多个引用被限制在局部空间内,并且所有这些引
用都不能“逸出”到这个空间以外的地方,那么 HotSpot 会要求 JIT 进行一系列的运行时优化。其中一种优化就
是锁省略(lock elision)。如果锁的引用限制在局部空间中,说明只有创建这个锁的线程才会访问该锁。在
这种条件下,同步块中的值永远不会存在竞争。这意味这我们永远不可能真的需要这把锁,它可以被安全地忽略
掉。考虑下面的方法: publicString concatBuffer(String s1, String s2, String s3) {,

StringBuffer sb = new StringBuffer();

sb.append(s1);

sb.append(s2);

sb.append(s3);

return sb.toString();

 }

图 1. 使用局部的 StringBuffer 连接字符串

如果我们观察变量 sb,很快就会发现它仅仅被限制在 concatBuffer 方法内部了。进一步说,到 sb 的所有引用永
远不会“逸出”到 concatBuffer 方法之外,即声明它的那个方法。因此其他线程无法访问当前线程的 sb 副本。根
据我们刚介绍的知识,我们知道用于保护 sb 的锁可以忽略掉。

从表面上看,锁省略似乎可以允许我们不必忍受同步带来的负担,就可以编写线程安全的代码了,前提是在同步
的确是多余的情况下。锁省略是否真的能发挥作用呢?这是我们在后面的基准测试中将要回答的问题。


简析偏向锁(Biased locking explained)
大多数锁,在它们的生命周期中,从来不会被多于一个线程所访问。即使在极少数情况下,多个线程真的共享数
据了,锁也不会发生竞争。为了理解偏向锁的优势,我们首先需要回顾一下如何获取锁(监视器)。

获取锁的过程分为两部分。首先,你需要获得一份契约.一旦你获得了这份契约,就可以自由地拿到锁了。为了获
得这份契约,线程必须执行一个代价昂贵的原子指令。释放锁同时就要释放契约。根据我们的观察,我们似乎需
要对一些锁的访问进行优化,比如线程执行的同步块代码在一个循环体中。优化的方法之一就是将锁粗化,以包
含整个循环。这样,线程只访问一次锁,而不必每次进入循环时都进行访问了。但是,这并非一个很好的解决方
案,因为它可能会妨碍其他线程合法的访问。还有一个更合理的方案,即将锁偏向给执行循环的线程。

将锁偏向于一个线程,意味着该线程不需要释放锁的契约。因此,随后获取锁的时候可以不那么昂贵。如果另一
个线程在尝试获取锁,那么循环线程只需要释放契约就可以了。Java 6 的 HotSpot/JIT 默认情况下实现了偏向锁
的优化。


简析锁粗化(Lock coarsening explained)
另一种线程优化方式是锁粗化(或合并,merging)。当多个彼此靠近的同步块可以合并到一起,形成一个同步
块的时候,就会进行锁粗化。该方法还有一种变体,可以把多个同步方法合并为一个方法。如果所有方法都用一
个锁对象,就可以尝试这种方法。考虑图 2 中的实例。 public static String
concatToBuffer(StringBuffer sb, String s1, String s2, String s3) {
sb.append(s1);

sb.append(s2);

sb.append(s3);

return

}

图 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 列出的结果比我所期望的要少,但确实可以从中看出各种优化能够除去锁产生的大部分开销。但是,我
的同事在运行这些测试时产生了不同的结果,这似乎对测试结果的真实性提出了挑战。这个基准测试真的测量锁
的开销了么?我们的结论成熟么?或者还有没有其他的情况?在本文的第二部分里,我们将会深入研究这个基准
测试,力争回答这些问题。在这个过程中,我们会发现获取结果并不困难,困难的是判断出这些结果是否可以回
答前面提出的问题。 public class LockTest {

private static final int MAX = 20000000; // 20 million




public static void main(String[] args) throws InterruptedException {

// warm up the method cache

for (int i = 0; i < MAX; i++) {

    concatBuffer("Josh", "James", "Duke");

    concatBuilder("Josh", "James", "Duke");

}




System.gc();

Thread.sleep(1000);




System.out.println("Starting test");

long start = System.currentTimeMillis();

for (int i = 0; i < MAX; i++) {

    concatBuffer("Josh", "James", "Duke");

}

long bufferCost = System.currentTimeMillis() - start;
System.out.println("StringBuffer: " + bufferCost + " ms.");




System.gc();

Thread.sleep(1000);




start = System.currentTimeMillis();

for (int i = 0; i < MAX; i++) {

    concatBuilder("Josh", "James", "Duke");

}

long builderCost = System.currentTimeMillis() - start;

System.out.println("StringBuilder: " + builderCost + " ms.");

System.out.println("Thread safety overhead of StringBuffer: "

+ ((bufferCost * 10000 / (builderCost * 100)) - 100) + "%n");




}




public static String concatBuffer(String s1, String s2, String s3) {

    StringBuffer sb = new StringBuffer();

    sb.append(s1);

    sb.append(s2);

    sb.append(s3);

    return sb.toString();

}




public static String concatBuilder(String s1, String s2, String s3) {

    StringBuilder sb = new StringBuilder();

    sb.append(s1);
sb.append(s2);

        sb.append(s3);

        return 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


Java 6 中的线程优化真的有效么? ——第二部分
作者 Jeroen Borgers 译者 韩锴 发布于 2008 年 10 月 29 日 上午 2 时 54 分

社区 Java 主题 性能和可伸缩性 标签 并行计算, JVM, 多线程, 并发, Java SE

在本文的第一部分中,我们通过一个单一线程的基准,比较了同步的 StringBuffer 和非同步的 StringBuilder 之
间的性能。从最初的基准测试结果来看,偏向锁提供了最佳的性能,比其他的优化方式更有效。测试的结果似乎
表明获取锁是一项昂贵的操作。但是在得出最终的结论之前,我决定先对结果进行检验:我请我的同事们在他们
的机器上运行了这个测试。尽管大多数结果都证实了我的测试结果,但是有一些结果却完全不同。在本文的第二
部分中,我们将更深入地看一看用于检验测试结果的技术。最后我们将回答现实中的问题:为什么在不同的处理
器上的锁开销差异如此巨大?

相关厂商内容

InfoQ 中文站电子杂志《架构师》试刊号发布

Java 6 中的线程优化真的有效么?

技术沙龙:Solaris 网络虚拟机构架和 Grails 框架分析(11.1 杭州)

SOY Framework:Java 富客户端快速开发框架

IDC:《软件商成长路线图》白皮书免费下载
相关赞助商

InfoQ 中文站 Java 社区,关注公司 Java 社区的变化与创新,通过新闻、文章、视频访谈和演讲以及迷你书等为
中国 Java 技术社区提供一流资讯。


基准测试中的陷阱
通过一个基准测试,尤其是一个“小规模基准测试”(microbenchmark),来回答这个问题是非常困难的。多半
情况下,基准测试会出现一些与你期望测量的完全不同的情景。即使当你要测量影响这个问题的因素时,结果也
会被其他的因素所影响。有一点在这个实验开始之初就已经很明确了,即这个基准测试需要由其他人全面地进行
审查,这样我才能避免落入报告无效基准测试数据的陷阱中。除了其他人的检查以外,我还使用了一些工具和技
术来校验结果,这些我会在下面的几节中谈到。


结果的统计处理
大多数计算机所执行的操作都会在某一固定的时间内完成。就我的经验而言,我发现即使是那些不确定性的操
作,在大多数条件下基本上也能在固定的时间内完成。正是根据计算的这种特性,我们可以使用一种工具,它通
过测量让我们了解事情何时开始变得不正常了。这样的工具是基于统计的,其测量结果会有些出入。这就是说,
即使看到了一些超过正常水平的报告值,我也不会做过多过的解释的。原因是这样的,如果我提供了指令数固定
的 CPU,而它并没有在相对固定的时间内完成的话,就说明我的测量受到了一些外部因素的影响。如果测试结果
出现了很大的异常,则意味着我必须找到这个外部的影响进而解决它。

尽管这些异常效果会在小规模基准测试中被放大,但它不至于会影响大规模的基准测试。对于大规模的基准测试
来说,被测量的目标应用程序的各个方面会彼此产生干扰,这会带来一些异常。但是异常仍然能够提供一些很有
益的信息,可以帮助我们对干扰级别作出判断。在稳定的负荷下,我并不会对个别异常情况感到意外;当然,异
常情况不能过多。对于那些比通常结果大一些或小一些的结果,我会观察测试的运行情况,并将它视为一种信
号:我的基准测试尚未恰当地隔离或者设置好。这样对相同的测试进行不同的处理,恰恰说明了全面的基准测试
与小规模基准测试之间的不同。

最后一点,到此为止仍然不能说明你所测试的就是你所想的。这至多只能说明,对于最终的问题,这个测试是最
有可能是正确的。

预热方法的缓存

 JIT 会编译你的代码,这也是众多影响基准测试的行为之一。Hotspot 会频繁地检查你的程序,寻找可以应用
 某些优化的机会。当找到机会后,它会要求 JIT 编译器重新编译问题中的某段代码。此时它会应用一项技术,
 即当前栈替换(On Stack Replacement,OSR),从而切换到新代码的执行上。执行 OSR 时会对测试产生
 各种连锁影响,包括要暂停线程的执行。当然,所有这样的活动都会干扰到我们的基准测试。这类干扰会使测
 试出现偏差。我们手头上有两款工具,可以帮助我们标明代码何时受到 JIT 的影响了。第一个当然是测试中出
 现的差异,第二个是-XX:-PrintCompilation 标记。幸运的是,如果不是所有的代码在测试的早期就进行 JIT
 化处理,那么我们可以将它视为另外一种启动时的异常现象。我们需要做的就是在开始测量前,先不断地运行
 基准测试,直到所有代码都已经完成了 JIT 化。这个预热的阶段通常被称为“预热方法的缓存 ”。


大多数 JVM 会同时运行在解释的与本机的模式中。这就是所谓的混合模式执行。随着时间的流逝,Hotspot 和
JIT 会根据收集的信息将解释型代码转化为本机代码。Hotspot 为了决定应该使用哪种优化方案,它会抽样一些调
用和分支。一旦某个方法达到了特定的阈值后,它会通知 JIT 生成本机代码。这个阈值可以通过-
XX:CompileThreshold 标记来设定。例如,设定-XX:CompileThreshold=10000,Hotspot 会在代码被执行
10,000 次后将它编译为本机代码。
堆管理

下一个需要考虑的是垃圾收集,或者更广为人知的名字—堆管理。在任何应用程序执行的过程中,都会定期地发
生很多种内存管理活动。它们包括:重新划分栈空间大小、回收不再被使用的内存、将数据从一处移到另一处等
等。所有这些行为都导致 JVM 影响你的应用程序。我们面对的问题是:基准测试中是否需要将内存维护或者垃圾
回收的时间包括进来?问题的答案取决于你要解决的问题的种类。在本例中,我只对获取锁的开销感兴趣,也就
是说,我必须确保测试中不能包含垃圾回收的时间。这一次,我们又能够通过异常的现象来发现影响测试的因
素,一旦出现这种问题,垃圾回收都是一个可能的怀疑对象。明确问题的最佳方式是使用 -verbose:gc 标志,开
启 GC 的日志功能。

在这个基准测试中,我做了大量的 String、StringBuffer 和 StringBuilder 操作。在每次运行的过程中大
概会创建 4 千万个对象。对于这样一种数量级的对象群来说,垃圾回收毫无疑问会成为一个问题。我使用两项技
术来避免。第一,提高堆空间的大小,防止在一个迭代中出现垃圾回收。为此,我利用了如下的命令行: >java
-server -XX:+EliminateLocks -XX:+UseBiasedLocking -verbose:gc -XX:NewSize=1500m -
XX:SurvivorRatio=200000 LockTest

然后,加入清单 1 的代码,它为下一次迭代准备好堆空间。 System.gc();

Thread.sleep(1000);

清单 1. 运行 GC,然后进行短暂的休眠。

休眠的目的在于给垃圾回收器充分的时间,在释放其他线程之后完成工作。有一点需要注意:如果没有 CPU 任何
活动,某些处理器会降低时钟频率。因此,尽管 CPU 时钟会自旋等待,但引入睡眠的同时也会引入延迟。如果你
的处理器支持这种特性,你可能必须要深入到硬件并且关闭掉“节能”功能才行。

前面使用的标签并不能阻止 GC 的运行。它只表示在每一次测试用例中只运行一次 GC。这一次的暂停非常小,它
产生的开销对最终结果的影响微乎其微。对于我们这个测试来说,这已经足够好了。

偏向锁延迟

还有另外一种因素会对测试结果产生重要的影响。尽管大多数优化都会在测试的早期发生,但是由于某些未知的
原因,偏向锁只发生在测试开始后的三到四秒之后。我们又要重述一遍,异常行为再一次成为判断是否存在问题
的重要标准了。-XX:+TraceBiasedLocking 标志可以帮助我们追踪这个问题。还可以延长预热时间来克服偏向
锁导致的延迟。

Hotspot 提供的其他优化

 Hotspot 不会在完成一次优化后就停止对代码的改动。相反,它会不断地寻找更多的机会,提供进一步的优
 化。对于锁来说,由于很多优化行为违反了 Java 存储模型中描述的规范,所以它们是被禁止的。然而,如果
 锁已经被 JIT 化了,那么这些限制很快就会消失。在这个单线程化的基准测试中,Hotspot 可以非常安全地将
 锁省略掉。这样就会为其他的优化行为打开大门;比如方法内联、提取循环不变式以及死代码的清除。


如果仔细思考下面的代码,可以发现 A 和 B 都是不变的,我们应该把它抽取出来放到循环外面,并引入第三个变
量,这样可以避免重复的计算,正如清单 3 中所示的那样。通常,这都是程序员的事情。但是 Hotspot 可以识
别出循环不变式并把它们抽取到循环体外面。因此,我们可以把代码写得像清单 2 那样,但是它执行时其实更类
似于清单 3 的样子。 int A = 1;

 int B = 2;

 int sum = 0;
for (int i =   0; i < someThing; i++) sum += A + B;

清单 2 循环中包含不变式           int A = 1;

  int B = 2;

  int sum = 0;

  int invariant = A + B;

  for (int i =   0; i < someThing; i++) sum += invariant;

清单 3 不变式已抽取到循环之外

这些优化真的应该允许么?还是我们应该做一些事情防止它的发生?这个有待商榷。但至少,我们应该知道是否
应用了这些优化。我们绝对要避免“死代码消除”这种优化的出现,否则它会彻底扰乱我们的测试!Hotspot 能够
识别出我们没有使用 concatBuffer 和 concatBuilder 操作的结果。或者可以说,这些操作没有边界效应。因此
没有任何理由执行这些代码。一旦代码被标识为已“死亡”,JIT 就会除去它。好在我的基准测试迷惑了 Hotspot,
因此它并没有识别出这种优化,至少目前还没有。

如果由于锁的存在而抑制了内联,反之没有锁就可能出现内联,那么我们要确保在测试结果中没有包含额外的方
法调用。现在可以用到的一种技术是引入一个接口(清单 4)来迷惑 Hotspot。 public interfaceConcat
{

       String concatBuffer(String s1, String s2, String s3);

       String concatBuilder(String s1, String s2, String s3);


public class LockTest implements Concat {




...}

清单 4 使用接口防止方法内联

防止内联的另一种方法是使用命令行选项-XX:-Inline。我已经验证,方法内联并没有给基准测试的报告带来任何
不同。

执行栈输出

最后,请看下面的输出结果,它使用了下面的命令行标识。 >java -server -XX:+DoEscapeAnalysis
-XX:+PrintCompilation -XX:+EliminateLocks -XX:+UseBiasedLocking -XX:
+TraceBiasedLocking LockTest
图 1 基准测试的执行栈输出

JVM 默认会启动 12 个线程,包括:主线程、对象引用处理器、Finalize、Attach 监听器等等。上图中第一个灰
色段显示的是这些线程的对齐,它们可以使用偏向锁(注意所有地址都以 00 结尾)。你尽管忽略可以忽略它们。
接下来的黄色段包含了已编译方法的信息。我们看一下第 5 行和 12 行,能够发现它们都标记了一个额外的“s”。
表 1 的信息告诉我们这些方法都是同步的。包含了“%”的各行已经使用了 OSR。红色的行是偏向锁被激活的地
方。最底下的蓝绿色框是基准测试开始计时的地方。从记录基准测试开始时间的输出中可以看到,所有编译都已
经发生了。这说明前期的预热阶段足够长了。如果你想了解日志输出规范的更多细节,可以参考这个页面和这篇
文章。
表 1 编译示例码

单核系统下的结果

尽管我的多数同事都在使用 Intel Core 2 Duo 处理器,但还是有一小部分人使用陈旧的单核机器。在这些陈旧的
机器上,StringBuffer 基准测试的结果和 StringBuilder 实现的结果几乎相同。由于产生这种不同可能是多种因
素使然,因此我需要另外一个测试,尝试忽略尽可能多的可能性。最好的选择是,在 BIOS 中关闭 Core 2 Duo
中的一个核,然后重新运行基准测试。运行的结果如图 2 所示。




图 2 单核系统的性能

在多核环境下运行的时候,关闭了三种优化行为后获得了一个基准值。这次,StringBuilder 又保持了平稳的吞吐
量。更有趣的是,尽管 StringBuffer 比 StringBuilder 要稍慢,但是在多核平台下,StringBuffer 的性能更接近
于 StringBuilder。从这个测试开始我们将一步步勾勒出基准测试的真实面目。

在多核的世界中,线程间共享数据的现实呈现出一种全新的面貌。所有现代的 CPU 必须使用本地存储的缓存,将
获取指令和数据的延迟降到最低。当我们使用锁的时候,会导致一次存储关卡(Barrier)被插入到执行路径中。
存储关卡像一个信号,它通知 CPU 此时必须和其他所有的 CPU 进行协调,以此获得最新的数值。为了完成这个
任务,CPU 之间将要彼此通讯,从而导致每个处理器暂定当前正在运行的应用程序线程。这个过程要花多少时间
已经成了 CPU 存储模型的指标之一。越是保守的存储模型,越是线程安全的,但是它们在协调各个处理器核的时
候也要花费更多的时间。在 Core 2 Duo 上,第二个核将固定的运行基准从 3731ms 提高到了 6574ms,或者说
增加了 176%。很明显,Hotspot 所提供的任何帮助都能明显改进我们的应用程序的总体性能。


逸出分析真的起作用了么?
现在,还有一种优化很明显会起作用,但是我们还没有考虑,它就是锁省略。锁省略是最近才实现的技术,而且
它依赖于逸出分析,后者是一种 Profiling 技术,其自身也是刚刚才实现的。为了稳妥一些,各公司和组织都宣称
这些技术只有在有限的几种情况下才起作用。比如,在一个简单的循环里,对一个局部变量执行递增,且该操作
被包含在一个同步块内,由一个局部的锁保护着。这种情况下逸出分析是起作用的
[http://blog.nirav.name/2007_02_01_archive.html]。同时它在 Mont Carlo 的 Scimark2 基准测试中可
以工作(参见[http://math.nist.gov/scimark2/index.html])。

将逸出分析包含在测试中

那么,为什么逸出分析可以用于上述的情况中,却不能用于我们的基准测试中?我曾经尝试过将 StringBuffer 和
StringBuilder 的部分方法进行内联。我也修改过代码,希望可以强制逸出分析运行。我想看到锁最终被忽略,而
性能可以获得大幅提升。老实说,处理这个基准测试的过程既困惑,又让人倍感挫折。我必须无数次地在编辑器
中使用 ctrl-z,以便恢复到前面一个我认为逸出分析应该起作用的版本,但是却不知由于什么原因,逸出分析却突
然不起作用了。有时,锁省略却又会莫名其妙地出现。

最后,我认识到激活锁省略似乎和被锁对象的数据大小有关系。你运行清单 2 的代码就会看到这一点。正如你所
看到的,无论运行多少次,结果都毫无区别,这说明 DoEscapeAnalysi 没有产生影响。 >java -server -
XX:-DoEscapeAnalysis EATest

thread unsafe: 941 ms.

thread safe: 1960 ms.

Thread safety overhead: 208%




>java -server -XX:+DoEscapeAnalysis EATest

thread unsafe: 941 ms.

thread safe: 1966 ms.

Thread safety overhead: 208%

在下面的两次运行中,我移除了 ThreadSafeObject 类中一个没有被用过的域。如你所见,当开启了逸出分
析,所有性能有了很大的提高。 >java -server -XX:-DoEscapeAnalysis EATest

thread unsafe: 934 ms.

thread safe: 1962 ms.

Thread safety overhead: 210%
>java -server -XX:+DoEscapeAnalysis EATest

thread unsafe: 933 ms.

thread safe: 1119 ms.

Thread safety overhead: 119%

逸出分析的数目在 Windows 和 Linux 上都能看到。然而在 Mac OS X 上,即使有额外未被使用的变量也不会有
任何影响,任何版本的基准测试的结果都是 120%。这让我不由地相信在 Mac OS X 上有效性的范围比其他系统
更广泛。我猜测这是由于它的实现比较保守,根据不同条件(比如锁对象数据大小和其他 OS 特定的特性)及早
地关掉了它。


结论
当我刚开始这个实验,解释应用各种锁优化的 Hotspot 的有效性的时候,我估计它将花费我几个小时的时间,最
终这会丰富我的 blog 的内容。但是就像其他的基准测试一样,对结果进行验证和解释的过程最终耗费了几周的时
间。同样,我也与很多专家进行合作,他们分别花费了大量时间检查结果,并发表他们的见解。即使在这些工作
完成以后,仍然很难说哪些优化起作用了,而哪些没有起作用。尽管这篇文章引述了一组测试结果,但它们是特
定我的硬件和系统的。大家可以考虑是否能在自己的系统上看到相同类型的测试结果。另外,我最初认为这不过
是个小规模基准测试,但是后来它逐渐既要满足我,也要满足所有审核代码的人,而且去掉了 Hotspot 不必要的
优化。总之,这个实验的复杂度远远地超出了我的预期。

如果你需要在多核机器上运行多线程的应用程序,并且关心性能,那么很明显,你需要不断地更新所使用的 JDK
到最新版本。很多(但不是全部)前面的版本的优化都可以在最新的版本中获得兼容。你必须保证所有的线程优
化都是激活的。在 JDK 6.0 中,它们默认是激活的。但是在 JDK 5.0 中,你需要在命令行中显式地设置它们。如
果你在多核机器上运行单线程的应用程序,就要禁用除第一个核以外所有核的优化,这样会使应用程序运行得更
快。

在更低级的层面上,单核系统上锁的开销远远低于双核处理器。不同核之间的协调,比如存储关卡语义,通过关
掉一个核运行的测试结果看,很明显会带来系统开销。我们的确需要线程优化,以此降低这一开销。幸运的是,
锁粗化和(尤其是)偏向锁对于基准测试的性能确实有明显的影响。我也希望逸出分析与锁省略一起更能够做到
更好,产生更多的影响。这项技术会起作用,可只是在很少的情况下。客观地说,逸出分析仍然还处于它的初级
阶段,还需要大量的时间才能变得成熟。

最后的结论是,最权威的基准测试是让你的应用程序运行在自己的系统上。当你的多线程应用的性能没有符合你
的期望的时候,这篇文章能够为你提供了一些思考问题的启示。而这就是此文最大的价值所在。


对于 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

清单 1.




 public class LockTest {

            private static final int MAX = 20000000; // 20 million

            public static void main(String[] args) throws InterruptedException {

        // warm up the method cache

        for (int i = 0; i < MAX; i++) {

                             concatBuffer("Josh", "James", "Duke");

                             concatBuilder("Josh", "James", "Duke");

        }

                   System.gc();

                   Thread.sleep(1000);

                            long start = System.currentTimeMillis();

                   for (int i = 0; i < MAX; i++) {

                             concatBuffer("Josh", "James", "Duke");

                   }

                   long bufferCost = System.currentTimeMillis() - start;
System.out.println("StringBuffer: " + bufferCost + " ms.");

               System.gc();

               Thread.sleep(1000);

               start = System.currentTimeMillis();

               for (int i = 0; i < MAX; i++) {

               concatBuilder("Josh", "James", "Duke");

               }




long builderCost = System.currentTimeMillis() - start;




               System.out.println("StringBuilder: " + builderCost + " ms.");

               System.out.println("Thread safety overhead of StringBuffer: "




+ ((bufferCost * 10000 / (builderCost * 100)) - 100) + "%n");




    }




public static String concatBuffer(String s1, String s2, String s3) {




            StringBuffer sb = new StringBuffer();

            sb.append(s1);

            sb.append(s2);

            sb.append(s3);

            return sb.toString();

     }

public static String concatBuilder(String s1, String s2, String s3) {
StringBuilder sb = new StringBuilder();

               sb.append(s1);

               sb.append(s2);

               sb.append(s3);

               return sb.toString();




}




}




清单 2.

public class EATest {

    private static final int MAX = 200000000; // 200 million

    public static final void main(String[] args) throws InterruptedException {

        // warm up the method cache

        sumThreadUnsafe();

        sumThreadSafe();

        sumThreadUnsafe();

        sumThreadSafe();

        System.out.println("Starting test");

        long start;

        start = System.currentTimeMillis();

        sumThreadUnsafe();

        long unsafeCost = System.currentTimeMillis() - start;

        System.out.println(" thread unsafe: " + unsafeCost + " ms.");
start = System.currentTimeMillis();

    sumThreadSafe();

    long safeCost = System.currentTimeMillis() - start;

    System.out.println(" thread safe: " + safeCost + " ms.");

System.out.println("Thread safety overhead: "




            + ((safeCost * 10000 / (unsafeCost * 100)) - 100) + "%n");

}

public static int sumThreadSafe() {

    String[] names = new String[] { "Josh", "James", "Duke", "B" };

        ThreadSafeObject ts = new ThreadSafeObject();

        int sum = 0;

    for (int i = 0; i < MAX; i++) {

    sum += ts.test(names[i % 4]);

    }

    return sum;

}

public static int sumThreadUnsafe() {

    String[] names = new String[] { "Josh", "James", "Duke", "B" };

    ThreadUnsafeObject tus = new ThreadUnsafeObject();

    int sum = 0;

    for (int i = 0; i < MAX; i++) {

           sum += tus.test(names[i % 4]);

    }

    return sum;

}
}

final class ThreadUnsafeObject {

        // private int index = 0;

        private int count = 0;

        private char[] value = new char[1];

        public int test(String str) {

             value[0] = str.charAt(0);

             count = str.length();

            return count;

        }

}

final class ThreadSafeObject {

        private int index = 0; // remove this line, or just the '= 0' and it will go faster!!!

        private int count = 0;

        private char[] value = new char[1];

        public synchronized int test(String str) {

             value[0] = str.charAt(0);

             count = str.length();

            return count;

    }

}

More Related Content

Java 6中的线程优化真的有效么?

  • 1. Java 6 中的线程优化真的有效么? 作者 Jeroen Borgers 译者 韩锴 发布于 2008 年 10 月 22 日 上午 1 时 31 分 社区 Java 主题 性能和可伸缩性 标签 并行计算, JVM, 多线程, 并发, Java SE 介绍 — Java 6 中的线程优化 Sun、IBM、BEA 和其他公司在各自实现的 Java 6 虚拟机上都花费了大量的精力优化锁的管理和同步。诸如偏向 锁(biased locking)、锁粗化(lock coarsening)、由逸出(escape)分析产生的锁省略、自适应自旋锁 (adaptive spinning)这些特性,都是通过在应用程序线程之间更高效地共享数据,从而提高并发效率。尽管 这些特性都是成熟且有趣的,但是问题在于:它们的承诺真的能实现么?在这篇由两部分组成的文章里,我将逐 一探究这些特性,并尝试在单一线程基准的协助下,回答对于性能的问题。 相关厂商内容 InfoQ 中文站电子杂志《架构师》试刊号发布 Java 6 中的线程优化真的有效么? 技术沙龙:Solaris 网络虚拟机构架和 Grails 框架分析(11.1 杭州) SOY Framework:Java 富客户端快速开发框架 IDC:《软件商成长路线图》白皮书免费下载 相关赞助商 InfoQ 中文站 Java 社区,关注公司 Java 社区的变化与创新,通过新闻、文章、视频访谈和演讲以及迷你书等为 中国 Java 技术社区提供一流资讯。 悲观锁模型 Java 支持的锁模型绝对是悲观锁(其实,大多数线程库都是如此)。如果有两个或者更多线程使用数据时会 彼此干扰,这种极小的风险也会强迫我们采用非常严厉的手段防止这种情况的发生——使用锁。然而研究表 明,锁很少被占用。也就是说,一个访问锁的线程很少必须等待来获取它。但是请求锁的动作将会触发一系列 的动作,这可能导致严重的系统开销,这是不可避免的。 我们的确还有其他的选择。举例来说,考虑一下线程安全的 StringBuffer 的用法。问问你自己:是否你曾经明知 道它只能被一个线程安全地访问,还是坚持使用 StringBuffer,为什么不用 StringBuilder 代替呢? 知道大多数的锁都不存在竞争,或者很少存在竞争的事实对我们作用并不大,因为即使是两个线程访问相同数据 的概率非常低,也会强迫我们使用锁,通过同步来保护被访问的数据。“我们真的需要锁么?”这个问题只有在我 们将锁放在运行时环境的上下文中观察之后,才能最终给出答案。为了找到问题的答案,JVM 的开发者已经开始 在 HotSpot 和 JIT 上进行了很多的实验性的工作。现在,我们已经从这些工作中获得了自适应自旋锁、偏向锁和 以及两种方式的锁消除(lock elimination)——锁粗化和锁省略(lock elision)。在我们开始进行基准测试以 前,先来花些时间回顾一下这些特性,这样有助于理解它们是如何工作的。
  • 2. 逸出分析 — 简析锁省略(Escape analysis - lock elision explained) 逸出分析是对运行中的应用程序中的全部引用的范围所做的分析。逸出分析是 HotSpot 分析工作的一个组成部 分。如果 HotSpot(通过逸出分析)能够判断出指向某个对象的多个引用被限制在局部空间内,并且所有这些引 用都不能“逸出”到这个空间以外的地方,那么 HotSpot 会要求 JIT 进行一系列的运行时优化。其中一种优化就 是锁省略(lock elision)。如果锁的引用限制在局部空间中,说明只有创建这个锁的线程才会访问该锁。在 这种条件下,同步块中的值永远不会存在竞争。这意味这我们永远不可能真的需要这把锁,它可以被安全地忽略 掉。考虑下面的方法: publicString concatBuffer(String s1, String s2, String s3) {, StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); sb.append(s3); return sb.toString(); } 图 1. 使用局部的 StringBuffer 连接字符串 如果我们观察变量 sb,很快就会发现它仅仅被限制在 concatBuffer 方法内部了。进一步说,到 sb 的所有引用永 远不会“逸出”到 concatBuffer 方法之外,即声明它的那个方法。因此其他线程无法访问当前线程的 sb 副本。根 据我们刚介绍的知识,我们知道用于保护 sb 的锁可以忽略掉。 从表面上看,锁省略似乎可以允许我们不必忍受同步带来的负担,就可以编写线程安全的代码了,前提是在同步 的确是多余的情况下。锁省略是否真的能发挥作用呢?这是我们在后面的基准测试中将要回答的问题。 简析偏向锁(Biased locking explained) 大多数锁,在它们的生命周期中,从来不会被多于一个线程所访问。即使在极少数情况下,多个线程真的共享数 据了,锁也不会发生竞争。为了理解偏向锁的优势,我们首先需要回顾一下如何获取锁(监视器)。 获取锁的过程分为两部分。首先,你需要获得一份契约.一旦你获得了这份契约,就可以自由地拿到锁了。为了获 得这份契约,线程必须执行一个代价昂贵的原子指令。释放锁同时就要释放契约。根据我们的观察,我们似乎需 要对一些锁的访问进行优化,比如线程执行的同步块代码在一个循环体中。优化的方法之一就是将锁粗化,以包 含整个循环。这样,线程只访问一次锁,而不必每次进入循环时都进行访问了。但是,这并非一个很好的解决方 案,因为它可能会妨碍其他线程合法的访问。还有一个更合理的方案,即将锁偏向给执行循环的线程。 将锁偏向于一个线程,意味着该线程不需要释放锁的契约。因此,随后获取锁的时候可以不那么昂贵。如果另一 个线程在尝试获取锁,那么循环线程只需要释放契约就可以了。Java 6 的 HotSpot/JIT 默认情况下实现了偏向锁 的优化。 简析锁粗化(Lock coarsening explained) 另一种线程优化方式是锁粗化(或合并,merging)。当多个彼此靠近的同步块可以合并到一起,形成一个同步 块的时候,就会进行锁粗化。该方法还有一种变体,可以把多个同步方法合并为一个方法。如果所有方法都用一 个锁对象,就可以尝试这种方法。考虑图 2 中的实例。 public static String concatToBuffer(StringBuffer sb, String s1, String s2, String s3) {
  • 3. sb.append(s1); sb.append(s2); sb.append(s3); return } 图 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 的非同步版本)后,这条建议就不灵了。由于
  • 4. StringBuilder 与 StringBuffer 之间唯一的不同在于同步性,这似乎说明,测量两者之间性能差异的基准测试必 须关注在同步的开销上。我们的探索从第一个问题开始,非竞争锁的开销如何? 这个基准测试的关键(如清单 1 所示)在于将大量的字符串拼接在一起。底层缓冲的初始容量足够大,可以包含 三个待连接的字符串。这样我们可以将临界区内的工作最小化,进而重点测量同步的开销。 基准测试的结果 下图是测试结果,包括 EliminateLocks、UseBiasedLocking 和 DoEscapeAnalysis 的不同组合。 图 3. 基准测试的结果 对于结果的讨论 之所以使用非同步的 StringBuilder,是为了提供一个测量性能的基线。我也想了解一下各种优化是否真的能够影 响 StringBuilder 的性能。正如我们所看到的,StringBuilder 的性能可以保持在一个不变的吞吐量水平上。因为 这些技术的目标在于锁的优化,因此这个结果符合预期。在性能测试的另一栏中我们也可以看到,使用没有任何 优化的同步的 StringBuffer,其运行效率比 StringBuilder 大概要慢三倍。 仔细观察图 3 的结果,我们可以注意到从左到右性能有一定的提高,这可以归功于 EliminateLocks。不过,这些 性能的提升比起偏向锁来说又显得有些苍白。事实上,除了 C 列以外,每次运行时如果开启偏向锁最终都会提供 大致相同的性能提升。但是,C 列是怎么回事呢? 在处理最初的数据的过程中,我注意到有一项测试在六个测试中要花费格外长的时间。由于结果的异常相当明 显,因此基准测试似乎在报告两个完全不同的优化行为。经过一番考虑,我决定同时展示出高值和低值(B 列和 C
  • 5. 列)。由于没有更深入的研究,我只能猜测这里应用了一种以上的优化(很可能是两种),并且存在一些竞争条 件,偏向锁大多时候会取胜,但不非总能取胜。如果另一种优化占优了,那么偏向锁的效果要么被抑制,要么就 被延迟了。 这种奇怪的现象是逸出分析导致的。明确了这个基准测试的单线程化的本质后,我期待着逸出分析会消除锁,从 而将 StringBuffer 的性能提到了与 StringBuilder 相同的水平。但是很明显,这并没有发生。还有另外一个问 题;在我的机器上,每一次运行的时间片分配都不尽相同。更为复杂的是,我的几位同事在他们的机器上运行这 些测试,得到的结果更混乱了。在有些时候,这些优化并没有将程序提速那么多。 前期的结论 尽管图 3 列出的结果比我所期望的要少,但确实可以从中看出各种优化能够除去锁产生的大部分开销。但是,我 的同事在运行这些测试时产生了不同的结果,这似乎对测试结果的真实性提出了挑战。这个基准测试真的测量锁 的开销了么?我们的结论成熟么?或者还有没有其他的情况?在本文的第二部分里,我们将会深入研究这个基准 测试,力争回答这些问题。在这个过程中,我们会发现获取结果并不困难,困难的是判断出这些结果是否可以回 答前面提出的问题。 public class LockTest { private static final int MAX = 20000000; // 20 million public static void main(String[] args) throws InterruptedException { // warm up the method cache for (int i = 0; i < MAX; i++) { concatBuffer("Josh", "James", "Duke"); concatBuilder("Josh", "James", "Duke"); } System.gc(); Thread.sleep(1000); System.out.println("Starting test"); long start = System.currentTimeMillis(); for (int i = 0; i < MAX; i++) { concatBuffer("Josh", "James", "Duke"); } long bufferCost = System.currentTimeMillis() - start;
  • 6. System.out.println("StringBuffer: " + bufferCost + " ms."); System.gc(); Thread.sleep(1000); start = System.currentTimeMillis(); for (int i = 0; i < MAX; i++) { concatBuilder("Josh", "James", "Duke"); } long builderCost = System.currentTimeMillis() - start; System.out.println("StringBuilder: " + builderCost + " ms."); System.out.println("Thread safety overhead of StringBuffer: " + ((bufferCost * 10000 / (builderCost * 100)) - 100) + "%n"); } public static String concatBuffer(String s1, String s2, String s3) { StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); sb.append(s3); return sb.toString(); } public static String concatBuilder(String s1, String s2, String s3) { StringBuilder sb = new StringBuilder(); sb.append(s1);
  • 7. sb.append(s2); sb.append(s3); return 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 Java 6 中的线程优化真的有效么? ——第二部分 作者 Jeroen Borgers 译者 韩锴 发布于 2008 年 10 月 29 日 上午 2 时 54 分 社区 Java 主题 性能和可伸缩性 标签 并行计算, JVM, 多线程, 并发, Java SE 在本文的第一部分中,我们通过一个单一线程的基准,比较了同步的 StringBuffer 和非同步的 StringBuilder 之 间的性能。从最初的基准测试结果来看,偏向锁提供了最佳的性能,比其他的优化方式更有效。测试的结果似乎 表明获取锁是一项昂贵的操作。但是在得出最终的结论之前,我决定先对结果进行检验:我请我的同事们在他们 的机器上运行了这个测试。尽管大多数结果都证实了我的测试结果,但是有一些结果却完全不同。在本文的第二 部分中,我们将更深入地看一看用于检验测试结果的技术。最后我们将回答现实中的问题:为什么在不同的处理 器上的锁开销差异如此巨大? 相关厂商内容 InfoQ 中文站电子杂志《架构师》试刊号发布 Java 6 中的线程优化真的有效么? 技术沙龙:Solaris 网络虚拟机构架和 Grails 框架分析(11.1 杭州) SOY Framework:Java 富客户端快速开发框架 IDC:《软件商成长路线图》白皮书免费下载
  • 8. 相关赞助商 InfoQ 中文站 Java 社区,关注公司 Java 社区的变化与创新,通过新闻、文章、视频访谈和演讲以及迷你书等为 中国 Java 技术社区提供一流资讯。 基准测试中的陷阱 通过一个基准测试,尤其是一个“小规模基准测试”(microbenchmark),来回答这个问题是非常困难的。多半 情况下,基准测试会出现一些与你期望测量的完全不同的情景。即使当你要测量影响这个问题的因素时,结果也 会被其他的因素所影响。有一点在这个实验开始之初就已经很明确了,即这个基准测试需要由其他人全面地进行 审查,这样我才能避免落入报告无效基准测试数据的陷阱中。除了其他人的检查以外,我还使用了一些工具和技 术来校验结果,这些我会在下面的几节中谈到。 结果的统计处理 大多数计算机所执行的操作都会在某一固定的时间内完成。就我的经验而言,我发现即使是那些不确定性的操 作,在大多数条件下基本上也能在固定的时间内完成。正是根据计算的这种特性,我们可以使用一种工具,它通 过测量让我们了解事情何时开始变得不正常了。这样的工具是基于统计的,其测量结果会有些出入。这就是说, 即使看到了一些超过正常水平的报告值,我也不会做过多过的解释的。原因是这样的,如果我提供了指令数固定 的 CPU,而它并没有在相对固定的时间内完成的话,就说明我的测量受到了一些外部因素的影响。如果测试结果 出现了很大的异常,则意味着我必须找到这个外部的影响进而解决它。 尽管这些异常效果会在小规模基准测试中被放大,但它不至于会影响大规模的基准测试。对于大规模的基准测试 来说,被测量的目标应用程序的各个方面会彼此产生干扰,这会带来一些异常。但是异常仍然能够提供一些很有 益的信息,可以帮助我们对干扰级别作出判断。在稳定的负荷下,我并不会对个别异常情况感到意外;当然,异 常情况不能过多。对于那些比通常结果大一些或小一些的结果,我会观察测试的运行情况,并将它视为一种信 号:我的基准测试尚未恰当地隔离或者设置好。这样对相同的测试进行不同的处理,恰恰说明了全面的基准测试 与小规模基准测试之间的不同。 最后一点,到此为止仍然不能说明你所测试的就是你所想的。这至多只能说明,对于最终的问题,这个测试是最 有可能是正确的。 预热方法的缓存 JIT 会编译你的代码,这也是众多影响基准测试的行为之一。Hotspot 会频繁地检查你的程序,寻找可以应用 某些优化的机会。当找到机会后,它会要求 JIT 编译器重新编译问题中的某段代码。此时它会应用一项技术, 即当前栈替换(On Stack Replacement,OSR),从而切换到新代码的执行上。执行 OSR 时会对测试产生 各种连锁影响,包括要暂停线程的执行。当然,所有这样的活动都会干扰到我们的基准测试。这类干扰会使测 试出现偏差。我们手头上有两款工具,可以帮助我们标明代码何时受到 JIT 的影响了。第一个当然是测试中出 现的差异,第二个是-XX:-PrintCompilation 标记。幸运的是,如果不是所有的代码在测试的早期就进行 JIT 化处理,那么我们可以将它视为另外一种启动时的异常现象。我们需要做的就是在开始测量前,先不断地运行 基准测试,直到所有代码都已经完成了 JIT 化。这个预热的阶段通常被称为“预热方法的缓存 ”。 大多数 JVM 会同时运行在解释的与本机的模式中。这就是所谓的混合模式执行。随着时间的流逝,Hotspot 和 JIT 会根据收集的信息将解释型代码转化为本机代码。Hotspot 为了决定应该使用哪种优化方案,它会抽样一些调 用和分支。一旦某个方法达到了特定的阈值后,它会通知 JIT 生成本机代码。这个阈值可以通过- XX:CompileThreshold 标记来设定。例如,设定-XX:CompileThreshold=10000,Hotspot 会在代码被执行 10,000 次后将它编译为本机代码。
  • 9. 堆管理 下一个需要考虑的是垃圾收集,或者更广为人知的名字—堆管理。在任何应用程序执行的过程中,都会定期地发 生很多种内存管理活动。它们包括:重新划分栈空间大小、回收不再被使用的内存、将数据从一处移到另一处等 等。所有这些行为都导致 JVM 影响你的应用程序。我们面对的问题是:基准测试中是否需要将内存维护或者垃圾 回收的时间包括进来?问题的答案取决于你要解决的问题的种类。在本例中,我只对获取锁的开销感兴趣,也就 是说,我必须确保测试中不能包含垃圾回收的时间。这一次,我们又能够通过异常的现象来发现影响测试的因 素,一旦出现这种问题,垃圾回收都是一个可能的怀疑对象。明确问题的最佳方式是使用 -verbose:gc 标志,开 启 GC 的日志功能。 在这个基准测试中,我做了大量的 String、StringBuffer 和 StringBuilder 操作。在每次运行的过程中大 概会创建 4 千万个对象。对于这样一种数量级的对象群来说,垃圾回收毫无疑问会成为一个问题。我使用两项技 术来避免。第一,提高堆空间的大小,防止在一个迭代中出现垃圾回收。为此,我利用了如下的命令行: >java -server -XX:+EliminateLocks -XX:+UseBiasedLocking -verbose:gc -XX:NewSize=1500m - XX:SurvivorRatio=200000 LockTest 然后,加入清单 1 的代码,它为下一次迭代准备好堆空间。 System.gc(); Thread.sleep(1000); 清单 1. 运行 GC,然后进行短暂的休眠。 休眠的目的在于给垃圾回收器充分的时间,在释放其他线程之后完成工作。有一点需要注意:如果没有 CPU 任何 活动,某些处理器会降低时钟频率。因此,尽管 CPU 时钟会自旋等待,但引入睡眠的同时也会引入延迟。如果你 的处理器支持这种特性,你可能必须要深入到硬件并且关闭掉“节能”功能才行。 前面使用的标签并不能阻止 GC 的运行。它只表示在每一次测试用例中只运行一次 GC。这一次的暂停非常小,它 产生的开销对最终结果的影响微乎其微。对于我们这个测试来说,这已经足够好了。 偏向锁延迟 还有另外一种因素会对测试结果产生重要的影响。尽管大多数优化都会在测试的早期发生,但是由于某些未知的 原因,偏向锁只发生在测试开始后的三到四秒之后。我们又要重述一遍,异常行为再一次成为判断是否存在问题 的重要标准了。-XX:+TraceBiasedLocking 标志可以帮助我们追踪这个问题。还可以延长预热时间来克服偏向 锁导致的延迟。 Hotspot 提供的其他优化 Hotspot 不会在完成一次优化后就停止对代码的改动。相反,它会不断地寻找更多的机会,提供进一步的优 化。对于锁来说,由于很多优化行为违反了 Java 存储模型中描述的规范,所以它们是被禁止的。然而,如果 锁已经被 JIT 化了,那么这些限制很快就会消失。在这个单线程化的基准测试中,Hotspot 可以非常安全地将 锁省略掉。这样就会为其他的优化行为打开大门;比如方法内联、提取循环不变式以及死代码的清除。 如果仔细思考下面的代码,可以发现 A 和 B 都是不变的,我们应该把它抽取出来放到循环外面,并引入第三个变 量,这样可以避免重复的计算,正如清单 3 中所示的那样。通常,这都是程序员的事情。但是 Hotspot 可以识 别出循环不变式并把它们抽取到循环体外面。因此,我们可以把代码写得像清单 2 那样,但是它执行时其实更类 似于清单 3 的样子。 int A = 1; int B = 2; int sum = 0;
  • 10. for (int i = 0; i < someThing; i++) sum += A + B; 清单 2 循环中包含不变式 int A = 1; int B = 2; int sum = 0; int invariant = A + B; for (int i = 0; i < someThing; i++) sum += invariant; 清单 3 不变式已抽取到循环之外 这些优化真的应该允许么?还是我们应该做一些事情防止它的发生?这个有待商榷。但至少,我们应该知道是否 应用了这些优化。我们绝对要避免“死代码消除”这种优化的出现,否则它会彻底扰乱我们的测试!Hotspot 能够 识别出我们没有使用 concatBuffer 和 concatBuilder 操作的结果。或者可以说,这些操作没有边界效应。因此 没有任何理由执行这些代码。一旦代码被标识为已“死亡”,JIT 就会除去它。好在我的基准测试迷惑了 Hotspot, 因此它并没有识别出这种优化,至少目前还没有。 如果由于锁的存在而抑制了内联,反之没有锁就可能出现内联,那么我们要确保在测试结果中没有包含额外的方 法调用。现在可以用到的一种技术是引入一个接口(清单 4)来迷惑 Hotspot。 public interfaceConcat { String concatBuffer(String s1, String s2, String s3); String concatBuilder(String s1, String s2, String s3); public class LockTest implements Concat { ...} 清单 4 使用接口防止方法内联 防止内联的另一种方法是使用命令行选项-XX:-Inline。我已经验证,方法内联并没有给基准测试的报告带来任何 不同。 执行栈输出 最后,请看下面的输出结果,它使用了下面的命令行标识。 >java -server -XX:+DoEscapeAnalysis -XX:+PrintCompilation -XX:+EliminateLocks -XX:+UseBiasedLocking -XX: +TraceBiasedLocking LockTest
  • 11. 图 1 基准测试的执行栈输出 JVM 默认会启动 12 个线程,包括:主线程、对象引用处理器、Finalize、Attach 监听器等等。上图中第一个灰 色段显示的是这些线程的对齐,它们可以使用偏向锁(注意所有地址都以 00 结尾)。你尽管忽略可以忽略它们。 接下来的黄色段包含了已编译方法的信息。我们看一下第 5 行和 12 行,能够发现它们都标记了一个额外的“s”。 表 1 的信息告诉我们这些方法都是同步的。包含了“%”的各行已经使用了 OSR。红色的行是偏向锁被激活的地 方。最底下的蓝绿色框是基准测试开始计时的地方。从记录基准测试开始时间的输出中可以看到,所有编译都已 经发生了。这说明前期的预热阶段足够长了。如果你想了解日志输出规范的更多细节,可以参考这个页面和这篇 文章。
  • 12. 表 1 编译示例码 单核系统下的结果 尽管我的多数同事都在使用 Intel Core 2 Duo 处理器,但还是有一小部分人使用陈旧的单核机器。在这些陈旧的 机器上,StringBuffer 基准测试的结果和 StringBuilder 实现的结果几乎相同。由于产生这种不同可能是多种因 素使然,因此我需要另外一个测试,尝试忽略尽可能多的可能性。最好的选择是,在 BIOS 中关闭 Core 2 Duo 中的一个核,然后重新运行基准测试。运行的结果如图 2 所示。 图 2 单核系统的性能 在多核环境下运行的时候,关闭了三种优化行为后获得了一个基准值。这次,StringBuilder 又保持了平稳的吞吐 量。更有趣的是,尽管 StringBuffer 比 StringBuilder 要稍慢,但是在多核平台下,StringBuffer 的性能更接近 于 StringBuilder。从这个测试开始我们将一步步勾勒出基准测试的真实面目。 在多核的世界中,线程间共享数据的现实呈现出一种全新的面貌。所有现代的 CPU 必须使用本地存储的缓存,将 获取指令和数据的延迟降到最低。当我们使用锁的时候,会导致一次存储关卡(Barrier)被插入到执行路径中。 存储关卡像一个信号,它通知 CPU 此时必须和其他所有的 CPU 进行协调,以此获得最新的数值。为了完成这个
  • 13. 任务,CPU 之间将要彼此通讯,从而导致每个处理器暂定当前正在运行的应用程序线程。这个过程要花多少时间 已经成了 CPU 存储模型的指标之一。越是保守的存储模型,越是线程安全的,但是它们在协调各个处理器核的时 候也要花费更多的时间。在 Core 2 Duo 上,第二个核将固定的运行基准从 3731ms 提高到了 6574ms,或者说 增加了 176%。很明显,Hotspot 所提供的任何帮助都能明显改进我们的应用程序的总体性能。 逸出分析真的起作用了么? 现在,还有一种优化很明显会起作用,但是我们还没有考虑,它就是锁省略。锁省略是最近才实现的技术,而且 它依赖于逸出分析,后者是一种 Profiling 技术,其自身也是刚刚才实现的。为了稳妥一些,各公司和组织都宣称 这些技术只有在有限的几种情况下才起作用。比如,在一个简单的循环里,对一个局部变量执行递增,且该操作 被包含在一个同步块内,由一个局部的锁保护着。这种情况下逸出分析是起作用的 [http://blog.nirav.name/2007_02_01_archive.html]。同时它在 Mont Carlo 的 Scimark2 基准测试中可 以工作(参见[http://math.nist.gov/scimark2/index.html])。 将逸出分析包含在测试中 那么,为什么逸出分析可以用于上述的情况中,却不能用于我们的基准测试中?我曾经尝试过将 StringBuffer 和 StringBuilder 的部分方法进行内联。我也修改过代码,希望可以强制逸出分析运行。我想看到锁最终被忽略,而 性能可以获得大幅提升。老实说,处理这个基准测试的过程既困惑,又让人倍感挫折。我必须无数次地在编辑器 中使用 ctrl-z,以便恢复到前面一个我认为逸出分析应该起作用的版本,但是却不知由于什么原因,逸出分析却突 然不起作用了。有时,锁省略却又会莫名其妙地出现。 最后,我认识到激活锁省略似乎和被锁对象的数据大小有关系。你运行清单 2 的代码就会看到这一点。正如你所 看到的,无论运行多少次,结果都毫无区别,这说明 DoEscapeAnalysi 没有产生影响。 >java -server - XX:-DoEscapeAnalysis EATest thread unsafe: 941 ms. thread safe: 1960 ms. Thread safety overhead: 208% >java -server -XX:+DoEscapeAnalysis EATest thread unsafe: 941 ms. thread safe: 1966 ms. Thread safety overhead: 208% 在下面的两次运行中,我移除了 ThreadSafeObject 类中一个没有被用过的域。如你所见,当开启了逸出分 析,所有性能有了很大的提高。 >java -server -XX:-DoEscapeAnalysis EATest thread unsafe: 934 ms. thread safe: 1962 ms. Thread safety overhead: 210%
  • 14. >java -server -XX:+DoEscapeAnalysis EATest thread unsafe: 933 ms. thread safe: 1119 ms. Thread safety overhead: 119% 逸出分析的数目在 Windows 和 Linux 上都能看到。然而在 Mac OS X 上,即使有额外未被使用的变量也不会有 任何影响,任何版本的基准测试的结果都是 120%。这让我不由地相信在 Mac OS X 上有效性的范围比其他系统 更广泛。我猜测这是由于它的实现比较保守,根据不同条件(比如锁对象数据大小和其他 OS 特定的特性)及早 地关掉了它。 结论 当我刚开始这个实验,解释应用各种锁优化的 Hotspot 的有效性的时候,我估计它将花费我几个小时的时间,最 终这会丰富我的 blog 的内容。但是就像其他的基准测试一样,对结果进行验证和解释的过程最终耗费了几周的时 间。同样,我也与很多专家进行合作,他们分别花费了大量时间检查结果,并发表他们的见解。即使在这些工作 完成以后,仍然很难说哪些优化起作用了,而哪些没有起作用。尽管这篇文章引述了一组测试结果,但它们是特 定我的硬件和系统的。大家可以考虑是否能在自己的系统上看到相同类型的测试结果。另外,我最初认为这不过 是个小规模基准测试,但是后来它逐渐既要满足我,也要满足所有审核代码的人,而且去掉了 Hotspot 不必要的 优化。总之,这个实验的复杂度远远地超出了我的预期。 如果你需要在多核机器上运行多线程的应用程序,并且关心性能,那么很明显,你需要不断地更新所使用的 JDK 到最新版本。很多(但不是全部)前面的版本的优化都可以在最新的版本中获得兼容。你必须保证所有的线程优 化都是激活的。在 JDK 6.0 中,它们默认是激活的。但是在 JDK 5.0 中,你需要在命令行中显式地设置它们。如 果你在多核机器上运行单线程的应用程序,就要禁用除第一个核以外所有核的优化,这样会使应用程序运行得更 快。 在更低级的层面上,单核系统上锁的开销远远低于双核处理器。不同核之间的协调,比如存储关卡语义,通过关 掉一个核运行的测试结果看,很明显会带来系统开销。我们的确需要线程优化,以此降低这一开销。幸运的是, 锁粗化和(尤其是)偏向锁对于基准测试的性能确实有明显的影响。我也希望逸出分析与锁省略一起更能够做到 更好,产生更多的影响。这项技术会起作用,可只是在很少的情况下。客观地说,逸出分析仍然还处于它的初级 阶段,还需要大量的时间才能变得成熟。 最后的结论是,最权威的基准测试是让你的应用程序运行在自己的系统上。当你的多线程应用的性能没有符合你 的期望的时候,这篇文章能够为你提供了一些思考问题的启示。而这就是此文最大的价值所在。 对于 Jeroen Borgers Jeroen Borger 是 Xebia 的资深咨询师。Xebia 是一家国际 IT 咨询与项目组织公司,专注于公司级 Java 和敏捷 开发。Jeroen 帮助他的客户攻克公司级 Java 系统的性能问题,他同时还是 Java 性能调试课程的讲师。他在从 1996 年开始就可以在不同的 Java 项目中工作,担任过开发者、架构师、团队 lead、质量负责人、顾问、审核 员、性能测试和调试员。他从 2005 年开始专注于性能问题。 鸣谢 没有其他人的鼎力相助,是不会有这篇文章的。特别感谢下面的朋友: Dr. Cliff Click,原 Sun 公司的 Server VM 主要架构师,现工作在 Azul System;他帮我分析,并提供了很多 宝贵的资源。
  • 15. 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 清单 1. public class LockTest { private static final int MAX = 20000000; // 20 million public static void main(String[] args) throws InterruptedException { // warm up the method cache for (int i = 0; i < MAX; i++) { concatBuffer("Josh", "James", "Duke"); concatBuilder("Josh", "James", "Duke"); } System.gc(); Thread.sleep(1000); long start = System.currentTimeMillis(); for (int i = 0; i < MAX; i++) { concatBuffer("Josh", "James", "Duke"); } long bufferCost = System.currentTimeMillis() - start;
  • 16. System.out.println("StringBuffer: " + bufferCost + " ms."); System.gc(); Thread.sleep(1000); start = System.currentTimeMillis(); for (int i = 0; i < MAX; i++) { concatBuilder("Josh", "James", "Duke"); } long builderCost = System.currentTimeMillis() - start; System.out.println("StringBuilder: " + builderCost + " ms."); System.out.println("Thread safety overhead of StringBuffer: " + ((bufferCost * 10000 / (builderCost * 100)) - 100) + "%n"); } public static String concatBuffer(String s1, String s2, String s3) { StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); sb.append(s3); return sb.toString(); } public static String concatBuilder(String s1, String s2, String s3) {
  • 17. StringBuilder sb = new StringBuilder(); sb.append(s1); sb.append(s2); sb.append(s3); return sb.toString(); } } 清单 2. public class EATest { private static final int MAX = 200000000; // 200 million public static final void main(String[] args) throws InterruptedException { // warm up the method cache sumThreadUnsafe(); sumThreadSafe(); sumThreadUnsafe(); sumThreadSafe(); System.out.println("Starting test"); long start; start = System.currentTimeMillis(); sumThreadUnsafe(); long unsafeCost = System.currentTimeMillis() - start; System.out.println(" thread unsafe: " + unsafeCost + " ms.");
  • 18. start = System.currentTimeMillis(); sumThreadSafe(); long safeCost = System.currentTimeMillis() - start; System.out.println(" thread safe: " + safeCost + " ms."); System.out.println("Thread safety overhead: " + ((safeCost * 10000 / (unsafeCost * 100)) - 100) + "%n"); } public static int sumThreadSafe() { String[] names = new String[] { "Josh", "James", "Duke", "B" }; ThreadSafeObject ts = new ThreadSafeObject(); int sum = 0; for (int i = 0; i < MAX; i++) { sum += ts.test(names[i % 4]); } return sum; } public static int sumThreadUnsafe() { String[] names = new String[] { "Josh", "James", "Duke", "B" }; ThreadUnsafeObject tus = new ThreadUnsafeObject(); int sum = 0; for (int i = 0; i < MAX; i++) { sum += tus.test(names[i % 4]); } return sum; }
  • 19. } final class ThreadUnsafeObject { // private int index = 0; private int count = 0; private char[] value = new char[1]; public int test(String str) { value[0] = str.charAt(0); count = str.length(); return count; } } final class ThreadSafeObject { private int index = 0; // remove this line, or just the '= 0' and it will go faster!!! private int count = 0; private char[] value = new char[1]; public synchronized int test(String str) { value[0] = str.charAt(0); count = str.length(); return count; } }