阅读 138

Java并发编程实战读书笔记三(java并发编程实战pdf)

第十章 避免活跃性危险

死锁

锁顺序死锁

两个线程试图以不同的顺序来获得相同的锁,可能产生锁顺序死锁。

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

动态的锁顺序死锁

有些程序无法由代码写死加锁顺序,比如锁由参数动态传入,如果按照入参顺序加锁,由于不同调用者传入入参顺序不同,仍然没有固定加锁顺序。这种情况需要定义算法,比较锁来获得加锁顺序。

在协作对象之间发生的死锁

协作对象之间相互调用,可能同时需要自己和对方加锁,查找这种死锁比较困难:如果在持有锁的情况下调用某个外部方法,那么就需要警惕死锁。

如果在持有锁时调用某个外部方法,那么将出现活跃性问题。在这个外部方法可能会后去其他所(这可能会产生死锁),或者阻塞时间过长,导致其他县城无法及时获得当前持有的锁。

开放调用

如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用(Open Call)。依赖于开放调用的类通常能表现出更好的行为,并且与那些在调用方法时需要持有锁的类相比,也更易于编写。这种通过开放调用来避免死锁的方法,类似于采用封装机制来提供线程安全的方法。

通常,如果只是为了语法紧凑或简单性(而不是因为整个方法必须通过一个锁来保护)而使用同步方法(而不是同步代码块),那么就会导致协作对象相互影响产生死锁。收缩同步代码块的保护范围有助于编写开放调用的方法,还可以提高伸缩性。

资源死锁

正如当多个线程相互持有彼此正在等待的锁而又不释放自己持有的锁时会发生死锁,当它们在相同的资源集合上等待时,也会发生死锁。

另一种基于资源的死锁形势就是线程饥饿死锁(Thread-Starvation Deadlock)。如果某些任务需要等待其他任务的结果,而又没有足够线程来执行被等待的任务,此时等待的任务无法释放线程资源,会无限等待下去。有界线程池/资源池与相互依赖的任务不能一起使用。

死锁的避免与诊断

如果必须获取多个数,那么在设计时必须考虑锁的顺序:尽量减少潜在的加锁交互操作,将获取锁时需要遵循的协议写入正式文档,并始终遵循这些协议。

支持定时的锁

还有一项技术可以检测死锁和从死锁中恢复过来,即显式使用Lock类中的定时tryLock功能来替代内置锁机制。

通过线程转储信息来分析死锁

JVM通过线程转储(Thread Dump)来帮助识别死锁的发生。

线程转储包括各个运行中的线程的栈追踪信息,这类似于发生异常时的栈追踪信息。线程转储包括加锁信息,例如每个线程持有了哪些锁,在哪些栈帧中获得这些锁,以及被堵塞的线程正在等待获取哪一个锁。在生成线程转储之前,JVM将在等待关系图中通过搜索循环来找出死锁。

其他活跃性危险

饥饿

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

改变线程优先级也可能导致饥饿。

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

糟糕的响应性

在GUI应用程序中,如果CPU密集型后台任务占用了过多时钟周期,可能导致事件线程响应性降低,从而降低用户体验,这种情况可以降低后台任务优先级。

活锁

活锁(Livelock)是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复执行相同的操作,而且总会失败。

当多个相互协作的线程都对彼此进行响应,从而修改各自的状态,并使得任何一个线程都无法继续执行时,就发生了活锁。

要解决这种活锁问题,需要带重试机制中引入随机性。

第十一章 性能和可伸缩性

提升性能的技术同样会增加复杂性,因此也就增加了在安全性和活跃性上发生失败的风险。提升性能总会令人满意,但始终要把安全性放在第一位。

对性能的思考

尽管使用多个线程的目标是提升整体性能,但与单线程的方法相比,使用多个线程,总会引入一些额外的性能开销。造成这些开销的操作包括线程之间的协调(例如加锁、触发信号以及同步内存等),增加的上下文切换,线程的创建和销毁,以及线程的调度等。如果过度的使用线程,那么这些开销甚至会超过由于提高吞吐量、响应性或者计算能力所带来的性能提升。另一方面,一个并发设计很糟糕的应用程序,其性能甚至比实现相同功能的串行程序的性能还要差。

要想通过并发来获得更好的性能,需要努力做好两件事情:更有效地利用现有处理资源,以及在出现新的处理资源时,使程序尽可能的利用这些新资源。

性能与可伸缩性

应用程序的性能可以采用多个指标来衡量,例如服务时间延迟,时间吞吐率,效率和伸缩性以及容量等。其中一些指标(服务时间、等待时机)用于衡量程序的“运行速度”,即某个指定的任务单元需要“多快”才能处理完成。另一些指标(生产量,吞吐量)用于衡量程序的处理能力,即在计算资源一定的情况下,能完成“多少”工作。

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

评估各种性能权衡因素

做出正确的权衡时通常会缺少对应的信息。

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

当进行决策时,有时候会通过增加某种形式的成本来降低另一种形式的开销(例如增加内存使用量以降低服务时间),也会通过增加开销来换取安全性。

对性能的提升可能是并发错误的最大来源。

由于并发错误是对最难追踪和消除的错误,因此对于任何可能引入这类错误的措施都需要谨慎实施。

在对性能的调优时,一定要有明确的性能需求。此外,还需要一个测试程序,以及真实的配置和负载等环境。

以测试为基准,不要猜测。

Amdahl定律

Amdahl定律的描述是:在增加计算资源的情况下,程序在理论上能够实现最高加速比。这个值取决于程序中可并行组件与创新组件所占的比重。假定F是必须被串行执行的部分,那么根据Amdahl定律,在包含N个处理器的机器中,最高的加速比为:

Speedup≤1F+(1−F)NSpeedup \le \cfrac {1}{F + \cfrac {(1 - F)}{N}}SpeedupF+N(1−F)1

线程引入的开销

在多个线程的调度和协调过程中都需要一定的性能开销:对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的开销。

上下文切换

如果可运行的线程数大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU,这将导致一次上下文切换。在这个过程中,将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。

切换上下文需要一定的开销,而在线程调度过程中,需要访问由操作系统和JVM共享的数据结构。

内存同步

同步操作的性能开销包括多个方面。在synchronized的和volatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏(Memory Barrier)。内存栅栏可以刷新缓存使缓存无效。刷新硬件的写缓存,以及停止执行管道。内存栅栏可能同样会对性能带来间接的影响,因为他们将抑制一些编译器优化操作。在内存栅栏中,大多数操作都是不能被重排序的。

现在JVM能通过优化来去掉一些不会发生的竞争,从而减少不必要的同步开销。

一些完备的JVM能通过逸出分析(Escape Analysis)来找出不会发布到堆的本地对象引用(因为这个引用是线程本地的)。

即使不进行逸出分析,编译器也可以执行锁粒度粗化(Lock Coarsening)操作,即将临近的同步代码块用同一个锁合并起来。

不要过度担心非竞争同步带来的开销。

某个线程中的同步可能会影响其他线程的性能,同步会增加共享内存总线上的通信量,总线的带宽是有限的,并且所有的处理器都将共享这条总线。如果有多个线程竞争同步带宽,那么所有使用了同步的线程都会受到影响。

阻塞

竞争的同步可能需要操作系统接入,从而增加开销。

同步可以分为有竞争力的同步和无竞争的同步。有竞争力的同步是指遇到锁竞争的情况,无竞争的同步是直接拿到了锁的情况。

减少锁的竞争

如果在锁上发生了竞争,那么将限制代码的可伸缩性。

在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源所。

有两个因素将影响在所上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间。

有3种方式可以降低锁的竞争程度:

  • 减少锁的持有时间。

  • 降低锁的请求频率。

  • 使用带有协调机制的独占锁,这些机制允许更高的并发性。

缩小锁的范围(“快进快出”)

降低发生竞争可能性的一种有效方式就是尽可能缩短锁的持有时间,例如可以将一些锁无关的代码移出同步代码块,尤其是那些开销比较大的操作,以及可能被阻塞的逃走,例如I/O操作。

尽管缩小同步代码块能提高可伸缩性,但同步代码块也不能过小——一些需要采用原子方法执行的操作(例如对某个不变性条件中的多个变量进行更新)必须包含在一个同步代码块中。此外,同步需要一定的开销,当把一个同步代码块分解为多个同步代码块时(在确保正确性的情况下),反而会对性能提升产生负面影响。在分解同步代码块时,理想的平衡点将与平台相关,但在实际情况中,仅当可以将一些“大量”的计算或阻塞操作从同步代码块中移出时,才应该考虑同步代码块的大小。

减小锁的粒度

另一种减小锁的持有时间的方式是降低线程请求锁的频率,从而减小发生竞争的可能性,这可以通过锁分解锁分段等技术来实现。这些技术能减小锁操作的粒度,并能实现更高的可伸缩性,然而使用的锁越多,那么发生死锁的风险也就越高。

锁分段

在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为所分段。

如果程序采用锁分段技术,那么一定要表现出在锁的竞争频率高于在锁保护的数据上发生竞争的频率。

避免热点域

当每个操作都请求多个变量时,锁的粒度很难降低。这是在性能与可伸缩性之间相互制衡的另一个方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,都会引入一些“热点域(Hot Field)“,而这些热点域往往会限制可伸缩性。

感觉热点域就是锁分段后又都要访问的一段代码,这显然又会影响伸缩性,这要对这段代码优化分散的不同的锁段中,必要时候,甚至可以牺牲一定准确性,参考ConcurrentHashMap中的size。

一些替代独占锁的方法

第三种降低竞争锁的影响的技术就是放弃使用独占锁,从而有助于使用一种友好并发的方式来管理共享状态。例如使用并发容器、读-写锁、不可变对象以及原子变量。

检测CPU的利用率

如果CPU没有得到充分利用,那么需要找出其中的原因,通常有以下几种原因:负载不足、I/O密集、外部限制、锁竞争。

对对象池说“不”

在并发应用程序中,对象池的表现更加糟糕。当线程分配新的对象时,基本上不需要在线程之间进行协调,因为对象分配器通常会使用线程本地的内存块,所以不需要在堆数据结构上进行同步。然而,如果这些线程从对象池中请求一个对象,那么就需要通过某种同步来协调对对象池数据结构的访问,从而可能使某个线程被阻塞。

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

减少上下文切换的开销

在许多任务中,都包含一些可能被堵塞的操作,当任务在并行和阻塞这两个状态之间转换时,就相当于一次上下文切换。

通过将I/O操作从处理请求的线程中分离出来,可以缩短处理请求的平均服务时间。

第十二章 并发程序的测试

并发测试大致分为两类,即安全测试与活跃性测试。安全性定义为“不发生任何错误的行为”,活跃性定义为“某个良好的行为始终会发生”。

活跃性测试包括进展测试和无进展测试两方面。

与活跃性测试相关的是性能测试。性能可以通过多个方面来衡量,包括

吞吐量:指一组并发任务中已完成任务所占的比例。

响应性:指请求从发出到已完成之间的时间(也称为延迟)。

可伸缩性:指在增加更多资源的情况下(通常指CPU),吞吐量(或者缓解短缺)的提升情况。

正确性测试

在为某个并发类设计单元测试时,首先需要进行与测试串行类时相同的分析——找出需要检查的不变性条件和后验条件。

基本的单元测试

最基本的测试类似于在串行上下文中执行的测试。

对阻塞操作的测试

大多数测试框架不能很好地支持并发性测试。

java.util.concurrent的一致性测试中,一定要将各种故障与特定的测试明确的关联起来,因此JSR 166专家组创建了一个基类,其中定义了一些方法可以在tearDown期间传递和报告失败信息,并遵循一个约定:每个测试必须等待他所创建的全部线程结束以后才能完成。你不需要考虑这么深入,关键的需求在于能否通过这些测试,以及是否在某个地方报告了失败信息,以便于诊断问题。

如果某方法需要在特定条件下堵塞,那么当特是这种行为时,只有当线程不再继续执行时,测试才是成功的。要测试一个方法的阻塞行为,类似于测试一个抛出异常的方法:如果这个方法可以正常返回,那么就意味着测试失败。

在测试方法的阻塞行为时,将引入额外的复杂性:当方法被成功的堵塞后,还必须使方法解除阻塞。实现这个功能的一种简单方式就是使用中断——在一个单独的线程中启动一个阻塞操作,等到线程阻塞后带中断它,然后宣告堵塞操作成功。当然这要求则宿方法通过提前返回货。或者抛出InterruptedException来响应中断。

下例给出了一种测试阻塞操作的方法:

package net.jcip.examples; import junit.framework.TestCase; /**  * TestBoundedBuffer  * <p/>  * Basic unit tests for BoundedBuffer  *  * @author Brian Goetz and Tim Peierls  */ public class TestBoundedBuffer extends TestCase {     private static final long LOCKUP_DETECT_TIMEOUT = 1000;     private static final int CAPACITY = 10000;     private static final int THRESHOLD = 10000;     void testTakeBlocksWhenEmpty() {         final SemaphoreBoundedBuffer<Integer> bb = new SemaphoreBoundedBuffer<Integer>(10);         Thread taker = new Thread() {             public void run() {                 try {                     int unused = bb.take();                     fail(); // if we get here, it's an error                 } catch (InterruptedException success) {                 }             }         };         try {             taker.start();             Thread.sleep(LOCKUP_DETECT_TIMEOUT);             taker.interrupt();             taker.join(LOCKUP_DETECT_TIMEOUT);             assertFalse(taker.isAlive());         } catch (Exception unexpected) {             fail();         }     } } 复制代码

安全性测试

在构建对并发类的安全性测试中,需要解决的关键问题在于要找出那些容易检查的属性,这些属性在发生错误的情况下极有可能失败,同时又不会使得错误检查代码人为的限制并发性。理想情况是,在测试属性中不需要任何同步机制。

测试中的线程数量应该多余CPU数量,这样在任意时刻都会有一些线程在运行,而另一些被交换出去,从而可以检查线程间交替行为的可预测性。

在一些测试中,通常要求执行完一定数量的操作后才能停止运行,如果在测试代码中出现了一个错误并抛出了一个异常,那么这个测试将永远不会结束。最常见的解决方法是:让测试框架放弃那些没有在规定时间内完成了测试,具体要等待多长时间,则要凭经验来确定,并且要对故障进行分析,以确保所出现的问题并不是由于没有等待足够长的时间而造成的。

资源管理的测试

测试的另一个方面,就是要判断类中是否没有做他不应该做的事情。例如资源泄漏。资源泄漏不仅会妨碍垃圾回收器回收内存(或者线程、文件句柄、套接字,数据库连接或其他有限资源),而且还会导致资源耗尽以及应用程序失败。

通过一些测量应用程序中内存使用情况的堆检查工具,可以很容易的测试出对内存的不合理占用,许多商业和开源的堆分析工具,都支持这种功能。

使用回调

在构造测试案例时,对客户提供的代码进行回调是非常有帮助的。回调函数的执行通常是在对象生命周期的一些已知位置上,并且在这些位置上非常适合判断不变性条件是否被破坏。

产生更多的交替操作

在多处理器系统上,如果处理器的数量少于活动线程的数量,那么与单处理器系统或者包含多个处理器的系统相比,将能产生更多的交替行为。同样,如果在不同的处理器数量、操作系统以及处理器架构的系统上进行测试,就可以发现那些在特定运行环境中才会出现的问题。

在访问共享状态的操作中,使用Thread.yield方法将产生更多的上下文切换。不过这与特定平台相关。

性能测试

性能测试将衡量典型测试用例中的端到端性能。理想情况下,在测试中应该反映出被测试对象在应用程序中的实际用法。

性能测试的第二个目标是根据经验值来调整各种不同的限制,例如线程数量,缓存容量等。

响应性衡量

吞吐量是并发程序中重要的性能指标。但有时候我们还需要知道某个动作经过多长时间才能执行完成,这时就要测试服务时间的变化情况。而且,如果能获得更小的服务时间变动性,那么更长的平均服务时间是有意义的。“可预测性“同样是一个非常有价值的性能特征。

避免性能测试的陷阱

垃圾回收

垃圾回收的执行时序是无法预测的,因此在执行测试时,可能使测试偏差。

有两种策略可以防止垃圾回收操作对测试结果产生偏差。第一种策略是确保垃圾回收操作在测试并行的整个期间都不会执行。第二种策略是确保垃圾回收操作在测试期间多次执行,这样测试程序就能充分反映出运行期间的内存分配与垃圾回收等开销。通常第二策略更好,它要求更长的测试时间,并且更有可能反映实际环境下的性能。

动态编译

当某个类第一次被加载时,就会按摩,会通过解释自己网的方式来执行她在某个时刻,如果一个方法命名的次数足够多,那么动态编译器会将它编译为机器代码。当编译完成后,代码的执行方式将从解释执行变成直接执行。基于各种原因,代码还可能被反编译(退回到解释执行)以及重新编译。

有一种方式可以防止动态编译对测试产生偏差,就是是程序运行足够长的时间。

对代码路径的不真实采样

运行时编译器根据收集到的信息对已编译的代码进行优化。JVM可以根据执行过程特定的信息来生成更优的代码,这意味着在编译某个程序的方法M时生成的代码交可能与编译另一个不同程序中的方法M时生成的代码不同。

不真实的竞争程度

要获得有实际意义的结果,在并发性能测试中,应该尽量模拟典型应用程序中的线程本地计算量以及并发协调开销。如果在真实应用程序的各个任务中执行的工作,与测试程序中执行的工作截然不同,那么测试出的性能瓶颈位置将是不准确的。

无用代码的消除

优化编译器能找出并消除那些不会对输出结果产生任何影响的无用代码(Dead Code)。由于基准测试通常不会执行任何计算,因此它们很容易在编译器的优化过程中被消除。在大多数情况下,编译器从程序中删除无用代码都是一种优化措施,但对于基准测试程序来说,却是一个大问题,因为这将使得被测试的内容变得更少。

要编写有效的性能测试程序,就需要告诉优化器,不要将基准测试当做无用代码而不划掉。这就要求在程序中对每个计算结果都要通过某种方式来使用,这种方式不需要同步或者大量的计算。

其他的测试方法

**测试的目标不是更多的发现错误,而是提高代码能按照预期方式公布的可信度。**在构建并发类能否表现出正确行为的可信度时,测试是一种非常重要的首选,但并不是唯一可用的QA方法。

代码审查

Code Review

静态分析工具

FindBugs,SonarQube

面向方面的测试技术

AOP可以用来确保不变性条件不被破坏,或者与同步策略的某些方面保持一致。

分析与检测工具

大多数商业分析工具都支持线程。这些工具在功能与执行效率上存在着差异,但通常都能给出对程序内部的详细信息。


作者:牛奶燕麦
链接:https://juejin.cn/post/7038241038015660046

 伪原创工具 SEO网站优化  https://www.237it.com/ 


文章分类
代码人生
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐