当我们说程序“太慢”时,通常是指两个性能属性之一-延迟或可伸缩性。 延迟描述了完成给定任务所需的时间,而可扩展性描述了在负载增加或计算资源增加的情况下程序性能如何变化。 高度争用对延迟和可伸缩性都不利。
为什么争用是这样的问题
有争议的同步速度很慢,因为它们涉及多个线程切换和系统调用。 当多个线程争用同一台监视器时,JVM必须维护一个线程队列以等待该监视器(并且此队列必须在处理器之间同步),这意味着在JVM或OS代码中花费的时间更多,而在您的操作系统中花费的时间更少。程序代码。 此外,争用会损害可伸缩性,因为即使可用的处理器可用,争用也会迫使调度程序对操作进行序列化。 当一个线程正在执行同步块时,任何等待进入该块的线程都将停止。 如果没有其他线程可用于执行,则处理器可能会处于空闲状态。
如果要编写可伸缩的多线程程序,则必须减少对关键资源的争用。 有许多技术可以做到这一点,但是在应用其中的任何一种之前,您需要仔细阅读代码并弄清楚将在普通监视器上进行同步的条件。 确定什么锁是瓶颈可能非常困难。 有时,锁隐藏在类库中或通过同步方法隐式指定,因此在查看代码时不太明显。 而且,用于检测争用的工具的当前状态非常差。
方法一:进进出出
减少争用可能性的一种显而易见的技术是使同步块尽可能短。 一个线程持有给定锁的时间越短,当第一个线程持有该锁时另一个线程将请求该锁的可能性就越低。 因此,尽管应该使用同步来访问或更新共享变量,但是通常最好在同步块之外进行任何线程安全的预处理或后处理。
清单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);
}
}
}
现在,访问服务映射的线程将不会与尝试访问用户映射的线程竞争。 (在这种情况下,通过使用Collections框架Collections.synchronizedMap
提供的同步包装器机制来创建地图,也可以获得相同的效果。)假设针对两个地图的请求是均匀分布的,在这种情况下,该技术将潜在竞争的数量减半。
将技术2应用于HashMap
HashMap
是服务器端Java应用程序中最常见的竞争瓶颈之一。 应用程序使用HashMap
缓存各种关键的共享数据(用户配置文件,会话信息,文件内容),并且HashMap.get
方法可能对应于许多字节码指令。 例如,如果您正在编写Web服务器,并且所有缓存的页面都存储在HashMap
,则每个请求都希望获取并保持该映射上的锁,这将成为瓶颈。
我们可以扩展锁粒度技术来处理这种情况,尽管我们必须小心,因为与这种方法相关的潜在Java内存模型(JMM)危险。 清单5中的LockPoolMap
公开了线程安全的get()
和put()
方法,但是将同步LockPoolMap
在锁池中,从而大大减少了争用。
LockPoolMap
是线程安全的,其功能类似于简化的HashMap
,但具有更吸引人的竞争属性。 同步不是在每个get()
或put()
操作上在整个映射上同步,而是在存储桶级别完成。 对于每个存储桶,都有一个锁,并且在遍历存储桶以进行读取或写入时会获取该锁。 这些锁是在创建地图时创建的(如果没有,则会存在JMM问题。)
如果您创建具有多个存储桶的LockPoolMap
,则许多线程将能够以更低的争用率并发使用地图。 但是,减少的争用并不是免费的。 通过不对全局锁进行同步,执行整体上作用于地图的操作size()
例如size()
方法size()
变得更加困难。 size()
的实现必须依次获取每个存储桶的锁,计算该存储桶中的节点数,然后释放锁并继续到下一个存储桶。 但是,一旦释放了先前的锁,其他线程现在就可以自由地修改先前的存储桶。 当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比较了三种共享地图实现的性能。 同步的HashMap
,未同步的HashMap
(不是线程安全的)和LockPoolMap
。 存在未同步的版本仅是为了显示争用的开销。 在使用Sun 1.3 JDK的双处理器系统Linux系统上,使用可变数量的线程在映射上执行了随机put()
和get()
操作的测试。 该表显示了每种组合的运行时间。 这个测试有些极端。 测试程序除了访问映射外什么也不做,因此争用将比实际程序多得多,但这只是为了说明争用的性能损失。
表1. HashMap
和LockPoolMap
之间的可伸缩性比较
线程数 | 不同步的HashMap(不安全) | 同步的HashMap | 锁池地图 |
---|---|---|---|
1个 | 1.1 | 1.4 | 1.6 |
2 | 1.1 | 57.6 | 3.7 |
4 | 2.1 | 123.5 | 7.7 |
8 | 3.7 | 272.3 | 16.7 |
16 | 6.8 | 577.0 | 37.9 |
32 | 13.5 | 1233.3 | 80.5 |
尽管所有实现对大量线程都具有相似的缩放特性,但是HashMap
实现在从一个线程转换为两个线程时会表现出巨大的性能损失,因为在每个单独的put()
和get()
操作上都会有争用。 与多个线程相比, LockPoolMap
技术比HashMap
技术快约15倍。 这种差异反映了由于计划开销和等待获取锁而花费的空闲时间所浪费的时间。 在具有更多处理器的系统上, LockPoolMap
的优势将更大。
技术三:锁崩溃
可以提高性能的另一种技术称为“锁崩溃”(请参见清单6)。 回想一下, Vector
类的方法几乎都是同步的。 假设您有一个Vector
String
值,并且正在搜索最长的String
。 进一步假设您知道元素将仅在末尾添加,并且不会被删除,从而使(大部分)安全访问数据,如getLongest()
方法所示,该方法仅循环遍历该元素的各个元素。 Vector
,调用elementAt()
来检索每个。
getLongest2()
方法非常相似,除了它在开始循环之前获取Vector
的锁。 这样的结果是,当elementAt()
尝试获取锁时,JVM会发现当前线程已经拥有该锁,并且不会竞争。 它延长了同步块,这似乎与“进入,退出”原理相反,但是由于避免了很多潜在的同步,因此它可以更快地进行,因为更少的时间将浪费在调度开销上。
在运行Sun JDK 1.3,测试程序与简单循环调用两个线程双处理器的Linux系统getLongest2()
要快10倍以上比一个叫getLongest()
虽然这两个程序具有相同的序列化程度,但调度开销却少得多。 再次,这是一个极端的示例,但是它表明争用的调度开销并不小。 即使使用单线程运行,折叠后的版本也要快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
,我们可以通过为每个线程提供自己的某些关键对象的副本来减少争用。 敬请关注!
翻译自: https://www.ibm.com/developerworks/java/library/j-threads2/index.html