11.1 对性能的思考
提升性能意味着用更少的资源做更多的事情
当操作性能由于某种特定的资源而受到限制时,我们通常将该操作成为资源密集型的操作,例如:CPU密集型,数据库密集型等
11.1.1性能与可伸缩性
在并发应用程序中针对可伸缩性进行设计和和调整时所采用的方法与传统的性能调优方法截然不同。
- 传统性能调优:speed does matter
目的:更小的代价完成更多的工作。
eg:缓存、算法复杂度优化 - 可伸缩性调优:number of useful resources does matter
目的:设法将问题的计算并行化从而利用更多的计算资源来完成更多的工作
11.2 Amdahl定律
并发线程都是由一系列的并行工作和串行工作组成的。有些任务本质上就是串行的。
随着处理器的增加,可以明显地看到,即使串行部分所占的百分比很小,也会极大地限制当增加计算资源时能够提升的吞吐率。
所有并发程序中都包含一些串行部分
程序清单11-1 对任务队列的串行访问:queue.take();
/**
所有工作者线程都共享一个从构造方法参数中传递进来的队列,
在对队列进行并发访问时,需要某种同步机制来维持队列的完整性
如果通过加锁来保护队列状态(实际上确是如此,见下面ArrayBlockingQueue源码部分),
那么当一个线程从队列中取出任务时,其它需要获取下一个任务的线程就必须等待,
这就是任务处理中的串行部分。
*/
public class WorkerThread extends Thread {
private final BlockingQueue queue;
public WorkerThread(BlockingQueue queue) {
this.queue = queue;
}
@Override
public void run() {
try {
Runnable task = queue.take();//串行部分
task.run();
} catch (InterruptedException e) {
return; //允许线程退出
}
}
}
--------------------------------ArrayBlockingQueue源码加锁---------------------------
/**
* BlockingQueue的实现
* 方法运行前后➕锁、解锁
*/
public class ArrayBlockingQueue extends AbstractQueue implements BlockingQueue, java.io.Serializable {
/** Main lock guarding all access */
final ReentrantLock lock;
public E poll() {
//每个方法加的都是同一个锁,貌似与synchronized修饰方法没啥大差别呢
final ReentrantLock lock = this.lock;
lock.lock();//➕锁
try {
return (count == 0) ? null : dequeue();
} finally {
lock.unlock();//解锁
}
}
public void put(E e) throws InterruptedException {
checkNotNull(e);
//每个方法加的都是同一个锁,貌似与synchronized修饰方法没啥大差别呢
final ReentrantLock lock = this.lock;//每个方法加的都是同一个锁
lock.lockInterruptibly();//➕锁
try {
while (count == items.length)
notFull.await();
enqueue(e);
} finally {
lock.unlock();//解锁
}
}
//其它方法......
}
串行容器和并发容器的吞吐率对比
吞吐量的差异来源于两个队列中不同比例的串行部分
- 同步的LinkedList:
- 单个锁保护整个队列
- 每个方法调用前后都➕锁解锁,导致插入、删除等操作都串行执行。
- 单个锁保护整个队列
- ConcurrretLinkedList:
- 更复杂的非阻塞队列算法:
- 原子引用更新各个链接指针,只对指针的操作串行执行
- 更复杂的非阻塞队列算法:
串行操作2:对结果进行处理
- 日志文件和结果容器:
- 多线程共享写入(串行)
- Map - Reduce :
- Reduce部分是串行
11.2.2 Amdahl定律的应用
11.3 线程引入的开销
11.3.1 上下文切换
可运行的线程数量 CPU数量,将某个正在运行的线程调度出来,然后让其他线程在CPU上运行。这将导致一次上下文切换。有一定的开销。新线程被切换进来,他所需要的数据不在当前处理器的本地缓存,因此首次调度会慢。
这就是为什么调度器会为每个可运行的线程分配一个最小执行时间,将上下文切换的开销分摊到不会中断的执行时间上,从而提高整体吞吐量。
Unix的 vmstat 检测
11.3.2内存同步
同步操作的性能开销:synchronized 和volatile提供的可见性保证中会使用到内存栅栏(Memory Barrier),它可以刷新缓存,使缓存无效。
区分有竞争的同步和无竞争的同步
synchronized针对无竞争的同步进行了优化(volatile通常是无竞争的)。
现代的JVM通过优化可以去掉一些不发生竞争的锁。
程序清单11.3.2 没有作用的同步(不要这么做)
synchronized(new Object()) {
//执行一些操作
}
程序清单11-3 可通过锁消除优化去掉的锁获取操作
/**
一些更完备的JVM能通过逸出分析(Escape Analysis)来找出不会发布到堆的本地对象引用(因此这个引用是线程本地的)。
对List的唯一引用就是stooges,并且所有封闭在栈中的变量都会自动成为线程本地变量。
在执行过程中,至少会将Vector上的锁 获取/释放 4次(每次调用add或toString都会执行一次)。
然而,一个智能运行的编译器通常会分析这些调用,
从而是stooges及其内部状态不会逸出,因此可以自动去掉这4次对锁的获取操作。
这个编译器优化成为锁消除优化(Lock Elision)* 英[ɪˈlɪʒn]:省音,省略部分读音*
IBM 的JVM支持,HotSpot预期从7开始支持。
*/
public String getStoogeNames() {
Vector stooges = new Vector();
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
return stooges.toString();
}
11.3.4阻塞
非竞争的操作只需在JVM中处理,而竞争的同步可能需要OS的介入,从而增加开销。
在锁上发生竞争时,竞争失败的线程肯定会发生阻塞。
阻塞实现方式
- 自旋等待(Spin-Wating:通过循环不断地尝试获取锁,直到成功)(适合等待时间短的切换)
- 操作系统挂起(适合等待时间长的)
11.4减少锁的竞争
在并发情况下,对可伸缩性最主要的威胁就是独占方式的资源锁。
有3种方式降低锁的竞争程度
1. 减少锁的持有时间
2. 降低锁的请求频率
3. 使用带有协调机制的独占锁,这些机制运行更高的并发性
11.4.1缩小锁的范围(快进快出)
方式一:缩短锁的持有时间,将一些与锁无关的代码移出代码块儿。尤其是那些开销较大的操作,以及可能被阻塞的操作,例如I/O操作。程序清单2-6
程序清单11-4 将一个锁不必要地持有过长时间
@ThreadSafe
public class AttributeStore {
@GuardedBy("this")
private final Map attributes = new HashMap<>();
//只有Map.get()方法才真正需要➕锁,却➕到了整个方法上
public synchronized boolean userLocationMatches(String name, String regexp) {
String key = "user." + name + ".location";
String location = attributes.get(key); //只有Map.get()方法才真正需要➕锁
if(location == null)
return false;
else
return Pattern.matches(regexp, location);
}
}
程序清单11-5 减少锁的持有时间
@ThreadSafe
public class BetterAttributeStore {
@GuardedBy("this")
private final Map attributes = new HashMap<>();//共享状态
public boolean userLocationMatches(String name, String regexp) {
String key = "user." + name + ".location";
String location;
synchronized(this) {
location = attributes.get(key);//共享状态只在此处被访问
}
if(location == null)
return false;
else
return Pattern.matches(regexp, location);
}
}
由于只有一个状态变量attributes,因此可以通过将线程安全性委托给其它的类来进一步提升它的性能(参见4.3节),通过线程安全的Map(HashTable、synchronizedMap 或 ConcurrentHashMap)来替代attributes,这样在无需在AtrributeStore中采用显示同步,缩小访问Map期间锁的范围。
同步代码块儿不宜太小,原子操作必须包含在一个同步代码块中,同步需要一定的开销,当把一个代码块儿分成多个同步代码块儿时,反而会对性能提升产生负面影响。
11.4.2减小锁的粒度
降低线程请求锁的频率:
将锁的请求分布到更多的锁上,将能有效地降低竞争。采用来
锁分解
锁分段
是不同的线程在不同的数据上操作,而不会互相干扰。
程序清单11-6 可以进行分解的程序
//ServerStatus甚至可以被分解成2个类,同时确保不丢失功能
//锁上会发生竞争,单数据上不会发生竞争
@ThreadSafe
public class ServerStatus {
@GuardedBy("this")private final Set users;
@GuardedBy("this")private final Set queries;
...
public synchronized void addUser(String u) {users.add(u);}
public synchronized void addQuery(String q) {queries.add(q);}
public synchronized void removeUser(String u) {users.remove(u);}
public synchronized void removeQuery(String q) {queries.remove(q);}
}
程序清单11-7 将ServerStatus改为为使用锁分解技术(显示锁分解)
@ThreadSafe
class BetterServerStatus {
@GuardedBy("users")private final Set users;
@GuardedBy("queries")private final Set queries;
...
public void addUser(String u) {
synchronized(users) {
users.add(u);
}
}
public synchronized void addQuery(String q) {
synchronized (queries) {
queries.add(q);
}
}
public synchronized void removeUser(String u) {
synchronized(users) {
users.remove(u);
}
}
public synchronized void removeQuery(String q) {
synchronized (queries) {
queries.remove(q);
}
}
}
对程序清单11-7进一步优化:(隐示锁分解)
将用户状态和查询状态分别委托给一个线程安全的Set,而不是使用显示的同步,能隐含对锁的分解,因为每个Set都会使用一个不同的 锁来保护
11.4.3 锁分段(ConcurrentHashMap)
将锁分解技术进一步扩展为对一组独立对象上的锁进行分解
ConcurrentHashMap中实现了一个包含16个锁的数组,每个所保护所有散列同的1/16,其中第N个散列同由第(N mod 16)个锁来保护。大约能把对于锁的请求减少到原来的1/16。这使得ConcurrentHashMap能够支持多达16个并发写入器。(使得拥有大量处理器的系统在高访问量的情况下实现更高的并发性)
程序清单11-8基于散列的Map中使用锁分段技术
@ThreadSafe
public class StripedMap {
//同步策略:buckets[n]由Locks[n%N_LOCKS]来保护
private static final int N_LOCKS = 16;
private final Node[] buckets;
private final Object[] locks;
private static class Node{
Node pre;
Node next;
Object key;
Object value;
public Node next() {return next;}
}
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() {
for(int i = 0; i < buckets.length; i++) {
synchronized (locks[i % N_LOCKS]) {
buckets[i] = null;
}
}
}
//...
}
11.4.4 避免热点域
ConcurrentHashMap的size(),为每一个分段都维持了一个计数,并通过每个分段的锁来维持这个值。
11.4.5 一些替代独占锁的方法
并发容器、读-写锁 、不可变对象、原子变量。
- 只读的数据结构,其中包含不变性可以完全不需要加锁操作。
- ReadWriteLock实现了多个读取操作和单个写入操作情况下的加锁规则:
如果多个读取操作不会修改共享资源,那么这些读取操作可以同时访问该共享资源,但执行写入操作时必须以独占的方式来获取锁。
11.4.6监测CPU的利用率
CPU利用不充足的原因:负载不充足、I/O密集、外部限制、锁竞争 等
11.4.7向对象池说“不”
在对象池中,对象能被循环使用,而不是由垃圾收集器回收,并在需要时重新分配。
11.5 比较Map的性能
ConcurrentHashMap的的实现中国假设,大多数常用操作都是获取某个已经存在的值,因此它对各种get操作进行了优化从而提供最高的性能和并发性。
ConcurrentHashMap大多数读操作并不会加锁,并且在写入操作和其它一些读操作中使用了锁分段技术。因此多个线程能并发地访问Map而不发生阻塞。