@@@ 在编写并发程序时,可以采用与编写串行程序时相同的设计原则与设计模式。二者的差异
在于,并发程序存在一定程度的不确定性,而在串行程序中不存在这个问题。这种不确定性将增加
不同交互模式以及故障模式的数量,因此在设计并发程序时必须对这些模式进行分析。
@@@ 在测试串行程序正确性与性能等方面所采用的技术,同样可以用于测试并发程序,但对于
并发程序而言,可能出错的地方远比串行程序多。
@@@ 在测试并发程序时,所面临的主要挑战在于:潜在错误的发生并不具有确定性,而是随机的。
要在测试中将这些故障暴露出来,就需要比普通的串行程序测试覆盖更广的范围并且执行更长的时间。
@@@ 并发测试大致分为两类,即安全性测试与活跃性测试。
------ 安全性:不发生任何错误的行为
------ 活跃性:某个良好的行为终究会发生
@@@ 在进行安全性测试时,通常会采用测试不变性条件的形式,即判断某个类的行为是否与其
规范保持一致。
@@@ 活跃性测试包括进展测试和无进展测试两方面,这些都是很难量化的------- 如何验证某个方法
是被阻塞了,而不只是运行缓慢?同样,如何测试某个算法不会发生死锁?要等待多久才能宣告它发生
了故障?
@@@ 性能测试,性能可以通过多个方面来衡量,包括:
吞吐量:指一组并发任务中已完成任务所占的比例。
响应性:指请求从发出到完成之间的时间(也称为延迟)。
可伸缩性:指在增加更多资源的情况下(通常指CPU),吞吐量(或者缓解短缺)的提升情况。
》》正确性测试
@@@ 在为某个并发类设计单元测试时,首先需要执行与测试串行类时相同的分析-------找出
需要检查的不变性条件和后验条件。
@@@ 在实际情况中,如果需要一个有界缓存,应该直接使用 ArrayBlockingQueue 或者
LinkedBlockingQueue ,而不是自己编写。
### 基本的单元测试
@@@ 在测试集中包含一组串行测试通常是有帮助的,因为它们有助于开始分析数据竞争之前
就找出与并发性无关的问题。
### 对阻塞操作的测试
@@@ 在测试并发的基本属性时,需要引入多个线程。
@@@ 大多数测试框架并不能很好地支持并发性测试:它们很少会包含相应的工具来创建线程或
监视线程,以确保它们不会意外结束。
---------- 如果在某个测试用例创建的辅助线程中发现一个错误,那么框架通常无法得知与这个线程
相关的是哪一个测试,所以需要通过一些工作将成功或失败信息传递回主测试线程,从而
才能将相应的信息报告出来。
@@@ 在 java.util.concurrent 的一致性测试中,一定要将各种故障与特定的测试明确地关联起来。
@@@ 每个测试必须等待它所创建的全部线程结束以后才能完成。
@@@ 如果某方法需要在特定条件下阻塞,那么当测试这种行为时,只有当线程不再继续执行时,
测试才是成功的。要测试一个方法的阻塞行为,类似于测试一个抛出异常的方法:如果这个方法可以
正常返回,那么就意味着测试失败。
@@@ 在测试方法的阻塞行为时,将引入额外的复杂性:当方法被成功的阻塞后,还必须使用方法
解除阻塞。实现这个功能的一种最简单的方式就是使用中断。
@@@ JVM 可以通过自旋等待来实现阻塞。类似地,由于在 Object.wait 或 Condition.await 等方法
上存在伪唤醒(Spurious Wakeup),因此,即使一个线程等待的条件尚未成真,也可能从
WAITING 或 TIMED_WAITING 等状态临时地转换到 RUNNABLE 状态。
### 安全性测试
@@@ 要想测试一个并发类在不可预测的并发访问情况下能否正确执行,需要创建多个线程来分别执行
put 和 take 操作,并在执行一段时间后判断在测试中是否会出现问题。
@@@ 如果要构造一些测试来发现并发类中的安全性错误,那么这实际上是一个 “ 先有蛋还是先有鸡 ”
的问题:测试程序自身就是并发程序。要开发一个良好的并发测试程序,或许比开发这些程序要测试的类
更加困难。
@@@ 在构建对并发类的安全性测试中,需要解决的关键问题在于,要找出那些容易检查的属性,这些
属性在发生错误的情况下极有可能失败,同时又不会使得错误检查代码人为地限制并发性。理想情况是,
在测试属性中不需要任何同步机制。
@@@ 测试在生产者---消费者模式中使用的类
方法一:检查被放入队列中和从队列中取出的各个元素
方法二:通过一个对顺序敏感的校验和计算函数来计算所有入列元素以及出列元素的校验和,并进行比较。
%%% 如果二者相等,那么测试就是成功的。
%%% 如果只有一个生产者将元素放入缓存,同时也只有一个消费者从中取出元素,那么这种
方法能发挥最大的作用,因为它不仅能测试出是否取出了正确的元素,而且还能测试出元素
被取出的顺序是否正确。
@@@ 要确保测试程序能正确地测试所有要点,就一定不能让编译器可以预先猜测到校验和的值。使用
连续的整数作为测试数据并不是一种好办法,因为得到的结果总是相同的,而一个智能的编译器通常可以
预先计算出这个结果。
@@@ 使用一些简单的伪随机函数。
--------- 你并不需要某种高质量的随机性,而只需要确保在不同的测试运行中都有不同的数字。
@@@ 根据系统平台的不同,创建线程和启动线程等操作可能需要较大开销。
@@@ 测试应该放在多处理器的系统上运行,从而进一步测试更多形式的交替运行。然而, CPU 的数量
越多并不一定会使测试越高效。要最大程度地检测出一些对执行时序敏感的数据竞争,那么测试中的线程数
量应该多于 CPU 数量,这样在任意时刻都会有一个线程在运行,而另一些被交换出去,从而可以检查线程
间交替行为的可预测性。
@@@ 在一些测试中通常要求执行完一定数量的操作后才能停止运行,如果在测试代码中出现了一个错误
并抛出了一个异常,那么这个测试将永远不会结束。
最常见的解决方法是:让测试框架放弃那些没有在规定时间内完成的测试,具体要等待多长时间,
则要凭借经验来确定,并且要对故障进行分析以确保所出现的问题并不是由于没有等待足够长的时间而造成
的。(这个问题并不仅限于对并发类的测试,在串行测试中也必须区分长时间的运行和无限循环)。
### 资源管理的测试
@@@ 测试的另一方面就是要判断类中是否没有做它不应该做的事情,例如资源泄露。对于任何持有或
管理其他对象的对象,都应该在不需要这些对象时销毁对它们的引用。这种存储资源泄露不仅会妨碍垃圾
回收器回收内存(或者线程 、 文件句柄 、 套接字 、 数据库连接或其他有限资源),而且还会导致资源
耗尽以及应用程序失败。
@@@ 通过一些测量应用程序中内存使用情况的堆检查工具,可以很容易地测试出对内存的不合理占用,
许多商业和开源的堆分析工具都支持这种功能。
### 使用回调
@@@ 在构造测试案例中,对客户提供的代码进行回调是非常有帮助的。回调函数的执行通常是在对象
生命周期的一些已知位置上,并且在这些位置上非常适合判断不变性条件是否被破坏。
@@@ 在测试线程池时,需要测试执行策略的多个方面:在需要更多的线程时创建新线程,在不需要时
不创建,以及当需要回收空闲线程时执行回收操作等。要构造一个全面的测试方案是很困难的,但其中
许多方面的测试都可以单独进行。
@@@ 通过使用自定义的线程工厂,可以对线程的创建过程进行控制。
### 产生更多的交替操作
@@@ 由于并发代码中的大多数错误都是一些低频率事件,因此在测试并发错误时需要反复地执行
许多次。
@@@ 如果在不同的处理器数量 、 操作系统以及处理器架构的系统上进行测试,就可以发现那些在特定
运行环境中才会出现的问题。
@@@ 有一种有用的方法可以提高交替操作的数量,以便能更有效地搜索程序的状态空间:在访问共享
状态的操作中,使用 Thread.yield 将产生更多的上下文切换。(这项技术的有效性与具体的平台相关,
因为 JVM 可以将 Thread.yield 作为一个空操作。如果使用一个睡眠时间较短的 sleep ,那些虽然更慢些,
但却更可靠)。
》》性能测试
@@@ 性能测试通常是功能测试的延伸。事实上,在性能测试中应该包含一些基本的功能测试,从而确保
不会对错误的代码进行性能测试。
@@@ 性能测试将衡量典型测试用例中的端到端性能。通常,要获得一组典型的使用场景并不容易,理想
情况下,在测试中应该反映出被测试对象在应用程序中的实际用法。
@@@ 性能测试的第二个目标是根据经验值来调整各种不同的限值,例如线程数量 、 缓存容量等。这些
限值可能依赖于具体平台的特性(例如,处理器的类型 、 处理器的步进级别 、 CPU 的数量或内存大小等),
因此需要动态地进行配置,而我们通常需要合理地选择这些值,从而使程序能够在更多的系统上良好地运行。
### 在 PutTakeTest 中增加计时功能
@@@ 在真实的生产者---消费者应用程序中,如果工作者线程要通过执行一些复杂的操作来生产和获取
各个元素条目,那么 CPU 空闲状态将消失,并且由于线程过多而导致的影响将变得非常明显。
### 多种算法的比较
@@@ java.util.concurrent 中的算法已经通过测试进行了调优,其性能也已经达到我们已知的最佳状态。
@@@ LinkedBlockingQueue 的可伸缩性要高于 ArrayBlockingQueue 。
@@@ 如果算法能通过多执行一些内存分配操作来降低竞争程度,那么这种算法通常具有更高的可伸缩性。
### 响应性衡量
@@@ 吞吐量的测量通常是并发程序最重要的性能指标。
@@@ 通过测量变动性,使我们能回答一些关于服务质量的问题。
@@@ 服务时间变动的测量比平均值的测量要略困难一些--------除了总共完成时间外,还要记录每个任务
的完成时间。
》》避免性能测试的陷阱
### 垃圾回收
@@@ 垃圾回收的执行时序是无法预测的,因此在执行测试时,垃圾回收器可能在任何时刻运行。
@@@ 有两种策略可以防止垃圾回收操作对测试结果产生偏差。
第一种策略是:确保垃圾回收操作在测试运行的整个期间都不会执行(可以在调用 JVM 时指定 -verbose:
gc 来判断是否执行了垃圾回收操作)
第二种策略是:确保垃圾回收操作在测试期间执行多次,这样测试程序就能充分反映出运行期间的内存分配
与垃圾回收等开销。
@@@ 在大多数采用生产者----消费者设计的应用程序中,都会执行一定数量的内存分配与垃圾回收等
操作--------生产者分配新对象,然后被消费者使用并放弃。如果将有界缓存测试运行足够长的时间,那么将
引发多次垃圾回收,从而得到更精确的结果。
### 动态编译
@@@ 与静态编译语言(例如,C 或 C++)相比,编写动态编译语言(例如 Java)的性能基准测试要
困难得多。
@@@ 在 HotSpot JVM (以及其他现代的 JVM ) 中将字节码的解释与动态编译结合起来使用。
当某个类第一次被加载时, JVM 会通过解释字节码的方式来执行它。在某个时刻,如果一个方法运行的
次数足够多,那么动态编译器会将它编译为机器代码,当编译完成后,代码的执行方式将从解释执行
变成直接执行。
@@@ 测量采用解释执行的代码速度是没有意义的,因为大多数程序在运行足够长的时间后,所有
频繁执行的代码路径都会被编译。
@@@ 防止动态编译对测试结果产生偏差的方式:
方式一: 使程序运行足够长的时间(至少数分钟),这样编译过程以及解释执行都只是总运行时间的
很小部分
方式二: 使代码预先运行一段时间并且不测试这段时间内的代码性能,这样在开始计时前代码就已经被
完全编译了。
补充
在 HotSpot 中,如果在运行程序时使用命令行选项 -xx : +PrintCompilation ,那么当动态编译
运行时将输出一条信息,你可以通过这条消息来验证动态编译是在测试之前,而不是在运行过程中执行。
@@@ JVM 会使用不同的后台线程来执行辅助任务。当在单次运行中测试多个不相关的计算密集性
操作时,一种好的做法是在不同操作的测试之间插入显式的暂停,从而使 JVM 能够与后台任务保持步调
一致,同时将被测试任务的干扰降至最低。
### 对代码路径的不真实采样
@@@ 运行时编译器根据收集到的信息对已编译的代码进行优化。JVM 可以与执行过程特定的信息来
生成更优的代码。
@@@ 测试程序不仅要大致判断某个典型应用程序的使用模式,还需要尽量覆盖在该应用程序中将执行
的代码路径集合。否则,动态编译器可能会针对一个单线程测试程序进行一些专门优化,但只要在真实的
应用程序中略微包含一些并行,都会使这些优化不复存在。因此,即便你只是想测试单线程的性能,也应
该将单线程的性能测试与多线程的性能测试结合在一起。
### 不真实的竞争程度
@@@ 并发的应用程序可以交替执行两种不同类型的工作:访问共享数据以及执行线程本地的计算。
根据两种不同类型工作的相关程度,在应用程序中将出现不同程度的竞争,并表现出不同的性能与可伸缩性。
@@@ 要获得有实际意义的结果,在并发测试中应该尽量模拟典型应用程序中的线程本地计算量
以及并发协调开销。如果在真实应用程序的各个任务中执行的工作,与测试程序中执行的工作截然不同,
那么测试出的性能瓶颈位置将是不准确的。
### 无用代码的消除
@@@ 在编写优秀的基准测试程序(无论是何种语言)时,一个需要面临的挑战就是:优化编译器能
找出并消除那些不会对输出结果产生任何影响的无用代码(Dead Code)。
由于基准测试通常不会执行任何计算,因此它们很容易在编译器优化过程中被消除。
@@@ 在 HotSpot 中,许多基准测试在 “ -server ” 模式下都能比在 “ -client ” 模式下运行得更好,
这不仅是因为 “ -server ” 模式的编译器能产生更有效的代码,而且这种模式更易于通过优化消除无用
代码。然而,对于将执行一定操作的代码来说,无用代码消除优化却不会去掉它们。在多处理器系统上,
无论在正式产品还是测试版本中,都应该选择 -server 模式而不是 -client 模式---------只是在测试程序中
必须保证它们不会受到无用代码消除优化的影响。
@@@ 要编写有效的性能测试程序,就需要告诉优化器不要将基准测试当作无用代码而优化掉。这就
要求在程序中对每个计算结果都要通过某种方式来使用,这种方式不需要同步或者大量的计算。
@@@ 有一个简单的技巧可以避免运算被优化掉而又不会引人过高的开销:即计算某个派生对象中
域的散列值(hashCode( )),并将它与任意一个值进行比较。
》》其他的测试方法
@@@ 测试的目标不是更多地发现错误,而是提高代码能按照预期方式工作的可信度。
@@@ 质量保证(QA)的目标应该是在给定的测试资源下实现最高的可信度。
@@@ 通过使用一些补充的测试方法,例如代码审查和静态分析等,可以获得比在使用任何单一方法
更多的可信度。
### 代码审查
@@@ 多人参与的代码审查通常是不可替代的。(另一方面,代码审查也不能取代测试)。
@@@ 并发专家能够比大多数测试程序更高效地发现一些微妙的竞争问题。(此外,一些平台的问题,
例如 JVM 的实现细节或处理器的内存模型等,都会屏蔽一些只有在特定的硬件或软件配置下才会出现
的错误)。
@@@ 代码审查的其他好处:它不仅能发现错误,通常还能提高描述实现细节的注释的质量,因此将
降低后期维护的成本和风险。
### 静态分析工具
@@@ 静态分析工具可以作为正式测试与代码审查的有效补充。
@@@ 静态代码分析是指在进行分析时不需要运行代码,而代码核查工具可以分析类中是否存在一些
常见的错误模式。
@@@ 在一些静态分析工具(例如,开源的 FindBugs)中包含了许多错误模式检查器,能够检测出
多种常见的错误,其中许多错误都很容易在测试与代码审查中遗漏。
@@@ 静态分析工具能生成一个警告列表,其中包含的警告信息必须通过手工方式进行检查,从而
确定这些警告是否表示真正的错误。
@@@ FindBugs 包含的检查器可以发现以下与并发相关的错误模式,而且一直在不断地增加新的
检查器:
---------- 不一致的同步
---------- 调用 Thread.run
---------- 未被释放的锁
---------- 空的同步块
---------- 双重检查加锁
---------- 在构造函数中启动一个线程
---------- 通知错误
---------- 条件等待中的错误
---------- 对 Lock 和 Condition 的误用
---------- 在休眠或者等待的同时持有一个锁
---------- 自旋循环
### 面向方面的测试技术
@@@ 面向方面编程(AOP)技术在并发领域的应用是非常有限的。
@@@ AOP 可以用来确保不变性条件不被破坏,或者与同步策略的某些方面保持一致。
------------ 例如,使用一个方面(Aspect)将所有非线程安全的 Swing 方法的调用都封装在一个
断言中,该断言确保这个调用是在事件线程中执行的。
@@@ 面向方面的测试技术很容易使用,并且可以发现一些复杂的发布错误和线程封闭错误。
### 分析和监测工具
@@@ 大多数商业分析工具都支持线程。大多数分析工具通常还为每个线程提供了一个时间线
显示,并且用颜色来区分不同的线程状态。从这些显示信息中可以看出程序对可用 CPU 资源的
利用率,以及当程序表现糟糕时,该从何处查找原因。
@@@ 内置的 JMX 代理同样提供了一些有限的功能来监测线程的行为。
》》小结
@@@ 要测试并发程序的正确性可能非常困难,因为并发程序的许多故障模式都是一些低频率事件,它们
对于执行时序 、 负载情况以及其他难以重视的条件都非常敏感。
@@@ 要测试并发程序的性能同样非常困难,与使用静态编译语言(例如 C)编写的程序相比,用 Java
编写的程序在测试起来更加困难,因为动态编译 、 垃圾回收以及自动优化等操作都会影响与时间相关的测试
结果。
@@@ 要想尽可能地发现潜在的错误以及避免它们在正式产品中暴露出来,我们需要将传统的测试技术
(要谨慎的避免上面讨论的各种陷阱)与代码审查和自动化分析工具结合起来,每项技术都可以找出其他
技术忽略的问题。