java

位置:IT落伍者 >> java >> 浏览文章

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


发布日期:2024年07月30日
 
Java 6中的线程优化真的有效么?

介绍 — Java 中的线程优化

SunIBMBEA和其他公司在各自实现的Java 虚拟机上都花费了大量的精力优化锁的管理和同步诸如偏向锁(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)如果锁的引用限制在局部空间中说明只有创建这个锁的线程才会访问该锁在这种条件下同步块中的值永远不会存在竞争这意味这我们永远不可能真的需要这把锁它可以被安全地忽略掉考虑下面的方法

publicString concatBuffer(String s String s String s) {

StringBuffer sb = new StringBuffer();

sbappend(s);

sbappend(s);

sbappend(s);

return sbtoString();

}

使用局部的StringBuffer连接字符串

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

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

简析偏向锁(Biased locking explained)

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

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

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

简析锁粗化(Lock coarsening explained)

另一种线程优化方式是锁粗化(或合并merging)当多个彼此靠近的同步块可以合并到一起形成一个同步块的时候就会进行锁粗化该方法还有一种变体可以把多个同步方法合并为一个方法如果所有方法都用一个锁对象就可以尝试这种方法考虑图中的实例

public static String concatToBuffer(StringBuffer sb String s String s String s) {

sbappend(s);

sbappend(s);

sbappend(s);

return

}

使用非局部的StringBuffer连接字符串

在这个例子中StringBuffer的作用域是非局部的可以被多个线程访问所以逸出分析会判断出StringBuffer的锁不能安全地被忽略如果锁刚好只被一个线程访问则可以使用偏向锁有趣的是是否进行锁粗化与竞争锁的线程数量是无关的在上面的例子中锁的实例会被请求四次前三次是执行append方法最后一次是执行toString方法紧接着前一个首先要做的是将这种方法进行内联然后我们只需执行一次获取锁的操作(为整个方法)而不必像以前一样获取四次锁了

这种做法带来的真正效果是我们获得了一个更长的临界区它可能导致其他线程受到拖延从而降低吞吐量正因为这些原因一个处于循环内部的锁是不会被粗化到包含整个循环体的

线程挂起 vs 自旋(Thread suspending versus spinning)

在一个线程等待另外一个线程释放某个锁的时候它通常会被操作系统挂起操作在挂起一个线程的时候需要将它换出CPU而通常此时线程的时间片还没有使用完当拥有锁的线程离开临界区的时候挂起的线程需要被重新唤醒然后重新被调用并交换上下文回到CPU调度中所有这些动作都会给JVMOS和硬件带来更大的压力

在这个例子中如果注意到下面的事实会很有帮助锁通常只会被占有很短的一段时间这就是说如果能够等上一会儿我们可以避免挂起线程的开销为了让线程等待我们只需将线程执行一个忙循环(自旋)这项技术就是所谓的自旋锁

当锁被占有的时间很短时自旋锁的效果非常好另一方面如果锁被占有很长时间那么自旋的线程只会消耗CPU而不做任何有用的工作因此带来浪费自从JDK 中引入自旋锁以来自旋锁被分为两个阶段自旋十个循环(默认值)然后挂起线程

自适应自旋锁(Adaptive spinning)

JDK 中引入了自适应自旋锁自适应意味着自旋的时间不再固定了而是取决于一个基于前一次在同一个锁上的自旋时间以及锁的拥有者的状态如果在同一个锁对象上自旋刚刚成功过并且持有锁的线程正在运行中那么自旋很有可能再次成功进而它将被应用于相对更长的时间比如个循环另一方面如果自旋很少发生过它将被遗弃避免浪费任何CPU周期

StringBuffer vs StringBuilder的基准测试

但是要想设计出一种方法来判断这些巧妙的优化方法到底多有效这条路并不平坦首要的问题就是如何设计基准测试为了找到问题的答案我决定去看看人们通常在代码中运用了哪些常见的技巧我首先想到的是一个非常古老的问题使用StringBuffer代替String可以减少多少开销?

一个类似的建议是如果你希望字符串是可变的就应该使用StringBuffer这个建议的缘由是非常明确的String是不可变的但如果我们的工作需要字符串有很多变化StringBuffer将是一个开销较低的选择有趣的是在遇到JDK 中的StringBuilder(它是StringBuffer的非同步版本)后这条建议就不灵了由于StringBuilder与 StringBuffer之间唯一的不同在于同步性这似乎说明测量两者之间性能差异的基准测试必须关注在同步的开销上我们的探索从第一个问题开始非竞争锁的开销如何?

这个基准测试的关键(如清单所示)在于将大量的字符串拼接在一起底层缓沖的初始容量足够大可以包含三个待连接的字符串这样我们可以将临界区内的工作最小化进而重点测量同步的开销

基准测试的结果

下图是测试结果包括EliminateLocksUseBiasedLocking和DoEscapeAnalysis的不同组合

基准测试的结果

关于结果的讨论

之所以使用非同步的StringBuilder是为了提供一个测量性能的基线我也想了解一下各种优化是否真的能够影响StringBuilder的性能正如我们所看到的StringBuilder的性能可以保持在一个不变的吞吐量水平上因为这些技术的目标在于锁的优化因此这个结果符合预期在性能测试的另一栏中我们也可以看到使用没有任何优化的同步的StringBuffer其运行效率比StringBuilder大概要慢三倍

仔细观察图的结果我们可以注意到从左到右性能有一定的提高这可以归功于EliminateLocks不过这些性能的提升比起偏向锁来说又显得有些苍白事实上除了C列以外每次运行时如果开启偏向锁最终都会提供大致相同的性能提升但是C列是怎么回事呢?

在处理最初的数据的过程中我注意到有一项测试在六个测试中要花费格外长的时间由于结果的异常相当明显因此基准测试似乎在报告两个完全不同的优化行为经过一番考虑我决定同时展示出高值和低值(B列和C列)由于没有更深入的研究我只能猜测这里应用了一种以上的优化(很可能是两种)并且存在一些竞争条件偏向锁大多时候会取胜但不非总能取胜如果另一种优化占优了那么偏向锁的效果要么被抑制要么就被延迟了

这种奇怪的现象是逸出分析导致的明确了这个基准测试的单线程化的本质后我期待着逸出分析会消除锁从而将StringBuffer的性能提到了与 StringBuilder相同的水平但是很明显这并没有发生还有另外一个问题在我的机器上每一次运行的时间片分配都不尽相同更为复杂的是我的几位同事在他们的机器上运行这些测试得到的结果更混乱了在有些时候这些优化并没有将程序提速那么多

前期的结论

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

public class LockTest { private static final int MAX = ; // million

public static void main(String[] args) throws InterruptedException { // warm up the method cache

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

concatBuffer(Josh James Duke);

concatBuilder(Josh James Duke);

}

Systemgc();

Threadsleep();

Systemoutprintln(Starting test);

long start = SystemcurrentTimeMillis();

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

concatBuffer(Josh James Duke);

}

long bufferCost = SystemcurrentTimeMillis() start;

Systemoutprintln(StringBuffer: + bufferCost + ms);

Systemgc();

Threadsleep();

start = SystemcurrentTimeMillis();

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

concatBuilder(Josh James Duke);

}

long builderCost = SystemcurrentTimeMillis() start;

Systemoutprintln(StringBuilder: + builderCost + ms);

Systemoutprintln(Thread safety overhead of StringBuffer:

+ ((bufferCost * / (builderCost * )) ) + %\n);

}

public static String concatBuffer(String s String s String s) { StringBuffer sb = new StringBuffer();

sbappend(s);

sbappend(s);

sbappend(s);

return sbtoString();

}

public static String concatBuilder(String s String s String s) {

StringBuilder sb = new StringBuilder(); sbappend(s); sbappend(s); sbappend(s); return sbtoString();

}

}

运行基准测试

我运行这个测试的环境是位的Windows Vista笔记本电脑配有Intel Core Duo使用Java _请注意所有的优化都是在Server VM上实现的但这在我的平台上不是默认的VM它甚至不能在JRE中使用只能在JDK中使用为了确保我使用的是Server VM我需要在命令行上打开server选项其他的选项包括

XX:+DoEscapeAnalysis off by default

XX:+UseBiasedLocking on by default

XX:+EliminateLocks on by default

编译源代码运行下面的命令可以启动测试

javaserver XX:+DoEscapeAnalysis LockTest

关于Jeroen Borgers

Jeroen Borger是Xebia的资深咨询师Xebia是一家国际IT咨询与项目组织公司专注于企业级Java和敏捷开发Jeroen帮助他的客户攻克企业级Java系统的性能问题他同时还是Java性能调试课程的讲师他在从年开始就可以在不同的Java项目中工作担任过开发者架构师团队lead质量负责人顾问审核员性能测试和调试员他从年开始专注于性能问题

               

上一篇:Java模式设计之单例模式(二)

下一篇:使用Java构造高可扩展应用