0. 累的时候闭上双眼~
并发测试:安全性测试(正确性测试)、活跃性测试(与之相关的是性能测试:吞吐量、响应性、可伸缩性)
测试代码同样会对执行时序或同步操作带来影响,这可能会掩盖一些可以暴露的错误。
本章节手写一个并发队列BoundBuffer作为被测样例
1. 正确性测试
当我们测试BoundBuffer在put()、take()行为是否正确(是否正确放映元素个数变化、触摸边界时候是否正确阻塞)时,可以借助Samaphore来实现对其元素个数的监听 以及 对边界情况阻塞 的效果。
1.1 对阻塞操作的测试
设想一下,如果被测程序在临界条件下正确进入阻塞状态之后,应该要如何通知到外部测试程序呢?
方案一(不建议):
使用Thread.getState来校验被测程序是否进入WAITING或TIMED_WAITING状态。
弊病:
JVM有可能并没有使被阻塞线程真的挂起,而是使其自旋。(略扯一点:object.wait、condition.await都会出现'伪唤醒'的情况)
即便忽视这种情况,被测程序进入到阻塞状态的过程也会消耗一定的时间
方案二(简单可控):
测试程序在被测程序进入阻塞后,中断其阻塞状态
难点:
测试程序需要把握中断的时机(需要sleep多久之后执行中断)
还可以使用限时的join保证中断后的被测程序即便是遭遇意料之外的情况,也能返回结果
基于方案二的实现;
1.2 安全性测试(元素个数变化是否正确)
由于测试程序本身也是并发程序,这使得测试程序的开发变得困难。为了不干扰被测程序的线程调度,理想的情况是,不对测试的属性使用任何同步机制。
像本案中的BoundBuffer的并发正确性测试,我们在测试程序可以使用产消模型的方式来测试放入、取出队列的各个元素。
实现方式:
方案一(不建议):
准备一个队列的副本,每次修改队列时,同步修改到副本
待被测程序运行结束之后,通过比较副本队列来验证其正确性
弊病:
副本需要同步队列的修改,这会干扰被测程序的线程调度
方案二(逻辑更加简单):
归纳到串行环境的测试上:
按一定顺序 产出 一系列已知的元素的元素
待被测程序运行结束之后,检查消费顺序是否按照原先 产出 的顺序
推广到并发环境的测试上:
由于并发执行的时序不可知 -> 我们不再关心消费的顺序 -> 转而关心是否 产出多少 就 消费多少
待被测程序运行结束之后,校验其 产出/消费 总和 -> 借助原子变量的可见性实现 -> 尽量的减少对线程调度的干扰
需要考虑的实际问题:
”聪明“的编译器早就猜到了这个总和 -> 每个线程都私有一个随机数生成器 -> 建议使用更加简单的、静态的、线程间通用的伪随机数生成函数
很多随机数生成器都是线程安全的 -> 带来额外的同步开销
被测程序中线程交替执行的激烈程序有限 -> 很可能先初始化好的线程先执行 -> 使用闭锁、栅栏缓解这种问题
线程的创建、启动都需要不小的开销
被测程序的线程数应该要多于CPU数量 -> 激化数据竞争
并发程序测试需要注意错误、异常的抛出 -> 程序可能无法在规定时间内结果 -> 测试程序需要对执行时间敏感
列举一个伪随机数生成函数
方案二的测试代码:
生产者&消费者实现代码:
1.3 资源管理的测试
测试的另一个方面就是要判读类中是否没有做它不应该做的事情,例如:资源泄露,我们应该在不需要这些对象时销毁他们的引用。
像BoundBuffer这类并发容器来说,资源管理显得尤为重要:我们可以限制其容量、缓存大小(阻塞无节制的 产出),防止资源耗尽 导致的 程序故障(例如:生产速度远大于消费速度)。
通过堆的快照(返回堆大小)来测试资源泄露。。。
插入/移出多个、大对象 -> heapSize2 远大于 heapSize1 -> 存在内存泄露(之所以这么做,是因为System.gc()并不会强制回收,除非配置参数告诉JVM)
一般来说,显式地将对象赋值Null,可能并不会带来多大的作用(JVM心里有数的),有时候还容易导致负面的影响。
1.4 使用回调(线程的创建、回收是否正常)
线程池通过调度线程来回调客户的代码,从而测试线程创建(由线程工厂创建)、回收是否正常(当前线程创建数量、是否创建新线程、是否回收闲置线程)
为了更容易观察到测试现象,我们可以基于线程池的特点,做一些测试的设计方案:
当线程池基本大小 小于 最大大小 时,线程池会根据需求创建、回收线程
可以适当执行一些 时间较长 的任务,这有助更好的观察线程池的行为是否如我们所预期
用于测试的线程池的线程工厂:
测试线程池的线程创建能力:
1.5 产生更多的交替操作
前面提到过:可以让处理器数量少于活动线程数
在访问共享状态的操作中(同步代码中),使用Thread.yield产生更多的上下文切换(JVM将Thread.yield实现为空操作,这跟具体平台相关)
2. 性能测试
性能测试通常包含了一些基本的功能测试,从而确保不会对错误的代码惊醒性能测试。
性能测试可以根据经验来调整各种不同的阈值,例如:线程数量、缓冲容量(这些指标可能需要参照具体的平台、硬件)
2.1 在PutTakeTaskTest中增加(对BoundBuffer)计时功能
我们可以借助栅栏实现对整体任务的任务时间,然后除以任务数量,从而得到平均每次任务执行的时间。
书上给出了在4路机器上的测试结果:
可以看出:
1、产消模式在不同参数组合下的吞吐率
2、有界缓存在不同线程数量下的可伸缩性(某一种参数组合下)
3、如何选择缓存大小
在这个测试中,随线程数量的增加,性能却略有回降:计算量有限时,大部分时间用作了线程阻塞、解除阻塞;当闲置CPU较多时,对任务并行执行本身是有帮助的,因此性能不会骤降。
需要注意的是,这个测试结果也忽略了一些实际的要素(当前测试没有模拟到的地方):生产者 的任务产出、任务入列到缓冲 的过程被简化了(这一点同消费者);
可以设想实际的情况:任务传递存在一些延迟下(任务执行时间较长),CPU闲置的情况将减少,线程数量过多造成的影响会被放大。
2.2 多种算法的比较(横向对比并发容器的性能)
BoundBuffer还没有达到LinkedBlockingQueue、ArrayBlockingQueue那样好(这也解释了这种缓存算法没有被选入类库中)
相比于ArrayBlockingQueue,LinkedBlockingQueue的可伸缩性显得更好,考虑到链表结构在每次执行插入时需要分配链表节点(数组的队列相比之下的GC、内存分配表现更好),但优化后的链表队列通过将头尾节点的更新操作分离 -> 多执行一些内存分配(内存分配是发生在线程本地的) -> 降低了竞争程度
2.3 响应性衡量
除了前面探讨的吞吐量,执行时间也是并发程序重要的性能指标。
书里还给出了TimedPutTakeTask在执行时间维度下的直方图:
这里的测试分别采用非公平的信号量(隐蔽栅栏)以及公平信号量(开放栅栏)
除非线程由于密集的同步需求而被持续的阻塞,否则非公平的信号量通常能实现更好的吞吐量,而公平的信号量则实现更低的变动性(平均的执行时间分布比较集中)。
3. 避免性能测试的陷阱
这一块涉及到很多比较实际的问题,很多地方并非都很熟悉,先做个笔记吧
3.1 垃圾回收
垃圾回收的执行时序是无法预测的,举栗:测试程序跑了N次迭代没有触发GC,在N+1次触发了GC
方案一(确保整个过程不发生GC):
实现:
调用JVM指令,判断是否发生GC?
方案二(确保整个过程触发多次GC,并非每次都触发):
实现:
书上并没有给出实例,应该是要评估测试程序
这要求需要更长的执行时间
好处
可以反应运行时的内存分配、回收的真实开销
3.2 动态编译
动态编译给性能测试带来的问题:
编译过程会消耗CPU资源;
实际运行时,还可能会有 反编译(回退到解释执行) 以及 重新编译 的复杂情况;
JVM会根据选择在应用程序线程 或者 后台线程 执行编译;
与静态编译语言(C、C++)相比,编写动态编译语言(Java)的性能基准测试要困难得多
像HotSpot等现代JVM通常会将 字节码解释 、动态编译 结合使用
一些可取的测试方案:
如何消减JVM动态编译的影响:
让程序运行足够长的时间
可以使一些原先需要动态编译代码在测试前预先完成编译
可以使得编译时间所占比率尽可能的小
当执行单次的测试的过程中发生多次不相关的计算密集型操作:
此时JVM可能使用不同的后台线程来辅助执行任务
思路使尽可能的消减后台线程带来的影响
实现:
在这些操作之间插入显式的暂停,使得JVM能与后台任务保持步调一致
3.3 对代码路径的不真实采样
运行时编译器会根据收集到信息对已编译的代码进行优化:
栗一:
程序A、程序B都调用到了方法m,但是实际编译优化后代码中两者调用的方法m代码可能有所差别
栗二:
JVM通过 单一调用转换(假设后面编译的其他程序对调用的方法m都是一样的) -> 将程序对某个方法m的虚拟方法调用 转换为 直接方法调用
后来加载到一个对方法m做了改写的类A -> 之前已编译的代码将失效
3.4 不真实的竞争程度
这一块前面也有所提及:如果测试过程中,线程本地的计算(任务执行时间)比较简短,可以无法模拟实际的情况,并且空闲的CPU还会增加一些不真实的竞争。
3.5 无用代码的消除
带来的影响:可能导致得到一份虚假的测试报告;如果优化消除导致性能提升了的话,这可能导致得到一份错误的测试报告。
大多数情况下,编译器消除无用代码都是一种优化措施(生成代码跟原来的有所差别,但并非将其直接拿掉)
举个例子:前面测试PutTakeTaskTest中我们为何选择使用伪随机数生成函数?是因为考虑编译器会在执行前"预判"到 最终的校验和 -> 导致测试程序并非如我们所愿,执行完整的过程 -> 中间累加校验和的关键并发代码被"优化"掉了。
以"-server"的模式启动HotSpot,该模式不仅可以生成比"-client"模式更有效的代码,还会在无用代码的优化上做的更好(一般来说,会使用优化来删除无用代码;对于有一定操作的无用代码,一样会执行无用代码的优化,但不会删除他们)。实际测试过程中,需要确保它们不会受到无用代码消除优化的影响。
4. 其他的测试方法
虽然我们希望一个测试程序能够"找出所有的错误",但是这是一个不切实际的目标。
测试的目标不是更多地发现错误,而是提高代码能够按照预期方式工作的可信度。
4.1 代码审查
即多人参与的代码审查通常是不可替代的
4.2 静态分析工具
书中通过推荐开源的FindBugs的诸多检查器来体现静态分析工具的作用(确保程序遵守规范):
不一致的同步:某个域每次被访问的时候,持有锁不是一致的
调用Thread.run:没有通过Thread.start方式启动线程(虽然Thread impl Runnable)
未被释放的锁:显式锁没有在finally块中释放
空的同步代码块:同名(虽然这在Java内存模型中具有一定的语义)
双重检查加锁:DCL是错误的用法!(忽视了"可见性")
在构造中启动一个线程:导致了this的隐式逸出
通知错误:同步代码块中使用notify,但没有修改任何状态
条件等待中的错误:当在条件队列上等待时,object.wait、condition.await应该在检查了状态谓语后(在某个循环中需要持有正确的锁)
对Lock和Condition的误用:
将Lock作为同步代码块来使用通常是一种错误的用法
调用Condition.await,而非Condition.wait(前者在第一次调用可以抛出IllegalMonitorsStateException,这使得测试过程中可以被发现)
在休眠或者等待的同时持有一个锁:可能导致严重的活跃性问题
自旋循环:如果代码中自旋检查的某个域非volatile,那么将无法确保自旋能结束(可以考虑使用闭锁或条件等待)
4.3 面向方面(切面)的测试技术
AOP在并发领域的应用是非常有限的
4.4 分析与检测工具
大多数商业分析工具都支持线程(一般都采用侵入式实现,因此可能会对程序的执行时序和行为产生极大的影响)
内置的JMX代理同样提供了有限的功能来监测线程的行为(ThreadInfo等)。
线程状态
发生阻塞的锁 或 条件队列
【可选(影响性能)】Thread Contention Monitoring 线程竞争监测
线程由于等待一个 锁 或 通知 而被阻塞的次数 ,等待的累计时间