java:Mustang 中的同步优化

Brian Goetz, 首席咨询师 , Quiotix

2005 年 11 月 07 日

在 9 月份的 Java 理论与实践 中,专栏作家 Brian Goetz 研究了 Escape 分析,这是许多 JVM 在相当一段时间内已经放入日程表的一项优化,也是预计会在 Mustang (Java™ SE 6)发行版的 HotSpot 中出现的优化。可以用 Escape 分析把基于堆的对象分配转换成不太昂贵的基于堆栈的分配,也可以用它做其他优化决策,包括对同步的运用进行优化。这个月,Brian 将介绍一些为 Mustang 安排的同步优化。请在本文附带的 讨论组 中与作者和其他读者分享您对本文的想法。(可以选择文章顶部或底部的 讨论 访问讨论组。) 请注意:这一期描述的是 Sun 的 HotSpot JVM 实现未来版本的特性。这里讨论的具体特性可能出现在 Java SE 6 (“Mustang”)中,也可能不出现;有些可能会推迟到 Java SE 7 (“Dolphin”)。

每当易变的变量在线程间共享时,都必须使用同步来确保一个线程所做的更新,能够及时地被其他线程看到。同步的主要方式就是使用 synchronized 块,它既提供了互斥又提供了可见性保证。(其他的同步形式包括 volatile 变量、java.util.concurrent.locks 中的 Lock 对象,以及原子变量。)当两个线程都想访问共享的易变变量时,这两个线程不仅必须使用同步,而且如果它们正在使用 synchronized 块,那么这些 synchronized 块还必须使用同一个锁对象。

在实践中,锁定分为两类:多数竞争锁和多数非竞争锁。多数竞争锁是应用程序中的“热”锁,例如保护线程池的共享工作队列的锁。不断地会有许多线程需要这些 锁保护的数据,所以可以想象,当要取得这个锁的时候,可能不得不等候其他人用完它。而多数非竞争锁保护的数据被访问得不那么频繁,所以多数时候,当线程要 取得锁时,不会有其他线程正在持有这个锁。大多数锁都不是频繁争用的,所以改善非竞争锁定的性能可以实际地改善应用程序的整体性能。

JVM 对于竞争锁请求和非竞争锁请求有不同的代码路径,分别是“慢路径”和“快路径”。在快路径的优化上,已经做了许多工作;Mustang 进一步改善了快路径和慢路径,并添加了许多能够完全清除某些锁定的优化。

锁省略

Java 内存模型规定,在另一个线程进入由同一个锁保护的同步块之前,一个线程应该退出同步块;这就意味着线程 A 在退出锁 M 保护的 synchronized 块时所能看到的所有内存操作,线程 B 进入 M 保护的 synchronized 块时都会看到,如图 1 所示。对于使用不同锁的 synchronized 块,没法估计它们的顺序 —— 就像根本没有同步一样。


图 1. Java 内存模型中的同步和可见性
Java 内存模型中的同步和可见性

毫无疑义,如果线程进入由一个没有任何其他线程在其上同步的锁保护的 synchronized 块,那么这个同步就没有效果,因而可以被优化器删除。(Java 语言规范 明确地允许这种优化。)这种场景听起来好像不太可能,但是确实存在这种场景对编译器属实的情况。清单 1 展示了一个线程本地锁对象的简化示例:


清单 1. 用线程本地对象作为锁


synchronized (new Object()) {
doSomething();
}

因为对锁对象的引用在其他线程可以使用它之前就消失了,所以编译器就可以认为以上同步可以删除,因为不可能有两个线程用这同一个锁进行同步。虽然没有人会直接使用清单 1 中的形式,但是与这个代码非常类似的情况是:可以证实与 synchronized 块关联的锁是一个线程本地变量。“线程本地”并不一定意味着变量由 ThreadLocal 类实现;它可以是任何变量,只要编译器能够证实没有其他线程访问这个变量即可。由本地变量引用的对象以及从来不会离开自己定义范围的对象,都满足这个测试 —— 如果对象局限在某些线程的堆栈内,那么其他线程就看不到这个对象的引用。(可以跨线程共享对象的惟一方式就是把对象的引用发布到堆中。)

碰巧的是,我们 上个月 讨论的 Escape 分析为编译器提供了需要的确切信息,以优化掉使用线程本地锁对象的 synchronized 块。如果编译器能够证实(使用 Escape 分析)某个对象从未发布到堆中,那么它肯定是个线程本地对象,所以任何使用这个对象作为锁的 synchronized 块在 Java 内存模型(JMM)下就都不会有效果,所以可以被清除掉。这个优化叫做锁省略,是为 Mustang 安排的另一个 JVM 优化。

用线程本地对象进行锁定的情况要比想像的出现得更频繁。有许多类,例如 StringBufferjava.util.Random,因为可能在多个线程中使用,所以是线程安全的,但是通常是以线程本地的方式使用它们。

请看清单 2 中的代码,这里在构建字符串值的时候使用了 VectorgetStoogeNames() 方法创建了一个 Vector,向它添加了几个字符串,然后调用 toString() 把它转换成字符串。对 Vector 方法的每一次调用 —— 三个 add() 调用和一个 toString() 调用 —— 都要求得到并释放 Vector 上的锁。虽然所有锁的获得都是非竞争的,所以很快,但是编译器实际上可以用锁省略把同步完全清除掉。


清单 2. 锁省略的候选方案


public String getStoogeNames() {
Vector v = new Vector();
v.add("Moe");
v.add("Larry");
v.add("Curly");
return v.toString();
}

因为所有 Vector 的引用都不会离开 getStoogeNames() 方法,所以它肯定是线程本地的,因而任何使用它作为锁的 synchronized 在 JMM 下都不会有效果。编译器可以内联 add()toString() 方法的调用,然后会认识到它要获得和释放线程本地对象上的锁,所以可以优化掉全部四个上锁-解锁操作。

我过去曾经说过试图避免同步一般是个坏主意。像锁省略这样的优化的到来,又增加了一条避免清除同步的理由 —— 编译器会在安全的时候自动清除同步,所以就把同步放在那吧。





回页首


自适应锁定

除了 Escape 分析和锁省略,Mustang 还有一些锁定性能方面的优化。当两个线程竞争同一个锁时,其中一个会得到锁,另一个不得不阻塞,直到锁可用。实现阻塞有两种显而易见的技术,即让操作系统 暂挂线程,直到线程被唤醒,或者使用旋转( spin) 锁。旋转锁基本上相当于以下代码:


while (lockStillInUse)
;

虽然旋转锁是 CPU 密集型的,显得效率低下,但是如果 争夺的锁被持有的时间非常短,那么旋转锁要比暂挂线程然后再唤醒它更有效率。暂挂和重新调度线程的开支很显著,其中包括 JVM、操作系统和硬件的工作。这向 JVM 实现人员提出了一个问题:如果锁只被持有很短的时间,那么旋转会更有效率;如果锁持有很长时间,那么暂挂会更有效率。由于没有指定应用程序的锁定时间分布 信息,所以多数 JVM 采取保守策略,在得不到锁的时候只是暂挂线程。

但是,有可能做得更好。对于每个锁,JVM 可以根据以前获得的行为,适应性地在旋转和暂挂之间进行选择。它可以试用旋转,然后如果在短时间内成功,那么就继续采用这种方式。另一方面,如果一定数量 的旋转获得锁失败,那么就可以判断这个锁持有“长时间”,然后就重新编译方法,只使用暂挂。可以在每个锁或每个锁站点基础上进行这个决策。

自适应方式的结果是整体性能更好。当 JVM 花了一些时间获得锁使用模式的监测信息之后,就可以对持有时间短的锁使用旋转,对持有时间长的锁使用暂挂。像这样的优化在静态编译环境中是不可能的,因为关于锁使用模式的信息在静态编译时得不到。





回页首


锁粗化

另一个可以用来降低锁定成本的优化是锁粗化(lock coarsening)。锁粗化就是把使用同一锁对象的相邻同步块合并的过程。如果编译器可以用锁省略清除锁定,那么它就可能用锁粗化降低开支。

清单 3 中的代码不一定是锁省略的候选方案(虽然在内联了 addStoogeNames() 之后,JVM 可能仍然能够省略锁定),但是它仍然可能从锁粗化受益。对 add() 的三个调用轮流获取 Vector 上的锁,做些工作,然后释放锁。编译器可以观察到有一个顺序的相邻块对同一个锁进行操作,并会把它们合并到单一块内。


清单 3. 锁粗化的候选方案


public void addStooges(Vector v) {
v.add("Moe");
v.add("Larry");
v.add("Curly");
}

这不仅会降低锁定支出,而且通过合并块,它把三个方法调用中的内容变成一个大的 synchronized 块,向优化器提供了一个更大的基本块。这样,锁粗化还可以让其他对锁定无所作为的优化措施发挥作用。

即使在 synchronized 块或方法调用之间有其他语句,编译器仍然能够执行锁粗化。编译器可以把语句移入 synchronized 块 —— 而不仅仅是把语句移出。所以,如果在 add() 之间有插入的语句,编译器仍然可以把整个 addStooges() 变成一个大的 synchronized 块,这个块使用 Vector 作为锁对象。

锁粗化可能要在性能和响应性之间进行平衡。把三个上锁-解锁对组合到一个上锁-解锁对,降低了指令数量和内存总线上同步流量的数量,从而提高了性 能。代价则是持有锁的时间可能会变长,从而延长了其他线程被锁在外面的时间,也会增大锁争用的可能性。但是,在每种情况下,锁被持有的时间相对都较短,所 以编译器可以根据同步保护的代码的长度应用启发式方法在此做出合理的权衡。(而且根据更大的基本块支持其他何种优化,在粗化的情况下,持有锁的时间甚至可 能不会延长。)





回页首


结束语

最近的每个 JVM 版本都提高了非竞争锁定的性能。Mustang 延续了这一趋势,既提高了非竞争锁定和竞争锁定的原始性能,还引入了可以消除许多锁操作的优化。





回页首


参考资料

学习

讨论




回页首


关于作者

 

Brian Goetz 作为专业的软件开发人员已经有 18 年了。他是 Quiotix 的首席咨询师,这是一家软件开发和咨询公司,位于加州 Los Altos。他还在几个 JCP 专家组服务。Brian 的新书 Java Concurrency In Practice 将于 2005 年底由 Addison-Wesley 出版。请参阅 Brian 在流行的业界出版物上 已发表和即将发表的文章

 
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值