Java多线程之虚假唤醒(原创)

Java多线程之虚假唤醒


首先需要说明的是,虚假唤醒不是Java语言特有的问题,而是多线程通信特有的问题,在java中就体现在 sychronized-wait-notify上,最典型的应用场景就是 生产者-消费者模式。

在网上翻看了很多关于虚假唤醒的文档,才发现大多数人说的都是错的。要么语焉不详,要么南辕北辙,不一而足。于是我决定自己写一篇文章来说一说:到底什么是虚假唤醒?


虚假唤醒的定义

无可避免的落入俗套,先放上虚假唤醒的定义。但我要说明的是,这个定义放在这儿你可以只是大致浏览一下,最终请读完整个文章之后再回来看这个定义,应该效果更佳。

A spurious wakeup happens when a thread wakes up from waiting on a condition variable that’s been signaled, only to discover that the condition it was waiting for isn’t satisfied. It’s called spurious because the thread has seemingly been awakened for no reason. But spurious wakeups don’t happen for no reason: they usually happen because, in between the time when the condition variable was signaled and when the waiting thread finally ran, another thread ran and changed the condition. There was a race condition between the threads, with the typical result that sometimes, the thread waking up on the condition variable runs first, winning the race, and sometimes it runs second, losing the race. ----from Wikipedia

当线程从等待状态中被唤醒时,只是发现未满足其正在等待的条件时,就会发生虚假唤醒。 之所以称其为虚假的,是因为该线程似乎无缘无故被唤醒。 虚假唤醒不会无缘无故发生,通常是因为在发起唤醒号和等待线程最终运行之间的临界时间内,线程不再满足竞态条件。

生产者-消费者场景讲起

生产者-消费者是多线程中教程中最常用的教学场景,主要用来模拟进程间通信,映射在java语言上,最常用的语法就是进程间通信三件套sychronized-wait-notify

现在有一个这样的场景,某甜品店进行蛋糕的生产和销售。由于甜品的特殊性,要求甜品店里库存的甜品不能大于100,避免卖不出去浪费。

单生产者-单消费者场景

在这种要求下,我们来使用代码模拟一下。首先假设甜品店只有一个生产进程和一个销售进程。

甜品类:

import java.util.concurrent.TimeUnit;

public class Cookie {
    // 甜品库存数目
    // 根据要求,这个值应该满足: 10 =< count <= 100
    private int count;

    public Cookie() {
    }

    public int getCount() {
        return count;
    }

    public void setCount(int count) {
        this.count = count;
    }

    public synchronized void create() {
        if (count >= 100) {
            try {
                System.out.println(Thread.currentThread().getName()+"被挂起,因为此时甜品库存已达到最高位100");
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 库存尚未达到最高位100
        count++;
        System.out.println(Thread.currentThread().getName()+"生产了一个甜品,当前甜品数目为:"+ count);
        this.notifyAll();
    }

    public synchronized void sale() {
        if (count <= 0) {
            try {
                TimeUnit.SECONDS.sleep(1);
                System.out.println(Thread.currentThread().getName()+"被挂起,因为此时已无甜品可卖。");
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 尚有甜品
        count--;
        System.out.println(Thread.currentThread().getName()+"出售了一个甜品,当前甜品数目为:"+ count);
        this.notifyAll();
    }
}

生产者类:

import java.util.concurrent.TimeUnit;

public class Product implements Runnable {
    private Cookie cookie;

    public Product(Cookie cookie) {
        this.cookie = cookie;
    }

    @Override
    public void run() {
        for (int i = 0; i < 20; i++) {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            cookie.create();
        }
    }
}

消费者类:

import java.util.concurrent.TimeUnit;

public class Customer implements Runnable {
    private Cookie cookie;

    public Customer(Cookie cookie) {
        this.cookie = cookie;
    }

    @Override
    public void run() {
        for (int i = 0; i < 20; i++) {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            cookie.sale();
        }
    }
}

主题逻辑类:

public class Main {
    public static void main(String[] args) {
        Cookie cookie = new Cookie();
        Runnable r1 = new Product(cookie);
        Runnable r2 = new Customer(cookie);

        Thread t1 = new Thread(r1, "生产者1号");
        Thread t2 = new Thread(r2, "消费者1号");

        t1.start();
        t2.start();
    }
}

有经验的读者可能已经发现了代码中的问题,这时作者故意预留的bug。

此时代码的输出为:

生产者1号生产了一个甜品,当前甜品数目为:1
消费者1号出售了一个甜品,当前甜品数目为:0
生产者1号生产了一个甜品,当前甜品数目为:1
消费者1号出售了一个甜品,当前甜品数目为:0
生产者1号生产了一个甜品,当前甜品数目为:1
消费者1号出售了一个甜品,当前甜品数目为:0
生产者1号生产了一个甜品,当前甜品数目为:1
消费者1号出售了一个甜品,当前甜品数目为:0
生产者1号生产了一个甜品,当前甜品数目为:1
消费者1号出售了一个甜品,当前甜品数目为:0
生产者1号生产了一个甜品,当前甜品数目为:1
消费者1号出售了一个甜品,当前甜品数目为:0
生产者1号生产了一个甜品,当前甜品数目为:1
消费者1号出售了一个甜品,当前甜品数目为:0
生产者1号生产了一个甜品,当前甜品数目为:1
消费者1号出售了一个甜品,当前甜品数目为:0
消费者1号被挂起,因为此时已无甜品可卖。
生产者1号生产了一个甜品,当前甜品数目为:1
消费者1号出售了一个甜品,当前甜品数目为:0
消费者1号被挂起,因为此时已无甜品可卖。
......

此时代码的运行是逻辑上是没有问题的,生产者生产一个,消费者就消费一个;当没有库存而消费者线程被唤醒时,则会被挂起。

但是这个代码是有严重设计缺陷的,在线程体进行条件判断时应该使用while而非if。之所以不出问题是因为生产者和消费者线程都只有一个。此时如果消费者线程被阻塞,则它只有等待生产者线程调用notifyAll来唤醒它,而在唤醒它之前,生产者已经完成了生产操作,从而使得库存没有出现大于100或是小于0的情况。

多生产者-多消费者场景

我们只需更改逻辑代码为:

public class Main {
    public static void main(String[] args) {
        Cookie cookie = new Cookie();
        Runnable r1 = new Product(cookie);
        Runnable r2 = new Product(cookie);
        Runnable r3 = new Product(cookie);
        Runnable r4 = new Customer(cookie);
        Runnable r5 = new Customer(cookie);
        Runnable r6 = new Customer(cookie);

        Thread t1 = new Thread(r1, "生产者1号");
        Thread t2 = new Thread(r2, "生产者2号");
        Thread t3 = new Thread(r3, "生产者3号");
        Thread t4 = new Thread(r4, "消费者1号");
        Thread t5 = new Thread(r5, "消费者2号");
        Thread t6 = new Thread(r6, "消费者3号");

        t1.start();
        t2.start();
        t3.start();
        t4.start();
        t5.start();
        t6.start();
    }
}

此时输出的结果为:

消费者2号被挂起,因为此时已无甜品可卖。
消费者3号被挂起,因为此时已无甜品可卖。
消费者1号被挂起,因为此时已无甜品可卖。
生产者3号生产了一个甜品,当前甜品数目为:1
消费者2号出售了一个甜品,当前甜品数目为:0
消费者1号出售了一个甜品,当前甜品数目为:-1
消费者3号出售了一个甜品,当前甜品数目为:-2
生产者1号生产了一个甜品,当前甜品数目为:-1
生产者2号生产了一个甜品,当前甜品数目为:0
消费者3号被挂起,因为此时已无甜品可卖。
消费者2号被挂起,因为此时已无甜品可卖。
消费者1号被挂起,因为此时已无甜品可卖。
生产者3号生产了一个甜品,当前甜品数目为:1
消费者3号出售了一个甜品,当前甜品数目为:0
消费者1号出售了一个甜品,当前甜品数目为:-1
消费者2号出售了一个甜品,当前甜品数目为:-2
生产者1号生产了一个甜品,当前甜品数目为:-1
生产者2号生产了一个甜品,当前甜品数目为:0
消费者3号被挂起,因为此时已无甜品可卖。
消费者1号被挂起,因为此时已无甜品可卖。
消费者2号被挂起,因为此时已无甜品可卖。
......

可以看出此时代码逻辑出现了问题,库存竟然出现了负数。

那么问题来自哪里呢?问题就来自于我们使用了if作为条件判断而不是while来做循环条件判断。

在说明两者的区别之前,我们需要明白,当一个线程调用同步对象的wait方法后,当前线程会:

  • 释放CPU
  • 释放对象锁
  • 只有等待该同步对象调用notify/notifyAll该线程才会被唤醒,唤醒后继续从wait处的下一行代码开始执行

这里最关键的就是唤醒后继续从wait处的下一行代码开始执行,这意味着:

  • 如果使用if,条件判断只进行一次,下次被唤醒的时候已经绕过了条件判断,从wait后的语句开始顺序执行;
  • 如果使用whilewait后的语句在循环体内,虽然绕过了上一次的条件判断,但终究会进入下一轮条件判断。

现在来分析上面例子出错的原因:

输出解释
消费者2号被挂起,因为此时已无甜品可卖。
消费者3号被挂起,因为此时已无甜品可卖。
消费者1号被挂起,因为此时已无甜品可卖。
此时甜品库存为0,三个线程去访问资源文件时,都依次被挂起。
生产者3号生产了一个甜品,当前甜品数目为:1生产者3生产了一个甜品,此时甜品库存为1。当前线程调用notifyAll,从waitSet中唤醒线程
消费者2号出售了一个甜品,当前甜品数目为:0消费者2号被唤醒,拿到CPU和对象锁,出售一个甜品,此时甜品库存为0。当前线程调用notifyAll,从waitSet中唤醒线程
消费者1号出售了一个甜品,当前甜品数目为:-1问题来了:由于notifyAll只能随机唤醒waitSet中的线程,它将消费者1唤醒了。由于之前阻塞时已经执行过了if,此处直接向下执行消费操作,没有在进行库存的条件判断。将库存从0变为了-1.

后续的所有问题就是这样导致的。这也就是为什么我们的条件判断应该用while的原因。总结起来,导致错误的原因有:

  • notify/notifyAll无法指定唤醒线程,只能从waitSet中随机唤醒
  • 被唤醒的线程从wait语句下一行开始执行,导致绕过了if的条件判断

这就是虚假唤醒吗?

很多教程中将这种必须使用while来替代if的操作成为虚假唤醒,我认为这是不对的。使用if来进行判断是代码的逻辑错误,而不是真正的虚假唤醒。

根据上面的代码,我们将if全部替换为while,调整后为的甜品类为:

import java.util.concurrent.TimeUnit;

public class Cookie {
    // 甜品库存数目
    // 根据要求,这个值应该满足: 10 =< count <= 100
    private int count;

    public Cookie() {
    }

    public int getCount() {
        return count;
    }

    public void setCount(int count) {
        this.count = count;
    }

    public synchronized void create() {
        while (count >= 100) {
            try {
                System.out.println(Thread.currentThread().getName() + "被挂起,因为此时甜品库存已达到最高位100");
                this.wait();
                if (count >= 100) {
                    System.out.println(Thread.currentThread().getName() + "被虚假唤醒了,因为此时没有满足它的执行条件count < 100.");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 库存尚未达到最高位100
        count++;
        System.out.println(Thread.currentThread().getName() + "生产了一个甜品,当前甜品数目为:" + count);
        this.notifyAll();
    }

    public synchronized void sale() {
        while (count <= 0) {
            try {
                TimeUnit.SECONDS.sleep(1);
                System.out.println(Thread.currentThread().getName() + "被挂起,因为此时已无甜品可卖。");
                this.wait();
                if (count <= 0) {
                    System.out.println(Thread.currentThread().getName() + "被虚假唤醒了,因为此时没有满足它的执行条件count > 0.");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 尚有甜品
        count--;
        System.out.println(Thread.currentThread().getName() + "出售了一个甜品,当前甜品数目为:" + count);
        this.notifyAll();
    }
}

执行结果为:

消费者2号被挂起,因为此时已无甜品可卖。
消费者1号被挂起,因为此时已无甜品可卖。
消费者3号被挂起,因为此时已无甜品可卖。
生产者3号生产了一个甜品,当前甜品数目为:1
消费者2号出售了一个甜品,当前甜品数目为:0
消费者3号被虚假唤醒了,因为此时没有满足它的执行条件count > 0.
消费者3号被挂起,因为此时已无甜品可卖。
消费者1号被虚假唤醒了,因为此时没有满足它的执行条件count > 0.
消费者1号被挂起,因为此时已无甜品可卖。
生产者2号生产了一个甜品,当前甜品数目为:1
生产者1号生产了一个甜品,当前甜品数目为:2
消费者1号出售了一个甜品,当前甜品数目为:1
消费者3号出售了一个甜品,当前甜品数目为:0
消费者2号被挂起,因为此时已无甜品可卖。
消费者3号被挂起,因为此时已无甜品可卖。
消费者1号被挂起,因为此时已无甜品可卖。
生产者3号生产了一个甜品,当前甜品数目为:1
消费者2号出售了一个甜品,当前甜品数目为:0
消费者1号被虚假唤醒了,因为此时没有满足它的执行条件count > 0.
消费者1号被挂起,因为此时已无甜品可卖。
消费者3号被虚假唤醒了,因为此时没有满足它的执行条件count > 0.
消费者3号被挂起,因为此时已无甜品可卖。
生产者2号生产了一个甜品,当前甜品数目为:1
生产者1号生产了一个甜品,当前甜品数目为:2
消费者2号出售了一个甜品,当前甜品数目为:1
消费者3号出售了一个甜品,当前甜品数目为:0

上述输出的Line 6,7 和Line19,20,21,22向我们展示了什么才是真正的虚假唤醒。

以Line6,7为例,Line5中消费者2消费了一个甜品,此时甜品库存为0,然后消费者2调用notifyAll方法,唤醒的却是同为消费者的消费者线程3 。紧接着消费者线程3又因为不满足while循环而在此被阻塞放入waitSet。

这样的一个过程在wait-notify机制下是无法避免的,因为notify是随机唤醒的。这导致上例中消费者线程3被唤醒,唤醒后的消费者线程3却又发现自己的执行条件并没有满足,从而在此进入阻塞。

现在让我们翻到文章的前面,再来看看虚假唤醒的定义:

当线程从等待状态中被唤醒时,只是发现未满足其正在等待的条件时,就会发生虚假唤醒。 之所以称其为虚假的,是因为该线程似乎无缘无故被唤醒。 虚假唤醒不会无缘无故发生,通常是因为在发起唤醒号和等待线程最终运行之间的临界时间内,线程不再满足竞态条件。

有没有觉得理解又加深了一点?

更多文章请关注作者同名公众号:Cratels学编程

  • 5
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值