同步不是敌人
大多数编程语言的语言规范都不会谈到线程和并发的问题;因为一直以来,这些问题都是留给平台或操作系统去详细说明的。但是,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()
),它用一个同步的版本来包装每个方法。
减少争用
当我们说一个程序“太慢”时,我们通常是指两个性能属性 — 等待时间和可伸缩性 — 中的一个。 等待时间指完成一个给定任务所花费的时间,而 可伸缩性则指随着负载的增加或给定计算资源的增加,程序的性能将怎样变化。严重的争用对等待时间和可伸缩性都不利。
争用为什么是这样一个问题
争用同步之所以慢,是因为它涉及多个线程切换和系统调用。当多个线程争用同一个管程时,JVM 将不得不维护一个等待该管程的线程队列(并且这个队列在多个处理器间必须是同步的),这就意味着花费在 JVM 或 OS 代码上的时间相对多了,而花费在程序代码上的时间则相对少了。而且,争用还削弱了可伸缩性,因为它迫使调度程序把操作序列化,即使有可用的空闲处理器也是如此。当一个线程正在执行一个同步块时,任何等待进入该块的线程都将被阻塞。如果没有其他的线程可供执行,那么处理器就将空闲。
如果想编写具可伸缩性的多线程程序,我们就必须减少对临界资源的争用。有很多技术可以做到这一点,但在应用它们之前,您需要仔细研究一下您的代码,判断出在什么情况下您需要在公共管程上同步。判断哪些锁是瓶颈很困难:有时候锁隐藏在类库中,有时候又通过同步方法隐式地指定,因此在阅读代码时,锁并不那么明显。而且,目前的争用检测工具也很差。
技术 1:放进去,取出来
使同步块尽可能小显然是降低争用可能性的一种技术。一个线程占用一个给定锁的时间越短,另一个线程在该线程仍占用锁时请求该锁的可能性就越小。因此在您应该使用同步去访问或更新共享变量时,在同步块的外面进行线程安全的预处理或后处理通常会更好些。
清单 1 演示了这种技术。我们的应用程序维护一个代表各种实体的属性的 HashMap
,给定用户的访问权列表就是这种属性中的一种。访问权被存储成一个以逗号隔开的权限列表。方法 userHasAdminAccess()
在全局属性表中查找用户的访问权,并判断该用户是否有被称为“ADMIN”的访问权。
清单 1. 在同步块中花费太多(多于必要时间)时间
public boolean userHasAdminAccess(String userName) { synchronized (attributesMap) { String rights = attributesMap.get("users." + userName + ".accessRights"); if (rights == null) return false; else return (rights.indexOf("ADMIN") >= 0); } }
这个版本的 userHasAdminAccess
是线程安全的,但它占用锁的时间比必要占用时间长太多。为创建串联的字符串“users.brian.accessRights”
,编译器将创建一个临时的 StringBuffer
对象,调用 StringBuffer.append
三次,然后调用StringBuffer.toString
,这意味着至少两个对象的创建和几个方法调用。接着,程序将调用 HashMap.get
检索该字符串,然后调用String.indexOf
抽取想要的权限标识符。 就占这个方法所做的全部工作的百分比而言,预处理和后处理的比重是很大的;因为它们是线程安全的,所以将它们移到同步块外面是有意义的,如清单 2 所示。
清单 2. 减少花费在同步块中的时间
public boolean userHasAdminAccess(String userName) { String key = "users." + userName + ".accessRights"; String rights; synchronized (attributesMap) { rights = attributesMap.get(key); } return ((rights != null) && (rights.indexOf("ADMIN") >= 0)); }
另一方面,有可能会过度使用这种技术。要是您想用一小块线程安全代码把要求同步的两个操作隔开,那么只使用一个同步块一般会更好些。
技术 2:减小锁的粒度
把您的同步分散在更多的锁上是减少争用的另一种有价值的技术。例如,假设您有一个类,它用两个单独的散列表来存储用户信息和服务信息,如清单 3 所示。
清单 3. 一个减小锁的粒度的机会
public class AttributesStore { private HashMap usersMap = new HashMap(); private HashMap servicesMap = new HashMap(); public synchronized void setUserInfo(String user, UserInfo userInfo) { usersMap.put(user, userInfo); } public synchronized UserInfo getUserInfo(String user) { return usersMap.get(user); } public synchronized void setServiceInfo(String service, ServiceInfo serviceInfo) { servicesMap.put(service, serviceInfo); } public synchronized ServiceInfo getServiceInfo(String service) { return servicesMap.get(service); } }
这里,用户和服务数据的访问器方法是同步的,这意味着它们在 AttributesStore
对象上同步。虽然这样做是完全线程安全的,但却增加了毫无实际意义的争用可能性。如果一个线程正在执行 setUserInfo
,就不仅意味着其它线程将被锁在 setUserInfo
和 getUserInfo
外面(这是我们希望的),而且意味着它们也将被锁在 getServiceInfo
和 setServiceInfo
外面。
通过使访问器只在共享的实际对象( userMap
和 servicesMap
对象)上同步可以避免这个问题,如清单 4 所示。
清单 4. 减小锁的粒度
public class AttributesStore { private HashMap usersMap = new HashMap(); private HashMap servicesMap = new HashMap(); public void setUserInfo(String user, UserInfo userInfo) { synchronized(usersMap) { usersMap.put(user, userInfo); } } public UserInfo getUserInfo(String user) { synchronized(usersMap) { return usersMap.get(user); } } public void setServiceInfo(String service, ServiceInfo serviceInfo) { synchronized(servicesMap) { servicesMap.put(service, serviceInfo); } } public ServiceInfo getServiceInfo(String service) { synchronized(servicesMap) { return servicesMap.get(service); } } }
现在,访问服务 map(servicesMap)的线程将不会与试图访问用户 map(usersMap)的线程发生争用。(在这种情况下,通过使用 Collections 框架提供的同步包装机制,即 Collections.synchronizedMap
来创建 map 可以达到同样的效果。)假设对两个 map 的请求是平均分布的,那么这种技术在这种情况下将把可能的争用数目减半。
在 HashMap 中应用技术 2
服务器端的 Java 应用程序中最普通的争用瓶颈之一是 HashMap
。应用程序使用 HashMap
来高速缓存所有类别的临界共享数据(用户概要文件、会话信息、文件内容), HashMap.get
方法可能对应于许多条字节码指令。例如,如果您正在编写一个 Web 服务器,而且所有的高速缓存的页都存储在 HashMap
中,那么每个请求都将需要获得并占用那个 map 上的锁,这就将成为一个瓶颈。
我们可以扩展锁粒度技术以应付这种情形,尽管我们必须很小心,因为有与这种方法有关的一些 Java 内存模型(Java Memory Model,JMM)危害。清单 5 中的 LockPoolMap
展示了线程安全的 get()
和 put()
方法,但把同步分散在了锁池中,充分降低了争用可能性。
LockPoolMap
是线程安全的,其功能类似于简化的 HashMap
,但却有更多吸引人的争用属性。同步不是在每个 get()
或 put()
操作上对整个 map 进行,而是在散列单元(bucket)级上完成。每个 bucket 都有一个锁,而且该锁在遍历 bucket(为了读或写)的时候被获取。锁在创建 map 的时候被创建(如果不在此时创建锁,将会出现 JMM 问题。)
如果您创建了带有很多 bucket 的 LockPoolMap
,那么将有很多线程可以并发地使用该 map,同时争用的可能性也被大大降低了。然而,减少争用并不是免费的午餐。由于没有在全局锁上同步,使得将 map 作为一个整体进行操作,例如 size()
方法,变得更加困难。 size()
的实现将不得不依次获取各个 bucket 的锁,对该 bucket 中的节点进行计数,释放锁,然后继续到下一个 bucket。然而前面的锁一旦被释放,其它的线程就将可以自由修改前面的 bucket。到 size()
完成对元素的计数时,结果很可能是错的。不过, LockPoolMap
技术在某些方面还是可以做得相当好的,例如共享高速缓存。
清单 5. 减小 HashMap 上锁的粒度
import java.util.*; /** * LockPoolMap implements a subset of the Map interface (get, put, clear) * and performs synchronization at the bucket level, not at the map * level. This reduces contention, at the cost of losing some Map * functionality, and is well suited to simple caches. The number of * buckets is fixed and does not increase. */ public class LockPoolMap { private Node[] buckets; private Object[] locks; private static final class Node { public final Object key; public Object value; public Node next; public Node(Object key) { this.key = key; } } public LockPoolMap(int size) { buckets = new Node[size]; locks = new Object[size]; for (int i = 0; i < size; i++) locks[i] = new Object(); } private final int hash(Object key) { int hash = key.hashCode() % buckets.length; if (hash < 0) hash *= -1; return hash; } public void put(Object key, Object value) { int hash = hash(key); synchronized(locks[hash]) { Node m; for (m=buckets[hash]; m != null; m=m.next) { if (m.key.equals(key)) { m.value = value; return; } } // We must not have found it, so put it at the beginning of the chain m = new Node(key); m.value = value; m.next = buckets[hash]; buckets[hash] = m; } } public Object get(Object key) { int hash = hash(key); synchronized(locks[hash]) { for (Node m=buckets[hash]; m != null; m=m.next) if (m.key.equals(key)) return m.value; } return null; } }
表 1 比较了共享 map 的三种实现的性能:同步的 HashMap
,非同步的 HashMap
(线程不安全的)和 LockPoolMap
。提供非同步的版本只是为了展示争用的开销。我们在使用 Sun 1.3 JDK 的双处理器系统 Linux 系统上,用不同数目的线程,运行了在 map 上执行随机进行 put()
和get()
操作的测试。该表展示了每个组合的运行时间。这个测试是有点极端的一个案例,测试程序只是访问 map,而不做任何别的事,因此它比实际的程序存在多得多的争用,设计这个测试只是为了说明争用对性能的损害。
表 1. HashMap
和 LockPoolMap
之间的可伸缩性比较
线程 | 非同步的 HashMap (不安全的) | 同步的 HashMap | LockPoolMap |
---|---|---|---|
1.1 | 1.4 | 1.6 | |
1.1 | 57.6 | 3.7 | |
2.1 | 123.5 | 7.7 | |
3.7 | 272.3 | 16.7 | |
16 | 6.8 | 577.0 | 37.9 |
32 | 13.5 | 1233.3 | 80.5 |
虽然在线程数量很多的情况下,所有的实现都表现出相似的伸缩性特征,但 HashMap
实现在从一个线程变到两个线程时却表现出对性能影响的巨大变化,因为此时每一个 put()
和 get()
操作都存在争用。在线程数大于 1 时, LockPoolMap
技术几乎比 HashMap
技术快 15 倍。这个差别反映了调度开销上的时间损失和用于等待获取锁的空闲时间。 LockPoolMap
的优势在拥有更多处理器的系统中将表现得更加明显。
技术 3:锁崩溃
另一种能提高性能的技术称为“锁崩溃”(请参阅清单 6)。回想一下, Vector
类的方法几乎都是同步的。假设您有一个 String
值的 Vector
,并想搜索最长的 String
。进一步假设您已经知道只会在末端添加元素,而且元素不会被删除,那么,像 getLongest()
方法所展示的那样访问数据是安全的(通常),该方法只是调用 elementAt()
来检索每个元素,简单地对 Vector
的元素作循环。
getLongest2()
方法非常相似,除了在开始循环之前获取 Vector
上的锁之外。这样做的结果是当 elementAt()
试图获取锁时,JVM 将注意到当前线程已经拥有锁,而且将不会参与争用。 getLongest2()
加大了同步块,这似乎违背了“放进去,取出来”的原则,但因为避免了很大量可能的同步,调度开销的时间损失也少了,速度仍然快得多。
在运行 Sun 1.3 JDK 的双处理器 Linux 系统上,拥有两个线程,仅仅循环调用 getLongest2()
的的测试程序比调用 getLongest()
的要快 10 倍以上。虽然两个程序的序列化程度相同,但前者调度开销的时间损失要少得多。这又是一个极端的示例,但它表明争用的调度开销并不是微不足道的。即使只运行一个线程,崩溃版的速度也要快约 30% :获取您已占用的锁比获取无人占用的锁要快得多。
清单 6. 锁崩溃
Vector v; ... public String getLongest() { int maxLen = 0; String longest = null; for (int i=0; i<v.size(); i++) { String s = (String) v.elementAt(i); if (s.length() > maxLen) { maxLen = s.length(); longest = s; } } return longest; } public String getLongest2() { int maxLen = 0; String longest = null; synchronized (v) { for (int i=0; i<v.size(); i++) { String s = (String) v.elementAt(i); if (s.length() > maxLen) { maxLen = s.length(); longest = s; } } return longest; } }
有时最好别同步
结论
争用同步会严重影响程序的可伸缩性。更糟的是,除非您进行实际的负载测试,否则与争用相关的性能问题并不总是会在开发和测试过程中表现出来。本文提供的技术能有效地降低程序的争用代价,并增大程序在出现非线性伸缩行为之前所能承受的负载。但在应用这些技术之前,您必须首先分析您的程序,以判断哪里可能出现争用。
在本系列的最后一部分,我们将讨论 ThreadLocal
,它是 Thread API 中经常被忽视的一个工具。通过使用 ThreadLocal
,给予每个线程它自己的特定临界对象的副本,我们就可以减少争用。别走开哦!
编写线程安全类是困难的。它不但要求仔细分析在什么条件可以对变量进行读写,而且要求仔细分析其它类能如何使用某个类。 有时,要在不影响类的功能、易用性或性能的情况下使类成为线程安全的是很困难的。有些类保留从一个方法调用到下一个方法调用的状态信息,要在实践中使这样的类成为线程安全的是困难的。
管理非线程安全类的使用比试图使类成为线程安全的要更容易些。非线程安全类通常可以安全地在多线程程序中使用,只要您能确保一个线程所用的类的实例不被其它线程使用。例如,JDBC Connection
类是非线程安全的 — 两个线程不能在小粒度级上安全地共享一个 Connection
— 但如果每个线程都有它自己的 Connection
,那么多个线程就可以同时安全地进行数据库操作。
不使用 ThreadLocal
为每个线程维护一个单独的 JDBC 连接(或任何其它对象)当然是可能的;Thread API 给了我们把对象和线程联系起来所需的所有工具。而 ThreadLocal 则使我们能更容易地把线程和它的每线程(per-thread)数据成功地联系起来。
什么是线程局部变量(thread-local variable)?
线程局部变量高效地为每个使用它的线程提供单独的线程局部变量值的副本。每个线程只能看到与自己相联系的值,而不知道别的线程可能正在使用或修改它们自己的副本。一些编译器(例如 Microsoft Visual C++ 编译器或 IBM XL FORTRAN 编译器)用存储类别修饰符(像 static
或volatile
)把对线程局部变量的支持集成到了其语言中。Java 编译器对线程局部变量不提供特别的语言支持;相反地,它用 ThreadLocal
类实现这些支持, 核心 Thread
类中有这个类的特别支持。
因为线程局部变量是通过一个类来实现的,而不是作为 Java 语言本身的一部分,所以 Java 语言线程局部变量的使用语法比内建线程局部变量语言的使用语法要笨拙一些。要创建一个线程局部变量,请实例化类 ThreadLocal
的一个对象。 ThreadLocal
类的行为与java.lang.ref
中的各种 Reference
类的行为很相似; ThreadLocal
类充当存储或检索一个值时的间接句柄。清单 1 显示了ThreadLocal
接口。
清单 1. ThreadLocal 接口
public class ThreadLocal { public Object get(); public void set(Object newValue); public Object initialValue(); }
get()
访问器检索变量的当前线程的值; set()
访问器修改当前线程的值。 initialValue()
方法是可选的,如果线程未使用过某个变量,那么您可以用这个方法来设置这个变量的初始值;它允许延迟初始化。用一个示例实现来说明 ThreadLocal 的工作方式是最好的方法。清单 2 显示了 ThreadLocal
的一个实现方式。它不是一个特别好的实现(虽然它与最初实现非常相似),所以很可能性能不佳,但它清楚地说明了 ThreadLocal 的工作方式。
清单 2. ThreadLocal 的糟糕实现
public class ThreadLocal { private Map values = Collections.synchronizedMap(new HashMap()); public Object get() { Thread curThread = Thread.currentThread(); Object o = values.get(curThread); if (o == null && !values.containsKey(curThread)) { o = initialValue(); values.put(curThread, o); } return o; } public void set(Object newValue) { values.put(Thread.currentThread(), newValue); } public Object initialValue() { return null; } }
这个实现的性能不会很好,因为每个 get()
和 set()
操作都需要 values
映射表上的同步,而且如果多个线程同时访问同一个ThreadLocal
,那么将发生争用。此外,这个实现也是不切实际的,因为用 Thread
对象做 values
映射表中的关键字将导致无法在线程退出后对 Thread
进行垃圾回收,而且也无法对死线程的 ThreadLocal
的特定于线程的值进行垃圾回收。
用 ThreadLocal 实现每线程 Singleton
线程局部变量常被用来描绘有状态“单子”(Singleton) 或线程安全的共享对象,或者是通过把不安全的整个变量封装进 ThreadLocal
,或者是通过把对象的特定于线程的状态封装进 ThreadLocal
。例如,在与数据库有紧密联系的应用程序中,程序的很多方法可能都需要访问数据库。在系统的每个方法中都包含一个 Connection
作为参数是不方便的 — 用“单子”来访问连接可能是一个虽然更粗糙,但却方便得多的技术。然而,多个线程不能安全地共享一个 JDBC Connection
。如清单 3 所示,通过使用“单子”中的 ThreadLocal
,我们就能让我们的程序中的任何类容易地获取每线程 Connection
的一个引用。这样,我们可以认为 ThreadLocal
允许我们创建 每线程单子。
清单 3. 把一个 JDBC 连接存储到一个每线程 Singleton 中
public class ConnectionDispenser { private static class ThreadLocalConnection extends ThreadLocal { public Object initialValue() { return DriverManager.getConnection(ConfigurationSingleton.getDbUrl()); } } private ThreadLocalConnection conn = new ThreadLocalConnection(); public static Connection getConnection() { return (Connection) conn.get(); } }
任何创建的花费比使用的花费相对昂贵些的有状态或非线程安全的对象,例如 JDBC Connection
或正则表达式匹配器,都是可以使用每线程单子(singleton)技术的好地方。当然,在类似这样的地方,您可以使用其它技术,例如用池,来安全地管理共享访问。然而,从可伸缩性角度看,即使是用池也存在一些潜在缺陷。因为池实现必须使用同步,以维护池数据结构的完整性,如果所有线程使用同一个池,那么在有很多线程频繁地对池进行访问的系统中,程序性能将因争用而降低。
用 ThreadLocal 简化调试日志纪录
其它适合使用 ThreadLocal
但用池却不能成为很好的替代技术的应用程序包括存储或累积每线程上下文信息以备稍后检索之用这样的应用程序。例如,假设您想创建一个用于管理多线程应用程序调试信息的工具。您可以用如清单 4 所示的 DebugLogger
类作为线程局部容器来累积调试信息。在一个工作单元的开头,您清空容器,而当一个错误出现时,您查询该容器以检索这个工作单元迄今为止生成的所有调试信息。
清单 4. 用 ThreadLocal 管理每线程调试日志
public class DebugLogger { private static class ThreadLocalList extends ThreadLocal { public Object initialValue() { return new ArrayList(); } public List getList() { return (List) super.get(); } } private ThreadLocalList list = new ThreadLocalList(); private static String[] stringArray = new String[0]; public void clear() { list.getList().clear(); } public void put(String text) { list.getList().add(text); } public String[] get() { return list.getList().toArray(stringArray); } }
在您的代码中,您可以调用 DebugLogger.put()
来保存您的程序正在做什么的信息,而且,稍后如果有必要(例如发生了一个错误),您能够容易地检索与某个特定线程相关的调试信息。 与简单地把所有信息转储到一个日志文件,然后努力找出哪个日志记录来自哪个线程(还要担心线程争用日志纪录对象)相比,这种技术简便得多,也有效得多。
ThreadLocal
在基于 servlet 的应用程序或工作单元是一个整体请求的任何多线程应用程序服务器中也是很有用的,因为在处理请求的整个过程中将要用到单个线程。您可以通过前面讲述的每线程单子技术用 ThreadLocal
变量来存储各种每请求(per-request)上下文信息。
ThreadLocal 的线程安全性稍差的堂兄弟,InheritableThreadLocal
ThreadLocal 类有一个亲戚,InheritableThreadLocal,它以相似的方式工作,但适用于种类完全不同的应用程序。创建一个线程时如果保存了所有 InheritableThreadLocal
对象的值,那么这些值也将自动传递给子线程。如果一个子线程调用 InheritableThreadLocal
的 get()
,那么它将与它的父线程看到同一个对象。为保护线程安全性,您应该只对不可变对象(一旦创建,其状态就永远不会被改变的对象)使用InheritableThreadLocal
,因为对象被多个线程共享。 InheritableThreadLocal
很合适用于把数据从父线程传到子线程,例如用户标识(user id)或事务标识(transaction id),但不能是有状态对象,例如 JDBC Connection
。
ThreadLocal 的性能
虽然线程局部变量早已赫赫有名并被包括 Posix pthreads
规范在内的很多线程框架支持,但最初的 Java 线程设计中却省略了它,只是在 Java 平台的版本 1.2 中才添加上去。在很多方面, ThreadLocal
仍在发展之中;在版本 1.3 中它被重写,版本 1.4 中又重写了一次,两次都专门是为了性能问题。
在 JDK 1.2 中, ThreadLocal
的实现方式与清单 2 中的方式非常相似,除了用同步 WeakHashMap
代替 HashMap
来存储 values 之外。(以一些额外的性能开销为代价,使用 WeakHashMap 解决了无法对 Thread 对象进行垃圾回收的问题。)不用说, ThreadLocal
的性能是相当差的。
Java 平台版本 1.3 提供的 ThreadLocal
版本已经尽量更好了;它不使用任何同步,从而不存在可伸缩性问题,而且它也不使用弱引用。相反地,人们通过给 Thread
添加一个实例变量(该变量用于保存当前线程的从线程局部变量到它的值的映射的 HashMap
)来修改 Thread
类以支持 ThreadLocal
。因为检索或设置一个线程局部变量的过程不涉及对可能被另一个线程读写的数据的读写操作,所以您可以不用任何同步就实现 ThreadLocal.get()
和 set()
。而且,因为每线程值的引用被存储在自已的 Thread
对象中,所以当对 Thread
进行垃圾回收时,也能对该 Thread
的每线程值进行垃圾回收。
不幸的是,即使有了这些改进,Java 1.3 中的 ThreadLocal
的性能仍然出奇地慢。据我的粗略测量,在双处理器 Linux 系统上的 Sun 1.3 JDK 中进行 ThreadLocal.get()
操作,所耗费的时间大约是无争用同步的两倍。性能这么差的原因是 Thread.currentThread()
方法的花费非常大,占了 ThreadLocal.get()
运行时间的三分之二还多。虽然有这些缺点,JDK 1.3 ThreadLocal.get()
仍然比争用同步快得多,所以如果在任何存在严重争用的地方(可能是有非常多的线程,或者同步块被频繁地执行,或者同步块很大), ThreadLocal
可能仍然要高效得多。
在 Java 平台的最新版本,即版本 1.4b2 中, ThreadLocal
和 Thread.currentThread()
的性能都有了很大提高。有了这些提高,ThreadLocal
应该比其它技术,如用池,更快。由于它比其它技术更简单,也更不易出错,人们最终将发现它是避免线程间出现不希望的交互的有效途径。
ThreadLocal 的好处
ThreadLocal
能带来很多好处。它常常是把有状态类描绘成线程安全的,或者封装非线程安全类以使它们能够在多线程环境中安全地使用的最容易的方式。使用 ThreadLocal
使我们可以绕过为实现线程安全而对何时需要同步进行判断的复杂过程,而且因为它不需要任何同步,所以也改善了可伸缩性。除简单之外,用 ThreadLocal
存储每线程单子或每线程上下文信息在归档方面还有一个颇有价值好处 — 通过使用ThreadLocal
,存储在 ThreadLocal
中的对象都是 不被线程共享的是清晰的,从而简化了判断一个类是否线程安全的工作。