级别: 初级 Brian Goetz (brian@quiotix.com), 软件顾问
2001 年 9 月 01 日 在本系列的
第 1 部分,我们讨论了无争用同步的性能开销。尽管常常听说同步方法调用的开销是非同步方法调用开销的 50 倍,这个数字实际上仍然相当容易产生误导。JVM 的每个后继版本在整体性能上的提高和无争用同步代价的降低使得无争用同步开销问题不再显得那么突出。但争用同步的代价仍然非常高昂。而且,严重的争用将严重损害应用程序的可伸缩性 — 随着负载的增加,存在严重争用同步的应用程序的性能将显著降低。本文将探讨能够减少争用的几种技术,以提高您程序的可伸缩性。
参加 Brian 的
多线程 Java 编程讨论论坛以获得您工程中的线程和并发问题的帮助。
当我们说一个程序“太慢”时,我们通常是指两个性能属性 — 等待时间和可伸缩性 — 中的一个。
等待时间指完成一个给定任务所花费的时间,而
可伸缩性则指随着负载的增加或给定计算资源的增加,程序的性能将怎样变化。严重的争用对等待时间和可伸缩性都不利。
争用为什么是这样一个问题
争用同步之所以慢,是因为它涉及多个线程切换和系统调用。当多个线程争用同一个管程时,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 ,给予每个线程它自己的特定临界对象的副本,我们就可以减少争用。别走开哦!
参考资料
关于作者
对本文的评价
|