Java性能权威指南-总结17
- 线程与同步的性能
- 线程池与ThreadPoolExecutor
- 线程池任务大小
- 设置ThreadPoolExecutor的大小
- 线程同步
- 同步的代价
线程与同步的性能
线程池与ThreadPoolExecutor
线程池任务大小
等待线程池来执行的任务会被保存到某类队列或列表中;当池中有线程可以执行任务时,就从队列中拉出一个。这会导致不均衡:队列中任务的数量有可能变得非常大。如果队列太大,其中的任务就必须等待很长时间,直到前面的任务执行完毕。例如一个超负荷的Web服务器:如果有个任务被添加到队列中,但是没有在3秒钟内执行,那用户很可能就去看另一个页面了。
因此,对于容纳等待执行任务的队列,线程池通常会限制其大小。根据用于容纳等待执行任务的数据结构的不同,ThreadPoolExecutor
会有不同的处理方式;应用服务器通常有一些调优参数,可以调整这个值。
就像线程池的最大线程数,这个值应该如何调优,并没有一个通用的规则。举例而言,假设某个应用服务器的任务队列中有30000个任务,有4个CPU可用,如果执行一个任务只需要50毫秒,同时假设这段时间不会到达新任务,则清空任务队列需要6分钟。这可能是可以接受的,但如果每个任务需要1秒钟,则清空任务队列需要2小时。因此,若要确定使用哪个值能带来需要的性能,测量真实应用是唯一的途径。
不管是哪种情况,如果达到了队列数限制,再添加任务就会失败。 ThreadPoolExecutor
有一个rejectedExecution
方法,用于处理这种情况(默认会抛出RejectedExecutionException
)。应用服务器会向用户返回某个错误:或者是HTTP状态码500
(内部错误),或者是Web服务器捕获错误,并向用户给出合理的解释消息——其中后者是最理想的。
设置ThreadPoolExecutor的大小
线程池的一般行为是这样的:创建时准备好最小数目的线程,如果来了一个任务,而此时所有的线程都在忙碌,则启动一个新线程(一直到达到最大线程数),任务就可以立即执行了。否则,任务被加入等待队列,如果任务队列中已经无法加入新任务,则拒绝。 不过,ThreadPoolExecutor
的表现可能和这种标准行为有点不同。
根据所选任务队列的类型,ThreadPoolExecutor
会决定何时启动一个新线程。 有以下3种可能:
SynchronousQueue
如果ThreadPoolExecutor
搭配的是SynchronousQueue
,则线程池的行为会和我们预计的一样,它会考虑线程数:如果所有的线程都在忙碌,而且池中的线程数尚未达到最大,则新任务会启动一个新线程。然而,这个队列没办法保存等待的任务:如果来了一个任务,创建的线程数已经达到最大值,而且所有线程都在忙碌,则新的任务总是会被拒绝。所以如果只是管理少量的任务,这是个不错的选择;但是对于其他情况,就不合适了。该类文档建议将最大线程数指定为一个非常大的值,如果任务完全是CPU密集型的,这可能行得通,但是我们会看到,其他情况下可能会适得其反。另一方面,如果需要一个容易调整线程数的线程池,这种选择会更好。
无界队列
如果ThreadPoolExecutor
搭配的是无界队列(比如LinkedBlockedingQueue
),则不会拒绝任何任务(因为队列大小没有限制)。这种情况下,ThreadPoolExecutor
最多仅会按最小线程数创建线程,也就是说,最大线程池大小被忽略了。如果最大线程数和最小线程数相同,则这种选择和配置了固定线程数的传统线程池运行机制最为接近。
有界队列
在决定何时启动一个新线程时,使用了有界队列(如ArrayBlockingQueue
)的ThreadPoolExecutor
会采用一个非常复杂的算法。比如,假设池的核心大小为4,最大为8,所用的ArrayBlockingQueue
最大为10。随着任务到达并被放到队列中,线程池中最多会运行4个线程(也就是核心大小)。即使队列完全填满,也就是说有10个处于等待状态的任务,ThreadPoolExecutor
也是只利用4个线程。
如果队列已满,而又有新任务加进来,此时才会启动一个新线程。 这里不会因为队列已满而拒绝该任务,相反,会启动一个新线程。新线程会运行队列中的第一个任务,为新来的任务腾出空间。在这个例子中,池中会有8个线程(最大线程数)的唯一一种情形是,有7个任务正在处理,队列中有10个任务,这时又来了一个新任务。
这个算法背后的理念是,该池大部分时间仅使用核心线程(4个),即使有适量的任务在队列中等待运行。这时线程池就可以用作节流阀(这是很有好处的)。如果积压的请求变得非常多,该池就会尝试运行更多线程来清理;这时第二个节流阀——最大线程数——就起作用了。
如果系统没有外部瓶颈,CPU周期也足够,那一切就都解决了:加入新的线程可以更快地处理任务队列,并很可能使其回到预期大小。该算法所适合的用例当然也很容易构造。
另一方面,该算法并不知道队列为何会突然增大。如果是因为外部的任务积压,那么加入更多线程并非明智之举。如果该线程所运行的机器已经是CPU密集型的,加入更多线程也是错误的。只有当任务积压是由额外的负载进入系统(比如有更多客户端发起HTTP请求)引发时,增加线程才是有意义的。(如果是这种情况,为什么要等到队列已经接近某个边界时才增加呢?如果有额外的资源供更多线程使用,则尽早增加线程将改善系统的整体性能。)
对于上面提到的每一种选择,都能找到很多支持或反对的论据,但是在尝试获得最好的性能时,可以应用KISS原则"Keep it simple,stupid"。可以将ThreadPoolExecutor
的核心线程数和最大线程数设为相同,在保存等待任务方面,如果适合使用无界任务列表,则选择LinkedBlockingQueue
;如果适合使用有界任务列表,则选择ArrayBlockingQueue
。
快速小结
- 有时对象池也是不错的选择,线程池就是情形之一:线程初始化的成本很高,线程池使得系统上的线程数容易控制。
- 线程池必须仔细调优。盲目向池中添加新线程,在某些情况下对性能会有不利影响。
- 在使用
ThreadPooLExecutor
时,选择更简单的选项通常会带来最好的、最能预见的性能。
线程同步
同步与Java并发设施在此部分,当用到“同步”(synchronization)这个术语时,它指的是这样的代码:这段代码在一个代码块内,它们对一组变量的访问看上去是串行的,每次只有一个线程能访问内存。具体而言,
既包括用synchronized关键字保护的代码块,也包括用java.util.concurrent.lock.Lock实例保护的代码,再就是java.util.concurrent和java.util.concurrent.atomic包内的代码。
严格来讲,atomic下的类并没有使用同步,至少从CPU编程术语来看是这样的。它们利用了“比较与交换”(Compare and Swap,CAS)CPU指令,而同步需要互斥访问某个资源。
在同步访问同一资源时,利用了CAS指令的线程不会阻塞,而对于需要同步锁的线程而言,如果另一个线程占据了该资源,则这个线程会阻塞。这两种方式之间存在性能的权衡。然而,即使CAS指令是无锁、非阻塞的,它们仍然会表现出阻塞方式所具有的大部分行为:在开发者看来,最终结果看上去还是线程只能串行地访问被保护内存。
同步的代价
同步代码对性能有两个方面的影响。其一,应用在同步块上所花的时间会影响该应用的可伸缩性。其二,获取同步锁需要一些CPU周期,所以也会影响性能。
- 同步与可伸缩性
当某个应用被分割到多个线程上运行时,加速比(Speedup)可以用如下等式定义(即Amdahl定律):
Speedup = 1 / ((1 - P) + P / N)
P
是程序并行运行部分所花的时间,N
是所用到的线程数(假定每个线程总有CPU可用)。所以,如果20%的代码是串行执行的(这意味着P是80%),有8个CPU可用,则可以预计存在并发的情况下加速比为3.33。
从这个等式可以看出一个关键事实,即随着P值的降低(也就是说,有更多代码是串行执行的),引入多个线程所带来的性能优势也会随之下降。限制串行块中的代码量之所以如此重要,原因就在于此。在这个例子中,有8个CPU可用,我们可能会希望速度提升8倍。但是在只有20%的代码串行执行时,引入多个线程的好处就少了一半多(只增加了3.33倍)。
- 锁定对象的开销
除了对可伸缩性的影响,同步操作本身还有两个基本的开销。首先是获取同步锁的成本。如果某个锁没有被争用(即两个线程没有同时尝试访问这个锁),那这方面的开销会相当小。synchronized
关键字和CAS
指令之间有轻微的差别。非竞争的synchronized
锁被称为非膨胀(uninflated)锁,获取非膨胀锁的开销在几百纳秒的数量级。非竞争的CAS代码损失会更小。
在存在竞争的条件下,开销会更高。当第2个线程尝试访问某个同步锁时,可以预见这个锁会变成膨胀的(inflated)。这个成本是固定的,不管是2个还是20个线程要访问同一个锁,执行的代码量是一样的。(20个线程都必须执行加锁代码,当然,成本会随线程数增加,但每个线程所花的时间是固定的,这是重点。)
对于使用CAS
指令的代码,当存在竞争时,开销是无法预测的。CAS
原语基于一种乐观的策略:线程设置某个值,执行一些代码,然后确保初始值没有被修改。如果值被修改了,那么基于CAS
的代码必须再次执行这些代码。在最坏的情况下,如果有两个线程,它们都在修改CAS
所保护的值,那么相互就会看到另一个线程同时也在修改这个值,就有可能会陷入无限循环。不过在实践中,两个线程不会进入这样的无限循环,但是随着竞争CAS
所保护值的线程数的增加,重试次数也会增加。(如果此处的操作是只读的,那基于CAS
的保护不会受竞争访问的影响。比如,不管有多少线程,它们都可以同时在同一个对象上调用AtomicLong.get()
方法,而不用因竞争付出任何代价。这是使用基于CAS
的设施的另一个重要优势。)
同步的目的是保护对内存中值(也就是变量)的访问。 变量可能会临时保存在寄存器中,这要比直接在主内存中访问更高效。寄存器值对其他线程是不可见的;当前线程修改了寄存器中的某个值,必须在某个时机把寄存器中的值刷新到主内存中,以便其他线程可以看到这个值。而寄存器值必须刷新的时机,就是由线程同步控制的。
实际的语言会非常复杂,最简单的理解是,当一个线程离开某个同步块时,必须将任何修改过的值刷新到主内存中。这意味着进入该同步块的其他线程将能看到最新修改的值。类似地,基于CAS的保护确保操作期间修改的变量被刷新到主内存中,标记为volatile
的变量,无论什么时候被修改了,总会在主内存中更新。
应该学习避免使用Java中性能不太高的构造,即使这看上去像“过早地优化”自己的代码(事实并非如此)。下面循环中有个有趣的案例,而且是一个现实中的例子:
Vector v;for (inti = 0; i < v.size(); i++) {process(v.get(i));}
在生产中,这个循环消耗的时间惊人。比较合乎逻辑的假设是,process()
方法是罪魁祸首。但事实并非如此,问题也不在于size()
和get()
方法调用本身(调用已经被编译器内联了)。Vector
类的size()
和get()
方法是同步的,所有这些调用所需要的寄存器刷新是很大的性能问题。
这段代码之所以不理想,还有其他一些原因。特别是,在某个线程调用size()
和调用get()
的中间时间内,Vector
对象的状态有可能会发生变化。如果在此之间,另一个线程移除了这个对象的最后一个元素,则get()
方法将抛出ArrayIndexOutofBoundsException
。除了代码中的语义问题,细粒度的同步也是较差的选择。
一种方案是将大量连续的、细粒度的同步调用包含在一个同步块内:
synchronized(v) {for (int i = 0; i < v.size(); i++){process(v.get(i));}}
如果process()
方法执行时间很长,并不能很好地解决问题,因为这个Vector
对象没法并行处理。另一个备选方案,复制并分割Vector
对象可能是必要的,这样就可以借助副本来并行处理其中的元素,不过其他线程仍然可能会修改原始的Vector
对象。
寄存器刷新的影响也和程序运行所在的处理器种类有关;有大量供线程使用的寄存器的处理器与较简单的处理器相比,将需要更多刷新。实际上,这段代码在许多环境上执行了很长时间,而没有出现问题。只是在尝试每个线程有大量寄存器可用、基于SPARC的大型机时,才出现了问题。
快速小结
- 线程同步有两个性能方面的代价:限制了应用的可伸缩性,以及获取锁是有开销的。
- 同步的内存语义、基于CAS的设施和volatile关键字对性能可能会有很大的影响,特别是在有很多寄存器的大型机上。