多线程编程-分析线程的状态及线程通信机制

本文在个人技术博客同步发布,详情可用力戳
亦可扫描屏幕右侧二维码关注个人公众号,公众号内有个人联系方式,等你来撩...

  多线程编程一直是普通程序员进阶为高级程序员的必备技能之一!他很难,难就难在难以理解、难以调试,出现了bug很难发现及排查。他很重要,因为我们可能随时都面对着线程的切换、调度,只是这些都由CPU来帮我们完成我们无法感知。

  记得我在刚开始学C语言的时候,只会在黑窗口里面打印一个helloworld、打印一个斐波拉契数列、打印乘法口诀表。当时觉得很枯燥,也不知道这个能学来干嘛。等到后面工作中才发现这些都是基础,有了这些基础才能做更高级一点的开发!其实多线程编程也是一样,学习基础的时候很枯燥,也不知道学了能干嘛。我不会多线程编程不也一样能写CRUD么,不也能实现工作中的需求么?但是要是想去大厂或者互联网公司,不会多线程可能就直接被pass掉了吧!

  本文不会讲什么是线程、什么是进程这些概念,不清楚这些概念的可以自己先去学习下,文中的代码都由java语言编写!

线程的基本状态

开局一张图,内容全靠“编”!我们先来看一张图。

  1582286718807

  这张图参考网上看到的一张图,它比较详细的描述了线程的生命周期以及几种基本状态。这张图我分了两块区域来讲,我们接下来先分析下区域一。

  目前正值新冠肺炎肆虐之际,口罩一时成了出门必备的稀缺之物!假设我们有个生产口罩的工厂,能够生产及销售口罩。

class MaskFactory {

    public void produce() throws InterruptedException {
        // #3
        // Thread.sleep(1000);
        System.out.println("生产了1个口罩");
    }

    public void consume() {
        System.out.println("消费了1个口罩");
    }
}

public class Demo1 {

    public static void main(String[] args) {

        final MaskFactory maskFactory = new MaskFactory();

        // #1
        Thread threadA = new Thread(new Runnable() {
            public void run() {
                try {
                    maskFactory.produce();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        // #2
        threadA.start();
    }
}

  上述代码中,我们创建了一个线程去生产口罩,那线程的的状态会经过什么样的变化呢?

1、在代码#1处,我们创建了一个新的线程,对应图中的状态New

2、这时候线程还不会运行,直到我们在代码#2处调用了start()方法,线程的状态经过线路1变为了Runable

3、此时,线程已经准备就绪,随时可能执行!至于什么时候能执行呢?这个是不确定的,完全取决于CPU的调度(当然,我们可以设置线程的优先级来控制线程获取执行时间的概率)!如果这条线程得到CPU执行时间之后,线程的状态会经过线路2变为Running,这是线程就在运行中了。

4、在上述代码中如果线程状态为Running后,不出意外会走线路3状态变为Dead,线程执行结束!

5、但是有时候就是可能会有意外的发生,那就是上线文切换!如果发生上下文切换,那么当前线程就会让出CPU资源,线程会从Running状态经过线路4又变回Runable状态!直到线程再次得到CPU的执行时间!也就是说线路2和线路4可能会来回多次!如图中所示,如果在Running状态的线程调用了yield()方法,那这个线程会主动让出CPU资源,状态从Running状态变回Runable状态!

6、代码#3处的注释如果放开,那线程在Running状态会经过线路5变为Blocked状态。在阻塞过程中,线程会让出CPU资源进入睡眠状态。等到阻塞过了指定的时间后,线程会经过线路6由Blocked状态变为Runable状态,等待CPU的调度再次执行!

  区域一的5个状态我们前面都已经分析过了,算是比较好理解的!区域二的2个状态就稍微有点复杂了,分别是Blocked in Object's Lock PoolBlocked in Object's Wait Pool。这两个状态的共同点是Blocked in Object's ...,这里的object是什么呢?什么对象?谁的对象?其实这就跟我们的锁密切相关了。从名字我们能看出来,其实这两个状态也属于阻塞状态,但是与我们上面说过的通过sleep()阻塞又不一样!

线程间的通信

我们继续改进上面的代码,并且启动两个线程,一个负责生产口罩一个负责消费口罩,分别执行10次。并且生产线程和消费线程必须交替的执行!代码如下:

class MaskFactory {

    private int number = 0;

    // 生产
    public synchronized void produce() {
        if (number != 0) {
            try {
                // 等待
                this.wait();
            } catch (InterruptedException e) {
            }
        }

        number++;
        System.out.println("生产线程" + Thread.currentThread().getName() + "执行,目前口罩数量:" + number);
        // 通知
        this.notifyAll();
    }

    // 消费
    public synchronized void consume() {
        if (number == 0) {
            try {
                // 等待
                this.wait();
            } catch (InterruptedException e) {
            }
        }

        number--;
        System.out.println("消费线程" + Thread.currentThread().getName() + "执行,目前口罩数量:" + number);
        // 通知
        this.notifyAll();
    }
}

public class Demo1 {

    public static void main(String[] args) {

        final MaskFactory maskFactory = new MaskFactory();

        // 1
        new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 10; i++) {
                    maskFactory.produce();
                }

            }
        }, "A").start();

        // 2
        new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 10; i++) {
                    maskFactory.consume();
                }

            }
        }, "B").start();
    }
}

  执行结果如何呢?

  1582120624902

  接下来我们就结合代码和之前,我们需要注意下面几点:

1、produce和consume方法上的synchronized

2、不满足条件时候的this.wait()

3、满足条件执行后的this.notifyAll()

  用过synchronized关键字的宝宝应该知道,在非静态方法上,锁定的是实例对象。wait和notifyAll方法前面的this也指向当前实例。我们进入wait方法和notifyAll方法发现他们都是object基类的方法。也就是说在java中任何对象都有这两个方法。既然这样,那我们可以可以随便new一个对象,然后在其中调用wait和notifyAll方法呢?答案是不行的!因为我们的等待和通知都是跟锁相关的,所以synchronized锁定的对象以及调用wait和notifyAll方法的对象必须是同一个!

  基于上面的前提我们再来分析一下另外两个阻塞状态:

1、如果方法有加synchronized关键字,那当A线程在进入这个方法后且状态为Running或者Blocked时,B线程再想进入则会被阻塞,也就是说B线程会从Running状态经过线路7变为Blocked in Object's Lock Pool状态,一直在这里阻塞着等待锁资源,所以这个等待区也叫等锁池。注意,produce和consume虽然是两个方法,但是他们锁定都是同一个对象!也就是说A线程在执行produce方法时候,如果B线程想进入consume方法,那也只能被阻塞!

2、如果A线程执行produce方法时发现number!=0,则会执行this.wait(),那A线程会从Running状态经过线路8变为Blocked in Object's Wait Pool状态,A线程释放锁资源!这时候线程就在这里等待阻塞着等待着被其他线程通知唤醒,所以这个等待区也叫等待池

3、如果B线程再次得到执行consume方法时发现number!=0,则会正常执行逻辑,并且执行this.notifyAll()。这时候在等待池中的线程A线程会收到通知被唤醒,状态由Blocked in Object's Wait Pool经过线路9变为Blocked in Object's Wait Pool,线程A就进入了等锁池等待着锁资源。当A线程再次竞争到锁资源时,状态会经过线路10变为Runable,等待着CPU的再次调度!

线程间的虚假唤醒

  到这里一切好像都很完美,如果线程A线程不满足条件则进行等待B线程执行后唤醒。线程B线程不满足条件则进行等待A线程执行后唤醒。那我们再多加两条线程C、D执行会出现什么样的结果呢?代码如下(MaskFactory类的代码与上一个例子一样):

class MaskFactory {

    private int number = 0;
    private int index = 1;

    // 生产
    public synchronized void produce() {
        if (number != 0) {
            try {
                // 等待
                this.wait();
            } catch (InterruptedException e) {
            }
        }

        number++;
        System.out.println("第"+ index++ + "行:生产线程" + Thread.currentThread().getName() + "执行,目前口罩数量:" + number);
        // 通知
        this.notifyAll();
    }

    // 消费
    public synchronized void consume() {
        if (number == 0) {
            try {
                // 等待
                this.wait();
            } catch (InterruptedException e) {
            }
        }

        number--;
        System.out.println("第"+ index++ + "行:消费线程" + Thread.currentThread().getName() + "执行,目前口罩数量:" + number);
        // 通知
        this.notifyAll();
    }
}

public class Demo1 {

    public static void main(String[] args) {

        final MaskFactory maskFactory = new MaskFactory();

        // 1
        new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 10; i++) {
                    maskFactory.produce();
                }

            }
        }, "A").start();

        // 2
        new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 10; i++) {
                    maskFactory.consume();
                }

            }
        }, "B").start();

        // 3
        new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 10; i++) {
                    maskFactory.produce();
                }

            }
        }, "C").start();

        // D
        new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 10; i++) {
                    maskFactory.consume();
                }

            }
        }, "D").start();
    }
}

  线程A、C负责生产,D、B线程负责消费,那输出结果是什么样子的呢?思考一分钟然后看结果:

  1582291734891

  其实输出结果我们谁也没法预期会怎么输出!但是可以确定的一点是生产线程和消费线程不一定会交替执行,也就是说不一定会按照01010101规律输出! 为什么说不一定呢?难道说也有可能会交替输出么?是的,我们多运行几次代码发现每次输出的结果都可能不一样!我们就结合上面的结果来分析一下:

1、前面四次都正常,我们可以理解为A、D线程在交替执行。

2、在第3行A线程执行完之后,A线程没有释放锁,而是继续循环再次调用produce方法,但是由于此时number!=0,所以A线程被阻塞了。

3、第4行D线程执行完成后唤醒了线程A,A线程得到了继续执行的机会。

4、但是CPU先调度了C线程,因此第5行C线程输出了1。

5、CPU并没有立即切换调度,而是让C线程继续循环再次调用produce方法,但是由于此时number!=0,所以C线程被阻塞了。

4、之前被唤醒的A线程再次抢占CPU,因此接着this.wait()后面的代码执行,但是此时number已经为1了,所以number++就变成2了,因此在第6行输出了2,并且唤醒了C线程。

6、被唤醒的C线程再次抢占CPU,因此接着this.wait()后面的代码执行,但是此时number已经为2了,所以number++就变成3了,因此在第7行输出了3。

.........

  整个过程有点绕,不清楚的得多看几遍才能慢慢理解。一个大前提是前面说到过的已经准备就绪的线程,随时可能执行!至于什么时候能执行呢?这个是不确定的,完全取决于CPU的调度!上述的现象产生的原因就是线程的虚假唤醒,也就是本不应该唤醒的线程被唤醒了,因此输出出现异常!那这样的问题该怎么解决呢?其实很简单:

//将if替换为while
while (number != 0) {
            try {
                // 等待
                this.wait();
            } catch (InterruptedException e) {
            }
        }

  只需要将之前代码中的if替换为while,当每次唤醒之后不是继续往下执行,而是再判断一次状态是否符合条件,不符合条件则继续等待!

精准通知顺序访问

  我们使用notifyAll的时候,只要是在等待池中的线程都会一股脑的全部都通知。那么这里我们思考一下,当等待池中有消费线程也有生产线程的时候,我们是不是可以在生产线程执行后只通知消费线程,在消费线程执行后只通知生产线程呢?

如果要实现精准的通知,那就得用另外一套锁逻辑了,我们先看实现代码(创建线程的逻辑与前面类似,为节约篇幅这里不再列出):

class MaskFactory {

    private int number = 0;

    private Lock lock = new ReentrantLock();
    Condition consumeCondition = lock.newCondition();
    Condition produceContion = lock.newCondition();

    // 生产
    public void produce() {
        lock.lock();
        try {
            while (number != 0) {
                // 等待一个生产的信号
                produceContion.await();
            }

            number++;
            System.out.println("生产线程" + Thread.currentThread().getName() + "执行,目前口罩数量:" + number);
            // 发出消费的信号
            consumeCondition.signalAll();
        } catch (Exception e) {
        } finally {
            lock.unlock();
        }

    }

    // 消费
    public void consume() {
        lock.lock();
        try {
            while (number == 0) {
                // 等待一个消费的信号
                consumeCondition.await();
            }

            number--;
            System.out.println("消费线程" + Thread.currentThread().getName() + "执行,目前口罩数量:" + number);
            // 发出生产的信号
            produceContion.signalAll();
        } catch (Exception e) {
        } finally {
            lock.unlock();
        }
    }
}

  这里我们就使用了ReentrantLock来替代synchronized,一个ReentrantLock对象可以创建多个通知条件,也就是代码中的consumeCondition和produceContion。当生产线程调用produce时发现不满足条件,则会执行produceContion.await()进行等待,直到有消费线程调用produceContion.signalAll()时,生产线程才会被唤醒!这也就实现了线程的精准通知!因此用ReentrantLock来替代synchronized可以更加灵活的控制线程!

常见面试题

  下面我们来看几个可能会遇到的面试题

synchornized与lock区别?

1、Lock 的锁定是通过java代码实现的,而 synchronized 是在 JVM 层面上实现的。

2、synchronized 在锁定时如果方法块抛出异常,JVM 会自动将锁释放掉,不会因为出了异常没有释放锁造成线程死锁。但是 Lock 的话就享受不到 JVM 带来自动的功能,出现异常时必须在 finally 将锁释放掉,否则将会引起死锁。

3、就像我们上面的例子,Lock能实现精准的通知,但是synchronized不行!

sleep()、yield()有什么区别?

1、从之前的图上能看出来,线程执行sleep方法后会先变成Blocked状态,等到了阻塞时间后会辩词Runable状态,而执行yield方法后会直接变成Runable状态。

2、sleep让出CPU资源而给其他线程运行机会时不会考虑线程的优先级,而yield只会给相同优先级或更高优先级的线程以运行的机会。

3、sleep()方法声明抛出InterruptedException,而yield()方法没有声明任何异常。

4、sleep()方法比yield()方法(跟操作系统CPU调度相关)具有更好的可移植性。

sleep()、与wait()有什么区别?

1、sleep方法时Thread类的静态方法,而wait是Object类的方法。

2、sleep会使线程进入阻塞状态,只有在阻塞时间到后才会重新进入就绪阶段等待cpu调度。而wait会使线程进入锁定对象的等待池,直到其他线程调用同一个对象的notify方法才会重新被激活。

3、wait方法必须在同步块中,并且调用wait方法的对象必须与同步块锁定的是同一个对象。调用wait方法的线程会释放锁资源,而在同步块中调用sleep方法的线程不会释放锁资源!

posted @ 2020-07-29 09:37  苏苏喂苏苏+  阅读(567)  评论(0编辑  收藏  举报