并发程序测试
一、并发程序测试
1.1 并发测试的挑战
1.2 并发测试的类型
1.2.1 安全性测试
- 安全性:不发生任何错误的行为
- 安全性测试:通常采用测试不变性条件和后验条件的形式,即判断某个类的行为在并发测试下,其状态是否与其规范一致
1.2.2 活跃性测试
- 活跃性:某个“良好”的行为“终究”会发生
- 活跃性测试与性能测试息息相关,活跃性测试比较难量化,所以常用性能测试替代
- 性能测试可通过多个方面来衡量:
- 吞吐量:一组并发任务在已完成任务中所占的比例
- 响应性(延迟):请求从发出到完成所用的时间
- 可伸缩性:在增加更多资源的情况下,吞吐量的提升情况
二、正确性测试
2.1 正确性测试的基本思路
- 找出需要检查的不变性条件和后验条件
- 为不变性条件和后验条件编写测试用例
- 在编写测试用例时,不断探索发现新的测试情况
- 为新的情况编写测试用例
2.2 常用的测试方法
2.2.1 直接验证测试
- 直接调用对象的对外接口方法,验证它的不变性条件和后验条件
1 2 3 4
| void testIsEmpty() { assertTrue(list.isEmpty()); }
|
2.2.2 回调钩子测试
- 有些对象拥有自己的一些生命周期,不同时期的状态有固定的规范
- 比如线程的生命周期可以是:创建、启动、阻塞、结束
- 可以在对象生命周期中添加一些钩子函数,在每个阶段调用测试方法,验证当时的状态是否正确
2.2.3 随机安全测试
- 想要测试在不可预测的并发访问情况下,执行结果是否一直是安全正确的
- 需要多次验证,并且每次验证应该是随机的,但是结果应该总是安全正确的
- 随机性结果的验证,可以通过校验和计算函数来实现,即对入参和返回进行校验比较
- 大多数随机生成器类都是线程安全的,使用它们会带来额外的同步开销,使用一些简单的随机生成器更好
简单的随机数生成函数(基于 hashCode + nanoTime + xorShift):
1 2 3 4 5 6 7 8 9
| int randomInt(Object object) { return xorShift(object.hashCode() * System.nanoTime()); } int xorShift(int y) { y ^= (y << 6); y ^= (y >>> 21); y ^= (y << 7); return y; }
|
2.2.4 阻塞中断测试
- 如果某个方法在特殊条件下进入了阻塞,那么只有当线程不再执行时,测试才是成功的
- 测试阻塞方法的方式:
- 在一个单独线程中启动阻塞方法
- 等待线程进入阻塞状态
- 在另一个线程中调用中断,将阻塞线程中断唤醒
- 阻塞方法要求能够响应中断并返回
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| void testBlock() { Thread t = new Thread(() -> { try { fail(); } catch (InterruptedException e) { } }); try { t.start(); Thread.sleep(LOCKUP_DETECT_TIMEOUT); t.interrupt(); t.join(LOCKUP_DETECT_TIMEOUT); assertTrue(t.isAlive()); } catch (InterruptedException e) { fail(); } }
|
2.2.5 资源管理测试
- 对于任何持有或管理其他对象的对象,应该在不需要的时候释放和销毁它们的引用
- 并发线程很多时,将会非常消耗资源,最终可能导致资源耗尽和应用程序故障失败
- 为了防止资源耗尽,需要测试线程拥有的资源是否已经正确释放
- 测试资源的占用和释放,可以采用一些分析工具,分析内存中的对象信息,比如堆中对象的垃圾回收情况
2.2.6 上下文切换测试
- 并发问题的来源,就是因为线程之间的资源竞争
- 为了提高并发错误发生的可能性,可以提高线程之间的上下文切换频率
- 可以通过使用 Thread.yield() 方法来产生更多的交替操作
三、性能测试
3.1 性能测试目的
- 通过衡量测试用例的性能,反映出对象在应用程序中的实际用途,比如并发容器适用于高并发的情况
- 根据测试结果,调整各种不同的限制参数,比如线程数量、缓存容量等
3.2 常用测试方法
3.2.1 计时功能测试
- 性能最直接的方式,就是计算并发任务的完成时间
- 所有并发任务总的完成时间、各个任务的完成时间、操作平均完成时间
- 计时测试,可能需要用到闭锁(latch)、栅栏(Barrier)等方式来同时为各个任务计时
3.2.2 算法对比测试
- 通过对比其他的算法实现,来衡量当前算法的性能
- 比如对比同步容器和并发容器的性能,就能明显看出各自的性能差异
3.2.3 稳定性测试
- 除了任务完成的速度,还应该测试任务在各种情况下的稳定性
- 比如,算法 A 的完成时间在 10ms ~ 200ms 之间,算法 B 的完成时间在 70ms ~ 100ms 之间,就说明算法 B 更具稳定性
- 有时候,稳定性比速度更具有价值,所以应视情况而定
四、并发测试的陷阱
4.1 垃圾回收
- 垃圾回收的执行无法预测,可以在任何时刻运行,测试不应该考虑垃圾回收的触发时机
- 防止垃圾回收造成测试偏差:
- 无垃圾回收:确保垃圾回收不会执行(通过打印gc日志查看)
- 平均垃圾回收:确保垃圾回收多次执行,基本平均到每次测试中,减少误差
- 平均垃圾回收策略,在实际环境中的表现会更好
4.2 动态编译
- 通常情况,JVM 都是通过解释字节码的方式来运行程序
- 但是如果某个方法频繁调用,JVM 的动态编译器会直接将方法编译为机器代码,后面直接就运行机器代码,不再解释字节码运行了
- 在某些情况下,动态编译执行还可能回退回解释执行
- 动态编译具有更好的性能,但是这会对性能测试造成一定的影响
- 避免动态编译影响的方式:
- 避免动态编译:测试应具有更普遍的随机性,避免动态编译的触发
- 错开动态编辑:等到动态编译之后,再开始测试性能
- 如果动态编译不可避免,那么性能测试就应该延迟到等动态编译完成后再开始
4.3 针对性优化
- JVM 会根据实际的执行过程,来生成更优的代码
- 也就是说,同一套代码,在不同环境下执行,被 JVM 优化后的代码可能会有不同
- 测试应覆盖执行代码的所有分支情况,避免 JVM 针对某种情况进行针对性优化
- 比如,如果执行某个方法时,一直都是只执行某条代码分支,多次执行以后,JVM 有可能会针对它进行代码优化
4.4 无用代码的消除
- 优化编译器,可能会消除对最终结果没有任何影响的无用代码
- 测试代码中,有些代码虽然不影响最终的结果,但也是必要的,它不应该被优化器给优化掉
- 可以通过查看机器代码,来验证测试代码是否被优化了
- 避免无用代码被优化掉的小技巧:使用对象的 hashCode 与任意值(如 nanoTime)比较
1 2 3 4
| if (object.hashCode() == System.nanoTime()) { System.out.println(" "); }
|
4.5 不真实竞争
- 测试程序,应该与实际使用相适应,否则测试结果就会显得不真实,毫无意义
- 如果实际使用中,任务都是用于计算密集型的,那么测试程序应该是计算密集型的
五、其他测试方法
5.1 代码审查
- 并发专家能够比大多数测试程序更高效地发现一些竞争问题(毕竟测试代码也是人写的)
- 通过找出常见的设计性问题,还可以提高代码的质量
- 可以提前发现问题,降低后期维护的成本和风险
5.2 静态代码分析
- 一些代码扫描工具(如 findBug),可以自动发现一些常见的代码错误
- 不一致的同步:加锁对象不一致,导致加锁无用
- 未释放的锁:锁释放没有放 finally 块中,异常时可能导致锁永远无法释放
- 调用 Thread.run():不要调用 Thread 的 run() 方法,而是调用 start() 方法
- 双重校验锁:错误的习惯用法,写不好就会出现一堆问题
- Lock 的误用:Lock 并不是作为同步块来使用的
- 自旋循环:自旋等待不加 volatile 可能会导致 CPU 无限高负载,闭锁和条件等待通常是一种更好的方式
5.3 面向方面技术
- 通过切面编程,去验证在各个阶段的状态和线程
- 并发领域的切面技术还不够成熟,作用有限
5.4 分析与监测工具
- 存在一些分析工具,可以给出程序内部的详细执行信息
- 比如线程状态、线程堆栈、线程锁对象、线程等待、死锁分析等
- 分析工具通常采用侵入式实现,有可能会对程序的执行时序和行为产生较大的影响