《Java并发编程实战》活跃性、性能与测试
一、避免活跃性危险
活跃性问题可以理解为死锁等,安全性和活跃性之间通常存在着某种制衡。加锁机制确保安全,但过度使用加锁可能导致锁顺序死锁;使用线程池和信号量来限制对资源的使用,但这些被限制的行为可能会导致资源死锁。
Java应用程序无法从死锁中恢复过来,因此在设计时需要排除那些可能导致死锁出现的条件。
1.死锁
数据库系统的设计中考虑了监测死锁以及从死锁中恢复,即当监测到一组事务发生了死锁时,将选择一个牺牲者并放弃这个事务,作为牺牲者的事务会释放它所持有的资源,从而使其他事物继续进行。
而JVM在解决死锁问题方面并没有数据库服务那么强大,当一组Java线程发生死锁时,这些线程永远不能再使用,恢复应用程序的唯一方式就是中止并重启它。
- 锁顺序死锁
两个线程试图以不同的顺序来获得相同的锁。 - 动态锁顺序死锁
由于嵌套的锁获取操作,无法控制参数的顺序,容易导致两个线程发生死锁,解决这个问题必须定义锁的顺序,并在整个应用程序中都按照这个顺序来获取锁。 - 在协作对象之间发生的死锁
在持有锁时调用某个外部方法,将可能出现活跃性问题,在这个外部方法中可能会获取其他锁,可能会产生死锁,如果阻塞时间过长会导致其他线程无法及时获得当前被持有的锁。 - 开放调用
在调用某个方法时不需要持有锁,那么这种调用被称为开放调用。在程序中应尽量使用开放调用,与那些在持有锁时调用外部方法的程序相比,更易于对依赖于开放调用的程序进行死锁分析。 - 资源死锁
当多个线程相互持有批次正在等待的锁二又不是房自己已持有的锁时会发生死锁,当它们在相同的资源集合上等待是,也会发生死锁。另一种基于资源的死锁形式是线程饥饿死锁,即有界线程池/资源池与相互依赖的任务不能一起使用。
2.死锁的避免与诊断
尽量减少潜在的加锁交互数量,将获取锁时需要遵循的协议写入正式文档并始终遵循这些协议。并且尽可能的使用开放调用,这样获取锁的实例比较简单,极大简化分析过程。
- 定时的锁
获取锁超时后后退并在一段时间内再次尝试,该技术只有在同事获取两个锁时才有效。 - 线程转储信息来分析死锁
线程转储包括各个运行中的线程的栈追踪信息,这类似于发生异常时的栈追踪信息,线程转储还包括加锁信息。诊断死锁时JVM可以告诉我们哪些锁导致了这个问题,涉及哪些线程,他们持有哪些其他的锁。
3.其他活跃性危险
死锁是最常见的活跃性危险,但在并发程序中还存在其他活跃性危险,包括饥饿、丢失信号和活锁等。
饥饿
线程由于无法访问他所需要的资源而不能继续执行,就发生了“饥饿”,引发饥饿最常见的资源就是CPU时钟资源。如果在Java应用程序中对线程的优先级使用不当,或者在持有锁时执行一些无法结束的结构,那么也可能导致饥饿。
日常应该避免使用线程优先级,这会增加平台依赖性,并可能导致活跃性问题,在大部分并发应用程序中都尽量使用默认的线程优先级。
糟糕的响应性
后台线程执行一个运行时间较长的任务,会与事件线程共同竞争资源,其他想要访问该线程相关的锁和资源的线程都必须等待很长时间。
活锁
活锁大概有两种,一是多个相互协作的线程都对彼此进行响应二修改各自的状态,彼此让出对方的路,但又在另一条路上相遇了,使得任何一个线程都无法继续执行;二是程序在处理某个消息时会发生错误,错误后进行回滚操作,再次独到这条消息时又会引起回滚,处理消息的县城没有阻塞,但也无法继续执行下去。
二、性能与可伸缩性
1.性能相关
提升性能意味着用更少的资源做更多的事情,资源密集型操作是性能受到某种特定资源限制的操作。
多线程带来的额外的开销:线程之间的协调,上下文切换,线程的创建与销毁,线程的调度等,过度的使用线程这些开销甚至会超过带来的性能提升。
避免不成熟的优化,如果程序运行的不够快,首先让程序正确,然后在提高运行速度。已测试结果为准,不要盲目猜测性能变化。
2.Amdahl定律
在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决于程序中可并行组件与串行组件所占的比重。这意味着一个算法虽然在4路系统中看似具有可伸缩性的算法,却可能含有一些隐藏的可伸缩性瓶颈,只是还没遇到。
3.线程引入的开销
多个线程的调度和协调过程都需要一定的性能开销,对于提升性能引入的线程来说,并行带来的性能提升必须超过并发导致的开销。
上下文切换
可运行的线程数大于CPU数,那么操作系统最终会将某个正在执行的线程调度出来,从而使其他线程能够使用CPU,这个过程就意味着需要保存当前运行线程的执行上下文,并且将新调度进来的线程的执行上下文设置为当前上下文。
切换上下文需要一定的开销,如果程序中发生越多的阻塞,CPU密集型程序就会发生越多的上下文切换,这增加了调度的开销,并降低吞吐量。
内存同步
在synchronized和volatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏(Memory Barrier),它可以刷新缓存、使缓存无效、刷新硬件的写缓冲,以及停止执行管道。内存栅栏可能同样会对性能带来间接的影响,因为他会抑制一些编译器优化操作,在内存栅栏中大多数操作都是不能被重排序的。
阻塞
在发生锁竞争时竞争失败的线程肯定会阻塞,JVM在实现阻塞行为时采用自旋等待或者通过操作系统挂起被阻塞的线程。当线程阻塞时,这个过程需要两次额外的上下文切换,以及所有必要的操作系统操作和缓存操作:被阻塞的线程在其执行时间片还未用完之前就被交换出去,而在随后当要获取的锁或者其他资源可用时又再次被切换回来。
4.减少锁的竞争
减少锁竞争来提高性能和可伸缩性。在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁。
三种方式降低锁的竞争程度:①减少所的持有时间 ②降低锁的请求频率 ③使用带有协调机制的独占锁,这些机制允许更高的并发性。
缩小锁的范围(快进快出)
尽可能缩短锁的持有时间,例如将一些与锁无关的代码移出同步代码块。缩小同步代码块可以提高可伸缩性,但也不能过小,一些需要采用原子方式执行的操作必须包含在同一个同步块中。
减小锁的粒度
降低线程请求锁的频率,通过锁分解和锁分段技术来实现,在这些技术中采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。这样实现了更高的可伸缩性,但使用的锁越多发生死锁的风险也越高。
锁分段
将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这样在有大量处理器的系统在高访问量的情况下可以实现更高的并发性,还可以进一步增加锁的数量,锁分段的劣势在于与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。
避免热点域
当每个操作都请求多个变量时,锁的粒度将很难降低,常见的优化措施例如将一些反复计算的结果缓存起来,都会引入一些“热点域”,而这些热点域往往会限制可伸缩性。
三、并发程序的测试
安全性测试通常采用测试不变性条件的形式,即判断某个类的行为是否与其规范保持一致。
活跃性测试包括进展测试和无进展测试两方面,比较难量化,例如如何验证某个方法是被阻塞了,而不是运行缓慢?如何测试某个算法不会发生死锁?要等待多久才能确定它发生了故障?
性能测试通过这些来衡量 ①吞吐量:一组并发任务已完成任务的比例 ②响应性:请求从发出到完成之间的时间(延迟) ③可伸缩性:更多资源的情况下,吞吐量的提升情况。
- 正确性测试
分析过程同串行类测试,找出需要检查的不变性条件和后验条件。
- 性能测试
衡量典型测试用例中的端到端性能,同时根据经验值来调整各种不同的限值,例如线程数量、缓存容量等。
性能测试的陷阱
- 垃圾回收
垃圾回收的执行时序时无法预测的,可能会引起某次测试是触发垃圾回收导致测试结果相差很大的情况。可通过JVM指令限制垃圾回收的执行或者固定垃圾回收的执行。 - 动态编译
当某个方法运行的次数足够多时,动态编译会将它编译为机器代码,编译完成之后代码的执行方式将从解释执行变成直接执行。 - 无用代码的消除
要编写有效的性能测试程序,就需要告诉优化器不要将基准测试当做无用代码而优化掉,这就要求在程序中对每个计算结果都要通过某种方式来使用,这种方式不需要同步或者大量的计算。
Ref
Briangoetz. Java并发编程实战[M]. 机械工业出版社, 2012.