文章目录
介绍了如何确保并发程序执行预期的任务,以及如何提高性能
1.死锁
多个线程由于存在环路的依赖关系而永远的等待下去,称为死锁。
1.1 锁顺序死锁
public class LeftRightDeadlock {
private final Object left = new Object();
private final Object right = new Object();
public void leftRight() {
synchronized (left) {
synchronized (right) {
//doSomething
}
}
}
public void rightLeft() {
synchronized (right) {
synchronized (left) {
//doSomething
}
}
}
}
上面程序发生死锁的原因是:两个线程尝试以不同的顺序获取相同的锁,相互等待而死锁。如果按照相同的顺序来请求锁,就不会出现循环的加锁依赖性,因此也不会产生死锁。
如果所有线程以固定的顺序获取锁,那么程序中就不会出现死锁的情况
1.2 动态的锁顺序死锁
public void transferMoney(Account fromAccount,Account toAccount){
synchronized (fromAccount) {
synchronized (toAccount) {
//do something
}
}
}
上述方法,看似每个线程都是按照顺序获取fromAccount和toAccount的锁,其实锁的顺序是根据参数的传入顺序来决定的
例如相同的两个账户A,B,线程1将A作为 fromAccount,B作为toAccount,线程2将B作为formAccount,将A作为toAccount,这样两个线程就同时持有对方需要的锁而阻塞陷入死锁
要解决这个问题,必须定义锁的顺序,并在整个应用程序中都按照这个顺序来获取锁
可以使用System.identityHashCode
方法指定锁的顺序。
private static final Object tieLock = new Object();
public void transferMoney(Account fromAccount, Account toAccount) {
class Helper {
public void transfer() {
//do something
}
}
int fromHash = System.identityHashCode(fromAccount);
int toHash = System.identityHashCode(toAccount);
if (fromHash < toHash) {
synchronized (fromAccount) {
synchronized (toAccount) {
new Helper().transfer();
}
}
} else if (toHash < fromHash) {
synchronized (toAccount) {
synchronized (fromAccount) {
new Helper().transfer();
}
}
} else {
//为了避免两个hash值相同时发生死锁,定义一个tieLock锁,确保同一时刻只有一个线程以它自己的顺序执行这两个锁。
//为什么不直接使用这种方式?每次只有一个线程执行,性能很低,而相同hash值得情况非常少,以最小得代价换来了最大得安全性
synchronized (tieLock) {
synchronized (fromAccount) {
synchronized (toAccount) {
new Helper().transfer();
}
}
}
}
}
如果在Account中存在一个唯一的,不可变的并且具备可比性的键值(如账号),那么就不需要这么麻烦,直接锁住这个键值即可。
1.3 在协作对象之间发生死锁
两个锁并不一定必须在同一个方法中被获取。
public class Taxi {
private Point location,destination;
private final Dispatcher dispatcher;
public Taxi(Dispatcher dispatcher) {
this.dispatcher = dispatcher;
}
public synchronized Point getLocation(){
return location;
}
//这个方法会获取两个锁,Taxi的和Dispatcher的
public synchronized void setLocation(Point location) {
this.location = location;
if (location.equals(destination)) {
dispatcher.notifyAvailable(this);
}
}
class Dispatcher {
private final Set<Taxi> taxis;
private final Set<Taxi> availableTaxis;
public Dispatcher(Set<Taxi> taxis, Set<Taxi> availableTaxis) {
this.taxis = taxis;
this.availableTaxis = availableTaxis;
}
public synchronized void notifyAvailable(Taxi taxi) {
availableTaxis.add(taxi);
}
//这个方法也会获取两个锁
public synchronized Image getImage() {
Image image = new Image();
for (Taxi taxi : taxis) {
image.drawMarket(taxi.getLocation());
}
return image;
}
}
}
上面这样的死锁排查起来比较困难:如果在存在加锁的方法中调用了外部方法,就需要警惕死锁
如果在持有锁时调用某个外部方法,那么将出现活跃性问题。在这个外部方法中可能会获取其他锁(这可能会产生死锁),或者阻塞过长时间,导致其他线程无法及时获得当前被持有的锁
1.4 开放调用
如果在调用某个方法时不需要持有锁,那么称为开放调用。
类似于采用封闭机制来提供线程安全方法。
将上面的例子改造成开放调用:
public class Taxi {
private Point location,destination;
private final Dispatcher dispatcher;
public Taxi(Dispatcher dispatcher) {
this.dispatcher = dispatcher;
}
public synchronized Point getLocation(){
return location;
}
public void setLocation(Point location) {
boolean reachedDestination;
//同步方法改为同步代码块,只同步那些需要共享的变量
synchronized (this) {
this.location = location;
reachedDestination = location.equals(destination);
}
if (reachedDestination) {
dispatcher.notifyAvailable(this);
}
}
@ThreadSafe
class Dispatcher {
private final Set<Taxi> taxis;
private final Set<Taxi> availableTaxis;
public Dispatcher(Set<Taxi> taxis, Set<Taxi> availableTaxis) {
this.taxis = taxis;
this.availableTaxis = availableTaxis;
}
public synchronized void notifyAvailable(Taxi taxi) {
availableTaxis.add(taxi);
}
public Image getImage() {
//采用深复制形式,让线程不直接操作共享变量,避免线程安全的问题
Set<Taxi> copy;
synchronized (this) {
copy = new HashSet<>(taxis);
}
Image image = new Image();
for (Taxi taxi : copy) {
image.drawMarket(taxi.getLocation());
}
return image;
}
}
}
收缩同步代码块的保护范围可以提高可伸缩性。
在程序中应该尽量使用开放调用。与那些在持有锁时调用外部方法的程序相比,更易于对依赖于开放调用的程序进行死锁分析。
1.5 资源死锁
有界线程池/资源池与相互依赖的任务不能一起使用
2.死锁的避免与诊断
如果一个程序每次至多只能获得一个锁,那么就不会产生锁顺序死锁。
如果确实需要多个锁:尽量减少潜在的加锁交互数量,将获取锁时需要遵循的协议写入正式文档并始终遵循这些协议。
两阶段策略:
- 找出在什么地方将获取多个锁,然后对所有这些示例进行全局分析,确保它们在整个程序中获取锁的顺序都保持一致
- 尽可能地使用开放调用
2.1 支持定时地锁
显示地使用tryLock功能来代替内置锁机制。内置锁只要没有获得锁就会一直阻塞下去,而显示锁如果超时,就会返回一个失败信息。
定时锁只有在同时获取两个锁的时候才有效,如果在嵌套方法中请求多个锁,那么就算你知道发生了异常,也无法释放上层的锁。
2.2 通过线程转储信息来分析死锁
类似于发生异常时的栈追踪信息。
线程转储还包含了加锁信息,例如每个线程持有了哪些锁,在哪些栈帧中获取这些锁,以及被阻塞的线程正在等待获取哪一个锁。
3.其它活跃性危险
饥饿,丢失信号和活锁等。
3.1 饥饿
当线程由于无法访问它所需要的资源而不能继续执行时,就发生了“饥饿”。
要避免使用线程优先级,因为会增加平台依赖性,并可能导致活跃性问题。在大多数并发应用程序中,都可以使用默认的线程优先级
3.2 糟糕的响应性
不良的锁管理可能导致糟糕的响应性。如果某个线程长时间占有一个锁,那么其它线程必须等待很长时间。
3.3 活锁
该问题尽管不会阻塞线程,但也不会继续执行,因为线程将不断执行相同的操作,而且总会失败。
通常发生在处理消息的事务上:如果不能成功的处理某个消息,那么它会回滚整个事务,并将它重新放到队列开头。循环失败
多个相互协作的线程都对彼此进行响应从而修改各自的状态,并使得任何一个线程都无法继续执行时,就发生了活锁。
解决活锁为题,需要在重试机制中引入随机性。
例如,在网络上,两台机器尝试使用相同的载波发送数据包,那么这些数据包就会发生冲突。如果两台机器都选择在1s后重发,那么又会冲突,所以它们可以分别选择一个随机数,等待一段随机时间后发送。
小结
最常见的活跃性故障是锁顺序死锁。在设计时应该避免产生锁顺序死锁:确保线程在获取多个锁时采用一致的顺序。最好的解决办法时使用开放调用。