您的位置:首页 > 其它

第11章-性能与可伸缩性

2016-06-22 17:24 141 查看

11.1 对性能的思考

11.1.1 性能的提升意味着什么


提升性能意味着用更少的资源做更多的事情,

在任何时刻,提升性能的前提是要保证程序的正确性


当操作由于某种特定的资源而受到限制时,通常就称该操作为
资源密集型操作
,如
CPU密集型
数据库密集型
等,如果程序需要大量的运算工作,则称这个程序是计算密集型的。

11.1.2 如何获取更好的性能

更有效地利用现有处理资源

尽可能地利用新的处理资源

作者原文写的是:“要想通过并发来获得更好的性能,需要努力做好两件事:......”,省略号所述就是如上两点,个人认为无论是通过并发还是通过别的什么手段来获取更好的性能,都应该做到以上两点。

从性能监视的角度看,要获得良好的性能意味着CPU需要尽可能保持忙碌状态。

当程序是计算密集型(需要大量的计算)时,增加处理器可以提高性能,但是如果当前的程序都无法使现有处理器保持忙碌的话,那增加再多的处理器也无济于事了。

所以可以说,当程序无法有效利用现有处理资源(使之保持忙碌)的话,没有必要再增加新的处理资源,因为根本就没有意义。

11.1.3 并发为什么能够获取更好的性能

并发通过将应用程序分解到多个线程上执行,使得每个处理器都执行一些工作,从而使所有CPU都保持忙碌状态,因为如上所说,并发更有效地利用了现在的处理资源,所以能够获得更好的性能。

11.1.4 可伸缩性是什么

应用程序的性能其实包括两个方面(
“多快”
“多少”
):

运行速度(指定的任务单元需要
“多快”
才能处理完成)

处理能力(在计算资源一定的情况下,能完成
“多少”
工作)

针对以上两个方面,可以采用一些指标来衡量程序的性能,“运行速度”的衡量指标包括
服务时间
延迟时间
等,“处理能力”的衡量指标一般有
生产量
吞吐量
等。


可伸缩性指的是:当增加计算资源时(例如
CPU
内存
存储容量
I/O带宽
),程序的吞吐量或者处理能力能相应地增加


11.1.5 评估各种性能权衡因素


避免不成熟的优化。首先使程序正确,然后再提高运行速度--如果程序运行还不够快


在进行任何与性能相关的决策时,我们都需要考虑如下的问题

“更快”的含义是什么?

该方法在什么条件下运行得更快?是在低负载还是高负载情况下?大数据集还是小数据集?能否通过测试结果来验证答案?

这些条件在运行环境中的发生频率?能否通过测试结果来验证你的答案?

在其他不同条件的环境中能否使用这里的代码?

在实现这种性能提升时需要付出哪些隐含的代价,例如增加开发风险或维护开销?这种权衡是否合适?

最后,当你根据这些问题作出你的决策时,最重要的就是


以测试为基准,不要猜测


11.2 Amdahl定律


Amdahl定律一般称
阿姆达尔定律
,它描述的是:在增加计算资源的情况下,程序在理论上能够实现最高加速力,这个值取决于程序中可并行组件与串行组件所占的比重。假定
F
是必须被串行执行的部分,那么根据Amdahl定律,在包含
N
个处理器的机器中,最高的加速比为:

Speedup ≤ 1/(F+(1-F)/N)


从公式可以看出,当
N
趋近无穷大时,最大加速比趋近于
1/F
,比如,如果程序中有50%的计算需要串行执行,那么最高的加速比只能是2,10%时则为10。

Amdahl定律还量化了串行化的效率开销,在拥有10个处理器的系统中,如果有10%的部分需要串行执行,此时最高加速比为5.3(53%的使用率),在拥有100个处理器的系统中加速比则可以达到9.2(9%的使用率),但是,即使拥有无限多的CPU,加速比也达不到10。

11.2.1 所有并发程序中都包含一些串行部分

public class WorkerThread extends Thread {
private final BlockingQueue<Runnalbe> queue;

public WorkerThread(BlockingQueue queue){
this.queue = queue;
}

public void run() {
while (true) {
try {
Runnable task = queue.take();
task.run();
} catch (InterruptException e) {
break;//允许线程退出
}
}
}
}

以上程序中假设N个线程正在执行,这些线程从一个共享的工作队列中取出任务进行处理,如果增加处理器,程序的性能是否会相应地变化呢?

看起来程序似乎能完全并行化:各个任务之间不会相互等待,因此处理器越多,能够并发处理的任务就越多。但是,在这个过程中包含了一个串行部分——从队列中获取任务。

程序中的工作队列为所有线程共享,所以在对此队列进行并发访问时需要采用同步机制来维护其完整性,若通过加锁来保护队列状态,那么当一个线程从队列中取出任务时,其它线程就必须等待,这就是任务处理过程中的串行部分。

事实上,就像以上程序所展示的一样:在所有的并发程序中都包含一些串行部分。

11.3 线程引入的开销

11.3.1 上下文切换

什么是上下文切换?

举个栗子:医院有固定的科室,以及一定数量的医护人员,如果平时病人不多的话,接诊是很轻松的事,倘若因为某天很多人都生病了,医院肯定忙不过来,这天,赵四去瞧病,刚拍完片子,医生突然接到通知要去处理一个急诊病人,由于人手不够,他只能先记录一下当前赵四都看了什么,然后告诉赵四:刘能腿折了,我先去看他,一会再回来看你

上面的例子反映在线程的调度上就是所谓的“上下文切换”:


如果可运行的线程数大于CPU的数量(病人太多,医生不够),那么操作系统最终会将某个正在运行的线程调度出来(赵四先等会),从而使其他线程能够使用CPU(医生能去给刘能看病),这将导致一次上下文切换,在此过程中将保存当前运行线程的执行上下文(赵四拍完片子了,一会回来做心电图),并将新调度出来的线程的执行上下文设置为当前上下文(当前任务变成急诊瘸腿刘能)


例子升级:如果医院突然来了许多急诊(乡村爱情故事剧组吃麻辣烫集体中毒),由于人手不够,就会出现好几个病人等着一个医生出急诊的情况,这些病人只能先等着了,如果有别的医生有空的话医院会把这个病人交给他看,但是,如果医护人员极度缺乏的话,就会造成很多病人等待的情况,也许医院忙活了一天,也看不了几个病人。

升级版的例子可以表示这样一种情况:当线程由于等待某个发生竞争的锁而被阻塞时,
JVM
通常会将这个线程挂起,并允许它被交换出去,如果线程频繁发生阻塞,那么它们将无法使用完整的调度时间片,CPU密集型的程序就会发生越多的上下文切换,增加了调度开销,因此吞吐量就降低了。

上下文切换的实际开销会随着平台的不同而变化,就比如每个医院的看病效率会不同,按照经验来看:在多数通用的处理器中,这个开销相当于
5000~10000
个始终周期,也就是
几微秒


11.3.2 内存同步

同步操作对性能的影响要区分
有竞争的同步
无竞争的同步
,无竞争的同步虽然也消耗一些时钟周期,但是对整体性能影响微乎其微,现代的JVM会去掉一些不会发生竞争的锁以减少不必要的同步开销,如:

//没有作用的同步
synchronized (new Object){
//do something...
}

同时还有JVM的
逸出分析
、编译器的
锁粒度粗化
等方法来减少同步的开销。

总之,减少内存同步开销的方法有三种:

去掉不会发生竞争的锁(锁消除优化,Java 7开始在HotSpot中支持)

逸出分析(找出不会发布到堆的本地对象-线程本地的引用)

编译期执行锁粒度粗化操作(将邻近的同步代码块用同一个锁合并)


另外,某个线程中的同步可能会影响到其他线程的性能。因为同步会增加共享内存总线上的信号量,总线带宽是有限的,而且所有的处理器都共享这条总线。所以如果有多个线程竞争同步带宽,那么所有使用了同步的线程都会受到影响


11.3.3 阻塞


当竞争的同步发生时,需要操作系统的介入,从而会增加一些开销。在锁上发生竞争时,竞争失败的线程肯定会
阻塞



阻塞的处理:

JVM
在实现阻塞行为时,可采用
自旋等待
(Spin-Waiting:通过循环不断尝试获取锁直到成功)

操作系统
挂起被阻塞的线程

以上两种方式的效率高低取决于上下文切换的开销和成功获取锁之前要等待的时间长短,等待时间短可以采用自旋等待,时间较长就采用挂起方式。

目前,大多数JVM在等待锁时都只是将线程挂起(挂起时包含两次额外的上下文切换:阻塞的线程交换出去和锁或资源可用时切换回来)。

11.4 减少锁的竞争


串行操作会降低可伸缩性(Amdahl定律),上下文切换也会降低性能。在锁上发生竞争时将同时导致这两种问题,因此减少锁的竞争能够提高性能和可伸缩性。


对某个独占锁保护的资源进行访问时采用串行方式——每次只有一个线程能访问它,由
Amdahl
定律可以推论出:


在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁


那么,哪些原因有可能导致在锁上发生竞争呢?

我们都去过公共厕所,有时候人少不用排队,有时候上厕所的人很多排了很长的队,更可恶的是有些人进去半天都出不来引得外面的人一顿抱怨。锁的竞争也一样,锁的请求频率和每次持有锁的时间会影响在锁上发生竞争的可能性,当二者乘积很小时大多数情况不会发生竞争,反之则不然。

有三种方式可以降低锁的竞争程度:

减少锁的持有时间(别长时间占用蹲位)

降低锁的请求频率(排队上厕所的人少)

使用带有协调机制的独占锁,这些机制允许更高的并发性(传说中的女生抢了男厕所地盘?)

11.4.1 缩小锁的范围(“快进快出”)

缩小锁的范围意味着在这个锁中执行更少的操作,因为与锁无关的代码越多,锁的持有时间就越长(去公厕上厕所,就不要在里面慢慢欣赏AV了),这样会加剧锁的竞争。

所以,尽可能将一些与锁无关的代码移出同步代码块,尤其是那些开销较大以及可能被阻塞的操作(
I/O
)。

示例:

public class ReduceLockRange {
/** 厕所蹲位信息 */
@Generated("this")
private Map<Integer, ShitHole> shitHoles = new HashMap<>();

public ReduceLockRange(Map<Integer, ShitHole> shitHoles) {
this.shitHoles = shitHoles;
}

/**
* 上厕所
*
* @param holeIndex 当前排队的蹲位编号
* @param customer  上厕所的人
* @return true or false
*/
public synchronized boolean gotoShit(Integer holeIndex, String customer) {
ShitHole hole;
String message;
hole = shitHoles.get(holeIndex);
if (hole.isInUse()) {
message = "对不起," + customer + ",第" + holeIndex + "个蹲位有人了";
System.out.println(message);
return false;
} else {
message = "第" + holeIndex + "个蹲位被" + customer + "承包了";
System.out.println(message);
return true;
}
}
}

在以上代码中,整个
gotoShit
方法都是用了
synchronized
来修饰,但是只有
shitHoles.get(holeIndex)
这个方法真正需要锁,构建
message
等操作不需要持有锁,但是它们或多或少会增加方法中锁的持有时间,所以把这些操作移出同步块,单独同步
hole = shitHoles.get(holeIndex);
这一句就可以了。

11.4.2 减小锁的粒度

降低线程请求锁的频率可以通过两种手段来实现

锁分解

锁分段


如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,每个锁只保护一个变量,从而提高可伸缩性并降低锁被请求的频率。


锁分解示例:

public class RestState {
@Generated("this")
public final Set<String> users;//食客
@Generated("this")
public final Set<String> reserves;//预定

/** ... */

public void addUser(String user){
synchronized (users){
users.add(user);
}
}

public void addReserves(String reserv){
synchronized (reserves){
reserves.add(reserv);
}
}
}

代码模拟一个餐厅的运营监控,客人用餐、用餐结束、预定开始、预定取消时会调用相应的
add
remove
来更新
RestState
对象,两种信息互相独立。所以代码中不是用
RestState
锁来保护用户状态和预定状态,而是对它们分别用锁,这样就减小了锁的粒度。


在锁上的竞争不激烈时,通过将一个锁分解成两个,能最大限度提升性能。


11.4.3 锁分段


有时候可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况称为所分段


例如,
ConcurrentHashMap
的实现就使用了一个包含16个锁的数组,每个锁保护所有散列桶的
1/16
,其中第N个散列桶由第
N mod 16
个锁来保护,假设散列函数具有合理分布性,并且关键字能够实现均匀分布,那么大约能把对于锁的请求减少到
原来的1/16
。锁分段使得
ConcurrentHashMap
能够支持多达16个并发的写入器,具体可以参看它的源码。

11.4.4 避免热点域


当每个操作都请求多个变量时,锁的粒度很难降低,常见的优化措施例如将一些反复计算的结果缓存起来,这时候就会引入一些
“热点域”



HashMap的
size
方法实现就是一个热点域的例子,此方法用于计算Map中的元素数量,在插入和移除元素时更新一个
计数器
,在单线程或完全同步的实现中,这种使用独立计数器的做法能提高类似size和isEmpty这些方法的执行速度,但却导致更难提升实现的可伸缩性,因为每个修改map的操作都要更新这个共享的计数器(即使使用了锁分段),计数器这时候就是一个热点域。

ConcurrentHashMap中的size将对每个分段进行枚举并将每个分段中的元素数量进行相加,而不是维护一个全局计数,它为每个分段都维护了一个独立的计数来避免枚举每个元素,并通过每个分段的锁来维护这个值,有效地避免了热点域问题。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: