Java 并发编程

Java并发编程笔记(十):性能与可伸缩性

2018-07-09  本文已影响4人  yeonon

线程的最主要目的是提高程序的运行性能。线程可以使程序更加充分的发挥系统可用的处理能力,从而提高系统资源利用率。此外,线程还可以使程序在运行现有任务的情况下立即开始处理新的任务,从而提高系统的响应性。

一、对性能的思考

提升性能意味着用更少的资源做更多的事,资源可以指代很多东西,CPU资源,IO资源,网络资源等等。使用多线程可以充分压榨系统的各种资源(当然这需要良好的设计),从而提高应用程序的性能,吞吐量,伸缩性等等。如果是一个设计很糟糕的并发程序,反而可能会降低系统的响应性等。要想通过并发来获得更好的性能,需要努力做好两件事:

  1. 更有效的地利用现有处理资源,以及在出现新的处理资源时使程序尽可能的利用这些资源。
  2. 要尽可能的保持忙碌状态,不包括CPU空转等情况。

性能和可伸缩性

性能可以有多个指标来衡量。例如服务时间,延迟时间,吞吐率,效率,可伸缩性等。一般来说服务时间,延迟时间越短越好,吞吐率,效率,可伸缩性越高越好(可能有点极端)。我们在设计并发程序的时候,可以从这些指标入手,从而提高应用程序的整体性能。

可伸缩性的概念是:在增加资源时,程序的吞吐量或者处理能力能相应的增加。例如从单机切换到集群时,增加了资源,如果应用程序具有良好的可伸缩性,那么整个系统的性能也会有所增加,比较好的情况下增加1倍,即1+1 = 2。但实际上往往是1 < 1+1 < 2。如果1+1 = 1或者 < 1,此时可能应该考虑一下是不是程序哪里设计不当了。

性能指标的重要性在不同的应用程序中可能会不同。对于实时系统,也许服务时间,延迟时间等指标会更加重要一些,而对于时间要求不高的系统,吞吐量,可伸缩性也许是首要考虑的指标。故要想提高性能,在开始设计,编码之前应该先对系统做一个评估,然后再“对症下药”。

评估各种性能权衡因素

几乎所有的工程决策中都会设计某些形式的权衡。在做出决策前,了解相应的信息是非常必要的。例如排序算法,总所周知,快速排序在大规模数据集上的执行效率非常高,但对于小数据集,冒泡排序实际上会更好,在这个例子中,我们需要了解的信息就是数据规模,数据特征(是杂乱无章的,还是大部分有序的等等)。但实际上这些信息有时候比较难以获取,例如Java类库中的Sort方法,因为这个是写在Java类库里的,设计者并不知道将来会遇到怎样的数据,这也是为什么大多数优化措施不成熟的原因之一。

在大多数决策中都包含多个变量,在做出决策之前,需要好好评估这些变量,从而选择出一个更适合目标场景的解决方案。无论最终选用哪种方案,都必须要保证一个前提:程序运行时正确的。如果程序运行是错误的,那么任何优化措施都是毫无意义的。所以最好是建立一个完备的测试工程来验证程序的正确性。

二、Amdahl定律

Amdahl定律是这样描述的:在增加计算资源的情况下,程序在理论上能够实现最高的加速比,这个值取决于程序中可并行组件与串行组件所占的比重。有如下公式:


公式

其中F表示必须被串行执行的部分,N表示有N个处理器。可以使用极限的思维去看待这个公式,带入一些数据尝试一些,可以得到一些很有趣的结论。
下图是处理器利用率在不同的串行比例以及处理器数量情况下的变化曲线:

在串行部分所占比例下的最高利用率

简单分析上图可以得出如下结论:

  1. 并不是处理器越高,利用率越高,甚至反而是降低的。
  2. 串行比例越低,处理器利用率越高。

变化趋势啥的也可以分析一下,再次不多做分析了。

注意到上面并没有列出串行比例为0%的情况(我们做实验的时候常常选择一些边界情况,不是吗?),原因是并不存在完全并行化的程序,换句话说就是并行程序中或多或少一定会有串行执行的部分。

Amdahl定律作用是能量化当有更多计算资源时的加速比,前提是准确估计出执行过程中串行部分所占的比例。准确的估计串行比例可能会非常困难,但即使不够准确,Amdahl定律也是有用的。

三、线程引入的开销

多线程应用需要有线程调度,即存在上下文切换,调度协调等开销,故对于为了提升性能而引入多线程的情况,多线程带来的性能提高必须超过线程调度的开销。

上下文切换

其实线程不存在真正的并行(进程在多个处理器的机器中可以实现真正的并行),只能是宏观上的“并行”,即通过快速的切换线程使得在某一时间间隔内看到多个线程都在执行。这个切换指的就是上下文切换。

线程的上下文切换是需要一定开销的(进程的上下文切换开销更高),因为这涉及到操作系统和JVM(需要保存寄存器的状态等)。操作系统会有一个线程的最小运行时间限制,这样做的原因是避免频繁的线程切换,使得线程上下文切换消耗的时间占比较小,从而提高整体的吞吐量。

内存同步

同步操作也是有不小的开销的,synchronized和volatile提供的可见性保证中会使用一些特殊的指令,即内存屏障,内存屏障可以刷新缓存,使缓存无效,这会影响性能。所以除非有必要,否则应尽量不适用同步操作,否则会对系统的整体性能带来很大的影响(思考蝴蝶效应)。

阻塞

有竞争的同步当在锁上发生竞争的时,失败的一方肯定会进入阻塞状态,JVM实现阻塞时,可以采用自旋等待(通过循环不断的尝试获取锁,直到成功),也可以采用将线程挂起。如果等待时间较短,适合自旋,否则将线程挂起会比较好,但其实等待时间是难以预测的,故有些JVM会根据历史等待时间做一些分析来在这两种方式之间选择,不过大多数JVM都选择将线程挂起的方式。

在线程挂起的过程中将包含额外的两次线程切换,在被阻塞时将线程切换出去,当资源可用的时,又将线程切换进来。这是一个不小的开销。

四、减少锁的竞争

在锁上发生竞争会降低可伸缩性,且由上述可知,发生竞争肯定会有一方失败,即被挂起(在大多数JVM上),从而也会有上下文切换的开销。因此减少锁的竞争能够提高性能和可伸缩性。

在并发程序中,锁是通用的保证线程安全的手段(有些情况下会使用CAS等)。因此锁竞争也是会存在的,那如何减少锁的竞争呢?有三种方式可以降低锁的竞争程度:

缩小锁的范围

对应上述三种方式就是降低锁的持有时间,我们在应该尽量缩小锁的范围,降低锁的持有时间,通常的做法是将与同步无关的代码移出被锁锁住的临界区。例如尽量不要在方法级别上使用synchronized,应该分析好具体哪些代码需要同步,从而决定应将哪些区域归为临界区。

这种方式的结果是从线程获得锁到释放锁的时间会比较短,也许另一个线程还没开始来尝试获取锁,当前线程就已经释放锁了,又或者是另一个线程等待时间很短,从而降低了锁的竞争。

减小锁的粒度

这也是减少持有锁的时间。总所周知,ConcurrentHashMap中使用了分段锁的机制,其实这就是减少锁的粒度的实践。这种做法划分不同的区域,然后每个区域使用多个不同的锁使得这些区域相互独立,程序可以并发的访问不同区域,从而实现更高的可伸缩性。然而,使用的锁越多,越有可能发生死锁,这也是需要考虑的。

一些替代独占锁的方法

这是对应上述三个方式的第三种,除了独占锁,还有一些其他类型的锁,例如读写锁等。在Java中实现的读写锁是ReadWriteLock,这种锁的特性是“读操作不互斥,写操作互斥”,非常适合读多写少的场景。

还有就是一些原子操作,在JDK中提供了很多原子操作,它们都是以Atomic开头的,例如AtomicInteger,AtomicBoolean等。其API都是原子操作,内部是使用Unsafe的CAS方法实现的。在很多情况下,CAS的性能比锁会高,所以可以多多使用这些原子类。

小结

使用多线程最主要的目的是为了提高性能,使得程序充分利用计算资源,性能的指标有很多,但通常我们的重点是吞吐量和可伸缩性,而不是服务时间(除了对时间要求高的系统)。为了设计具有更高吞吐量和可伸缩性的应用程序,我们需要考虑很多因素,也需要很多技术手段去实现。

上一篇下一篇

猜你喜欢

热点阅读