Java多线程— —线程 虚假唤醒 问题剖析


 

好久没写博客,最近在学习过程中遇到一个拦路虎:

多线程通信中的虚假唤醒导致数据不一致的问题,

看了很多资料,也去一些博主文章下请教,

发现大家的解释都没理解到点子上,

都是在最关键的地方囫囵吞枣地一句带过,

这让人很沮丧,

遂写此文,

自我记录,

有需沟通可留言。


1、什么是虚假唤醒?

虚假唤醒就是在多线程执行过程中,线程间的通信未按照我们幻想的顺序唤醒,故出现数据不一致等不符合我们预期的结果。比如 我的想法是:加1和减1交替执行,他却出现了2甚至3这种数:请看下面例子:

假设有四个线程A、B、C、D同时启动,我们定义A和B为加法线程,C和D为减法线程,每个线程执行5次回到原点,我们的期望结果是:0,1,0,1,0,1......0,1,0

顺此进行,但执行结果却是:

package ldk.test;

/**
 * @Author: ldk
 * @Date: 2020/12/18 16:03
 * @Describe:
 */
public class ThreadTest {



    public static void main(String[] args) {
        Data data = new Data();
        //生产者线程A
        new Thread(() -> {
            for (int i = 0;i < 5;i++) {
                try {
                    data.increment();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"A").start();

        //生产者线程B
        new Thread(() -> {
            for (int i = 0;i < 5;i++) {
                try {
                    data.increment();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"B").start();

        //消费者线程C
        new Thread(() -> {
            for (int i = 0;i < 5;i++) {
                try {
                    data.decrement();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"C").start();

        //消费者线程D
        new Thread(() -> {
            for (int i = 0;i < 5;i++) {
                try {
                    data.decrement();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"D").start();
    }



    //数据类
    static class Data {
        //表示数据个数
        private int number = 0;

        public synchronized void increment() throws InterruptedException {
            if (number != 0) {
                this.wait();
            }
            number++;
            System.out.println(Thread.currentThread().getName() + "生产了数据:" + number);
            this.notify();
        }

        public synchronized void decrement() throws InterruptedException {
            if (number == 0) {
                this.wait();
            }
            number--;
            System.out.println(Thread.currentThread().getName() + "消费了数据:" + number);
            this.notify();
        }
    }
}

2、为什么会出现虚假唤醒?

准确的说为什么会出现2或者3甚至是-2,-3这种情况?

我们先来说清楚概念:1、wait()方法  2、notify方法

wait:此方法出自Object类,所有对象均可调用此方法,它的应用主要是跟出身自Thread类的sleep方法作比较。
sleep方法说白了就是迫使当前线程拿着锁睡眠指定时间,时间一到手里拿着锁自动醒来,还可以往下继续执行。
wait方法有两种使用方式,一个带参数指定睡眠时间(我们不讨论这种实现),一个不带参数指定无限睡眠,这两种方式均可迫使当前线程进入睡眠
  状态,但是不同于sleep,wait是放开锁去睡的,只有当前锁对象调用了notify或者notifyAll方法才会醒来,但手里是没有锁的,
  相对应就没有了立即执行下去的权利,而是进入了就绪状态,随时准备与其他线程进行争抢CPU的执行权。而且wait方法一般情况是配合sync使用的。
notify:说到notify就不得不提起notifyAll,执行完的效果是,前者通过某种底层算法(没去深究原理)唤醒所有wait(阻塞中)
   的线程中的一个,理所当然,被唤醒之后自然而然获得了锁,因为就他一个线程嘛,进而拥有了继续执行下去的权利);后者是唤醒所有
   阻塞线程,进而被唤醒的所有线程进行一个公平竞争,只有一个胜出者可以幸运的继续下去,其他的线程继续回到阻塞状态。

好的,概念整明白了,再继续说:为什么会出现 2,3 等不正常的数字,我们看程序比较极端的执行:

假设: 1、A抢到锁执行 ++                 1
     2、A执行notify发现没有人wait,继续拿着锁执行 ,A判断不通过,A阻塞   1
    3、B抢到锁 ,B判断不通过,B阻塞    1

 
  4、C 抢到锁 执行--    0
    5、C 执行Notify 唤醒A, A执行++      1    
    6、A 执行notify唤醒B ,B执行++       2  (注意这个地方恰巧唤醒B,那么B 从哪阻塞的就从哪唤醒,B继续执行wait下面的++操作,导致出现2)

再多一些解释:那么为什么会出现
-2,-3,因为我们的减法判断是 ==0的时候才阻塞,一旦为-1,就会为false,再次执行--操作;

看完上面的步骤分析,我们可以总结出两大问题:
1、第6步唤醒了B是极大的错误,因为B的醒来不是我们想要看到的,我们需要的C或者D醒来,这就是本文题目所说的虚假唤醒,
我们就要像个办法,过滤掉B;
2、想的深入的同学可能会发现,上面代码本应有20步,为什么到了17步停止了,这就是唤醒不当,所有线程均被置为阻塞状态

3、怎么解决虚假唤醒?

直接上代码:主要修改了  1、if判断为while判断   2、notify 为notifyAll

解释:

while是为了再一次循环判断刚刚争抢到锁的线程是否满足继续执行下去的条件,条件通过才可以继续执行下去,不通过的线程只能再次进入wait状态,由其他活着的、就绪状态的线程进行争抢锁

notifyAll主要是解决线程死锁的情况,每次执行完++或者--操作,都会唤醒其他所有线程为活着的、就绪的、随时可争抢的状态。

package ldk.test;

/**
 * @Author: ldk
 * @Date: 2020/12/18 16:03
 * @Describe:
 */
public class ThreadTest {



    public static void main(String[] args) {
        Data data = new Data();
        //生产者线程A
        new Thread(() -> {
            for (int i = 0;i < 5;i++) {
                try {
                    data.increment();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"A").start();

        //生产者线程B
        new Thread(() -> {
            for (int i = 0;i < 5;i++) {
                try {
                    data.increment();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"B").start();

        //消费者线程C
        new Thread(() -> {
            for (int i = 0;i < 5;i++) {
                try {
                    data.decrement();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"C").start();

        //消费者线程D
        new Thread(() -> {
            for (int i = 0;i < 5;i++) {
                try {
                    data.decrement();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"D").start();
    }



    //数据类
    static class Data {
        //表示数据个数
        private int number = 0;

        public synchronized void increment() throws InterruptedException {
            //关键点,这里应该使用while循环
            while (number != 0) {
                this.wait();
            }
            number++;
            System.out.println(Thread.currentThread().getName() + "生产了数据:" + number);
            this.notifyAll();
        }

        public synchronized void decrement() throws InterruptedException {
            //关键点,这里应该使用while循环
            while (number == 0) {
                this.wait();
            }
            number--;
            System.out.println(Thread.currentThread().getName() + "消费了数据:" + number);
            this.notifyAll();
        }
    }
}

      举一个生活小栗子:就好比你的公司雇佣了五个保安,工作要求是:保安轮流站岗,必须按照1、2、3、4、5这个顺序来站岗。

  那么我们上面的程序就是,1号保安站岗结束,唤来其他四个保安,然后让每个保安举手抢答,谁先举手我就先判断谁,你是2号就轮到你站岗,你不是2号,就继续wait,然后继续举手抢答,如此一来即可解决公平的站岗需求。

  其中最关键的是:1、首先,每次工作结束都需要唤醒所有线程来任我挑选;

          2、其次,你争抢到锁,我会对你进行一次有效判断,合格才放行。


思考:此demo利用 sync、wait、notifyAll组合实现线程有序调度,

但是,深入思考的童鞋可能会发现,

如果线程过多,每次执行notifyAll,

然后让线程自由争抢,可能会影响性能。

那我们能不能对线程进行精准唤醒呢?

请看下篇:

《Java多线程 — — lock、await 、signal 组合 替换 sync、wait、notifyAll,实现精准唤醒线程》

URL:https://www.cnblogs.com/dk1024/p/14164836.html