<Java Concurrency in Practice> 读书笔记 - Part3: 活跃, 性能与测试

Table of Contents

第10章 避免活跃性危险

死锁

如果所有线程以固定的顺序来获得锁, 那么在程序中就不会出现锁顺序的死锁问题.

通过锁顺序来避免死锁. 在制定锁顺序时, 可以使用 System.identityHashCode 方法, 该方法将返回由 Object.hashCode 返回的值.
在极少数的情况下, 两个对象可能拥有相同的散列值, 为了避免这种情况, 在获得两个锁之前, 可以首先获得一个“加时赛”锁, 从而保证每次只有一个线程以未知的顺序获得这两个锁.

如果在持有锁时调用某个外部方法, 那么将出现活跃性问题. 在这个外部方法中可能获得其他锁(这可能会产生死锁), 或者阻塞时间过长, 导致其他线程无法及时获得当前被持有的锁.
在程序中应尽量使用开放调用. 与那些在持有锁时调用外部方法的程序相比, 更易于对依赖于开放调用的程序进行死锁分析.

死锁的避免与诊断

通过线程转储(Thread Dump)来帮助识别死锁的发生.
线程转储包括各个运行中的线程的栈追踪信息, 这类似于发生异常时的栈追踪信息,
线程转储还包含加锁信息, 例如每个线程持有了哪些锁, 在哪些栈帧中获得这些锁, 以及被阻塞的线程正在等待获取哪一个锁.

# jmap -dump:[live],format=b,file=<file-path> <pid>
jmap -dump:live,format=b,file=/tmp/dump.hprof 12587

其他活跃性危险

饥饿
当线程由于无法访问它锁需要的资源而不能继续执行时, 就发生了“饥饿”. 引发饥饿的最常见的资源就是 CPU 时钟周期.
如果在 Java 应用程序中对线程的优先级使用不当, 或者在持有锁时执行一些无法结束的结构(例如无限循环, 或无限制地等待某个资源), 那么也可能导致饥饿, 因为其他需要这个锁的线程将无法得到它.

要避免使用线程优先级, 因为这会增加平台依赖性, 并可能导致活跃性问题. 在大多数并发应用中, 都可以使用默认的线程优先级.

糟糕的响应性
CPU 密集型的后台任务可能对响应性造成影响, 因为它们会与事件线程共同竞争 CPU 的时钟周期. 在这种情况下可以降低它们的线程优先级, 从而提高前台程序的响应性.

活锁
当多个相互协作的线程都对彼此进行响应从而修改各自的状态, 并使得任何一个线程都无法继续执行时, 就发生了活锁.
就像两个过于礼包的人在半路上面对面地相遇: 他们彼此都让出对方的路, 然而又在另一条路上相遇了. 因此他们就这样反复的避让下去.

可以通过等待随机长度的时间和回退来有效地避免活锁的发生.

第11章 性能与可伸缩性

对性能的思考

提升性能意味着用更少的资源做更多的事情. 对于一个给定的操作, 通常会缺乏某种特定的资源, 例如 CPU 时钟周期, 内存, 网络带宽, I/O带宽, 数据库请求, 磁盘空间以及其他资源.
当操作性能由于某种特定的资源而受到限制时, 我们通常将该操作称为资源密集型的操作, 例如 CPU 密集型, 数据库密集型等.
想要通过并发来获得更好的性能, 需要做好两件事情: 更有效地利用现有处理资源, 以及在出现新的处理资源时尽可能地利用这些新资源.

可伸缩性是指: 当增加计算资源时(例如 CPU, 内存, 存储容量或 I/O 带宽), 程序的吞吐量或者处理能力能响应地增加.

对于服务器应用程序来说, “多少”这个方面 ———— 可伸缩性, 吞吐量和生产量, 往往比“多快”这个方面更受重视。

避免不成熟的优化. 首先使程序正确, 然后再提高运行速度 ———— 如果它还运行得不够快.

在大多数性能决策中都包含有多个变量, 并且非常依赖于运行环境. 在使某个方案比其他方案“更快”之前, 首先问自己一些问题:

  • “更快”的含义是什么?
  • 该方法在什么条件下运行得更快? 在低负载还是高负载的情况下? 大数据集还是小数据集? 能否通过测试结果来验证你的答案?
  • 这些条件在运行环境中的发生频率? 能否通过测试结果来验证你的答案?
  • 在其他不同条件的环境中能否使用这里的代码?
  • 在实现这种性能提升时需要付出哪些隐含的代价, 例如增加开发风险或维护开销? 这种权衡是否合适?
以测试为基准, 不要猜测.

Amdahl 定律

假定 \(F\) 是必须被串行执行的部分, 在包含 \(N\) 个处理器的机器中, 最高的加速比为:
\[ Speedup \leq \frac {1} {F + \frac {(1-F)} {N} } \]
利用率的定义为: 加速比除以处理器的数量.

在所有并发程序中都包含一些串行部分. 如果你认为在你的程序中不存在串行部分, 那么可以再仔细检查一遍.

在评估一个算法时, 要考虑算法在数百个或数千个处理器的情况下的性能表现, 从而对可能出现的可伸缩性局限有一定程度的认识.

线程引入的开销

单线程程序既不存在线程调度, 也不存在同步开销, 而且不需要使用锁来保证数据结构的一致性.
在多个线程的调度和协调过程中都需要一定的性能开销: 对于为了提升性能而引入的线程来说, 并行带来的性能提升必须超过并发导致的开销.

  • 上下文切换: 保存当前运行线程的执行上下文, 并将新调度进来的线程的执行上下文设置为当前上下文
  •  内存同步: synchronized 和 volatile 提供的可见性保证中可能会使用一些特殊指令, 即内存栅栏. 内存栅栏可以刷新缓存, 是缓存无效, 刷新硬件的写缓冲, 以及停止执行管道.
           内存栅栏可能同样会对性能带来间接的影响, 因为它们将抑制一些编译器优化操作. 在内存栅栏中, 大多数操作都是不能被重排序的.
  •    阻塞: 当在锁上发生竞争时, 竞争失败的线程肯定会阻塞. JVM 在实现阻塞行为时, 一般采用自旋等待或者通过操作系统挂起被阻塞的线程.
不要过度担心非竞争同步带来的开销. 这个基本的机制已经非常快了, 并且 JVM 还能进行额外的优化以进一步降低或清除开销.
因此, 我们应该将优化重点放在那些发生锁竞争的地方.

减少锁的竞争

串行操作会降低可伸缩性, 并且上下文切换也会降低性能. 在锁上发生竞争时将同事导致这两种问题, 因此减少锁的竞争能够提高性能和可伸缩性.

在并发程序中, 对可伸缩性的最主要威胁就是独占方式的资源锁.
有3种方式可以降低锁的竞争程度:
- 减少锁的持有时间.
- 降低锁的请求频率.
- 使用带有协调机制的独占锁, 这些机制允许更高的并发性.

具体操作:

  • 缩小锁的的范围. 将一些与锁无关的代码移出同步代码块, 尤其是哪些开销较大的操作, 以及可能被阻塞的操作, 例如 I/O 操作.
  • 减小锁的颗粒. 可以通过锁分解和锁分段等技术来实现, 在这些技术将采用多个相互独立的锁来保护独立的状态变量, 从而改变这些变量在之前由单个锁来保护的情况.
  • 避免热点域. 当每个操作都请求多个变量时, 锁的粒度将很难降低. 一些常见的优化措施, 例如将一些反复计算的结果缓存起来, 都会引入一些“热点域”, 而这些热点域往往会限制可伸缩性.
  • 一些替代独占锁的方法: 使用并发容器, 读写锁, 不可变对象以及原子变量.

如果 CPU 没有得到充分利用, 通常有一下几种原因:

  • 负载不充足. 可以在测试时增加负载, 并检查利用率, 响应时间和服务时间等指标的变化.
  • I/O 密集. 判断某个应用程序是否是磁盘 I/O 密集型, 或者检测网络的通信流量级别来判断它是否需要提高带宽.
  • 外部限制. 如果应用程序依赖外部服务, 例如数据库或 Web 服务. 可以使用分析工具或数据库管理工具来判断在等待外部服务的结果时需要多少时间.
  • 锁竞争. 使用分析工具可以知道在程序中存在何种程度的锁竞争, 以及在哪些锁上存在“激烈的竞争”.

如果应用程序正在使 CPU 保持忙碌状态, 那么可以使用监控工具来判断是否能通过增加额外的 CPU 来提升程序性能.

通常, 对象分配操作的开销比同步的开销更低.

第12章 并发程序的测试

性能测试可以通过多个方便来衡量, 包括:

  • 吞吐量: 指一组并发任务中已完成任务所占的比例.
  • 影响性: 指请求从发出到完成之间的时间(也称为延时).
  • 可伸缩性: 指在增加更多资源的情况下(通常指CPU), 吞吐量(或者缓解短缺)的提升情况.

正确性测试

  • 基本的单元测试. 找出需要检查的不变性条件和后验条件.
  • 对阻塞操作的测试. 每个测试必须等待它锁创建的全部线程结束以后才能完成.
  • 安全性测试.
  • 资源管理的测试.
  • 使用回调.
  • 产生更多的交替操作. 在访问共享状态的操作中, 使用 Thread.yield 将产生更多的上下文切换.
在构建对并发类的安全性测试中, 需要解决的关键问题在于, 要找出哪些容易检查的属性, 这些属性在发生错误的情况下极有可能失败, 同时又不会使得错误检查代码人为地限制并发性.
理想情况是, 在测试属性中不需要任何同步机制.
这些测试应该放在多处理器的系统上运行, 从而进一步测试更多形式的交替运行. 然而, CPU 的数量越多并不一定会使测试越高效.
要最大程度地检测出一些对执行时序敏感的数据竞争, 那么测试中的线程数量应该多于 CPU 数量, 这样在任意时刻都会有一些线程在运行, 而另一些被交换出去, 从而可以检查线程间交替行为的可预测性.

性能测试

  • 增加计时功能
  • 多种算法比较
  • 响应性衡量

避免性能测试的陷阱

  • 垃圾回收. 垃圾回收的执行时序是福娃预测的.
  • 动态编译. 当某个类第一次被加载时, JVM 会通过解译字节码的方式来执行它. 如果一个方法运行的次数足够多, 那么动态编译器会将它编译为机器代码, 当编译完成后, 代码的执行方式将从解释执行变成直接执行.
  • 对代码路径的不真实采样. 运行时编译器根据收集到的信息对已编译的代码进行优化.
  • 不真实的竞争程度. 并发的应用程序可以交替执行两种不同类型的工作: 访问共享数据以及执行线程本地的计算.
  • 无用代码的消除. 优化编译器能找出并消除哪些不会对输出结果产生任何影响的无用代码.
要编写有效的性能测试程序, 就需要告诉优化器不要将基准测试当作无用代码而优化掉.
这就要求在程序中对每个计算结果都要通过某种方式来使用, 这种方式不需要同步或者大量的计算.

其他的测试方法

  • 代码审查. 正如单元测试和压力测试在查找并发错误时是非常高效和重要的手段, 多人参与的代码审查通常是不可替代的.
  • 静态分析工具. 成为正式测试与代码审查的有效补充.
  • 面向切面的测试技术.
  • 分析与监测工具.

Author: Saul Lawliet

Created: 2022-03-21 Mon 15:34