性能与可伸缩性

在开发的时候首先考虑安全上的问题,然后再来考虑将程序的性能提升到极限。

对性能的思考

对于一个给定的操作,通常会缺乏某一种特定的资源限制它的性能,就像常说的短板理论,比如CPU时钟周期、内存、网络带宽、I/O带宽、数据库请求(这个应该是现在高并发的一个瓶颈)、磁盘空间以及其他资源。当操作系统因为某种资源而受到限制时,我们通常将该操作称为资源“密集型”的操作,如CPU密集型,数据库密集型等。

尽管使用多线程的目标是提升整体性能,但与单线程的方法相比,使用多个线程总会引入一些额外的性能开销。造成这些开销的操作包括:线程之间的协调(例如加锁、触发信号以及内存同步等),增加的上下文切换,线程的创建和销毁,以及线程的调度等。如果过度地使用线程,那么这些开销甚至会超过由于提高吞吐量、响应性或者计算能力所带来的性能提升。另一方面,一个并发设计很糟的应用程序,其性能甚至比实现相同功能的串行程序的性能还要差。

想要通过并发来获得更好的性能,需要努力做好两件事:更有效地利用现有处理资源,以及在出现新的处理资源时使程序尽可能地利用这些新资源。如果程序是计算密集型的,那么可以通过增加处理器来提高性能。通过将应用程序分解到多个线程上执行,使得每个处理器都执行一些工作,从而使所有CPU都保持忙碌状态。

性能与可伸缩性

应用程序的性能可以采用多个指标来衡量,例如服务时间、延迟时间、吞吐率、效率、可伸缩性以及容量等。其中一些指标(服务时间、等待时间)用于衡量程序的“运行速度”。另一些指标(生产量、吞吐量)用于程序的“处理能力”。

可伸缩性是指当增加计算资源时,程序的吞吐量或者处理能力相应的增加

在并发程序中针对可伸缩性进行设计和调整时所采用的方法与传统的性能调优方法截然不同。当进行性能调优时,其目的通常是用更小的代价完成相同的工作,例如使用缓存之前的计算结果等。在进行可伸缩性调优时,其目的是设法将问题的计算并行化,从而能利用更多的计算资源来完成更多的工作。

性能的这两个方面---“多快”和“多少”,是完全独立的,有时候甚至是相互矛盾的。要实现更高的可伸缩性或硬件利用率,通常会增加各个任务所要处理的工作量,例如把任务分解为多个“流水线”子任务时。具有讽刺意味的是,大多数提高单线程程序性能的技术,往往会破坏可伸缩性。

三层程序模型:表现层、业务逻辑层和持久化层是彼此独立,如果我们把这三层融合到同一个应用程序中那么性能肯定高于应用程序分为多层次分布到多个系统的性能。但是同时它们的可伸缩性就降低了。

当这种单一的系统 到达自身处理能力的极限时,要进一步提升它的处理能力将非常困难 。因此我们通常倾向于更好的可伸缩性。

评估各种性能权衡因素

避免不成熟的优化,首先使程序正确,然后再提高运行速度-如果它还运行的不够快,就是常说的。如果程序没有出现什么 问题,那么 就不要动它。

常用的优化包括:增加内存使用量以降低延迟、增加开销换取安全性。在大多数性能决策中都包含有多个变量,并且非常依赖于运行环境。在使某个方案比其他方案“更快”之前,首先问自己一些问题:

  • “更快”的含义是什么?
  • 该方法在什么 条件下运行得更快?低负载还是高负载?大数据集还是小数据集?能否通过测试结果来验证你的答案?
  • 这些条件在运行环境中发生频率?能否通过测试来验证你的答案?
  • 在其他不同条件的环境中能否使用这里的代码?
  • 在实现这种性能提升时需要付出哪些隐含的代价,例如增加开发风险或维护开销?这种权衡是否合适?

不要猜测,以测试为基准。

Amdahl定律

大多数并发程序都是由一系列的并行工作和串行工作组成的。Amdahl定律描述的是:在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决于程序中可并行组件与串行组件所占的比重。假定F是必须被串行执行的部分,那么根据定律,在包含N个处理器的机器中,最高的加速比为:

Speedup<=1/F+((1-F)/N)

当N趋于无穷大,最大的加速比趋近于1/F。因此,如果程序有50%的计算需要串行执行,那么最高的加速比只能是2(不管有多少个线程可用);如果在程序中有10%的计算需要串行执行,那么最高的加速比将接近10.Amdahl定律还量化了串行化的效率开销。在拥有10个处理器的系统中,如果程序中有10%的部分需要串行执行,那么最高的加速比为5.3(53%的使用率),在拥有100个处理器的系统中,加速比可以达到9.2(92%的使用率)。这里可以画一下函数图进行分析。

单任务的处理时间不仅包括执行任务Runnable的时间,也包括从共享队列中取出任务的时间。如果使用LinkedBlockingQueue作为工作队列,那么出列操作被阻塞的可能性将小于使用同步LinkedList时发生阻塞的可能性,因为LinkedBlockingQueue使用了一种可伸缩性更高的算法。然而,无论访问何种共享数据结构,基本上都会在程序中引入一个串行部分。

所以需要串行执行的部分越少(少使用锁),可实现的最高加速比越高。不存在完全并行化的程序,就算最简单的并发程序也有从队列里获取Runnable这个串行部分.

比如:在线程数量不断增加时,ConcurrentLinkedQueue(使用了一种更复杂的非阻塞队列的算法)能比synchronizedLinkedLIst(单个锁来保护整个队列的状态)有两个倍的吞吐率。因为在第一个队列中,只有对指针的更新操作需要串行执行,第二个则是整个的插入或者删除操作都将串行执行。

通过上面的分析我们可以从Amdahl定律中看出,CPU的扩展会到达一个瓶颈,也就是说并不是CPU越多性能就会变得特别快,在CPU数量足够时,增加再多的CPU也无法达到性能上的跃升,此时需要考虑降低串行部分所占的比例

线程引入的开销

对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的开销。

上下文切换

调度会导致上下文的切换。切换上下文并不只是JVM和OS的开销,也会导致一些缓存缺失。

当线程由于等待某个线程发生竞争的锁而被阻塞时,JVM通过会将这个线程挂起,并允许它交换出去。如果线程频繁地发生阻塞,那么 它们将无法使用完整的调度时间片,那么就发生越多的上下文切换,增加调度开销,并因此降低吞吐量。(无阻塞的算法同样有助于减小上下文切换。)

大多数通用的处理器中,上下文的切换相当于5k到10k的时钟周期,也就是几微秒。

Unix系统中的vmstat命令和Windows系统的perfmon工具都能报告上下文切换次数以及在内核 中执行时间所占比例等 信息。如果内核占用率超过10%,那么通常表示 调度活动发生很频繁。很可能是由于 I/O或者竞争锁的阻塞引起的。

内存同步

在synchronized和volatile提供的可见性保证 中可能会使用一些特殊指令,即内存栅栏。内存栅栏可以刷新缓存,使缓存无效,刷新硬件的写缓冲,以及停止执行管道。内存栅栏可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存栅栏中,大多数操作都是不能被重排序的。

非竞争同步是被鼓励采用的。它对应用程序整体性能的影响微乎其微。而有竞争的同步会在破坏安全性的同时,经历一个非常痛苦的除错过程。

现在的JVM能通过优化来去掉一些不会发生竞争的锁,从而减少不必要的同步开销。比如:

synchronized (new Object()) {
  // 执行一些操作。
}

一些更完备的JVM能通过逸出分析来找出不会发布到堆的本地对象引用(因此这个引用是线程本地的)。在下面的这段代码中,至少会将stooges的锁获取释放4次(最后的toString()也是一次,一个智能的运行时编译器会分析这些调用,从而合并锁的获取等操作变成一次的锁获取和释放。并且在第一次执行后把getStoogeNames重新编译为仅返回第一次执行的结果。

public String getStoogeNames(){  
    List<String> stooges=new ArrayList<String>();  
    stooges.add("adf");  
    stooges.add("adf");  
    stooges.add("adf");  
    return stooges.toString();  
}

即使没有逸出分析,编译器也可以执行锁粒度粗化(Lock Coarsening)操作,即将邻近的同步代码块用一个锁合并起来。这里被优化后只会有一次的锁获取和释放。

不要过度担心非竞争同步带来的开销。这个基本的机制已经非常快乐,并且JVM还能进行额外的优化以进一步降低或消除开销。因此,我们应该将优化重点放在那些发生锁竞争的地方。

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

阻塞

JVM在实现阻塞行为时,可以采用自旋等待或者通过操作系统挂起被阻塞的线程。两者效率高低取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。如果等待时间较短,则适合自旋,否则适合OS阻塞。JVM根据历史等待时间分析,进行选择。但是大多数JVM在等待锁时都只是将线程挂起。

减少锁的竞争(提高可伸缩性)

我们已经知道串行操作会降低可伸缩性,并且上下文切换也会降低性能。在锁上发生竞争时将同时导致这两种问题,因此减少锁的竞争能够提高性能和可伸缩性。

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

有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,持有锁的时间 。如果二者乘积很小,那么大多数获取锁的操作都不会发生竞争。

所以,降低锁的竞争程序有三个方法:

  • 减少锁的持有时间。
  • 降低锁的请求频率。
  • 使用带有协调机制的独占锁,这些机制允许更高的并发性。

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

其实就是为了减少锁的持有时间。

减小锁的粒度

就是降低线程请求锁的频率(从而减小发生竞争的可能性)。这可以通过锁分解和锁分段等技术来实现,在这些技术中将采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。这些技术能减小锁操作的粒度,并能实现更高的可伸缩性,然而,使用的锁越多,那么发生死锁的风险也就越高。

锁分段

在某些情况下,可以将锁分解技术进一步扩展对一组独立对象上的锁进行分解。这种情况叫做锁分段。例如:在ConcurrentHashMap的实现中使用了一个包含16个锁的数组。每个锁保护所有散列桶的16分之1。从而实现更好的并发性。能够支持16个并发的写入器

锁分段的劣势在于:与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。通常,在执行一个操作时最多只需获取一个锁,但在某些情况下需要加锁整个容器,比如当ConcurrentHashMap需要扩展映射范围,以及重新计算键值的散列值要分布到更大的桶集合中,就需要获取分段所集合中的所有锁。

避免热点区域

当操作请求多个变量的时候,锁的粒度将很难降低。这是在性能与可伸缩性之间相互制衡的另一个方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,都会引入一些“热点域”,而这些热点域往往会限制可伸缩性。

比如一个hashmap的size。优化的方法就是加一个计数器。但是对于ConcurrentHashMap来说。当并发的对其进行操作时,每次put和remove都需要改变这个计数器。所以在这个类里这个计数器就被叫做热点域,是可伸缩性的瓶颈。所以这里采取避免热点域的方法是对于应用锁分段的散列桶,ConcurrentHashMap中的size将对每个分段进行枚举(每个分段提供独立的计数器)并将每个分段的元素数量相加。并通过每个分段的锁来维护这个值。

一些替代独占锁的方法

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

ReadWriteLock实现了一种在多个读取操作以及单个写入操作情况下的加锁规则:如果多个读取操作都不会修改共享资源,那么这些读取操作可以同时访问该共享资源,但在执行写入操作时必须以独占方式来获取锁。对于读取操作占多数的数据结构,ReadWriteLock能提供比独占锁更高的并发性。而对于只读的数据结构,其中包含的不变性可以完全不需要加锁操作。

原子变量提供了一种方式来降低更新“热点域”时的开销,例如静态计数器、序时发生器、或者对链表数据结构中头节点的引用。(使用AtomicLong来维护Servlet的计数器)原子变量类提供了在整数或者对象引用上的细粒度原子操作(因此可伸缩性更高),并使用了现代处理器中提供的底层并发原语(例如比较并交换)。如果在类中只包含少量的热点域,并且这些域不会与其他变量参与到不变性条件中,那么用原子变量来替代它们能提高可伸缩性(通过减少算法中的热点域,可以提高可伸缩性——虽然原子变量能降低热点域的更新开销,但并不能完全消除。)

监测CPU的利用率

当测试可伸缩性时,通常要确保处理器得到充分利用。一些工具,例如Unix系统上的vmstat和mpstat。

如果所有CPU的利用率并不均匀(有些CPU在忙碌,而其他CPU却并非如此),那么你的首要目标就是进一步找出程序的并行性。

如果测试时,cpu没能充分发挥性能,那么就不能得到相对准确的结果,如果cpu没有得到充分利用需要找出其中的原因,通常是以下几种:

  • 负载不充足
  • I/O密集:利用iostat查看是不是I/O密集型的。
  • 外部限制(数据库或web服务)
  • 锁竞争(线程栈桢中查找“waiting to lock monitor...”)

向对象池说不

在早期的JVM中,对象的分配和垃圾回收的执行速度非常慢,所以许多开发人员使用对象池技术来解决这一问题。如今Java中的对象内存分配速度已经比C语言还快了。所以这项技术如果你还在使用,并且应用在并发的程序中,已经可以考虑是不是应该放弃它了。因为它带来的锁竞争问题的开销远大于对象分配的开销。不过在J2ME或者RTSJ,需要对象池技术来提高内存管理或响应性管理的效率。

实例:比较Map性能

ConcurrentHashMap、ConcurrentSkipListMap,以及通过synchronizedMap来包装的HashMap和TreeMap。前两种Map是线程安全的,而后面两个map则通过同步封装器来确保线程安全性。

减少上下文切换的开销

在许多任务中都包含一些可能被阻塞的操作。当任务在运行和阻塞这两个状态之间切换时,就相当于一次上下文切换。在服务器应用程序中,发生阻塞的原因之一就是在处理请求时产生各种日志消息。为了说明如何通过减少上下文切换的次数来提高吞吐量,我们将对两种日志方法的调度行为进行分析。

第一点就是减少锁的持有时间。因为持有时间越短那么发生竞争的情况就越少,上下文切换的次数就少。反应在程序就是请求服务的时间不宜过长。

减少锁竞争的来源。比如通过把I/O操作从处理请求的线程转移到一个专门的线程。

results matching ""

    No results matching ""