Java 线程与同步的性能优化

本文探讨的主题是,如何挖掘出Java线程和同步设施的最大性能。

1、线程池与ThreadPoolExecutor

1)线程池与ThreadPoolExecutor

线程池的实现可能有所不同,但基本概念与工作方式是一样的:有一个队列(或多个),任务被提交到这个队列中。一定数量的线程去该队列中获取任务,然后执行。任务执行完成后,线程会返沪队列,检索另一个任务并执行。如果没有需要执行的任务,则线程等待。

线程池的大小,与线程池的性能密切相关。
线程池有:最小线程数,最大线程数。
最小线程数:核型池大小。线程的创建成本比较高,线程池中会有最小数目的线程,随时待命执行任务,以提高任务执行效率。
最大线程数:线程需要占用一定的系统资源,空线线程太多会占用过多系统资源,反过来会其他线程/进程的运行效率,故需要设置一个最大数量。最大线程数还是一个必要的限流阀,防止一次执行太多线程。

Java API中常用的线程池:ThreadPoolExecutor。

2)最大线程数

最大线程数的设置取决于负载特性与底层硬件,特别的,最优线程数也与每个任务阻塞的频率有关。
一般来说,最大线程至少设置为CPU的核数。
等于cpu核数:每个线程分配到一个单独的cpu上执行。但线程的平均执行效率与基准并不成严格的线性比。主要原因:1。线程需自身协同&选取执行任务。2。尽管没有其他用户级任务,但cpu还需执行其他系统级的任务。
大于cpu核数:如果是CPU密集型任务,CPU为系统性能的瓶颈所在,线程数大于cpu时性能反而会降低,可能刚开始影响不大,但随着线程数增多,性能会越差。如果是I/O密集型任务,则系统瓶颈未必是cpu,可能是外部资源,此时添加线程会对系统性能产生严重影响。
小于cpu核数:此时应用服务器负载小于100%,可以预留剩余CPU资源去执行非线程池任务的额外任务。
注:基准指的是单线程的执行效率。

设置最优线程数量非常重要的第一步是找到系统真正的瓶颈所在。因为,如果向系统瓶颈出增加负载(大于cpu核数),性能会显著下降。
当设置线程数大小方面出现问题时,系统很大程度长也会出现问题,所以,充足的测试非常关键。

3)最小线程数(minThread)

大部分情况下,开发者会直接了当的将最小线程数与最大线程数设置为同一值。
出发点:
防止系统创建太多线程,以节省系统资源。
设置的值应该确保系统可以处理预期的最大负载。
指定最小线程数的负面影响相当小。线程在线程池创建时分配,还是按需分配,或在预热阶段分配,对性能的影响可以忽略不计。

另一可以调优的地方为:线程的空闲时间。
一般而言,对于线程数为最小值的线程池,新线程一旦创建出来后,应该至少保留几分钟,以处理任何负载飙升。应该避免新线程任务执行完后很快就退出,然后短时间内又需要为新的任务而创建新的线程。空闲时间应该以分钟计,而且至少在10m~30m之间。

存留一些空闲线程,对应用性能影响通常微乎其微。一般而言,线程本身不会占用太多大量的堆空间。但是线程局部对象所占用的总的内存量,应该加以限制。

4)线程池任务大小

等待线程池执行的任务,会被存放到一个队列或列表中,当有空闲线程可以执行任务时,就从队列中拉取一个。
当队列中任务数量非常大时,任务就需要等待很长时间,直到前面任务执行完毕,这会导致不均衡。

线程池通常会限制其大小。当达到队列数限制时,再添加任务就会失败。(此时可异常报错or封装错误信息)

5)设置ThreadPoolExecutor的大小

线程池的一般应为是:线程池创建时,准备好最小数目的线程数,当需要执行一个任务时,如果线程都处于忙碌状态,就会启动一个新的线程(一直到达到最大线程数),去执行该任务。如果达到最大线程数,任务会被添加到等待队列,如果已经达到等待队列无法加入新任务时,则拒绝之。但ThreadPoolExecutor的表现与此标准行为有点不同。

根据所选择的任务队列类型不同,TreadPoolExecutor 决定启动一个新线程也不同:

  • synchronousQueue:线程池的表现与标准行为相同,不同的是,这个队列不能保存等待任务。当线程数达到最大数目时,添加任务会被拒绝。适用于管理只有少量任务的情况;该类文档建议将最大线程数设置为一个非常大的值(适用于CPU密集型,其他情况不适用)。
  • 无界队列:LinkedBlockingQueue,因为没有大小限制,所有不会拒绝任何任务。此时,线程池会按照最小数目创建线程,最大线程数无用。如果两个值相同,则这与固定线程数的传统线程池相似。大线程数就起作用了。(如果是任务积压,加入更多线程非常明智;如果已经是CPU密集型任务,加入更多资源是错误的)
  • 有界队列:ArrayBlockingQueue,线程池创建最小数目的线程,当一个任务进来,如果线程都处于忙绿状态,该任务被添加到缓存队列,直到缓存队列已经满了;而此时又有新任务进来时,才会启动一个新线程。这里不会因为队列满了而拒绝任务。
    有界队列的理念是:大部分时间使用核心线程,即使有适量的任务在队列中等待运行。此时,队列就可用作第一个节流阀。如果任务请求继续变多,第二个节流阀是最

6)最佳实践

线程初始化成本很高,线程池是的系统上的线程数容易控制。
线程池必须仔细调优。盲目向池中添加新线程,在某些情况下对性能反而不利。
使用线程池,在尝试获得更好的性能时,使用KISS原则:Keep it Simple,Stupid。可以将线程池最大线程数和最小线程数设置为相同,在保存等待任务方面,如果适合使用无界队列,则选择LinkedBlockingQueue;如果适合使用有界队列,则选择ArrayBlockingQueue。

2、ForkJoinPol

1)定义

ForkJoinPool 与 ThreadPoolExecutor类一样,也实现了Executor和ExecutorService接口。
ForkJoinPool在内部使用一个无界任务队列,供构造器中所指定数目的线程来运行,如果没有指定线程数,则默认为该机器的CPU数。
ForkJoinPool是为了配合采用分治算法的使用而设计:任务可以递归的分解为子集。这些子集可以并行处理,然后每个子集的结果被归并到一个结果中。经典例子:快速排序。

2)分治算法

分治算法的重点:算法会创建大量的任务,而这些任务只有相对较少的几个线程来管理。
ForkJoinPool允许其中的线程创建新任务,之后挂起当前任务,任务被挂起后,线程可以执行其他等待的任务(父任务必须等待子任务完成)。
fork()和join()方法是关键,这些方法你使用来一系列内部的,从属于每个线程的队列来操纵任务,并将线程从执行一个任务切换到执行另一个。

3)ForkJoinPool vs ThreadPoolExecutor

尽管分治技术非常的强大,但是滥用也可能会导致行性能变糟糕。
如,把数组划分为多个断,使用ThreadPoolExecutor让多个线程扫描数组,也是非常容易的。
测试中,ThreadPoolExecutor完全不需要GC,而每个ForkJoinPool测试花费1~2秒在GC上。对于性能差异而言,这一点所占比重很大,但是这个并非故事的全部:创建和管理任务对象的开销也会伤害ForkJoinnPool的性能。

执行某些任务所花的时间比其他任务长,这种情况叫做不均衡。
一般而言,如果任务是均衡的,使用分段的ThreadPoolExecutor性能更高;而如果任务是不均衡的,则使用ForkJoinPool性能更好。

应该花写心思去定,算法中递归任务什么时候结束最为合适。创建太对任务会降低性能,但如果创建任务太少,任务所需执行时间又长短不一,也会降低性能。

4)Java8 自动化并性

Java8引入了自动化并行特定种类代码的能力,这种并行化就依赖于ForkJoinPool类的使用。
Java8为这个类加入了一个新特性:一个公共的池,可供任何没有显示指定给某个特定池的ForkJoinTask使用。这个公共池是ForkJoinPool类的一个static元素,其大小默认设置为目标机器上的处理器数。

Stream流的forEach()方法将为数据列表中的每个元素创建一个任务,每个任务都会由公共的ForkJoinTask池处理。
设置ForkJoinTask池的大小和设置其他任务线程池同样重要,如果想确保CPU可供作其他任务使用,可以考虑减小公共线程池的线程数;如果公共线程池中的任务会阻塞等待I/O或其他数据,可以考虑增大线程数。
通过-Djava.util.concurrent.ForkJoinPool.common.parallelism=N来设定。

3、线程同步

1)同步的代价

(1)同步与可伸缩性

加速比公式Speedup ,Amdahl定律
1
加速比 = ——————– (P:程序并行运行部分耗时, N:所用到的总线程数)
( 1 – P ) + P / N
假定每个线程总有CPU可用,随着P值的降低,引入多线程所带来的性能优势也会随之下降。
限制串行块中的代码量之所以如此重要,原因就在于此。提供x的CPU,本来希望提升x倍的性能,但在P != 1时,实际提升倍数 < x.

(2)锁定对象的开销

* 获取同步锁的开销

无锁竞争时,synchronized关键字和CAS指令之间有轻微差别。此时,synchronized锁被称为非膨胀锁(uninflated) ,获取非膨胀锁的开销在几百纳秒的数量级;而CAS指令损失更小。
有锁竞争时,多个线程存在竞争的条件下,开销会更高。此时,synchronized修饰的同步锁会变为膨胀锁,成本随线程数的增多而增加,但每个线程的成本是固定的;而使用CAS指令时,开销是无法预测的,随着线程数增加,重试次数也会增加。

* volatile关键字,寄存器刷新

Java特有的,依赖于Java内存模型JMM。
同步的目的是保护对内存中值(变量)的访问,变量可能会临时保存在寄存器中,这比直接在主内存中访问更高效。
寄存器值对其他线程是不可见的,当前线程修改来寄存器中的某个值,必须在某个时机把寄存器中的值刷新到主内存中,以便能其他线程可以看到这个值。而寄存器值必须刷新的时机,就是由线程同步控制的。

实际的语言会非常复杂,简单的理解是,当一个线程离开某个同步块时,必须将任何修改过的值刷新到主内存中。这意味着进入该同步快的其他线程将能看到最新修改的值。
类似的,基于CAS的保护确保操作期间修改的变量被刷新到主内存中年,标记为volatile的变量,无论什么时候被修改来,总会在主内存中更新。
寄存器刷新的影响也和程序运行所在的处理器种类有关,有大量供线程使用的寄存器的处理器与较简单的处理器相比,将需要更多的刷新。

2)避免同步

如果同步可以避免,那加锁的损失就不会影响应用的性能。
两种方式:

* 每个线程使用哪个不同的对象。
* 使用基于CAS的替代方案。

通常情况下,在比较基于CAS的设施和传统同步是,可以使用以下指导原则:
如果访问的是不存在竞争的资源,你那么基于CAS的保护也稍快与传统的同步(虽然你完全不使用保护会更快)。
如果访问的资源存在轻度或适度的竞争,那么基于CAS的保护也快于传统的同步(而且往往快的更多)。
随着访问资源的竞争越来越剧烈,在某一时刻,传统的同步就会成为更高效的选择。在实践中,这只会出现在运行着大量线程的非常大型的机器上。
当被保护的值有多个读取,但不会被写入时,基于CAS的保护不会受竞争的影响。

3)伪共享

(1)伪共享怎样造成的

再多线程程序中,伪共享问题过去相当隐蔽,但是随着多核机器成为标配,很多同步性能问题更明显的浮出水面来。伪共享就是一个越来越重要的问题。

伪共享之所以会出现,跟CPU处理其高速缓存的方式有关。考虑一个简单类中的数据:
public class DataHolder { private volatile long l1; private volatile long l2; private volatile long l3; private volatile long l4; }

这里的每个long值都保存在毗邻的内存位置中,当程序要操作其中一个long值时(如l2),一大块内存会被加载到当前所用的某个CPU核上,
当另一个线程要操作另外一个long值时(比如l3),则会加载同样一段内存到另一个和的缓存行中(cache line)。

大多数情况下,像这样呢的加载是有意义的,如果程序访问的对象中的某个特定实例变量,则很有可能访问邻接的实例变量。如果这写实例变量都被加载到当前核的高速缓存中,内存访问就非常快,这是很大的性能优势。
这个模式的缺点是,当程序更新本地缓存中的某一个值时,当前的核必须通知其他所有核,这个内存被修改了。其他内存必须作废其缓存行,并重新从内存中加载。

结果并非如此:当一个特定线程在修改了某个volatile值时,其他每个线程的缓存行都会作废,内存值必须重新加载。
严格来讲,伪共享未必会涉及同步(或volatile)变量:不论何时,CPU缓存中有任何数据被写入了,其他保存了同样范围数据的缓存都必须作为。然而,切记Java内存模型要求数据只在同步原语(包括CAS和volatile构造)结束时必须写入主内存。

(2)如何监测?

目前没有清晰完整的答案,两个可能的方案:

  • 目标处理器厂商提供的用于诊断伪共享的工具。
  • 需要一些直觉和实验去监测伪共享。

(3)如何纠正

  • 涉及的变量避免频繁的写入。对于频繁地修改volatile变量或者退出同步代码块,伪共享的影响才很大。
  • 填充相关的变量,以免被加载到相同的缓存行中。

4、JVM线程调优

1)调节线程栈大小

在内存比较稀缺的机器上,可以减少线程栈大小。
每个线程都有一个原生栈,操作系统用她来保存该线程的调用栈信息。一般而言,32位的JVM有128k的栈,64位的JVM有256k的栈就足够来。如果设置的过小,当某个线程的调用栈非常大时,会抛出StackOverflowError。
通过 -Xss=N,设置线程栈大小。

2)偏向锁

当锁被征用时,JVM(和操作系统)可以选择如何分配锁。锁可以被公平的授予,每个线程以轮转调度方式(round-robin)获得锁。还有一种方案,即锁可以偏向于对它访问最为频繁的线程。

偏向锁的背后理论依据是,如果一个线程最近用到了某个锁,那么线程下一次执行由同一把锁保护的代码所需的数据可能仍然保存在处理器的缓存中。如果个给这个线程优先获得这把锁的泉流,缓存命中率可能就会增加。如果实现了这点,性能会有所改进。

但是因为偏向锁也需要一些薄记信息,故优势性能可能会更糟糕。
特别是,使用了某个线程池的应用(包括大部分应用服务器),在偏向锁生效的情况下,性能会更糟糕。在那种编程模型下,不同的线程有同等的机会访问争用的锁。对于类应用,使用 -XX:-UseBiasedLocking选项禁用偏向锁,会稍稍改进性能。偏向锁默认是开启的。

3)自旋锁

在处理同步锁的竞争问题时,JVM有两种选择。对于想要获取锁而陷入阻塞的线程,可以让他进入忙循环(自旋),执行一些指令然后在次检查这个锁。也可以把这个线程放入一个队列,在锁可用时通知它(使得CPU可供其他线程使用呢)。

如果多个线程竞争的锁的被持有时间较短,那忙循环方式快的多;如果被持有时间较长,则更适合线程等待通知的方式。

JVM会在这两种情况间寻求合理的平衡,自动调整将线程移交到待通知队列中之前的自旋时间。
如果想影响JVM处理自旋的方式,唯一合理的方式就是让同步代码块尽可能短;这样可以限制与程序功能没有直接关系的自旋的量,也降低了进入通知队列的机会。

4)线程优先级

操作系统会为机器上运行的每个线程计算一个“当前“(current)优先级。当前优先级会考虑Java指派的优先级(开发者定义的),但还会考虑其他许多因素,其中最重要的一个是:自线程上次运行到现在所持续时间。这可以保证所有线程都有机会在某个时间点运行,不论优先级高低,没有线程会一直处于“饥饿“状态。这两个因素之间的平衡会随操作系统的不同而有所差异。
但是,不管哪种情况,都不能依赖线程的优先级来影响其性能。如果某些任务比其他任务更重要,就必须使用应用层逻辑来划分优先级。
在某种程度上,通过将任务指派给不同的线程池,并修改那些池的大小来解决。

5、监控线程与锁

在做性能分析时,总的要注意亮点:总线程数和线程花在等待锁或其他资源上的时间。至于如何监控就要借助相应的工具来实现了,这部分内容暂不讲解。