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

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

对性能的思考

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

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

如果过度使用线程,那么这些开销甚至会超过由于提高吞吐量、响应性或计算能力所带来的提升。另一方面,如果一个设计的很差的并发程序,其性能也许比同功能的单线程的串行程序还要差。

要想要通过并发来获得更好的性能,需要努力做到两件事:

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

从性能视角,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 users;
    public final Set 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 users;
    public final Set 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程序中串行操作的主要来源是独占方式的资源锁,以及采用非独占的锁或非阻塞的锁来代替独占锁。

你可能感兴趣的:(java,多线程,并发编程,synchronized,后端开发)