避免活跃度危险

安全性和活跃度通常互相牵制。

使用锁来保证线程安全,但是滥用锁可能会引起锁顺序死锁。

使用线程池和信号量来约束资源的使用,但不知道那些管辖范围内的活动可能形成的资源死锁。

8.1死锁

死锁:当线程A占有锁L时,想要获得锁M,但是同时,线程B持有M,并尝试获得L,两个线程将永远等待下去。(发生在多个线程因为环路的锁依赖关系而永远等待的情况下

数据库系统的设计就针对了检测死锁,以及从死锁中恢复。当数据库服务器监测到一个事务集发生了死锁,它会选择一个牺牲者,使它退出事务。

一个类如果有发生死锁的潜在可能并不意味着死锁每次都将发生,它只发生在该发生的情况下,当死锁出现的时候,往往是遇到了最糟糕的情况——高负载。

8.1.1锁顺序死锁

  1. //这是一个锁顺序死锁例子
  2. //如果请求锁的顺序相同,就不会出现循环的锁依赖现象,也就不会产生死锁了。
  3. public class LeftRightDeadlock{
  4. private final Object left = new Object();
  5. private final Object right = new Object();
  6. public void leftRight(){
  7. synchronized(left){
  8. synchronized(right){
  9. doSomething();
  10. }
  11. }
  12. }
  13. public void rightLeft(){
  14. synchronized(right){
  15. synchronzied(left){
  16. doSomethingElse();
  17. }
  18. }
  19. }
  20. }

如果所有线程以通用的固定秩序获得锁,程序就不会出现锁顺序死锁问题了

8.1.2动态的锁顺序死锁

//这个线程看起来都是通过同样的顺序获得锁的。
//事实上锁的顺序取决于传递给transferMoney的参数的顺序。
//如果两个线程同时调用,一个从X向Y转账,一个从Y向X转账,那么就会发生死锁
public class transferMoney(Account fromAccount,
                           Account toAccount,
                           DollarAmount amount){
    synchronized(fromAccount){
        synchronized(toAccount){
            if(fromAccount.getBalance().compareTo(amount) < 0){
                throw new InsufficientFundsException();
            }else{
                fromAccount,debit(amount);
                toAccount.credit(amount);
            }
        }
    }
}

因为参数的顺序是超出控制的,所以必须制定锁的顺序,并且在整个应用程序中,获得锁都必须始终遵守这个既定的顺序。

//通过对象的哈希值来定义锁的顺序。
//可能两个对象的哈希值相同,这时使用一个“加时赛”锁。但是这个锁可能会成为并发性的瓶颈。
private static final Object tieLock = new Object();

public void transferMoney(final Account fromAcct,
                         final Account toAcct,
                          final DollarAmount amount){
    class Helper{
        public void transfer(){
            if (fromAcct.getBalance().compareTo(amount) < 0){
                throw new Exception();
            }else{
                fromAcct.debit(amount);
                toAcct.credit(amount);
            }
        }
    }

    int fromHash = System.identityHashCode(fromAcct);
    int toHash = System.identitHashCode(toAcct);

    //通过对象的哈希值来制定锁顺序,避免顺序死锁
    if(fromHash < toHash){
        synchronized(fromAcct){
            synchronized(toAcct){
                new Helper().transfer();
            }
        }
    }else if(fromHash > toHash){
        synchronized(toAcct){
            synchronized(fromAcct){
                new Helper().transfer();
            }
        }
    }else{//如果两个对象的哈希值相同,进入“加时赛”
        synchronized(tieLock){
            synchronized(fromAcct){
                synchronized(toAcct){
                    new Helper().transfer();
                }
            }
        }
    }
}

8.1.3协作对象间的死锁

//当两个类同时执行时,当Taxi类运行到setLocation,Dispatcher运行到getImage时,会发生死锁。
class Taxi{
    @GuardedBy("this")
    private Point location, destination;

    public Taxi(Dispatcher dispatcher){
        this.dispatcher = dispatcher;
    }

    public synchronized Point getLocation(){
        return location;
    }

    public synchronized void setLocation(Point location){
        this.location = location;
        if(location.equlas(destination))
            //此时该线程已经拥有了Taxi的锁,尝试去获取Dispatcher的锁
            dispatcher.notifyAvailable(this);
    }
}


calss 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)
            //此时该线程已经拥有了Dispatcher的锁,尝试去获取Taxi的锁
            image.drawMarker(t.getLocation());
        return image;
    }
}

持有锁的时候调用外部方法是在挑战活跃度问题外部方法可能会获得其他锁产生死锁的风险),或者遭遇严重超时的阻塞。当你持有锁的时候会延迟其他试图获得该锁的线程。

8.1.4开放调用

开放调用:调用的方法不需要持有锁。

尽可能的使用开放调用,这比获得多重锁后识别代码路径更简单,因此可以确保用一致的顺序获得锁。

//使用更小的synchronized块避免了死锁的风险
class Taxi{
    @GuardedBy("this")
    private Point location, destination;

    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(location.equlas(reachedDestination))
            dispatcher.notifyAvailable(this);
    }
}


calss 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;
    }
}

在程序中尽量使用开放调用。依赖于开放调用的程序,相比于那些在持有锁的时候还可以调用外部方法的程序,更容易进行死锁自由度的分析。

8.1.5资源死锁

当线程相互间等待对方持有的锁,并且谁都不会释放自己的锁时就会发生死锁,当线程持有和等待的目标变为资源时也会发生与之相类似的死锁

加入你有两个放入池中的资源,比如分别是两个数据库的连接池,资源池通常是通过信号量实现的,当池为空时发生阻塞。如果线程A持有至数据库D1的连接,并等待连接到数据库D2,而线程B持有至D2的连接并等待到D1的连接。

另一种形式是基于资源的死锁是线程饥饿死锁。一个任务将工作提交到单线程化的Executor,并等待该Executor中的其他任务返回它的结果,在这种情况下,第一个任务将永远会等待下去,使这个任务以及所有在Executor中执行的其他任务永久停滞下来。

8.2避免和诊断死锁

在使用定义良好的锁的程序中,检测代码中死锁自由度的策略分为两个部分:

  • 识别什么地方会获取多个锁(使这个集合尽量小),对这些示例进行全局分析,确保它们锁的顺序在程序中保持一致。
  • 尽可能使用开放调用。

8.2.1尝试定时的锁

另一项监测死锁和从死锁中恢复的技术,是使用每个显式Lock类中定时tryLock特性,来替代使用内部锁机制。

在内部锁机制中,只要没有获得锁,就会永远保持等待,而显式的锁使你能够定义超时的时间,在规定时间之后tryLock还没有获得锁就返回失败

当尝试获得定时锁失败时,你并不需要知道原因。不过至少有机会知道你的尝试已经失败,记录下有用的信息,并重新开始计算。

使用定时锁来获得多重锁能够有效应对死锁,如果获得锁的请求超时,可以释放这个锁,并后退,等待一会后再尝试。

8.2.2通过线程转储分析死锁

JVM使用线程转储来帮助我们识别死锁的发生。

线程转储包括每个运行中线程的栈追踪信息,以及与之相似并随之发生的异常,线程转储也包括锁的信息哪个锁由哪个线程获得获得这些锁的栈结构阻塞线程正在等待哪个锁

生成线程转储之前,JVM在表示“正在等待”关系的有向图中搜索循环来寻找死锁

在Unix平台,可以按下Ctrl-\键,在Windows平台按下 Ctrl-Break 键,来触发线程转储。很多IDE都要求线程转储。

如果使用了显式Lock类,线程转储获得的Lock的信息与内部锁提供的信息相比粗略了很多,内部锁与获得它的线程的栈框架相关联显式Lock只会与获得它的线程相关联

windows分析线程转储

1.选择已启动应用程序的命令行控制台窗口。

2.现在,在控制台窗口上,发出“ Ctrl + Break”命令。

3.这将生成线程转储。线程转储将被打印在控制台窗口本身上。

注1:在几台笔记本电脑(例如我的Lenovo T系列)中,“ Break”键被拔下。在这种情况下,您必须在Google上找到“ Break”的等效键。在我的情况下,事实证明“功能键+ B”等效于“断裂”键。因此,我不得不使用“ Ctrl + Fn + B”来生成线程转储。

注2:但是这种方法的一个缺点是线程转储将打印在Windows控制台本身上。如果不以文件格式获取线程转储,则很难使用线程转储分析工具(例如http://fasthread.io)。因此,当您从命令行启动应用程序时,将输出重定向到文本文件,即示例,如果您正在启动应用程序“ SampleThreadProgram”,则将发出以下命令:

java -classpath . SampleThreadProgram

而是像这样启动SampleThreadProgram

java -classpath . SampleThreadProgram > C:\workspace\threadDump.txt 2>&1

因此,当您发布“ Ctrl + Break”时,线程转储将发送到C:\ workspace \ threadDump.txt文件。

JVisualVM分析线程转储

启动程序后,右键进程,然后选择”线程Dump”

8.3其他的活跃度危险

死锁是最主要的活跃度危险,但仍可能会遇到一些其他的活跃度危险:饥饿、丢失信号、活锁。

8.3.1饥饿

饥饿当线程访问它所需要的资源时却被永久拒绝,以至于不能再继续进行了

最常见的引发饥饿的资源是CPU周期。

使用线程的优先级不当可能会引发饥饿,在锁中执行无终止构建也可能引起饥饿,因为其他需要这个锁的线程永远不可能得到它。

抵制使用线程优先级,它改变线程优先级的效果往往不明显,但是却让程序的行为变得与平台相关了,并且引入了饥饿发生的风险。

8.3.2弱响应性

主要发生在GUI应用程序中使用后台线程的情况下。

不良的锁管理也可能引起弱响应性,如果一个线程长时间占有一个锁(正在进行耗时的工作),其他想要访问该容器的线程就必须等待很长时间。

8.3.3活锁

活锁:线程中活跃度失败的一种形式,尽管没有被阻塞线程却仍然不能继续因为它不断重试相同的操作,却总是失败

活锁通常发生在消息处理的应用程序中

因为**消息处理失败**的话,其中**传递消息的底层框架会回退整个事务**,并把它**置回队首**,如果消息处理程序对某种特定类型的消息处理存在BUG,每次处理都会失败,那么它就会不停的重复。(这就是“毒药信息”问题)

活锁同样发生在多个相互协作的线程间,当它们为了彼此间响应而修改了状态,使得没有一个线程能够继续前进,那么就发生了活锁。

解决活锁的一种方案就是对重试机制引入一些随机性

例如:在以太网络上,如果数据包发生了冲突,会通过使用一个随机组件进行当代重试。(如果都非常精准的在一秒后重试,那么就会不断的冲突下去)

在并发程序中,通过随机等待撤回进行重试能够相当有效地避免活锁的发生

总结

活跃度失败是非常严重的问题,除了短时间地终止应用程序,没有任何机制可以恢复这种失败

最常见的活跃度失败是:锁顺序死锁。

在设计时:确保多个线程在获得多个锁时,使用一致的顺序

最好是在程序中使用开放调度,减少一个程序一次请求多个锁的情况,并且使这样的多重锁请求的发生更加明显。