对性能的思考
I.性能与可伸缩性
衡量程序的性能:
“运行速度”-服务时间、等待时间
“处理能力”-生产量、吞吐量
可伸缩性指的是:当增加计算资源时(例如CPU、内存、存储容量或I/O带宽),程序的吞吐量或者处理能力能相应地增加。
当进行性能调优时,其目的通常是用更小的代价完成相同的工作;在进行可伸缩性调优时,其目的是设法将问题的计算并行化,从而能利用更多的计算资源来完成更多的工作。
II.评估各种性能权衡因素
避免不成熟的优化。首先使程序正确,然后再提高运行速度。
Amdahl定律
在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决于程序中可并行组件与串行组件所占的比重(显然越少越好)。
线程引入的开销
I.上下文切换
切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和JVM共享的数据结构。vmstat(UNIX),perfmon(Windows)
II.内存同步
竞争性同步带来的开销
III.阻塞
竞争的同步可能需要操作系统的介入,从而增加开销。JVM在实现阻塞行为时,可以采用自旋等待(Spin Waiting,指通过循环不断地尝试获取锁,直到成功)或通过操作系统挂起被阻塞的线程。
减少锁的竞争
串行操作会降低可伸缩性,并且上下文操作也会降低性能。在锁上发生竞争时将同时导致这两种问题,因此减少锁的竞争能够提高性能和可伸缩性。
I.缩小锁的范围(“快进快出”)
降低发生竞争可能性的一种有效方式就是尽可能缩短锁的持有时间。例如,可以将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作(I/O)。同步需要一定的开销,当把一个同步代码块分解为多个同步代码块时,反而会对提升性能产生负面影响。
示例代码:
优化前
public class AttributeStore {
private final Map<String, String> attributes = new HashMap<>();
public synchronized boolean userLocationMatches(String name, String regexp) {
String key = "user." + name + ".location";
String location = attributes.get(key);
if (null == location)
return false;
else
return Pattern.matches(regexp, location);
}
}
优化后
public class BetterAttributeStore {
private final Map<String, String> attributes = new HashMap<>();
public boolean userLocationMatches(String name, String regexp) {
String key = "user." + name + ".location";
String location;
synchronized(this){
location = attributes.get(key);
}
if (null == location)
return false;
else
return Pattern.matches(regexp, location);
}
}
II.减小锁的粒度(锁分解)
如果一个锁需要保护多个互相独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,最终降低每个锁被请求的频率。
示例代码:
优化前
public class ServerStatus {
public final Set<String> users;
public final Set<String> queries;
//......
public synchronized void addUser(String u) {
users.add(u);
}
public synchronized void removeUser(String u) {
users.remove(u);
}
public synchronized void addQuery(String u) {
queries.add(u);
}
public synchronized void removeQuery(String u) {
queries.remove(u);
}
}
优化后
public class BetterServerStatus {
public final Set<String> users;
public final Set<String> queries;
//......
public void addUser(String u) {
synchronized (users) {
users.add(u);
}
}
public void removeUser(String u) {
synchronized (users) {
users.remove(u);
}
}
public void addQuery(String u) {
synchronized (queries) {
queries.add(u);
}
}
public void removeQuery(String u) {
synchronized (queries) {
queries.remove(u);
}
}
}
III.锁分段
在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。例如,在ConcurrentHashMap的实现中使用了一个包含16个锁的数组,每个锁保护所有散列通的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。劣势:实现独占访问更难且开销更高。
示例代码:
public class StripedMap {
//同步策略:buckets[n]由locks[n%N_LOCKS]来保护
private static final int N_LOCKS = 16;
private final Node[] buckets;
private final Object[] locks;
public StripedMap(int numBuckets) {
buckets = new Node[numBuckets];
locks = new Object[N_LOCKS];
for (int i = 0; i < N_LOCKS; i++) {
locks[i] = new Object();
}
}
private final int hash(Object key) {
return Math.abs(key.hashCode() % buckets.length);
}
public Object get(Object key) {
int hash = hash(key);
synchronized (locks[hash % N_LOCKS]) {
for (Node m = buckets[hash]; m != null; m = m.next)
if (m.key.equals(key))
return m.value;
}
return null;
}
public void clear() {
int hash;
for (int i = 0; i < buckets.length; i++) {
hash = hash(buckets[i].key);
synchronized (locks[hash % N_LOCKS]) {
buckets[i] = null;
}
}
}
private static class Node {
public Node next;//...
public Object key;
public Object value;
}
}
IV.避免热点域
当每个操作都请求多个变量时,锁的粒度将很难降低。这是在性能与可伸缩性之间互相制衡的另一个方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,都会引入一些“热点域(Hot Field)”,而这些热点域往往会限制可伸缩性。例如HashMap中的计数器。为了避免热点域,ConcurrentHashMap为每个分段都维护了一个独立的计数器,并通过每个分段的锁来维护这个值,size将对每个分段进行枚举并将每个分段中的元素数量相加(每当调用size时,将返回值缓存到一个volatile变量中,并且每当容器被修改时,使这个缓存中的值无效(将其设为-1),如果发现缓存的值非负,那么表示这个值是正确的,可以直接返回,否则重新计算)。
V.替代独占锁
使用并发容器、读-写锁、不可变对象以及原子变量。
减少上下文切换的开销
许多任务中都包含一些可能被阻塞的操作。当任务在运行和阻塞这两个状态之间转换时,就相当于一次上下文切换。在服务器应用程序中,发生阻塞的原因之一就是在处理请求时产生各种日志消息。通过将I/O操作从处理请求的线程中分离出来,可以缩短处理请求的平均服务时间。调用log方法的线程将不会再因为等待输出流的锁或者I/O完成而被阻塞,它们只需将消息放入队列,然后就返回到各自的任务中。
参考:《Java并发编程实战》