互联网科技

面试必问系列,源码解析多线程绝对不容忽视得问题:线程活性故障

2020-12-19  本文已影响0人  java架构师联盟

看多了各种多线程得内容,我们是不是忘记了某一个很重要得知识点——线程活性故障

线程活性故障是由于资源稀缺性或者程序自身的问题导致线程一直处于非 Runnable 状态,或者线程虽然处于 Runnable 状态但是其要执行的任务一直无法取得进展的一种故障现象

关注公众号:Java架构师联盟,每日更新技术好文

下面就来介绍几种常见类型的线程活性故障:

  1. 死锁
  2. 锁死
  3. 线程饥饿
  4. 活锁

死锁

对于死锁得问题,我们有一个非常非常好玩的问题---哲学家吃饭,干饭人干饭魂,我们就通过这个讲解引入一下 啊

假设有 5 个哲学家,他们的生活只是思考和吃饭。这些哲学家共用一个圆桌,每位都有一把椅子。在桌子中央有一碗米饭,在桌子上放着 5 根筷子(图 1 )。

面试必问系列,源码解析多线程绝对不容忽视得问题:线程活性故障

当一位哲学家思考时,他与其他同事不交流。时而,他会感到饥饿,并试图拿起与他相近的两根筷子(筷子在他和他的左或右邻居之间)。一个哲学家一次只能拿起一根筷子。显然,他不能从其他哲学家手里拿走筷子。当一个饥饿的哲学家同时拥有两根筷子时,他就能吃。在吃完后,他会放下两根筷子,并开始思考。
哲学家就餐问题是一个经典的同步问题,这不是因为其本身的实际重要性,也不是因为计算机科学家不喜欢哲学家,而是因为它是大量并发控制问题的一个例子。这个代表性的例子满足:在多个进程之间分配多个资源,而且不会出现死锁和饥饿。

我们可以用一段程序来模拟并验证上述的情况

先对筷子 Chopstick 进行定义,其能被操作的行为只有两种,即:拿起放下

package com.test.lock;

/**
 * @author :biws
 * @date :Created in 2020/12/18 22:29
 * @description:
 */
public class Semaphore extends Object {
    private int count;

    public Semaphore(int startingCount){
        count = startingCount;
    }

  //放下

    public void down(){
        synchronized (this) {
            while (count <= 0) {
                // We must wait
                try {
                    wait();
                } catch (InterruptedException ex) {
                    // I was interupted, continue onwards
                }
            }

            // We can decrement the count
            count--;
        }

    }

  //拿起
    public void up(){
        synchronized (this) {
            count++;
            //notify a waiting thread to wakeup
            if (count == 1 ) {
                notify();
            }
        }
    }
}

最后,运行程序后,只要我们为哲学家设定每次思考和吃饭的耗时时间不要太长,那么应该就能很快看到程序没有继续输出日志了,似乎被卡住了,此时即发生了死锁

package com.test.lock;

/**
 * @author :biws
 * @date :Created in 2020/12/18 22:30
 * @description:
 */
public class Philosopher extends Thread {
    // Shared by all Philosophers
    public final static int N = 5;              // Number of philosophers

    public final static int THINKING = 0;       // Philosopher is thinking
    public final static int HUNGRY = 1;         // Philosopher is hungry
    public final static int EATING = 2;         // Philosopher is eating

    private static int state[] = new int[N];    // Array to keep track of
    // everyones state

    private static Semaphore mutex = new Semaphore(1);  // Mutual exclusion for
    // critical regions
    private static Semaphore s[] = new Semaphore[N];    // One for each
    // Philosopher
    // Instance variable
    public int myNumber;                    // Which philosopher am I
    public int myLeft;                      // Number of my left neighbor
    public int myRight;                     // Number of my right neighbor

    public Philosopher(int i) {             // Make a philosopher
        myNumber = i;
        myLeft = (i + N - 1) % N;               // Compute the left neighbor
        myRight = (i + 1) % N;                // Compute the right neighbor
    }

    public void run() {                     // And away we go
        while (true) {
            think();                        // Philosopher is thinking
            take_forks();                   // Acquire two forks or block
            eat();                          // Yum-yum, spahgetti
            put_forks();                    // Put both forks back on the table
        }
    }

    public void take_forks() {               // Take the forks I need
        mutex.down();                       // Enter critical region
        state[myNumber] = HUNGRY;           // Record the fact that I am hungry
        test(myNumber);                     // Try to acquire two forks
        mutex.up();                         // Leave critical region
        s[myNumber].down();                 // Block if forks were not acquired
    }

    public void put_forks() {
        mutex.down();                       // Enter critical region
        state[myNumber] = THINKING;         // Philosopher has finished eating
        test(myLeft);                       // See if left neighbor can now eat
        test(myRight);                      // See if right neighbor can now
        // eat
        mutex.up();                         // Leave critical region
    }

    public void test(int k) {                // Test philosopher k,
        // from 0 to N-1
        int onLeft = (k + N - 1) % N;           // K's left neighbor
        int onRight = (k + 1) % N;            // K's right neighbor
        if (state[k] == HUNGRY
                && state[onLeft] != EATING
                && state[onRight] != EATING) {

            // Grab those forks
            state[k] = EATING;
            s[k].up();
        }
    }

    public void think() {
        System.out.println("Philosopher " + myNumber + " is thinking");
        try {
            sleep(1000);
        } catch (InterruptedException ex) {
        }
    }

    public void eat() {
        System.out.println("Philosopher " + myNumber + " is eating");
        try {
            sleep(5000);
        } catch (InterruptedException ex) {
        }
    }

    public static void main(String args[]) {

        Philosopher p[] = new Philosopher[N];

        for (int i = 0; i < N; i++) {
            // Create each philosopher and their semaphore
            p[i] = new Philosopher(i);
            s[i] = new Semaphore(0);

            // Start the threads running
            p[i].start();
        }
    }

}

根据输出日志可以分析出,最后每位哲学家均拿到了其左手边的筷子,且均在等待右手边的筷子被放下,但此时由于筷子是独占资源,所以每位哲学家都只能干瞪着眼无法吃饭,最终导致了死锁

但是我的结果,没怎么等得时间特别长

面试必问系列,源码解析多线程绝对不容忽视得问题:线程活性故障

大家可以多等一会,然后自己跟我一样执行一下,如果有时间的话,我写着写着就实在写不下去了,嘿嘿嘿

面试必问系列,源码解析多线程绝对不容忽视得问题:线程活性故障

二、死锁的产生条件

哲学家就餐问题反映了发生死锁的必要条件,线程一旦发生死锁,那么这些线程及相关的共享资源就一定同时满足以下条件:

资源互斥。涉及的资源必须是排他性资源,即每个资源每次只能由一个线程持有

资源不可抢夺。涉及的资源只能由其持有线程主动释放,其它线程无法从持有线程中主动夺得

占用并等待其它资源。涉及的线程当前至少已经持有了一个排他性资源,并在申请其它资源,而这些资源同时又被其它线程所持有。在这个资源等待过程中,线程不会主动释放持有的现有资源

循环等待资源。在涉及到的所有线程列表内部,每个线程均在互相等待其它线程释放持有的资源,形成了互相等待的圆形依赖关系。即存在一个处于等待状态的线程集合 {T1, T2, ..., Tn},其中 Ti 等待的资源被 T(i+1) 占有(i 大于等于 1 小于 n),Tn 等待的资源被 T1 占有

当产生死锁得时候,以上条件就一定同时成立

但是需要注意一点:

当上述条件即使同时成立也未必就一定能产生死锁。所以这也是为什么在测试的时候不出问题,但是一上线就出各种问题得原因

三、规避死锁

从上诉的四个发生死锁的必要条件来反推,我们只要消除死锁产生的任意一个必要条件就可以规避死锁了。由于锁具有排他性且只能由其持有线程来主动释放,因此由锁导致的死锁只能从消除“占用并等待资源”和消除“循环等待资源”这两个方向入手。

今天有点累了,所以从网上找到一些相应得解决方案,附伪代码,给大家提供一个思路,后面我会将代码进行提交

方法一

至多只允许四位哲学家同时去拿左筷子,最终能保证至少有一位哲学家能进餐,并在用完后释放两只筷子供他人使用。

设置一个初值为 4 的信号量 r,只允许 4 个哲学家同时去拿左筷子,这样就能保证至少有一个哲学家可以就餐,不会出现饿死和死锁的现象。

原理:至多只允许四个哲学家同时进餐,以保证至少有一个哲学家能够进餐,最终总会释放出他所使用过的两支筷子,从而可使更多的哲学家进餐。

面试必问系列,源码解析多线程绝对不容忽视得问题:线程活性故障

方法二

仅当哲学家的左右手筷子都拿起时才允许进餐。

解法 1:利用 AND 型信号量机制实现。

原理:多个临界资源,要么全部分配,要么一个都不分配,因此不会出现死锁的情形。

面试必问系列,源码解析多线程绝对不容忽视得问题:线程活性故障

解法 2:利用信号量的保护机制实现。

原理:通过互斥信号量 mutex 对 eat() 之前取左侧和右侧筷子的操作进行保护,可以防止死锁的出现。

面试必问系列,源码解析多线程绝对不容忽视得问题:线程活性故障

方法三

规定奇数号哲学家先拿左筷子再拿右筷子,而偶数号哲学家相反。

原理:按照下图,将是 2,3 号哲学家竞争 3 号筷子,4,5 号哲学家竞争 5 号筷子。1 号哲学家不需要竞争。最后总会有一个哲学家能获得两支筷子而进餐。

面试必问系列,源码解析多线程绝对不容忽视得问题:线程活性故障 面试必问系列,源码解析多线程绝对不容忽视得问题:线程活性故障

四、锁死

等待线程由于唤醒其所需的条件永远无法成立,或者是其它线程无法唤醒这个线程导致其一直处于非运行状态(线程并未终止)从而任务一直取得进展,那么我们称这个线程被锁死

锁死和死锁之间有着共同的外在表现:故障线程一直处于非运行状态而使得其任务无法进展。死锁针对的是多个线程,而锁死可能只是作用在一个线程上。例如,一个调用了 Object.wait() 处于等待状态的线程,由于发生异常或者是代码缺陷,导致一直没有外部线程调用 Object.notify() 方法来唤醒等待线程,使得线程一直处于等待状态无法运行,此时就可以说该线程被锁死

锁死和死锁的产生条件是不同的,即便是在产生死锁的所有必要条件都不成立的情况下(此时死锁不可能发生),锁死仍可能出现。因此应对死锁的办法未必能够用来避免锁死现象的发生。按照锁死产生的条件来分,锁死包括信号丢失锁死和嵌套监视器锁死

1、信号丢失锁死

信号丢失锁死是由于没有相应的通知线程来唤醒等待线程而使等待线程一直处于等待状态的一种活性故障

例如,某个等待线程在执行 Object.wait() 前没有对保护条件进行判断,而此时保护条件实际上已经成立了,然而此后可能并无其他线程会来唤醒等待线程,因为在等待线程获得 Object 内部锁之前保护条件已经是处于成立状态了,这就使得等待线程一直处于等待状态,其任务一直无法取得进展

信号丢失锁死的另外一个常见例子是由于 CountDownLatch.countDown() 没有放在 finally块中,而如果 CountDownLatch.countDown() 的执行线程运行时抛出未捕获的异常时, CountDownLatch.await() 的执行线程就会一直处于等待状态从而任务一直无法取得进展

例如,对于以下代码,当 ServiceB 抛出异常时,main 线程就会由于一直无法收到唤醒通知从而一直处于等待状态

fun main() {
    val serviceManager = ServicesManager()
    serviceManager.startServices()
    println("等待所有 Services 执行完毕")
    val allSuccess = serviceManager.checkState()
    println("执行结果: $allSuccess")
}

class ServicesManager {

    private val countDownLatch = CountDownLatch(2)

    private val serviceList = mutableListOf<AbstractService>()

    init {
        serviceList.add(ServiceA("ServiceA", countDownLatch))
        serviceList.add(ServiceB("ServiceB", countDownLatch))
    }

    fun startServices() {
        serviceList.forEach {
            it.start()
        }
    }

    fun checkState(): Boolean {
        countDownLatch.await()
        return serviceList.find { !it.checkState() } == null
    }

}

abstract class AbstractService(private val countDownLatch: CountDownLatch) {

    private var success = false

    abstract fun doTask(): Boolean

    fun start() {
        thread {
//            try {
//                success = doTask()
//            } finally {
//                countDownLatch.countDown()
//            }
            success = doTask()
            countDownLatch.countDown()
        }
    }

    fun checkState(): Boolean {
        return success
    }

}

class ServiceA(private val serviceName: String, countDownLatch: CountDownLatch) : AbstractService(countDownLatch) {

    override fun doTask(): Boolean {
        Thread.sleep(2000)
        println("${serviceName}执行完毕")
        return true
    }

}

class ServiceB(private val serviceName: String, countDownLatch: CountDownLatch) : AbstractService(countDownLatch) {

    override fun doTask(): Boolean {
        Thread.sleep(3000)
        if (Random.nextBoolean()) {
            throw  RuntimeException("$serviceName failed")
        } else {
            println("${serviceName}执行完毕")
        }
        return true
    }

}

2、嵌套监视器锁死

嵌套监视器锁死是嵌套锁导致等待线程永远无法被唤醒的一种活性故障

来看以下伪代码。假设存在一个等待线程,其先后持有了 monitorX 和 monitorY 两个不同的锁,当等待线程监测到当前执行条件不成立时,调用了 monitorY.wait() 等待通知线程来唤醒自身,并同时释放了锁 monitorY

    synchronized(monitorX) {
        //...
        synchronized(monitorY) {
            while (!somethingOk) {
                monitorY.wait()
            }
            //执行目标行为
        }
    }

相应的通知线程其伪代码如下所示。通知线程需要持有了 monitorX 和 monitorY 两个锁才能执行到 monitorY.notifyAll() 这行代码来唤醒等待线程。而等待线程执行 monitorY.wait() 时仅会释放 monitorY,而不会释放 monitorX。这使得通知线程由于一直获得 monitorX, 从而导致等待线程一直无法被唤醒而一直处于 BLOCKED 状态

    synchronized(monitorX) {
        //...
        synchronized(monitorY) {
            //...
            somethingOk = true
            monitorY.notifyAll()
            //...
        }
    }

这种由于嵌套锁导致通知线程始终无法唤醒等待线程的活性故障就被称为嵌套监视器锁死

五、线程饥饿

线程饥饿是指线程一直无法获得所需资源从而导致任务无法取得进展的一种活性故障现象

产生线程饥饿的一种情况是:线程一直没有被分配到处理器时间片。这种情况一般是由于处理器时间片一直被高优先级的线程抢占,低优先级的线程一直无法获得运行机会,此时即发生了线程饥饿现象。Thread 类提供了修改线程优先级的成员方法setPriority(Int),定义了整数一到十之间的十个优先级级别。不同的操作系统会有不同的线程优先级等级,JVM 会把这 Thread 类的十个优先级级别映射到具体的操作系统所定义的线程优先级关系上。但是我们所设置的线程优先级对线程调度器来说只是一个建议,当我们将一个线程设置为高优先级时,极可能会被线程调度器忽略,也可能会使该线程过度优先执行而别的线程一直得不到处理器时间片,从而导致线程饥饿。因此我们应该尽量避免修改线程的优先级

把锁看做一种资源,那么死锁也是一种线程饥饿。死锁的结果是所有故障线程都无法获得其所需的全部锁,从而使得其任务一直无法取得进展,这就相当于线程无法获得所需的全部资源从而导致任务无法取得进展,即产生了线程饥饿

发生线程饥饿并不一定同时存在死锁。因为线程饥饿可能只发生在一个线程上(例如上述的低优先级线程无法获得时间片),且即使是同时发生在多个线程上,也可能并不满足死锁发生的必要条件之一:循环等待资源,因为此时涉及到的多个线程所等待的资源可能并没有相互依赖关系

六、活锁

活锁指的是任务和任务的执行线程均没有被阻塞,但由于某些条件没有满足,导致线程一直在重复尝试—失败—尝试的过程,任务一直无法取得进展。也就是说,产生活锁的线程虽然处于 Runnable 状态,但是一直在做无用功

例如,对于上述的哲学家问题,假设某位哲学家“比较有礼貌”,当其拿起了左手边的筷子时,如果恰好有其他哲学家需要这根筷子,有礼貌的哲学家就主动放下筷子,让给其他哲学家使用。在最极端的情况下,每当有礼貌的哲学家一想要吃饭并拿起左手边的筷子时,就有其他哲学家需要这根筷子,此时有礼貌的哲学就会一直处于拿起筷子-放下筷子-拿起筷子这样一个循环过程中,导致一直无法吃饭。此时并没有发生死锁,但对于有礼貌的哲学家所代表的线程来说就是发生了活锁

到这里,基本几个概念都涵盖了,但是这套代码实现说时候,有点头疼,容我慢慢来更新吧,嘿嘿嘿,定时了,晚安

上一篇下一篇

猜你喜欢

热点阅读