从0学习java并发编程实战-读书笔记-性能与可伸缩性(10)

线程的最主要目的是提升程序的运行性能。虽然咱们但愿得到更好的性能,可是始终须要把安全性放在第一位。首先须要保证程序能正确运行,而后仅当程序的性能需求和测试结果要求程序执行的更快时,才应该设法提升它的运行速度。在设计并发程序时,最重要的一般不是把性能提至极限。java

对性能的思考

提高性能意味着用更少的资源作更多的事。当操做性能因为某种特定的资源而受到限制时,咱们一般将该操做称为资源密集型的操做,例如CPU密集型,数据库密集型,IO密集型等。
尽管使用多个线程的目标是提高总体性能,可是与单线程方法相比,使用多个线程总会引入一些额外的开销。例如:web

  • 线程间的协调(例如加锁,触发信号,内存同步等)
  • 上下文切换
  • 线程的建立与销毁
  • 线程的调度

若是过分使用线程,那么这些开销甚至会超过因为提升吞吐量、响应性或计算能力所带来的提高。另外一方面,若是一个设计的不好的并发程序,其性能也许比同功能的单线程的串行程序还要差。算法

要想要经过并发来得到更好的性能,须要努力作到两件事:数据库

  • 更有效地利用现有处理资源
  • 在出现新的处理资源的时候使程序尽量利用这些新资源

从性能视角,CPU要尽量保持忙碌状态,若是程序是计算密集型的,那么能够经过增长处理器来提高性能。若是程序没法使现有的CPU保持忙碌,那么增长再多的CPU也无济于事。数组

性能与可伸缩性

应用程序的性能能够采用多个指标来衡量,例如:缓存

  • 服务时间
  • 延迟时间
  • 吞吐率
  • 效率
  • 可伸缩性
  • 容量
可伸缩性是指:当增长计算资源时,例如(CPU、内存、存储容量、IO带宽),程序的吞吐量或者处理能力能相应的增长。
在并发应用程序中针对可伸缩性进行设计和调整时采用的方法与传统性能调优大相径庭。
  • 当进行性能调优时,目的一般是使用更小的代价完成相同的工做,例如使用缓存、优化算法。
  • 而对可伸缩性进行调优时,其目的是将问题的计算并行化,从而能利用更多的计算资源来完成更多的工做。

性能在多快多少这两方面是彻底独立的,有时候甚至是互相矛盾的。要实现更高的可伸缩性或硬件利用率,一般会增长各个任务的所要处理的工做量,例如把任务分解为多个“流水线”子任务。
咱们熟悉的三层程序模型,即在模型中的表现层,业务逻辑层和持久化层是彼此独立的,而且可能由不一样的系统来处理。这很好的说明了提升可伸缩性一般会形成性能损失的缘由。若是把表现层,业务逻辑层和持久化层都融合到单个应用程序中,那么在处理第一个工做单元时,其性能确定要高于将应用程序分为多层并将不一样层次分布到多个系统时的性能。单一的应用程序能减小开销,例如:安全

  • 不一样层次之间传递任务的网络延时
  • 不须要分解计算过程到多个层次(例如任务排队,线程协调,数据复制时都存在开销)

然而,一旦单一的系统到达自身处理能力的极限时,会遇到一个更严重的问题:要进一步提高它的处理能力很是困难。所以咱们会接受每一个工做单元执行更长的时间或者消耗更多的计算资源,以换取应用程序在增长更多资源的状况下处理更高的负载。
对于服务器应用程序来讲,“多少”这个方面(可伸缩性,吞吐量,生存量)每每比“多快”更受重视。(在交互式应用程序中,延迟也许更加剧要,这样用户就不用等待进度条)。性能优化

评估各类性能权衡因素

在几乎全部的工程决策中都会涉及某些形式的权衡。在作出正确的权衡时一般会缺乏相应的信息。例如,快速排序算法在大规模数据集上执行效率很是高,可是对小规模数据集来讲,冒泡排序实际上更加高效。服务器

避免不成熟的优化。首先使程序正确,而后再提升运行速度(若是它还运行的不够快)。
当进行决策的时候,有时候会经过增长某种形式的成本,来下降另外一种形式的开销(例如用空间换时间),也会经过增长开销来换取安全性。不少性能优化措施一般都是经过牺牲可读性和可维护性为代价,代码越精巧或者越晦涩,也许就越难以理解和维护。
有时候优化措施会破坏面向对象原则,例如打破封装,有时候,会带来更高的错误风险,由于一般来讲,越快的算法越抽象。
在使某个方案比另外一个方案更快以前,先问本身一些问题:
  • “更快”的含义是什么
  • 该方法在什么条件下运行的更快?在高负载仍是低负载?大数据集仍是小数据集?可否经过测试结果来验证你的答案?
  • 这些条件在运行环境中的发生频率?可否经过测试或者数据验证你的答案?
  • 在其余不一样条件的环境可否使用这里的代码?
  • 在实现这种策略的时候须要付出哪些隐藏的代价,例如增长开发风险仍是维护开销,这种权衡是否合适?
以测试为基准,不要瞎猜

Amdahl定律

在有些问题中,若是可用资源越多,那么问题解决速度越快。
而有些任务本质上是串行的,增长再多资源也没法提高速度。
若是使用线程主要是为了发挥多个处理器的处理能力,那么就必须对问题进行合理的并行分解,使得程序能有效的使用这种潜在的并行能力。
Amdahl定律描述的是:在增长计算资源的状况下,程序理论上可以实现最高加速比,这个值取决于程序中可并行组件与串行组件所占的比重。网络

假定F是必须被串行执行的部分,N是处理器个数。那么按照Amdahl定律,最高的加速比为:
$$Speedup<=\frac{1}{F+\frac{1-F}{N}}$$

当N接近无穷大时,最大的加速比趋近于1/F。所以,若是程序有50%的计算须要串行执行,那么最高的加速比只能是2(无论有多少线程可用),若是程序中有10%的须要串行执行,那么最高的加速比将接近于10.

Amdahl还量化了串行化的效率开销。在拥有10个处理器的系统中,若是程序有10%的部分须要串行执行,那么最高加速比只有5.3(53%的使用率),在拥有100个处理器的系统中,加速比能够达到9.2(9%的使用率),即便拥有再多的CPU,也没法达到10的加速比。

全部的并发都拥有必定拥有一部分串行部分。

Amdahl定律的应用

若是能准确估计除执行过程当中串行部分所占的比例,那么Amdahl定律就能量化当有更多计算资源可用时的加速比。虽然直接测量串行部分的比例很是困难,但即便在不进行测试的状况下Amdahl定律仍然是有用的。
随着多核CPU成为主流,系统可能拥有数百个甚至数千个处理器,一些在4路系统中看似可伸缩性的算法,可能有可伸缩性瓶颈,只是还没遇到而已。

线程引入的开销

单线程程序既不存在线程调度,也不存在同步开销,并且不须要使用锁来保证数据结构的一致性。在多个线程的调度和协调过程都须要必定的性能开销:对于为了提高性能而引入的线程来讲,并行带来的性能提高必须超过并发致使的开销。

上下文切换

若是主线程是惟一的线程,那么它基本不会被调度出去。另外一方面,若是可运行的线程数大于CPU的数量,那么操做系统最终会从某个正在运行的线程调度出来,从而使其余线程可以使用CPU。这将致使一次上下文的切换,在这个过程当中将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。
切换上下文须要必定的开销,而在线程调度过程当中须要访问由操做系统和JVM共享的数据结构。应用程序、操做系统以及JVM都是用同一组相同的CPU。JVM和操做系统的代码中消耗越多的CPU时钟周期,应用程序可用的CPU时钟周期就越少。
可是上下文的切换的开销并不仅包含JVM和操做系统的开销。当一个新的线程被切换进来时,它所须要的数据可能并不在当前处理器的本地缓存中,所以上下文切换可能会致使一些缓存缺失,于是线程在首次调度运行时会更加缓慢。
这就是为何调度器会为每一个可运行的线程分配一个最小执行时间:它将上下文切换的开销分摊到更多不会中断的执行时间上,以提升总体的吞吐量(以损失相应性为代价)。
当线程因为等待某个发生竞争的锁而被阻塞的时候,JVM一般会将这个线程挂起,并容许它被交换出去。若是线程频繁的发生阻塞,那它们将没法使用完整的调度时间片。在程序中发生越多的阻塞(包括阻塞I/O、等待发生竞争的锁、或者在条件变量上等待),与CPU密集型的程序就会发生越屡次上下文切换,从而增长调度开销,所以下降吞吐量。(无阻塞算法一样有助于减少上下文切换)
上下文切换的实际开销会随平台的不一样而变化,然而按照经验来看:在大多数通用的处理器中,上下文切换的开销至关于5000 - 10000个时钟周期,也就是几微秒。
UNIX系统的vmstat命令和Windows系统的perfmon工具能报告上下文切换次数以及在内核中执行时间所占比例等信息。若是内核占用率比较高(超过10%),那么一般表示调动活动发生得很频繁,这极可能是由I/O或者锁竞争引发的。

内存同步

同步操做的性能开销包括多个方面。
在synchronized和volatile提供的可见性保证中可能会使用一些特殊的指令,即内存栅栏(Memory Barrier)。内存栅栏能够刷新缓存,使缓存无效,刷新硬件的写缓存,以及中止执行管道。内存栅栏可能一样会对性能带来间接的影响,由于它们将抑制一些编译器的优化操做。在内存栅栏中,大多数操做使不能被重排序的。
在评估同步操做带来的性能影响时,区分有竞争的同步和无竞争的同步很是重要。synchronized机制针对无竞争的同步进行了优化(volatile一般是无竞争的),虽然无竞争同步开销并不为零,可是它对总体性能的影响微乎其微。

不要过分担忧非竞争同步带来的开销,这个基本的机制已经很是快了,而且JVM还能进行额外的优化以进一步的下降或消除开销,因此应该将优化重点放在那些发生锁竞争的地方。
某个线程中的同步可能会影响其余线程的性能。同步会增长共享内存总线上的通讯量,总线的带宽是有限的,而且全部的处理器都共享这条总线。若是有多个线程竞争同步带宽,那么全部使用了同步的线程都会受到影戏。

阻塞

非竞争的同步能够彻底在JVM中进行处理,而竞争的同步可能须要操做系统的介入,从而增长开销。在锁上发生竞争时,竞争失败的线程确定会阻塞。JVM在实现阻塞行为的时候,能够采用自旋等待(Spin-Waiting),或者经过操做系统挂起被阻塞的线程。这两种方式的效率高低,要取决于上下文切换的开销和在成功获取锁以前须要等待的时间。若是等待时间短,则适合采用自旋等待的方式。若是等待时间较长,则适合采用线程挂起的方式。大多数JVM在等待锁时都是将线程挂起。
当线程没法获取某个锁或者因为在某个条件等待或在I/O操做上阻塞时,须要被挂起,在这个过程当中将包含两次额外的上下文切换,以及全部必要的操做系统操做和缓存操做:被阻塞的线程在其执行时间片还未用完以前就被交换出去,而在随后当要获取的锁或者其余资源可用的时候,又再次被切换回来。

减小锁的竞争

串行操做会下降可伸缩性,而且上下文切换也会下降性能。在锁上发生竞争将同时致使这两种问题,所以减小锁的竞争可以提升性能和可伸缩性。
但对由某个独占锁保护的资源进行访问时候,将采用串行的方式,一次只能由一个线程能访问它。可是得到这种安全性是须要代价的,若是在锁上持续发生竞争,那么将限制代码的可伸缩性。

在并发程序中,对可伸缩性最主要的威胁就是独占方式的资源锁。
有两个因素将影响在锁上发生竞争的可能性:
  • 锁的请求频率
  • 每次持有该锁的时间

若是两者的乘积很小,那么大多数获取锁的操做都不会发生竞争,所以在该锁上的竞争不会对可伸缩性形成严重影响。然而,若是在锁上的请求量特别高,那么须要获取该锁的线程被阻塞并等待。在极端状况下,即使有大量工做须要完成,CPU仍会被闲置。

有3种方式能够下降锁的竞争程度:
  • 减小锁的持有时间
  • 减小锁的请求频率
  • 使用带有协调机制的独占锁,这些机制容许更高的并发性。

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

下降发生竞争可能性的一种有效方式就是尽量缩短锁的持有时间。例如将一些与锁无关的代码移出同步代码块。若是持有锁的时间过长,将会影响伸缩性。
尽管缩小同步代码块能提升可伸缩性,但同步代码块也不能太小,一些须要采用原子方式执行的操做必须在包含在一个同步块中。
此外,同步须要必定的开销,当把一个代码库块分解为多个代码块时,反而会对性能提高产生负面影响。

实际状况下,仅当能够讲一些“大量”的计算或阻塞操做从同步代码块移出时,才应该考虑同步代码块的大小。

减少锁的粒度

另外一种减少锁的持有时间的方式是下降线程请求锁的频率,从而减少发生竞争的可能性。这能够经过锁分解和锁分段等技术来实现,在这些技术中将采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在以前由单个锁来保护的状况。这些技术能减少锁操做的粒度,并能实现更高的可伸缩性,然而使用的锁越多,发生死锁的风险也就越高。
若是一个锁须要保护多个相互独立的状态变量,那么能够将这个锁分解为多个锁,而且每一个锁只保护一个变量,从而提升可伸缩性,并最终下降每一个锁被请求的频率。

对锁进行分解

public class ServerStatus(){
    public final Set<String> users;
    public final Set<String> queries;
    ...
    public synchronized void addUser(String u){
        user.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);
    }
}

代码能够分解为:

public class ServerStatus(){
    public final Set<String> users;
    public final Set<String> queries;
    ...
    public void addUser(String u){
        synchronized(users){
            user.add(u);
        }
    }
    public void addQuery(String q){
        synchronized(queries){
            queries.add(q);
        }
    }
    public void removeUser(String u){
        synchronized(users){
            users.remove(u);
        }
    }
    public void removeQuery(String q){
        synchronized(queries){
            queries.remove(q);
        }
    }
}

对竞争中的锁进行分解,其实是把这些锁转变为非竞争的锁,从而能有效的提升性能和可伸缩性。

锁分段

把一个竞争紧张的锁分解为两个锁时,这两个锁可能都存在着激烈的竞争。虽然采用两个线程并发执行能提升一部分可伸缩性,但在一个拥有多个处理器的系统中,仍然没法给可伸缩性带来极大的提升。
虽然采用两个线程并发执行能提升一部分可伸缩性,但在一个拥有多个处理器的系统中,仍然没法给可伸缩性带来极大的提升。
在某些状况下,能够将锁分解技术进一步拓展为一组独立对象上的锁进行分解,这种状况被称为锁分段
concurrentHashMap的实现中使用了一个包含16个锁的数组,每一个锁保护全部散列桶的1/16,其中第N个散列桶由第N mod 16(取模)来保护。假设散列函数具备合理的分布性,而且关键字可以实现均匀分布,那么这样大约能把锁的竞争下降至1/16。正是这项技术使得concurrentHashMap可以支持多达16个并发的写入器。(要使得拥有大量处理器的系统在高访问量的状况下实现更高的并发性,还能够进一步增长锁的数量,但仅当你能证实并发写入线程的竞争足够激烈并须要突破这个限制时,才能将锁分段的数量超过默认的16个)。
锁分段的劣势在于,与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难且开销更高。
一般,在执行一个操做的时候最多只须要获取一个锁,但在某些状况下须要加锁整个容器,例如当ConcurrentHashMap须要扩展映射范围,以及从新计算键值的散列值要分布到更大的桶集合中时,就须要获取分段锁集合中的全部锁(要获取内置锁的一个集合,能采用的惟一方式是递归)。

避免热点域

锁分解和锁分段技术均可以提升可伸缩性,由于它们都能使不一样的线程在不一样的数据(或者同一数据的不一样部分上操做),而不会相互干扰。若是程序采用锁分段技术,那么必定要表现出在锁上的竞争频率高于在锁保护的数据上发生竞争的频率。
当每一个操做都请求多个变量时,锁的粒度很难下降。这是性能和可伸缩性之间相互制衡的另外一个方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,都会引入一些热点域,这些热点域每每会限制可伸缩性。
当实现HashMap的时候,你须要考虑如何在size方法中计算元素的数量,最简单的方法就是每次调用的时候都统计一下元素的数量。一种常见的优化策略是,在插入和移除元素时更新计数器。
在单线程或者采用彻底同步的实现中,使用一个独立的计数器能很好地提升相似size和isEmpty这些方法的执行速度,但却致使更难以提高实现的可伸缩性,由于每一个修改map的操做都要更新这个共享的计数器。即便使用锁分段来实现散列链,那么在对计数器访问进行同步时,也会从新致使在使用独占锁时存在的可伸缩性问题。一个看似性能优化的措施,缓存size的结果,已经变成了一个可伸缩性问题。在这种状况下,计数器也被称为热点域,由于致使元素数量发生变化的方法都须要访问它。
ConcurrentHashMap中的size将对每一个分段进行枚举并将每一个分段中的元素数量相加,而不是维护一个全局的计数,每一个分段维护了一个独立的计数,并经过每一个分段的锁来维护这个值。

一些替代独占锁的方法

第三种下降竞争锁的影响的技术就是放弃使用独占锁,从而有助于使用一种友好并发的方式来管理共享状态。例如使用并发容器、读-写锁、不可变对象以及原子变量。

  • ReadWriteLock实现了一种在多个读取操做以及单个写入操做状况下的加锁规则:若是多个读取操做都不会修改共享资源,那么这些读取操做能够同时访问该共享资源,可是执行写入操做时必须以独占的方式来获取锁。对于读取操做占大多数的数据结构,ReadWriteLock能提供比独占锁更好的并发性。而对于只读的数据结构而言,其中包含的不变性能够彻底不须要加锁操做。
  • 原子变量提供了一种方式来下降更新热点数据时的开销,例如静态计数器,序列发生器,或者对链表数据结构中头节点的引用。原子变量类提供了在整数或者对象引用上的细粒度原子操做(所以伸缩性更高),并使用了现代处理器中提供的底层并发原语(例如比较并交换compare-and-swap)。若是在类中包含少许的热点域,而且这些域不会与其余变量参与到不变性条件中,那么用原子变量来替代它们提高可伸缩性。

监测CPU利用率

若是全部CPU没有获得充分利用(有些CPU很忙碌,有些很空闲),那么首要目标就是进一步找出程序中的并行性。不均匀的利用代表大多数计算都是有一小组线程完成的,而且应用程序没有利用其余的处理器。

  • 负载不充足:测试的程序中可能没有足够多的负载,由于能够在测试的时候增长负载,并检查利用率,响应时间和服务时间等指标的变化。若是产生足够多的负载使应用程序达到饱和,那么可能须要大量的计算机能耗,而且问题处于客户端是否有足够能力,而不是被测试系统。
  • I/O密集:判断某个应用程序是不是IO密集的,或者经过监测网络的通讯流量级别来判断它是否须要高带宽。
  • 外部限制:若是应用程序依赖外部服务,例如数据库或者webservice,那么性能瓶颈可能不在你本身的代码中。
  • 锁竞争:使用分析工具能够知道在程序中存在何种程度的锁竞争,以及在哪些锁上存在激烈的竞争。

对对象池说“不”

一般,对象分配操做的开销比同步的开销低不少。
如今已经没人用对象池了。

小结

因为使用线程经常是为了充分利用多个处理器的计算能力,所以在并发程序性能的讨论中,一般更多地将侧重点放在吞吐量和可伸缩性上,而不是服务时间。Amdahl定律告诉咱们,程序的可伸缩性主要取决于在全部代码中必须被串行执行的代码比例。由于java程序中串行操做的主要来源是独占方式的资源锁,以及采用非独占的锁或非阻塞的锁来代替独占锁。

相关文章
相关标签/搜索