(十四)深入并发之线程、进程、纤程、协程、管程与死锁、活锁、锁饥饿详解

2,693 阅读36分钟

引言

在之前对于并发编程这个模块的内容已经阐述了很多篇章了,而本章的目的则是为了对前述的内容做个补充,重点会围绕着锁的状态与并行处理的方式进行展开论述。

一、进程、线程、纤程、协程、管程概念理解

在现在你可能会经常看到进程、线程、纤程、协程、管程、微线程、绿色线程....一大堆xx程的概念,其实这些本质上都是为了满足并行执行、异步执行而出现的一些概念。

因为随着如今的科技越来越发达,计算机目前多以多核机器为主,所以之前单线程的串行执行方式注定无法100%程度发挥出硬件该有的性能。同时,为了满足互联网时代中日益渐增的用户基数,我们开发的程序往往需要更优异的性能,更快的执行效率,更大的吞吐量才可。

为了方便理解,我们可以先把操作系统抽象为了一个帝国。并且为了方便理解这些概念,下面也不会太过官方死板的做概念介绍。

1.1、进程(Progress)

进程也就是平时所说的程序,比如在操作系统上运行一个谷歌浏览器,那么就代表着谷歌浏览器就是一个进程。进程是操作系统中能够独立运行的个体,并且也作为资源分配的基本单位,由指令、数据、堆栈等结构组成。
安装好一个程序之后,在程序未曾运行之前也仅是一些文件存储在磁盘上,当启动程序时会向操作系统申请一定的资源,如CPU、存储空间和I/O设备等,OS为其分配资源后,会真正的出现在内存中成为一个抽象的概念:进程。

其实操作系统这个帝国之上,在运行时往往有着很多个进程存在,你可以把这些进程理解成一个个的工厂,根据各自的代码实现各司其职。如通过Java编写一个程序后运行在操作系统上,那么就相当于在OS帝国上注册了一家工厂,该工厂具体的工作则由Java代码的业务属性决定。

随着计算机硬件技术的不断进步,慢慢的CPU架构更多都是以多核的身份出现在市面上,所以对于程序而言,CPU利用率的要求会更高。但是进程的调度开销是比较大的,并且在并发中切换过程效率也很低,所以为了更高效的调度和满足日益复杂的程序需求,最终发明了线程。

1.2、线程(Thread)

在操作系统早期的时候其实并没有线程的概念,到了后来为了满足并发处理才推出的一种方案,线程作为程序执行的最小单位,一个进程中可以拥有多条线程,所有线程可以共享进程的内存区域,线程通常在运行时也需要一组寄存器、内存、栈等资源的支撑。现如今,程序之所以可以运行起来的根本原因就是因为内部一条条的线程在不断的执行对应的代码逻辑。

假设进程现在是OS帝国中的一个工厂,那么线程就是工厂中一个个工位上的工人。工厂之所以能够运转的根本原因就在于:内部每个工位上的工人都各司其职的处理自己分配到的工作。

多核CPU中,一个核心往往在同一时刻只能支持一个内核线程的运行,所以如果你的机器为八核CPU,那么理论上代表着同一时刻最多支持八条内核线程同时并发执行。当然,现在也采用了超线程的技术,把一个物理芯片模拟成两个逻辑处理核心,让单个处理器都能使用线程级并行计算,进而兼容多线程操作系统和软件,减少了CPU的闲置时间,提高的CPU的运行效率。比如四核八线程的CPU,在同一时刻也支持最大八条线程并发执行。

在OS中,程序一般不会去直接申请内核线程进行操作,而是去使用内核线程提供的一种名为LWP的轻量级进程(Lightweight Process)进行操作,这个LWP也就是平时所谓的线程,也被成为用户级线程。

1.2.1、线程模型

在如今的操作系统中,用户线程与内核线程主要存在三种模型:一对一模型、多对一模型以及多对多模型。而Java中使用的则是一对一模型,在之前分析Java内存模型JMM时曾详细分析过。

一对一模型

一对一模型是指一条用户线程对应着内核中的一条线程,而Java中采用的就是这种模型,如下:
Java线程一对一模型
一对一模型是真正意义上的并行执行,因为这种模型下,创建一条Java的Thread线程是真正的在内核中创建并映射了一条内核线程的,执行过程中,一条线程不会因为另外一条线程的原因而发生阻塞等情况。不过因为是直接映射内核线程的模式,所以数量会存在上限。并且同一个核心中,多条线程的执行需要频繁的发生上下文切换以及内核态与用户态之间的切换,所以如果线程数量过多,切换过于频繁会导致线程执行效率下降。

多对一模型

顾名思义,多对一模型是指多条用户线程映射同一条内核线程的情况,对于用户线程而言,它们的执行都由用户态的代码完成切换。
线程多对一模型

这种模式优点很明显,一方面可以节省内核态到用户态切换的开销,第二方面线程的数量不会受到内核线程的限制。但是缺点也很明显,因为线程切换的工作是由用户态的代码完成的,所以如果当一条线程发生阻塞时,与该内核线程对应的其他用户线程也会一起陷入阻塞。

多对多模型

多对多模型就可以避免上面一对一和多对一模型带来的弊端,也就是多条用户线程映射多条内核线程,这样即可以避免一对一模型的切换效率问题和数量限制问题,也可以避免多对一的阻塞问题,如下:
线程多对多模型

1.3、协程(Coroutines)

协程是一种基于线程之上,但又比线程更加轻量级的存在,这种由程序管理的轻量级线程也被称为用户空间线程,对于内核而言是不可见的。正如同进程中存在多条线程一样,线程中也可以存在多个协程。

协程在运行时也有自己的寄存器、上下文和栈,协程的调度完全由用户控制,协程调度切换时,会将寄存器上下文和栈保存到分配的私有内存区域中,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。

前面把线程比作了工厂工位上的固定工人,那么协程更多的就可以理解为:工厂中固定工位上的不固定工人。一个固定工位上允许有多个不同的工人,当轮到某个工人工作时,就把上一个工人的换下来,把这个要工作的工人换上去。或者当前工人在工作时要上厕所,那么就会先把当前工作的工人撤下去,换另一个工人上来,等这个工人上完厕所回来了,会再恢复它的工作。
协程有些类似于线程的多对一模型。

1.4、纤程(Fiber)

纤程(Fiber)是Microsoft组织为了帮助企业程序的更好移植到Windows系统,而在操做系统中增加的一个概念,由操作系统内核根据对应的调度算法进行控制,也是一种轻量级的线程。

纤程和协程的概念一致,都是线程的多对一模型,但有些地方会区分开来,但从协程的本质概念上来谈:纤程、绿色线程、微线程这些概念都属于协程的范围。纤程和协程的区别在于:

  • 纤程是OS级别的实现,而协程是语言级别的实现,纤程被OS内核控制,协程对于内核而言不可见。

1.5、管程(Monitors)

管程(Monitors)提供了一种机制,线程可以临时放弃互斥访问,等待某些条件得到满足后,重新获得执行权恢复它的互斥访问。相信这个概念对于熟悉多线程编程的Java程序员而言并不是陌生,因为在Java中Synchronized关键字就是基于它实现的,不太了解的可以去看之前的文章:全面剖析Synchronized关键字

1.6、XX程小结

先如今各种程出现的根本原因是由于多核机器的流行,所以程序实现中也需要最大程度上考虑并行、并发、异步执行,在最大程序上去将硬件机器应有的性能发挥出来。以Java而言,本身多线程的方式是已经可以满足这些需求的,但Java中的线程资源比较昂贵,是直接与内核线程映射的,所以在上下文切换、内核态和用户态转换上都需要浪费很多的资源开销,同时也受到操作系统的限制,允许一个Java程序中创建的纤程数量是有限的。所以对于这种一对一的线程模型有些无法满足需求了,最终才出现了各种程的概念。

从实现级别上来看:进程、线程、纤程是OS级别的实现,而绿色线程、协程这些则是语言级别上的实现。
从调度方式上而言:进程、线程、绿色线程属于抢占式执行,而纤程、协程则属于合作式调度。
从包含关系上来说:一个OS中可以有多个进程,一个进程中可以有多条线程,而一条线程中则可以有多个协程、纤程、微线程等。

二、死锁、活锁与锁饥饿概念理解

在多核时代中,多线程、多进程的程序虽然大大提高了系统资源的利用率以及系统的吞吐量,但并发执行也带来了新的一系列问题:死锁、活锁与锁饥饿。

死锁、活锁与锁饥饿都是程序运行过程中的一种状态,而其中死锁与活锁状态在进程中也是可能存在这种情况的,接下来先简单阐述一下这些状态的含义。

2.1、何谓死锁(DeadLock)?

死锁是指两个或两个以上的线程(或进程)在运行过程中,因为资源竞争而造成相互等待的现象,若无外力作用则不会解除等待状态,它们之间的执行都将无法继续下去。举个栗子:

某一天竹子和熊猫在森林里捡到一把玩具弓箭,竹子和熊猫都想玩,原本说好一人玩一次的来,但是后面竹子耍赖,想再玩一次,所以就把弓一直拿在自己手上,而本应该轮到熊猫玩的,所以熊猫跑去捡起了竹子前面刚刚射出去的箭,然后跑回来之后便发生了如下状况:
熊猫道:竹子,快把你手里的弓给我,该轮到我玩了....
竹子说:不,你先把你手里的箭给我,我再玩一次就给你....
最终导致熊猫等着竹子的弓,竹子等着熊猫的箭,双方都不肯退步,结果陷入僵局场面....。
相信这个场景各位小伙伴多多少少都在自己小时候发生过,这个情况在程序中发生时就被称为死锁状况,如果出现后则必须外力介入,然后破坏掉死锁状态后推进程序继续执行。如上述的案例中,此时就必须第三者介入,把“违反约定”的竹子手中的弓拿过去给熊猫......
当然,类似于这样的死锁案例还有很多现实中的例子,比如:哲学家进餐等。

2.2、活锁(LiveLock)是什么?

活锁是指正在执行的线程或进程没有发生阻塞,但由于某些条件没有满足,导致反复重试-失败-重试-失败的过程。与死锁最大的区别在于:活锁状态的线程或进程是一直处于运行状态的,在失败中不断重试,重试中不断失败,一直处于所谓的“活”态,不会停止。而发生死锁的线程则是相互等待,双方之间的状态是不会发生改变的,处于所谓的“死”态。

死锁没有外力介入是无法自行解除的,而活锁状态有一定几率自行解除。

其实本质上来说,活锁状态就是指两个线程虽然在反复的执行,但是却没有任何效率。正如生活中那句名言:“虽然你看起来很努力,但结果却没有因为你的努力而发生任何改变”,也是所谓的做无用功。同样举个生活中的栗子理解:

生活中大家也都遇见过的一个事情:在一条走廊上两个人低头玩手机往前走,突然双方一起抬头都发现面对面快撞上了,然后双方同时往左侧跨了一步让开路,然后两个人都发现对方也到左边来了,两个人想着再回到右边去给对方让路,然后同时又向右边跨了一步,然后不断重复这个过程,再同时左边跨、右边跨、左边跨........
这个栗子中,虽然双方都在不断的移动,但是做的却是无用功,如果一直这样重复下去,可能从太阳高照到满天繁星的时候,双方还是没有走出这个困境。
这个状态又该如何打破呢?主要有两种方案,一种是单方的,其中有一方打破“同步”的频率。另一种方案则是双方之间先沟通好,制定好约定之后再让路,比如其中一方开口说:你等会儿走我这边,我往那边走。而另一方则说:好。

在程序中,如果两条线程发生了某些条件的碰撞后重新执行,那么如果再次尝试后依然发生了碰撞,长此下去就有可能发生如上案例中的情况,这种情况就被称为协同导致的活锁。

比如同时往某处位置写入数据,但同时只能允许一条线程写入数据,所以在写入之前会检测是否有其他线程存在,如果有则放弃本次写入,过一段时间之后再重试。而此时正好有两条线程同时写入又相互检测到了对方,然后都放弃了写入,而重试的时间间隔都为1s,结果1s后这两条线程又碰头了,然后来回重复这个过程.....

当然,在程序中除开上述这种多线程之间协调导致的活锁情况外,单线程也会导致活锁产生,比如远程RPC调用中就经常出现,A调用B的RPC接口,需要B的数据返回,结果B所在的机器网络出问题了,A就不断的重试,最终导致反复调用,不断失败。

活锁解决方案

活锁状态是有可能自行解除的,但时间会久一点,不过在编写程序时,我们可以尽量避免活锁情况发生,一方面可以在重试次数上加上限制,第二个方面也可以把重试的间隔时间加点随机数,第三个则是前面所说的,多线程协同式工作时则可以先在全局内约定好重试机制,尽量避免线程冲突发生。

2.3、啥又叫锁饥饿(LockStarving)?

锁饥饿是指一条长时间等待的线程无法获取到锁资源或执行所需的资源,而后面来的新线程反而“插队”先获取了资源执行,最终导致这条长时间等待的线程出现饥饿。

ReetrantLock的非公平锁就有可能导致线程饥饿的情况出现,因为线程到来的先后顺序无法决定锁的获取,可能第二条到来的线程在第十八条线程获取锁成功后,它也不一定能够成功获取锁。

锁饥饿这种问题可以采用公平锁的方式解决,这样可以确保线程获取锁的顺序是按照请求锁的先后顺序进行的。但实际开发过程中,从性能角度而言,非公平锁的性能会远远超出公平锁,非公平锁的吞吐量会比公平锁更高。

当然,如果你使用了多线程编程,但是在分配纤程组时没有合理的设置线程优先级,导致高优先级的线程一直吞噬低优先级的资源,导致低优先级的线程一直无法获取到资源执行,最终也会使低优先级的线程产生饥饿。

三、死锁产生原因/如何避免死锁、排查死锁详解

关于锁饥饿和活锁前面阐述的内容便已足够了,不过对于死锁这块的内容,无论在面试过程中,还是在实际开发场景下都比较常见,所以再单独拿出来分析一个段落。

在前面提及过死锁的概念:死锁是指两个或两个以上的线程(或进程)在运行过程中,因为资源竞争而造成相互等待的现象。而此时可以进一步拆解这句话,可以得出死锁如下结论:

  • ①参与的执行实体(线程或进程)必须要为两个或两个以上。
  • ②参与的执行实体都需要等待资源方可执行。
  • ③参与的执行实体都均已占据对方等待的资源。
  • ④死锁情况下会占用大量资源而不工作,如果发生大面积的死锁情况可能会导致程序或系统崩溃。

3.1、死锁产生的四个必要条件

而诱发死锁的根本从前面的分析中可以得知:是因为竞争资源引起的。当然,产生死锁存在四个必要条件,如下:

  • ①互斥条件:指分配到的资源具备排他使用性,即在一段时间内某资源只能由一个执行实体使用。如果此时还有其它执行实体请求资源,则请求者只能等待,直至占有资源的执行实体使用完成后释放才行。
  • ②不可剥夺条件:指执行实体已持有的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
  • ③请求与保持条件:指运行过程中,执行实体已经获取了至少一个资源,但又提出了新的资源请求,而该资源已被其它实体占用,此时当前请求资源的实体阻塞,但在阻塞时却不释放自己已获得的其它资源,一直保持着对其他资源的占用。
  • ④环状等待条件:指在发生死锁时,必然存在一个执行实体的资源环形链。比如:线程T1等待T2占用的一个资源,线程T2在等待线程T3占用的一个资源,而线程T3则在等待T1占用的一个资源,最终形成了一个环状的资源等待链。

以上是死锁发生的四个必要条件,只要系统或程序内发生死锁情况,那么这四个条件必然成立,只要上述中任意一条不符合,那么就不会发生死锁。

3.2、系统资源的分类

操作系统以及硬件平台上存在各种各样不同的资源,而资源的种类大体可以分为永久性资源、临时性资源、可抢占式资源以及不可抢占式资源。

3.2.1、永久性资源

永久性资源也被称为可重复性资源,即代表着一个资源可以被执行实体(线程/进程)重复性使用,它们不会因为执行实体的生命周期改变而发生变化。比如所有的硬件资源就是典型的永久性资源,这些资源的数量是固定的,执行实体在运行时即不能创建,也不能销毁,要使用这些资源时必须要按照请求资源、使用资源、释放资源这样的顺序操作。

3.2.2、临时性资源

临时性资源也被称为消耗性资源,这些资源是由执行实体在运行过程中动态的创建和销毁的,如硬件中断信号、缓冲区内的消息、队列中的任务等,这些都属于临时性资源,通常是由一个执行实体创建出来之后,被另外的执行实体处理后销毁。比如典型的一些消息中间件的使用,也就是生产者-消费者模型。

3.2.3、可抢占式资源

可抢占式资源也被称为可剥夺性资源,是指一个执行实体在获取到某个资源之后,该资源是有可能被其他实体或系统剥夺走的。可剥夺性资源在程序中也比较常见,如:

  • 进程级别:CPU、主内存等资源都属于可剥夺性资源,系统将这些资源分配给一个进程之后,系统是可以将这些资源剥夺后转交给其他进程使用的。
  • 线程级别:比如Java中的ForkJoin框架中的任务,分配给一个线程的任务是有可能被其他线程窃取的。

可剥夺性资源还有很多,诸如上述过程中的一些类似的资源都可以被称为可剥夺性资源。

3.2.4、不可抢占式资源

同样,不可抢占式资源也被称为不可剥夺性资源,不可剥夺性是指把一个执行实体获取到资源之后,系统或程序不能强行收回,只能在实体使用完后自行释放。如:

  • 进程级别:磁带机、打印机等资源,分配给进程之后只能由进程使用完后自行释放。
  • 线程级别:锁资源就是典型的线程级别的不可剥夺性资源,当一条线程获取到锁资源后,其他线程不能剥夺该资源,只能由获取到锁的线程自行释放。

3.2.5、资源引发的死锁问题

前面曾提到过一句,死锁情况的发生必然是因为资源问题引起的,而在上述资源中,竞争临时性资源和不可剥夺性资源都可能引起死锁发生,也包括如果资源请求顺序不当也会诱发死锁问题,如两条并发线程同时执行,T1持有资源M1,线程T2持有M2,而T2又在请求M1T1又在请求M2,两者都会因为所需资源被占用而阻塞,最终造成死锁。

当然,也并非只有资源抢占会导致死锁出现,有时候没有发生资源抢占,就单纯的资源等待也会造成死锁场面,如:服务A在等待服务B的信号,而服务B恰巧也在等待服务A的信号,结果也会导致双方之间无法继续向前推进执行。不过从这里可以看出:A和B不是因为竞争同一资源,而是在等待对方的资源导致死锁

对于这个例子有人可能会疑惑,这不是活锁情况吗?
答案并非如此,因为活锁情况讲究的是一个“活”字,而上述这个案例,双方之间都是处于相互等待的“死”态。

3.3、死锁案例分析

上述对于死锁的理论进行了大概阐述,下来来个简单例子感受一下死锁情景:

public class DeadLock implements Runnable {
    public boolean flag = true;

    // 静态成员属于class,是所有实例对象可共享的
    private static Object o1 = new Object(), o2 = new Object();

    public DeadLock(boolean flag){
        this.flag = flag;
    }

    @Override
    public void run() {
        if (flag) {
            synchronized (o1) {
                System.out.println("线程:" + Thread.currentThread()
                        .getName() + "持有o1....");
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                System.out.println("线程:" + Thread.currentThread()
                        .getName() + "等待o2....");
                synchronized (o2) {
                    System.out.println("true");
                }
            }
        }
        if (!flag) {
            synchronized (o2) {
                System.out.println("线程:" + Thread.currentThread()
                        .getName() + "持有o2....");
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                System.out.println("线程:" + Thread.currentThread()
                        .getName() + "等待o1....");
                synchronized (o1) {
                    System.out.println("false");
                }
            }
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(new DeadLock(true),"T1");
        Thread t2 = new Thread(new DeadLock(false),"T2");
        // 因为线程调度是按时间片切换决定的,
        // 所以先执行哪个线程是不确定的,也就代表着:
        //  后面的t1.run()可能在t2.run()之前运行
        t1.start();
        t2.start();
    }
}

// 运行结果如下:
    /*
      线程:T1持有o1....
      线程:T2持有o2....
      线程:T2等待o1....
      线程:T1等待o2....
    */

如上是一个简单的死锁案例,在该代码中:

  • flag==true时,先获取对象o1的锁,获取成功之后休眠500ms,而发生这个动作的必然是t1,因为在main方法中,我们将t1任务的flag显式的置为了true
  • 而当t1线程睡眠时,t2线程启动,此时t2任务的flag=false,所以会去获取对象o2的锁资源,然后获取成功之后休眠500ms
  • 此时t1线程睡眠时间结束,t1线程被唤醒后会继续往下执行,然后需要获取o2对象的锁资源,但此时o2已经被t2持有,此时t1会阻塞等待。
  • 而此刻t2线程也从睡眠中被唤醒会继续往下执行,然后需要获取o1对象的锁资源,但此时o1已经被t1持有,此时t2会阻塞等待。
  • 最终导致线程t1、t2相互等待对象的资源,都需要获取对方持有的资源之后才可继续往下执行,最终导致死锁产生。

3.4、死锁处理

对于死锁的情况一旦出现都是比较麻烦的,但这也是设计并发程序避免不了的问题,当你想要通过多线程编程技术提升你的程序处理速度和整体吞吐量时,对于死锁的问题也是必须要考虑的一项,而处理死锁问题总的归纳来说可以从如下四个角度出发:

  • ①预防死锁:通过代码设计或更改配置来破坏掉死锁产生的四个条件其中之一,以此达到预防死锁的目的。
  • ②避免死锁:在资源分配的过程中,尽量保证资源请求的顺序性,防止推进顺序不当引起死锁问题产生。
  • ③检测死锁:允许系统在运行过程中发生死锁情况,但可设置检测机制及时检测死锁的发生,并采取适当措施加以清除。
  • ④解除死锁:当检测出死锁后,便采取适当措施将进程从死锁状态中解脱出来。

3.4.1、预防死锁

前面提过,预防死锁的手段是通过破坏死锁产生的四个必要条件中的一个或多个,以此达到预防死锁的目的。

破坏“互斥”条件

在程序中将所有“互斥”的逻辑移除,如果一个资源不能被独占使用时,那么死锁情况必然不会发生。但一般来说在所列的四个条件中,“互斥”条件是不能破坏的,因为程序设计中必须要考虑线程安全问题,所以“互斥”条件是必需的。因此,在死锁预防里主要是破坏其他几个必要条件,不会去破坏“互斥”条件。

破坏“不可剥夺”条件

破坏“不可剥夺性”条件的含义是指取消资源独占性,一个执行实体获取到的资源可以被别的实体或系统强制剥夺,在程序中可以这样设计:

  • ①如果占用资源的实体下一步资源请求失败,那么则释放掉之前获取到的所有资源,后续再重新请求这些资源和另外的资源(和分布式事务的概念有些类似)。
  • ②如果一个实体需要请求的资源已经被另一个实体持有,那么则由程序或系统将该资源释放,然后让给当前实体获取执行。这种方式在Java中也有实现,就是设置线程的优先级,优先级高的线程是可以抢占优先级低的资源先执行的。
破坏“请求与保持”条件

破坏“请求与保持”条件的意思是:系统或程序中不允许出现一个执行实体在获取到资源的情况下再去申请其他资源,主要有两种方案:

  • ①一次性分配方案:对于执行实体所需的资源,系统或程序要么一次性全部给它,要么什么都不给。
  • ②要求每个执行实体提出新的资源申请前,释放它所占有的资源。

但总归来说,这种情况也比较难满足,因为程序中难免会有些情况下要占用多个资源后才能一起操作,就比如最简单的数据库写入操作,在Java程序这边需要先获取到锁资源后才能通过连接对象进行操作,但获取到的连接对象在往DB表中写入数据的时候还需要再和DB中其他连接一起竞争DB那边的锁资源方可真正写表。

破坏“环状等待链”条件

破坏“环状等待链”条件实际上就是要求控制资源的请求顺序性,防止请求顺序不当导致的环状等待链闭环出现。

这个点主要是在编码的时候要注意,对于一些锁资源的获取、连接池、RPC调用、MQ消费等逻辑,尽量保证资源请求顺序合理,避免由于顺序性不当引起死锁问题出现。

预防死锁小结

因为预防死锁的策略需要实现会太过苛刻,所以如果真正的在程序设计时考虑这些方面,可能会导致系统资源利用率下降,也可能会导致系统/程序整体吞吐量降低。

总的来说,预防死锁只需要在系统设计、进程调度、线程调度、业务编码等方面刻意关注一下:如何让死锁的四个必要条件不成立即可。

3.4.2、避免死锁

避免死锁是指系统或程序对于每个能满足的执行实体的资源请求进行动态检查,并且根据检查结果决定是否分配资源,如果分配后系统可能发生死锁,则不予分配,反之则给予资源分配,这是一种保证系统不进入死锁状态的动态策略。

避免死锁的常用算法
  • ①有序资源分配法:这种方式大多数被操作系统应用于进程资源分配。假设此时有两个进程P1、P2,进程P1需要请求资源顺序为R1、R2,而进程P2使用资源的顺序则为R2、R1。如果这个情况下两个进程并发执行,采用动态分配法的情况下是有一定几率发生死锁的,所以可以采用有序资源分配法,把资源分配的顺序改为如下情况,从而做到破坏环路条件,避免死锁发生。
    • P1:R1,R2
    • P2:R1,R2
  • 银行家算法:银行家算法顾名思义是来源于银行的借贷业务,有限的本金要应多个客户的借贷周转,为了防止银行家资金无法周转而倒闭,对每一笔贷款,必须考察其借贷者是否能按期归还。在操作系统中研究资源分配策略时也有类似问题,系统中有限的资源要供多个进程使用,必须保证得到的资源的进程能在有限的时间内归还资源,以供其他进程使用资源,确保整个操作系统能够正常运转。如果资源分配不得到就会发生进程之间环状等待资源,则进程都无法继续执行下去,最终造成死锁现象。
    • OS实现:把一个进程需要的、已占有的资源情况记录在进程控制块中,假定进程控制块PCB其中“状态”有就绪态、等待态和完成态。当进程在处于等待态时,表示系统不能满足该进程当前的资源申请。“资源需求总量”表示进程在整个执行过程中总共要申请的资源量。显然,每个进程的资源需求总量不能超过系统拥有的资源总数,通过银行家算法进行资源分配可以避免死锁。

上述的两种算法更多情况下是操作系统层面对进程级别的资源分配算法,而在程序开发中又该如何编码才能尽量避免死锁呢?大概有如下两种方式:

  • ①顺序加锁
  • ②超时加锁

对于上述中的两种方式从字面意思就可以理解出:前者是保证锁资源的请求顺序性,防止请求顺序不当引起资源相互等待,最终造成死锁发生。而后者则是获取锁超时中断的意思,在JDK级别的锁,如ReetrantLock、Redisson等,都支持该方式,也就是在指定时间内未获取到锁资源则放弃获取锁资源。

3.4.3、检测死锁

检测死锁这块也分为两个方向来谈,也就是分别从进程和线程两个角度出发。进程级别来说,操作系统在设计的时候就考虑到了进程并行执行的情况,所以有专门设计死锁的检测机制,该机制能够检测到死锁发生的位置和原因,如果检测到死锁时会暴力破坏死锁条件,从而使得并发进程从死锁状态中恢复。

而对于Java程序员而言,如果在线上程序运行中发生了死锁又该如何排查检测呢?我们接着来进行详细分析。

Java线上排查死锁问题实战

先借用前面3.3阶段的DeadLock死锁案例代码,操作如下:

D:\> javac -encoding utf-8 DeadLock.java
D:\> java DeadLock
线程:T1持有o1....
线程:T2持有o2....
线程:T2等待o1....
线程:T1等待o2....

在前面3.3案例中,实际上T1永远获取不到o1,而T2永远也获取不到o2,所以此时发生了死锁情况。那假设如果在线上我们并不清楚死锁是发生在那处代码呢?其实可以通过多种方式定位问题:

  • ①通过jps+jstack工具排查。
  • ②通过jconsole工具排查。
  • ③通过jvisualvm工具排查。
  • PS:当然你也可以通过其他一些第三方工具排查问题,但前面两种都是JDK自带的工具。

先来看看jps+jstack的方式,此时保持原先的cmd/shell窗口不关闭,再新开一个窗口,输入jps指令:

D:\> jps
19552 Jps
2892 DeadLock

jps是JDK安装位置bin目录下自带的工具,其作用是显示当前系统的Java进程情况及其进程ID,可以从上述结果中看出:ID2892的进程是刚刚前面产生死锁的Java程序,此时我们可以拿着这个ID再通过jstack工具查看该进程的dump日志,如下:

D:\> jstack -l 2892

显示结果如下:
jstack工具查看死锁

可以从dump日志中明显看出,jstack工具从该进程中检测到了一个死锁问题,是由线程名为T1、T2的线程引起的,而死锁问题的诱发原因可能是DeadLock.java:41、DeadLock.java:25行代码引起的。而到这一步之后其实就已经确定了死锁发生的位置,我们就可以跟进代码继续去排查程序中的问题,优化代码之后就可以确保死锁不再发生。


再来看看jconsole的方式,首先按win+r调出运行窗口,然后输入JConsole命令,紧接着会得到一个如下界面:

JConsole初始界面
然后紧接着可以双击本地进程中PID为2892的Java程序,进入之后选择导航栏中的线程选项,如下:
JConsole工具排查死锁-1

最后再点击底部的“检测死锁”的选项即可,最终就能非常方便快捷的检测到程序中的死锁情况,如下:
JConsole工具排查死锁-2
通过JConsole这个工具能够更加方便的检测死锁问题,并且还带有可视化的图形界面,相对比之前的jps+jstack方式来说,更加友好。


再来看看jvisualvm工具的方式,同样的在开一个命令行窗口,然后在其内输入:jvisualvm,如下:

D:\> jvisualvm

然后同样的可以得到一个可视化的图像界面:
jvisualvm工具初始界面
然后可以在左侧本地的DeadLock进程上右键→选择“打开”,最终可以得到如下界面:
jvisualvm工具排查死锁-1
从界面中的提示可以明确看出:当前Java进程中检测到了死锁,发生死锁的线程为T1、T2,然后点击右侧的“线程Dump”按钮,同样可以查看具体跟踪日志,如下:
jvisualvm工具排查死锁-2

从线程Dump日志中可以清晰看见定位到的死锁相关信息,以及死锁发生的位置等。

3.4.4、解除死锁

当排查到死锁的具体发生原因和发生位置之后,就应立即釆取对应的措施解除死锁,避免长时间的资源占用导致最终拖垮程序或系统。

而一般操作系统处理进程级别的死锁问题主要用三种方式:

  • ①资源剥夺法。挂起某些死锁进程,并剥夺它的资源,将这些资源分配给其他的死锁进程。但应当合理处置被挂起的进程,防止进程长时间挂起而得不到资源,一直处于资源匮乏的状态。
  • ②撤销进程法。强制撤销部分、甚至全部死锁进程并剥夺这些进程的资源。撤销的原则可以按进程优先级、进程重要性和撤销进程代价的高低进行。
  • ③进程回退法。让一个或多个进程回退到足以避免死锁发生的位置,进程回退时自己释放资源而不是被剥夺。要求系统保持进程的历史信息,设置还原点。

当然,这些对于非底层开发程序员而言不必太过关注,重点我们还是放在线程级别的死锁问题解决上面,比如经过上一个阶段之后,我们已经成功定位死锁发生位置又该如何处理死锁问题呢?一般而言在Java程序中只能修改代码后重新上线程序,因为大部分的死锁都是由于代码编写不当导致的,所以将代码改善后重新部署即可。

其实在数据库中是这样处理死锁问题的,数据库系统中考虑了检测死锁和从死锁中恢复。当DB检测到死锁时,将会选择一个线程(客户端那边的连接对象)牺牲者并放弃这个事务,作为牺牲者的事务会放弃它占用的所有资源,从而使其他事务继续执行,最终当其他死锁线程执行完毕后,再重新执行被强制终止的事务。

而你的项目如果在短时间内也不能重启,那么只能写一个与DB类似的死锁检测器+处理器,然后通过自定义一个类加载器将该类动态加载到JVM中(需提前设计),然后在运行时通过你编写的死锁处理机制,强制性的掐断死锁问题。

但对于这种方式我并不太建议使用,因为强制掐断线程执行,可能会导致业务出现问题,所以对于Java程序的死锁问题解决,更多的还是需要从根源:代码上着手解决,因为只有当代码正确了才能根治死锁问题。

四、总结

本篇重点是对于之前篇章中未提及的一些概念和问题做个补充,主要叙述了一些如今出现的新概念,以及对于一些并发执行时会出现的其他问题进行了分析,至此《并发编程》系列大致完结。