线程的主要目的就是提高程序的运行性能。此外,线程还可以使程序在运行现有任务的情况下立即开始处理新的任务,从而提高系统的响应性。
性能的思考
提升性能意味着可以用更少的资源做更多的事情。但是提升性能会带来额外的复杂度,这会增加线程的安全性和活跃性上的风险。
我们渴望提升性能,但是还是要以安全为首要的。首先要保证程序能够安全正常的运行,然后在需要的时候进行性能优化。这个要求很类似于重构,呵呵
相较于单线程,多线程有很多独有的性能开销因素:
- 线程之间的协调(如,加锁、内存同步等)
- 增加的上下文切换
- 线程的创建和销毁
- 线程调度
由上可见,如果过度的使用线程,这些额外的开销可能会超过我们所做的性能提升。所以,要想合理的通过并发来提升性能,我们要注意:
- 更有效的利用现有处理资源
- 在出现新的处理资源时,使程序尽可能地利用这些资源
性能指标
性能存在有2种指标来衡量,分别是“多快”和“多少”。
1)服务时间、等待时间等指标,用来衡量程序的运行速度,即指定的任务需要“多快”才能处理完成。
2)生产量、吞吐量、可伸缩性等指标用于衡量程序的处理能力,即在计算资源一定的情况下,能完成“多少”工作。
这里可伸缩性指的是,当增加计算资源时(如CPU、内存、存储容量或带宽),程序的吞吐量或是处理能力能相应的提升。
根据可伸缩性定义我们可以猜测,如果可以无限制的增加资源,程序的处理能力是否可以无限制的提升?
答案肯定是不行的。我们可以根据Amdahl定律:
speedup <= 1/(F+(1-F)/N)
其中,F为程序中必须串行的部分,N为可以增加的处理器个数。
可见,在增加计算资源的情况下,程序理论上能够实现最高加速比,这个值取决于程序中可并行组件和串行组件所占的比重。
线程引入的开销
我们已经知道了,并行在带来性能提升的同时会带来由于并发而引起的开销。
上下文切换
如果可运行的线程多于CPU数,那么为了保证所有线程的运行,操作系统会将某个正在运行的线程调度出来,从而使其他线程可以使用CPU。这将会导致一次上下文切换,这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。
内存同步
阻塞
JVM在实现阻塞时,可以采用自旋等待(通过循环不断地尝试获取锁,直到成功)或是通过操作系统挂起被阻塞的线程。
减少锁的竞争
减少锁的竞争可以提高性能和可伸缩性。这是由于独占锁,会导致其保护的资源串行执行。所以在并发程序中,对可伸缩性最大的威胁就是独占方式的资源锁。
有2中因素将会影响到锁竞争:
- 锁的请求频率
- 每次持有该锁的时间
如果上述2者的乘积很小,那么大多数锁的操作都不会发生竞争。
所以,可以总结出3种方式来降低锁的竞争程度:
- 减少锁的持有时间
- 降低锁的请求频率
- 使用带有协调机制的独占锁
缩小锁的范围(快进快出)
尽可能缩短锁的持有时间,将一些与锁无关的程序移出同步代码块,只要确保原子性的操作在一个同步块中就好了
减小锁的粒度
降低线程请求锁的频率。可以通过锁分解和锁分段技术来实现,这些技术采用独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。但是要注意,随着锁越多,死锁的风险也就越高。
锁分解
如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。
锁分段
可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。如,ConcurrentHashMap就使用了一个包含16个锁的数组,每个锁负责保护所有散列桶的1/16,这使得ConcurrentHashMap可以支持16个写入器同时写入。
锁分段也存在一个问题,如果执行toString或是hashCode之类的操作,需要锁定整个容器的时候,就需要获取所有的锁,这容易导致活跃性问题。
一些替代独占锁的方法
- 使用并发容器
- 读-写锁(ReadWriteLock),读操作可以同时访问该共享资源;写操作以独占的方式进行
- 原子变量
监测CPU的利用率
确保处理器可以得到充分利用。