一、避免活跃性危险
1.死锁
锁顺序死锁:
在线程A持有锁L并想获得锁M的同时,线程B持有锁M并尝试获得锁L,那么这两个线程将永远地等待下去,这种死锁情况称为锁顺序死锁。
//LockTest
public class LockTest {
private final Object left = new Object();
private final Object right = new Object();
public void leftRight() {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
Log.d("LockTest", "zwm, leftRight, acquire left lock before. Thread: " + Thread.currentThread().getName());
synchronized (left) {
Log.d("LockTest", "zwm, leftRight, acquire left lock after, acquire right lock before. Thread: " + Thread.currentThread().getName());
synchronized (right) {
Log.d("LockTest", "zwm, leftRight, acquire right lock after. Thread: " + Thread.currentThread().getName());
doSomething();
}
}
}
}
});
thread.start();
}
public void rightLeft() {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
Log.d("LockTest", "zwm, leftRight, acquire right lock before. Thread: " + Thread.currentThread().getName());
synchronized (right) {
Log.d("LockTest", "zwm, leftRight, acquire right lock after, acquire left lock before. Thread: " + Thread.currentThread().getName());
synchronized (left) {
Log.d("LockTest", "zwm, leftRight, acquire left lock after. Thread: " + Thread.currentThread().getName());
doSomething();
}
}
}
}
});
thread.start();
}
public void doSomething() {
Log.d("LockTest", "zwm, doSomething. Thread: " + Thread.currentThread().getName());
}
}
//测试代码
private void testMethod() {
Log.d(TAG, "zwm, testMetod");
LockTest lockTest = new LockTest();
lockTest.leftRight();
lockTest.rightLeft();
}
//输出log
2019-07-22 09:39:32.372 zwm, testMetod
2019-07-22 09:39:32.373 zwm, leftRight, acquire left lock before. Thread: Thread-12
2019-07-22 09:39:32.374 zwm, leftRight, acquire left lock after, acquire right lock before. Thread: Thread-12
2019-07-22 09:39:32.374 zwm, leftRight, acquire right lock after. Thread: Thread-12
2019-07-22 09:39:32.374 zwm, doSomething. Thread: Thread-12
2019-07-22 09:39:32.374 zwm, rightLeft, acquire right lock before. Thread: Thread-13
2019-07-22 09:39:32.374 zwm, rightLeft, acquire right lock after, acquire left lock before. Thread: Thread-13
2019-07-22 09:39:32.374 zwm, leftRight, acquire left lock before. Thread: Thread-12
2019-07-22 09:39:32.374 zwm, rightLeft, acquire left lock after. Thread: Thread-13
2019-07-22 09:39:32.374 zwm, doSomething. Thread: Thread-13
2019-07-22 09:39:32.374 zwm, rightLeft, acquire right lock before. Thread: Thread-13
2019-07-22 09:39:32.374 zwm, leftRight, acquire left lock after, acquire right lock before. Thread: Thread-12
2019-07-22 09:39:32.374 zwm, rightLeft, acquire right lock after, acquire left lock before. Thread: Thread-13
如果所有线程以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁问题。
在协作对象之间发生的死锁:
如果在持有锁时调用某个外部方法,那么将出现活跃性问题。在这个外部方法中可能会获取其他锁(这可能会产生死锁),或者阻塞时间过长,导致其他线程无法及时获得当前被持有的锁。
开放调用:
如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用。依赖于开发调用的类通常能表现出更好的行为,并且与那些在调用方法时需要持有锁的类相比,也更易于编写。
资源死锁:
正如当多个线程相互持有彼此正等待的锁而又不释放自己已持有的锁时会发生死锁,当它们在相同的资源集合上等待时,也会发生死锁。
另一种基于资源的死锁形式就是线程饥饿死锁。
2.死锁的避免与诊断
在使用细粒度锁的程序中,可以通过使用一种两阶段策略来检查代码中的死锁:首先,找出在什么地方将获得多个锁,然后对所有这些实例进行全局分析,从而确保它们在整个程序中获得锁的顺序都保持一致。尽可能地使用开放调用,这能极大地简化分析过程。
支持定时的锁:
有一项技术可以检测死锁和从死锁中恢复过来,即显示使用Lock类中的定时tryLock功能来代替内置锁机制。当使用内置锁时,只要没有获得锁,就会永远等待下去,而显示锁则可以指定一个超时时限,在等待超过该时间后tryLock会返回一个失败信息。
通过线程转储信息来分析死锁:
虽然防止死锁的主要责任在于你自己,但JVM仍然通过线程转储(Thread Dump)来帮助识别死锁的发生。
3.其他活跃性危险
尽管死锁是最常见的活跃性危险,但在并发程序中还存在一些其他的活跃性危险,包括:饥饿、丢失信号和活锁等。
饥饿:
当线程由于无法访问它所需要的资源而不能继续执行时,就发生了"饥饿"。
要避免使用线程优先级,因为这会增加平台依赖性,并可能导致活跃性问题。在大多数并发应用程序中,都可以使用默认的线程优先级。
糟糕的响应性:
如果某个线程长时间占有一个锁(或许正在对一个大容器进行迭代,并且对每个元素进行计算密集的处理),而其他想要访问这个容器的线程就必须等待很长时间。
活锁:
活锁是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复执行相同的操作,而且总会失败。要解决活锁问题,需要在重试机制中引入随机性。
二、性能与可伸缩性
提升性能总会令人满意,但始终要把安全性放在第一位。首先要保证程序能正确运行,然后仅当程序的性能需求和测试结果要求程序执行得更快时,才应该设法提高它的运行速度。在设计并发的应用程序时,最重要的考虑因素通常并不是将程序的性能提升至极限。
可伸缩性指的是,当增加计算资源时(例如CPU、内存、存储容量或I/O带宽),程序的吞吐量或者处理能力能相应地增加。
1.线程引入的开销
单线程程序既不存在线程调度,也不存在同步开销,而且不需要使用锁来保证数据结构的一致性。在多个线程的调度和协调过程中都需要一定的性能开销:对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的开销。
上下文切换:
如果主线程是唯一的线程,那么它基本上不会被调度出去。另一方面,如果可运行的线程数大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU。这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。
内存同步:
不要过度担心非竞争同步带来的开销。这个基本的机制已经非常快了,并且JVM还能进行额外的优化以进一步降低或消除开销。因此,我们应该将优化重点放在那些发生锁竞争的地方。
阻塞:
非竞争的同步可以完全在JVM中进行处理,而竞争的同步可能需要操作系统的介入,从而增加开销。当在锁上发生竞争时,竞争失败的线程肯定会阻塞。JVM在实现阻塞行为时,可以采用自旋等待或者通过操作系统挂起被阻塞的线程。这两种方式的效率高低,要取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。如果等待时间较短,则适合采用自旋等待方式,而如果等待时间较长,则适合采用线程挂起方式。
2.减少锁的竞争
在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁。有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间。
有3种方式可以降低锁的竞争程度:
- 减少锁的持有时间。
- 降低锁的请求频率。
- 使用带有协调机制的独占锁,这些机制允许更高的并发性。
缩小锁的范围("快进快出"):
降低发生竞争可能性的一种有效方式就是尽可能缩短锁的持有时间。例如,可以将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作,例如I/O操作。
尽管缩小同步代码块能提高可伸缩性,但同步代码块也不能过小,一些需要采用原子方式执行的操作(例如对某个不变性条件中的多个变量进行更新)必须包含在一个同步块中。此外,同步需要一定的开销,当把一个同步代码块分解为多个同步代码块时(在确保正确性的情况下),反而会对性能提升产生负面影响。
减小锁的粒度:
另一种减小锁的持有时间的方式是降低线程请求锁的频率(从而减小发生竞争的可能性)。这可以通过锁分解和锁分段等技术来实现。在这些技术中将采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。这些技术能减小锁操作的粒度,并能实现更高的可伸缩性,然而,使用的锁越多,那么发生死锁的风险也就越高。
锁分段:
在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。例如,在ConcurrentHashMap的实现中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。假设散列函数具有合理的分布性,并且关键字能够实现均匀分布,那么这大约能把对于锁的请求减少到原来的1/16。正是这项技术使得ConcurrentHashMap能够支持多达16个并发的写入器。
锁分段的一个劣势在于:与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。通常,在执行一个操作时最多只需获取一个锁,但在某些情况下需要加锁整个容器,例如当ConcurrentHashMap需要扩展映射范围,以及重新计算键值的散列值要分布到更大的桶集合中时,就需要获取分段锁集合中的所有的锁。
避免热点域:
当每个操作都请求多个变量时,锁的粒度将很难降低。这是在性能与可伸缩性之间相互制衡的另一个方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,都会引入一些"热点域",而这些热点域往往会限制可伸缩性。
ConcurrentHashMap中的size将对每个分段进行枚举并将每个分段中的元素数量相加,而不是维护一个全局计数。为了避免枚举每个元素,ConcurrentHashMap为每个分段都维护了一个独立的计数,并通过每个分段的锁来维护这个值。
一些替代独占锁的方法:
第三种降低竞争锁的影响的技术就是放弃使用独占锁,从而有助于使用一种友好并发的方式来管理共享状态。例如,使用并发容器、读-写锁、不可变对象以及原子变量。
ReadWriteLock实现了一种在多个读取操作以及单个写入操作情况下的加锁规则:如果多个读取操作都不会修改共享资源,那么这些读取操作可以同时访问该共享资源,但在执行写入操作时必须以独占方式来获取锁。对于读取操作占多数的数据结构,ReadWriteLock能提供比独占锁更高的并发性。而对于只读的数据结构,其中包含的不变性可以完全不需要加锁操作。
原子变量提供了一种方式来降低更新"热点域"时的开销。如果在类中只包含少量的热点域,并且这些域不会与其他变量参与到不变性条件中,那么用原子变量来替代它们能提高可伸缩性。
向对象池说"不":
通常,对象分配操作的开销比同步的开销更低。当线程分配新的对象时,基本上不需要在线程之间进行协调,因为对象分配器通常会使用线程本地的内存块,所以不需要在堆数据结构上进行同步。然而,如果这些线程从对象池中请求一个对象,那么就需要通过某种同步来协调对象池数据结构的访问,从而可能使某个线程被阻塞。
三、并发程序的测试
并发测试大致分为两类:安全性测试与活跃性测试。
在进行安全性测试时,通常会采用测试不变性条件的形式,即判断某个类的行为是否与其规范保持一致。
与活跃性测试相关的是性能测试。性能测试可以通过多个方面来衡量,包括:
- 吞吐量。
指一组并发任务中已完成任务所占的比例。 - 响应性。
指请求从发出到完成之间的时间(也称为延迟)。 - 可伸缩性。
指在增加更多资源的情况下(通常指CPU),吞吐量(或者缓解短缺)的提升情况。
避免性能测试的陷阱:
- 垃圾回收。
- 动态编译。
- 对代码路径的不真实采样。
- 不真实的竞争程度。
- 无用代码的消除。
其他的测试方法:
- 代码审查。
- 静态分析工具。
- 面向方面的测试技术。
- 分析与监测工具。
网友评论