【读后感】Java Concurrency in Practice:10.并发程序的测试

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 线程竞争监测
	线程由于等待一个 锁 或 通知 而被阻塞的次数 ,等待的累计时间
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

肯尼思布赖恩埃德蒙

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值