与许多其它的编程语言不同,Java语言规范包括对线程和并发的明确支持。语言本身支持并发,这使得指定和管理共享数据的约束以及跨线程操作的计时变得更简单,但是这没有使得并发编程的复杂性更易于理解。这个三部分的系列文章的目的在于帮助程序员理解用Java 语言进行多线程编程的一些主要问题,特别是线程安全对 Java程序性能的影响。
请点击文章顶部或底部的 讨论进入由 Brian Goetz 主持的 “Java线程:技巧、窍门和技术”讨论论坛,与本文作者和其他读者交流您对本文或整个多线程的想法。注意该论坛讨论的是使用多线程时遇到的所有问题,而并不限于本文的内容。
大多数编程语言的语言规范都不会谈到线程和并发的问题;因为一直以来,这些问题都是留给平台或操作系统去详细说明的。但是,Java 语言规范(JLS)却明确包括一个线程模型,并提供了一些语言元素供开发人员使用以保证他们程序的线程安全。
对线程的明确支持有利也有弊。它使得我们在写程序时更容易利用线程的功能和便利,但同时也意味着我们不得不注意所写类的线程安全,因为任何类都很有可能被用在一个多线程的环境内。
许多用户第一次发现他们不得不去理解线程的概念的时候,并不是因为他们在写创建和管理线程的程序,而是因为他们正在用一个本身是多线程的工具或框架。任何用过 Swing GUI 框架或写过小服务程序或 JSP 页的开发人员(不管有没有意识到)都曾经被线程的复杂性困扰过。
Java 设计师是想创建一种语言,使之能够很好地运行在现代的硬件,包括多处理器系统上。要达到这一目的,管理线程间协调的工作主要推给了软件开发人员;程序员必须指定线程间共享数据的位置。在 Java 程序中,用来管理线程间协调工作的主要工具是 synchronized
关键字。在缺少同步的情况下,JVM 可以很自由地对不同线程内执行的操作进行计时和排序。在大部分情况下,这正是我们想要的,因为这样可以提高性能,但它也给程序员带来了额外的负担,他们不得不自己识别什么时候这种性能的提高会危及程序的正确性。
synchronized 真正意味着什么?
大部分 Java 程序员对同步的块或方法的理解是完全根据使用互斥(互斥信号量)或定义一个临界段(一个必须原子性地执行的代码块)。虽然 synchronized
的语义中确实包括互斥和原子性,但在管程进入之前和在管程退出之后发生的事情要复杂得多。
synchronized
的语义确实保证了一次只有一个线程可以访问被保护的区段,但同时还包括同步线程在主存内互相作用的规则。理解 Java 内存模型(JMM)的一个好方法就是把各个线程想像成运行在相互分离的处理器上,所有的处理器存取同一块主存空间,每个处理器有自己的缓存,但这些缓存可能并不总和主存同步。在缺少同步的情况下,JMM 会允许两个线程在同一个内存地址上看到不同的值。而当用一个管程(锁)进行同步的时候,一旦申请加了锁,JMM 就会马上要求该缓存失效,然后在它被释放前对它进行刷新(把修改过的内存位置写回主存)。不难看出为什么同步会对程序的性能影响这么大;频繁地刷新缓存代价会很大。
使用一条好的运行路线
如果同步不适当,后果是很严重的:会造成数据混乱和争用情况,导致程序崩溃,产生不正确的结果,或者是不可预计的运行。更糟的是,这些情况可能很少发生且具有偶然性(使得问题很难被监测和重现)。如果测试环境和开发环境有很大的不同,无论是配置的不同,还是负荷的不同,都有可能使得这些问题在测试环境中根本不出现,从而得出错误的结论:我们的程序是正确的,而事实上这些问题只是还没出现而已。
|
争用情况定义
争用情况是一种特定的情况:两个或更多的线程或进程读或写一些共享数据,而最终结果取决于这些线程是如何被调度计时的。争用情况可能会导致不可预见的结果和隐蔽的程序错误。
|
|
另一方面,不当或过度地使用同步会导致其它问题,比如性能很差和死锁。当然,性能差虽然不如数据混乱那么严重,但也是一个严重的问题,因此同样不可忽视。编写优秀的多线程程序需要使用好的运行路线,足够的同步可以使您的数据不发生混乱,但不需要滥用到去承担死锁或不必要地削弱程序性能的风险。
同步的代价有多大?
由于包括缓存刷新和设置失效的过程,Java 语言中的同步块通常比许多平台提供的临界段设备代价更大,这些临界段通常是用一个原子性的“test and set bit”机器指令实现的。即使一个程序只包括一个在单一处理器上运行的单线程,一个同步的方法调用仍要比非同步的方法调用慢。如果同步时还发生锁定争用,那么性能上付出的代价会大得多,因为会需要几个线程切换和系统调用。
幸运的是,随着每一版的 JVM 的不断改进,既提高了 Java 程序的总体性能,同时也相对减少了同步的代价,并且将来还可能会有进一步的改进。此外,同步的性能代价经常是被夸大的。一个著名的资料来源就曾经引证说一个同步的方法调用比一个非同步的方法调用慢 50 倍。虽然这句话有可能是真的,但也会产生误导,而且已经导致了许多开发人员即使在需要的时候也避免使用同步。
严格依照百分比计算同步的性能损失并没有多大意义,因为一个无争用的同步给一个块或方法带来的是固定的性能损失。而这一固定的延迟带来的性能损失百分比取决于在该同步块内做了多少工作。对一个 空方法的同步调用可能要比对一个空方法的非同步调用慢 20 倍,但我们多长时间才调用一次空方法呢?当我们用更有代表性的小方法来衡量同步损失时,百分数很快就下降到可以容忍的范围之内。
表 1 把一些这种数据放在一起来看。它列举了一些不同的实例,不同的平台和不同的 JVM 下一个同步的方法调用相对于一个非同步的方法调用的损失。在每一个实例下,我运行一个简单的程序,测定循环调用一个方法 10,000,000 次所需的运行时间,我调用了同步和非同步两个版本,并比较了结果。表格中的数据是同步版本的运行时间相对于非同步版本的运行时间的比率;它显示了同步的性能损失。每次运行调用的都是清单 1 中的简单方法之一。
表格 1 中显示了同步方法调用相对于非同步方法调用的相对性能;为了用绝对的标准测定性能损失,必须考虑到 JVM 速度提高的因素,这并没有在数据中体现出来。在大多数测试中,每个 JVM 的更高版本都会使 JVM 的总体性能得到很大提高,很有可能 1.4 版的 Java 虚拟机发行的时候,它的性能还会有进一步的提高。
表 1. 无争用同步的性能损失
JDK
|
staticEmpty
|
empty
|
fetch
|
hashmapGet
|
singleton
|
create
|
Linux / JDK 1.1 |
9.2 |
2.4 |
2.5 |
n/a |
2.0 |
1.42 |
Linux / IBM Java SDK 1.1 |
33.9 |
18.4 |
14.1 |
n/a |
6.9 |
1.2 |
Linux / JDK 1.2 |
2.5 |
2.2 |
2.2 |
1.64 |
2.2 |
1.4 |
Linux / JDK 1.3 (no JIT) |
2.52 |
2.58 |
2.02 |
1.44 |
1.4 |
1.1 |
Linux / JDK 1.3 -server |
28.9 |
21.0 |
39.0 |
1.87 |
9.0 |
2.3 |
Linux / JDK 1.3 -client |
21.2 |
4.2 |
4.3 |
1.7 |
5.2 |
2.1 |
Linux / IBM Java SDK 1.3 |
8.2 |
33.4 |
33.4 |
1.7 |
20.7 |
35.3 |
Linux / gcj 3.0 |
2.1 |
3.6 |
3.3 |
1.2 |
2.4 |
2.1 |
Solaris / JDK 1.1 |
38.6 |
20.1 |
12.8 |
n/a |
11.8 |
2.1 |
Solaris / JDK 1.2 |
39.2 |
8.6 |
5.0 |
1.4 |
3.1 |
3.1 |
Solaris / JDK 1.3 (no JIT) |
2.0 |
1.8 |
1.8 |
1.0 |
1.2 |
1.1 |
Solaris / JDK 1.3 -client |
19.8 |
1.5 |
1.1 |
1.3 |
2.1 |
1.7 |
Solaris / JDK 1.3 -server |
1.8 |
2.3 |
53.0 |
1.3 |
4.2 |
3.2 |
清单 1. 基准测试中用到的简单方法
public static void staticEmpty() { }
public void empty() { }
public Object fetch() { return field; }
public Object singleton() {
if (singletonField == null)
singletonField = new Object();
return singletonField;
}
public Object hashmapGet() {
return hashMap.get("this");
}
public Object create() {
return new Object();
}
|
这些小基准测试也阐明了存在动态编译器的情况下解释性能结果所面临的挑战。对于 1.3 JDK 在有和没有 JIT 时,数字上的巨大差异需要给出一些解释。对那些非常简单的方法( empty
和 fetch
),基准测试的本质(它只是执行一个几乎什么也不做的紧凑的循环)使得 JIT 可以动态地编译整个循环,把运行时间压缩到几乎没有的地步。但在一个实际的程序中,JIT 能否这样做就要取决于很多因素了,所以,无 JIT 的计时数据可能在做公平对比时更有用一些。在任何情况下,对于更充实的方法( create
和 hashmapGet
),JIT 就不能象对更简单些的方法那样使非同步的情况得到巨大的改进。另外,从数据中看不出 JVM 是否能够对测试的重要部分进行优化。同样,在可比较的 IBM 和 Sun JDK 之间的差异反映了 IBM Java SDK 可以更大程度地优化非同步的循环,而不是同步版本代价更高。这在纯计时数据中可以明显地看出(这里不提供)。
从这些数字中我们可以得出以下结论:对非争用同步而言,虽然存在性能损失,但在运行许多不是特别微小的方法时,损失可以降到一个合理的水平;大多数情况下损失大概在 10% 到 200% 之间(这是一个相对较小的数目)。所以,虽然同步每个方法是不明智的(这也会增加死锁的可能性),但我们也不需要这么害怕同步。这里使用的简单测试是说明一个无争用同步的代价要比创建一个对象或查找一个 HashMap
的代价小。
由于早期的书籍和文章暗示了无争用同步要付出巨大的性能代价,许多程序员就竭尽全力避免同步。这种恐惧导致了许多有问题的技术出现,比如说 double-checked locking(DCL)。许多关于 Java 编程的书和文章都推荐 DCL,它看上去真是避免不必要的同步的一种聪明的方法,但实际上它根本没有用,应该避免使用它。DCL 无效的原因很复杂,已超出了本文讨论的范围(要深入了解,请参阅 参考资料里的链接)。
不要争用
假设同步使用正确,若线程真正参与争用加锁,您也能感受到同步对实际性能的影响。并且无争用同步和争用同步间的性能损失差别很大;一个简单的测试程序指出争用同步比无争用同步慢 50 倍。把这一事实和我们上面抽取的观察数据结合在一起,可以看出使用一个争用同步的代价至少相当于创建 50 个对象。
所以,在调试应用程序中同步的使用时,我们应该努力减少实际争用的数目,而根本不是简单地试图避免使用同步。这个系列的第 2 部分将把重点放在减少争用的技术上,包括减小锁的粒度、减小同步块的大小以及减小线程间共享数据的数量。
什么时候需要同步?
要使您的程序线程安全,首先必须确定哪些数据将在线程间共享。如果正在写的数据以后可能被另一个线程读到,或者正在读的数据可能已经被另一个线程写过了,那么这些数据就是共享数据,必须进行同步存取。有些程序员可能会惊讶地发现,这些规则在简单地检查一个共享引用是否非空的时候也用得上。
许多人会发现这些定义惊人地严格。有一种普遍的观点是,如果只是要读一个对象的字段,不需要请求加锁,尤其是在 JLS 保证了 32 位读操作的原子性的情况下,它更是如此。但不幸的是,这个观点是错误的。除非所指的字段被声明为 volatile
,否则 JMM 不会要求下面的平台提供处理器间的缓存一致性和顺序连贯性,所以很有可能,在某些平台上,没有同步就会读到陈旧的数据。有关更详细的信息,请参阅 参考资料。
在确定了要共享的数据之后,还要确定要如何保护那些数据。在简单情况下,只需把它们声明为 volatile
即可保护数据字段;在其它情况下,必须在读或写共享数据前请求加锁,一个很好的经验是明确指出使用什么锁来保护给定的字段或对象,并在你的代码里把它记录下来。
还有一点值得注意的是,简单地同步存取器方法(或声明下层的字段为 volatile
)可能并不足以保护一个共享字段。可以考虑下面的示例:
...
private int foo;
public synchronized int getFoo() { return foo; }
public synchronized void setFoo(int f) { foo = f; }
|
如果一个调用者想要增加 foo
属性值,以下完成该功能的代码就不是线程安全的:
...
setFoo(getFoo() + 1);
|
如果两个线程试图同时增加 foo
属性值,结果可能是 foo
的值增加了 1 或 2,这由计时决定。调用者将需要同步一个锁,才能防止这种争用情况;一个好方法是在 JavaDoc 类中指定同步哪个锁,这样类的调用者就不需要自己猜了。
以上情况是一个很好的示例,说明我们应该注意多层次粒度的数据完整性;同步存取器方法确保调用者能够存取到一致的和最近版本的属性值,但如果希望属性的将来值与当前值一致,或多个属性间相互一致,我们就必须同步复合操作 ― 可能是在一个粗粒度的锁上。
如果情况不确定,考虑使用同步包装
有时,在写一个类的时候,我们并不知道它是否要用在一个共享环境里。我们希望我们的类是线程安全的,但我们又不希望给一个总是在单线程环境内使用的类加上同步的负担,而且我们可能也不知道使用这个类时合适的锁粒度是多大。幸运的是,通过提供同步包装,我们可以同时达到以上两个目的。Collections 类就是这种技术的一个很好的示例;它们是非同步的,但在框架中定义的每个接口都有一个同步包装(例如, Collections.synchronizedMap()
),它用一个同步的版本来包装每个方法。
结论
虽然 JLS 给了我们可以使我们的程序线程安全的工具,但线程安全也不是天上掉下来的馅饼。使用同步会蒙受性能损失,而同步使用不当又会使我们承担数据混乱、结果不一致或死锁的风险。幸运的是,在过去的几年内 JVM 有了很大的改进,大大减少了与正确使用同步相关的性能损失。通过仔细分析在线程间如何共享数据,适当地同步对共享数据的操作,可以使得您的程序既是线程安全的,又不会承受过多的性能负担。