Java 多线程 - 活跃性与性能
一. 避免活跃性危险
1. 死锁
PS: 两个线程试图以不同的顺序来获得相同的锁, 可能会产生死锁
所有线程以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁问题
有时候,并不能清楚地知道是否在锁顺序上有足够的控制权来避免死锁的发生,如两个账户互相转账。
1) 如果在持有锁时调用某个外部方法,那么将出现活跃性问题。在这个外部方法中可能会获取其他锁(这可能会产生死锁),或者阻塞时间过长,导致其他线程无法及时获得当前被持有的锁。
2) 如果在持有锁的情况下调用外部方法,那么就需要警惕死锁。
3) 在协作对象间发生死锁. 这里容易发生死锁
class Taxi {
@GuardedBy("this") private Point location, destination;
private final Dispatcher dispatcher;
public Taxi(Dispatcher dispatcher) {
this.dispatcher = dispatcher;
}
public synchronized Point getLocation() {
return location;
}
public synchronized void setLocation(Point location) {
this.location = location;
if (location.equals(destination))
dispatcher.notifyAvailable(this);
}
public synchronized Point getDestination() {
return destination;
}
public synchronized void setDestination(Point destination) {
this.destination = destination;
}
}
class Dispatcher {
@GuardedBy("this") private final Set<Taxi> taxis;
@GuardedBy("this") private final Set<Taxi> availableTaxis;
public Dispatcher() {
taxis = new HashSet<Taxi>();
availableTaxis = new HashSet<Taxi>();
}
public synchronized void notifyAvailable(Taxi taxi) {
availableTaxis.add(taxi);
}
public synchronized Image getImage() {
Image image = new Image();
for (Taxi t : taxis)
image.drawMarker(t.getLocation());
return image;
}
}
1) 如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用。
2) 在程序中应尽量使用开放调用。与那些在持有锁时调用外部方法的程序相比,更易于对依赖于开放调用的程序进行死锁分析
3) 举例:修改上例使用开放调用避免死锁
class Taxi {
@GuardedBy("this") private Point location, destination;
private final Dispatcher dispatcher;
public Taxi(Dispatcher dispatcher) {
this.dispatcher = dispatcher;
}
public synchronized Point getLocation() {
return location;
}
public synchronized void setLocation(Point location) {
boolean reachedDestination;
synchronized (this) {
this.location = location;
reachedDestination = location.equals(destination);
}
if (reachedDestination)
dispatcher.notifyAvailable(this);
}
public synchronized Point getDestination() {
return destination;
}
public synchronized void setDestination(Point destination) {
this.destination = destination;
}
}
@ThreadSafe
class Dispatcher {
@GuardedBy("this") private final Set<Taxi> taxis;
@GuardedBy("this") private final Set<Taxi> availableTaxis;
public Dispatcher() {
taxis = new HashSet<Taxi>();
availableTaxis = new HashSet<Taxi>();
}
public synchronized void notifyAvailable(Taxi taxi) {
availableTaxis.add(taxi);
}
public Image getImage() {
Set<Taxi> copy;
synchronized (this) {
copy = new HashSet<Taxi>(taxis);
}
Image image = new Image();
for (Taxi t : copy)
image.drawMarker(t.getLocation());
return image;
}
}
两个线程分别持有彼此想要的资源而又不会释放
这些任务往往是产生线程饥饿死锁的主要来源,有界线程池 / 资源池与相互依赖的任务不能一起使用。
2. 死锁的避免与诊断
1) 如果一个线程每次至多只能获得一个锁,那么就不会产生锁顺序死锁
2) 如果必须获取多个锁,那么在设计时必须考虑锁的顺序:尽量减少潜在的加锁交互数量,将获取锁时需要遵循的协议写入正式文档并始终遵循这些文档。
即显式使用 Lock 类中的定时 tryLock 功能来代替内置锁机制。当使用内置锁时,只要没有获得锁,就会永远等待下去,而显式锁则可以指定一个超时时限,在等待超过该时间后 tryLock 会返回一个失败信息。如果超时时限比获取锁的时间要长很多,那么久可以在发生某个意外情况后重新获得控制权.(在同时获取两个锁时有效)
1) 虽然防止死锁的主要责任在自己,但 JVM 仍然通过线程转储来帮助识别死锁的发生. 过线程转储信息包括:
a) 各个运行中的线程的栈追踪信息, 这类似于发生异常时的栈追踪信息
b) 加锁信息, 例如每个线程持有了哪些锁,在哪些栈帧中获得这些锁,以及被阻塞的线程正在等待获取哪一个锁
2) 分析命令
Unix 平台: 向 JVM 进程发送 kill -3
Windows 平台: 按下 Ctrl-Break
3. 活跃性危险
1) 线程由于无法访问它所需要的资源时而不能继续执行
2) 要避免使用线程优先级,因为这会增加平台依赖性,并可能导致活跃性问题。在大多数并发应用程序中,都可以使用默认的线程优先级
3) 引发饥饿的最常见资源就是 CPU 时钟周期。如果在 Java 应用程序中对线程的优先级使用不当,或者持有锁时执行一些无法结束的结构,那么也可能导致饥饿,因为其他需要这个锁的线程将无法得到它
1) 后台任务若为 CPU 密集型,将可能影响程序响应性
2) 不良的锁管理,某个锁持有过长时间
1) 活锁是另一种形式的活跃性问题. 线程将不断重复执行相同的操作,而且总会失败(不会阻塞线程,但也不能继续执行)
2) 活锁通常发生在处理事务消息的应用程序中: 如果不能成功地处理某个消息,那么消息处理机制将会回滚整个事务,并将它重新放到队列的开头。
2) 活跃性故障是个非常严重的问题, 除了中止应用程序之外没有其他任何机制可以帮助这种故障恢复。解决:
a) 确保线程在获取多个锁时, 采用一致的顺序
b)更好的解决办法是: 在程序中始终使用开放调用
二. 性能与可伸缩性
1. 对性能的思考
提升性能: 用更少的资源(CPU、内存、磁盘、网络、IO), 做更多的事情.
多线程额外开销: 线程之间的协调(例如加锁、触发信号以及内存同步等),增加的上下文切换,线程的创建和销毁,以及线程的调度等
性能通过服务时间、延迟时间、吞吐率、效率、可伸缩性以及容量等衡量
当进行性能调优时,其目的通常是用更小的代价完成相同的工作
可伸缩性指的是:当增加计算资源时(例如CPU、内存、存储容量或I/O带宽),程序的吞吐量或者处理能力响应地增加
在进行可伸缩性调优时,其目的是设法将问题的计算并行化,从而能利用更多地计算资源来完成更多的工作。
我们通常会接受每个工作单元执行更长的时间或消耗更多的计算资源,以换取应用程序在增加更多资源的情况下处理更高的负载。(多少更重要)
1) 避免不成熟的优化。首先要让程序正确,然后再提高运行速度
2) 优化思考:
a) ”更快“的含义是什么?
b) 该方法在什么条件下运行得更快?在低负载还是高负载的情况下?大数据集还是小数据集?能否通过测试结果来验证你的答案?
c) 这些条件在运行环境中的发生频率?能否通过测试结果来验证你的答案?
d) 在其他不同条件的环境中能否使用这里的代码?
e) 在实现这种性能提升时需要付出哪些隐含地代价,例如增加开发风险或维护开销?这种权衡是否合适?
3) 所有结果以测试为基准,不要猜测
2. Amdahl 定律
Amdahl 定律描述的是:在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决于程序中可并行组件与串行组件所占的比重。
公式: Speedup <= 1 / (F + (1 - F) / N)
F 是必须被串行执行的部分所占比例
N 是机器中含有处理器的个数
3. 线程引入的开销
过程:保存当前运行线程的执行上下文,并将新调度进来的线程的上下文设置为当前上下文
切换上下文需要一定的开销,而在线程调度过程中需要访问操作系统和JVM共享的数据结构
上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢(调度器为每个可运行的线程分配一个最小执行时间,将上下文切换的开销分摊到更多不会中断的执行时间上, 用来来提高整体吞吐, )
1) synchronized 和 volative 提供的可见性保证中可能会使用一些特殊指令,即内存栅栏(Memory Barrier)。内存栅栏可以刷新缓存使缓存无效、刷新硬件的写缓冲、以及停止执行管道。内存栅栏可能同样会对缓存带来间接的影响,因为它们将抑制一些编译优化操作。在大多数的内存栅栏中,大多数操作都是不能被重排序的。
2) 评估同步操作带来的性能影响时,区分有竞争的同步和非竞争的同步非常重要;无竞争的同步对应用程序整体性能影响微乎其微,有竞争的同步对性能带了来较大的影响.现在的JVM能通过优化去掉一些不会发生竞争的锁,从而减少不必要的同步开销
3) 不要过度担心非竞争同步带来的开销,JVM已经进行了额外的优化以进一步降低或者消除开销,因此优化的重点放在有锁竞争的地方.
1) 当在锁上发生竞争时,竞争失败的线程肯定会阻塞
2) JVM 实现阻塞行为时, 使用的策略
a) 自旋等待: 通过循环不断地尝试获取锁(等待时间较短适合)
b) 挂起: 产生额外上下文开销(等待时间较长适合挂起)
PS: JVM 会根据历史等待时间分析, 然后在这两个之间选择,但是大多数都是把阻塞的线程挂起
4. 减少锁的竞争
1) 减少锁的竞争能够提高性能和可伸缩性
2) 并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁
3) 两个因素影响锁上发生竞争的可能性
a) 锁的请求频率
b) 每次持有该锁的时间
4) 可以通过三种方式减少锁的竞争
a) 减少锁的持有时间
b) 降低锁的请求频率
c) 使用带协调机制的独占锁
目标:尽可能缩短持有锁的时间
方法:超出共享变量,只对操作共享变量的代码加锁
理论:根据Amdahl定律,减少了必须串行执行的部分
注意:必要的原子操作不能分别加锁;锁粒度细化造成更多的同步开销,JVM会自动进行锁粒度粗化
1) 降低线程请求锁的频率
2) 锁分解或者锁分段,采用多个相互独立的锁来保护独立的状态变量,从而改变这些状态变量有单个锁来保护的情况,可以减小锁的粒度,但是发送死锁的风险提高
3) 锁分解:如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。
4) 对锁分解后每个新的细粒度锁上的访问将减少,分摊到两个锁上. 对竞争适中的锁进行分解时,实际上是把这些锁转变为”非竞争“的锁,从而有效地提高性能和可伸缩性。
@ThreadSafe
public class ServerStatusAfterSplit {
@GuardedBy("users") public final Set<String> users;
@GuardedBy("queries") public final Set<String> queries;
public ServerStatusAfterSplit() {
users = new HashSet<String>();
queries = new HashSet<String>();
}
public void addUser(String u) {
synchronized (users) {
users.add(u);
}
}
public void addQuery(String q) {
synchronized (queries) {
queries.add(q);
}
}
1) 将锁进一步扩展为对一组独立对象上得锁进行分解,被称为所分段;例如 ConcurrentHashMap 的机制,使用了一个包含 16 个锁的数组,每个锁保护所有散列桶的 16 分之一,其中第 N 个散列桶有第 N mod 16 锁来保护,可以允许 16 个并发的写入器;
锁分段一个缺点是,与采用单个锁实现独占访问相比,需要获得多个锁来实现独占访问将更加困难并且开销更高.
2) 有些操作需要独占整个对象,即需要全部的锁,这样开销更大。但有些操作即使需要获得全部的锁,但也不需要同时获得
public Object get(Object key) {
int hash = hash(key);
synchronized (locks[hash % N_LOCKS]) {
for (Node m = buckets[hash]; m != null; m = m.next)
if (m.key.equals(key))
return m.value;
}
return null;
}
public void clear() {
for (int i = 0; i < buckets.length; i++) {
synchronized (locks[i % N_LOCKS]) {
buckets[i] = null;
}
}
}
1) 如果程序采用锁分段或分解技术,那么一定要表现出在锁上的竞争频率高于在锁保护的数据上发生竞争的频率(例:ConcurrentHashMap 和 Map 中的每一項)
2) 热点域:数据上发生很高频率的竞争(例:HashMap.size())
3) 解决:ConcurrentHashMap 为每个分段都维护一个独立的 size 计数,并通过每个分段的锁来维护总 size
1) 降低竞争锁的影响技术: 放弃使用独占锁. 使用并发容器、读 - 写锁、不可变对象、原子变量。
2) ReadWriteLock
如果多个读取操作都不会修改共享资源,那么这些读取操作可以同时访问该共享资源,但在执行写入操作时必须以独占方式来获取锁
3) 原子变量
降低更新“热点域”时的开销,例如竞态计数器、序列发生器、或者对链表数据结构中头节点的引用。原子变量类提供了在整数或者对象引用上的细粒度原子操作(因此可伸缩性更高),并使用了现代处理器中提供的底层并发原语(例如比较并交换)
1) linux 命令
a) vmstat
b) mpstat
2) cpu 利用不充分的原因
a) 负载不充足。
b) I/O密集。*nix可用 iostat, windows 用 perfmon。
c) 外部限制。如数据库服务,web 服务等。
d) 锁竞争。可通过 jstack 等查看栈信息。
e) 如果 CPU 的利用率很高,并且总会有可运行的线程在等待 CPU,那么当增加更多地处理器时,程序的性能可能会得到提升。
三. 并发编程测试
1. 正确性测试
2. 性能测试
3. 避免性能测试的陷阱
4. 其他测试的方法