Java学习笔记之线程 (二)多线程通信

1、 多线程通信的引入

在上一篇文章Java学习笔记之线程 (一)开启线程方法、线程状态、线程安全性问题中探讨线程安全性问题时举的例子都是不同线程在执行同一个任务,比如说几个人(不同线程)都在买票(同一个任务)或者都在存钱。多线程的通信指的是多个线程在执行不同的任务,但是它们处理的资源却是相同的。比如说有一个煤堆,有辆车在往外拉煤,还有辆车在往里面运煤。下面通过一个例子来体现多线程的通信:

public class Test {
    public static void main(String[] args) {
        Person person = new Person();
        new Thread(new Input(person)).start();
        new Thread(new Output(person)).start();
    }
}

class Person {
    private String name; //姓名
    private String sex;  //性别
    
    //输入值
    public synchronized void set(String name, String sex){
        this.name = name;
        this.sex = sex;
    }

    //输出值
    public synchronized void out(){
        System.out.println("姓名: " + name + " 性别: " + sex);
    }
}

//输入线程
class Input implements Runnable {
    private Person p;

    public Input(Person p) {
        this.p = p;
    }

    @Override
    public void run() {
        int x = 0;
        while (true) {
            if(x % 2 == 0) {
                p.set("赵玉田", "男");
            } else {
                p.set("刘英", "女");
            }
            x++;
        }
    }

}

//输出线程
class Output implements Runnable {
    private Person p;

    public Output(Person p) {
        this.p = p;
    }

    @Override
    public void run() {
        while (true) {
            p.out();
        }
    }
}

在上面的代码中Input类用于不断地往Person对象里面传姓名和性别,Output类用于将它们打印出来。运行结果如下(截取了一部分):
在这里插入图片描述
上述运行结果虽然是没有错误的,但是并没有达到我们预期的效果,最好能够实现输入线程每输入一次,输出线程马上就能打印出来。要实现上述想法,需要用到线程中的等待唤醒机制,这种机制涉及到3个方法:

  1. wait():让线程处于冻结状态,冻结状态的线程存储到线程池中。
  2. notify():唤醒线程池中的任意一个线程
  3. notifyAll():唤醒线程池中所有线程

上述方法必须定义在同步中,而且它们的调用者必须是对象锁,因为这些方法都是用于操作线程状态的方法,必须明确到底操作的是哪个锁上的线程。比如说有一个锁是属于4个线程的,那么处于别的锁的线程就无法操作这个锁中那4个线程的状态;换句话说,只能用A锁的notify方法唤醒A锁中的线程。

将上面的代码改成如下:

public class Test {
    public static void main(String[] args) {
        Person person = new Person();
        new Thread(new Input(person)).start();
        new Thread(new Output(person)).start();
    }
}

class Person {
    private String name;
    private String sex;
    private boolean full = false;

    //输入值
    public synchronized void set(String name, String sex) {
        if (full) { //如果已经被赋值则冻结输入线程
            try {
                this.wait();  //因为同步方法的锁是this,所以调用的是this的wait方法
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }  
        //如果没被赋值则输入
        this.name = name;
        this.sex = sex;
        full = true;  //输入完毕将标志位设为true
        this.notify(); //输入完毕唤醒输出线程
    }

    //输出值
    public synchronized void out() {
        if (!full) {  //如果没被赋值则冻结输出线程
            try {
                this.wait();  //因为同步方法的锁是this,所以调用的是this的wait方法
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        //如果被赋值则将其输出
        System.out.println("姓名: " + name + " 性别: " + sex);
        full = false;  //输出完毕标志位设为false
        this.notify(); //输出完毕唤醒输入线程
    }

}

//输入线程
class Input implements Runnable {
    private Person p;

    public Input(Person p) {
        this.p = p;
    }

    @Override
    public void run() {
        int x = 0;
        while (true) {
            if (x == 0) {
                p.set("赵玉田", "男");
            } else {
                p.set("刘英", "女");
            }
            x = (x + 1) % 2;
        }
    }

}

//输出线程
class Output implements Runnable {
    private Person p;

    public Output(Person p) {
        this.p = p;
    }

    @Override
    public void run() {
        while (true) {
            p.out();
        }
    }
}

代码中的注释已经解释的很清楚了。总体的流程就是输入线程首先判断Person中是否已经被赋值,如果没有就给它赋值,赋值之后唤醒输出线程,如果已经被赋值就通过对象锁的wait方法将输入线程冻结;输出线程同样首先判断是否被赋值,若被赋值就将其输出,若未赋值就将输出线程冻结。这样一来,就实现了输入线程每次输入之后输出线程就马上将其输出。代码运行结果如下:

在这里插入图片描述
我们查看API发现,这三个方法都是定义在Object中的,这是因为,这些方法都是对象锁(又称为监视器)调用的,而任意对象都可以充当对象锁,所以要定义在Object中。

在这里插入图片描述

2、 生产者消费者问题

第一小节中的例子其实是一个简单的生产者消费者问题,输入线程相当于生产者,不停的给Person赋值,输出线程相当于消费者模型,负责将输入线程赋的值输出。在这个例子中生产者和消费者各有一个,接下来来讨论生产者消费者有多个的情况。

将上一节的例子改动一下:为了更符合实际,将Person类名字换成烤鸭,其中的方法换成生产烤鸭和消费烤鸭,输入线程改成生产线程,输出线程改成消费的线程。由于现实生活中不可能只有一个人制作烤鸭,只有一个消费者,所以在主方法中开启两个生产线程和两个消费线程。代码如下:

public class Test {
    public static void main(String[] args) {
        Duck duck = new Duck();
		//两个消费者,两个生产者
        new Thread(new Producer(duck)).start();
        new Thread(new Producer(duck)).start();
        new Thread(new Consumer(duck)).start();
        new Thread(new Consumer(duck)).start();

    }
}

//烤鸭类
class Duck {
    private int number = 1; //烤鸭编号
    private boolean full = false;

    //生产方法
    public synchronized void produce() {
        if (full) {
            try {
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("生产者" + Thread.currentThread().getName() + "生       产了第" + number + "只烤鸭");  //生产烤鸭
        number++;
        full = true;  //生产完毕将标志位设为true
        this.notify(); //生产完毕唤醒消费线程
    }

    //消费方法
    public synchronized void consume() {
        if (!full) {  //如果没被赋值则冻结输出线程
            try {
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("消费者" + Thread.currentThread().getName() + "消费了第" + number + "只烤鸭");
        full = false;  //生产完毕标志位设为false
        this.notify(); //消费完毕唤醒生产线程
    }

}

//生产线程
class Producer implements Runnable {
    private Duck d;

    public Producer(Duck d) {
        this.d = d;
    }

    @Override
    public void run() {
        while (true) {
           d.produce();
       }
    }
}

//消费线程
class Consumer implements Runnable {
    private Duck d;

    public Consumer(Duck d) {
        this.d= d;
    }

    @Override
    public void run() {
        while (true) {
            d.consume();
        }
    }
}

运行结果如下(截取部分):
在这里插入图片描述
可以看到第61760只烤鸭被不同的消费者消费了两次,说明存在安全问题,这种问题解释起来比较复杂。简单说,其根本问题在于线程的唤醒。上个例子中因为只有一个生产者和一个消费者,所以生产者一定会唤醒消费者,消费者也一定会唤醒生产者;但是这个例子中有两个生产者和两个消费者,而notify方法唤醒线程是随机的,如果消费者唤醒了生产者或者生产者唤醒了消费者还好说,一旦生产者唤醒了另一个生产者或者消费者唤醒了另一个消费者就出现了问题。针对这种问题,Java为开发者提供了解决方案:Lock和Condition。

3、 Lock和Condition用法

jdk1.5以后将同步和锁封装成了对象,并将操作锁的隐式方式定义到了对象中,将隐式动作变成了显式动作。意思就是如果使用synchronized,那么锁的获取和释放是我们看不见的(底层自动完成),用这种对象的方式我们可以主动的操作锁。

那么封装成的接口就是Lock,它的参考文档如下:
在这里插入图片描述
Lock的一个常见实现类是ReentrantLock(互斥锁),其基本用法如下:

    Lock lock = new ReentrantLock();
    lock.lock(); //获取锁
    try {
        //需要同步的代码
    } finally {
        lock.unlock(); //释放锁
    }

这样一来就是用Lock替代了synchronized实现代码的同步。那么如何使用对象锁(监视器)提供的方法(wait、notify、notifyAll)呢?那就是Condition接口,可以通过Lock的newCondition方法获取到它的实例,它提供了3个方法替代wait:

  1. await():替代了wait方法
  2. signal():替代了notify方法
  3. signalAll():替代了notifyAll方法

如果用对象锁的话,那么一个锁上只能有一组监视器方法(wait、notify、notifyAll),使用Condition的话,每次创建一个它的实例就获得一组监视器,画图解释如下:

在这里插入图片描述
对于对象锁来说,它的方法操作的是锁中的所有线程t0、t1、t2、t3;对于Lock来说,可以创建多个监视器来对线程分组,分组之后condition1的方法只能操作该组内的线程t0、t1,condition2也是同理。这样一来就可以实现生产者只唤醒消费者,消费者只唤醒生产者了。

将之前的代码改成如下:

public class Test {
    public static void main(String[] args) {
        Duck duck = new Duck();

        new Thread(new Producer(duck)).start();
        new Thread(new Producer(duck)).start();
        new Thread(new Consumer(duck)).start();
        new Thread(new Consumer(duck)).start();

    }
}

//烤鸭类
class Duck {
    private int number = 1; //烤鸭编号
    private boolean full = false;
    Lock lock = new ReentrantLock();
    Condition producer_con = lock.newCondition(); //创建生产者监视器
    Condition consumer_con = lock.newCondition(); //创建消费者监视器

    //生产方法
    public  void produce() {
        lock.lock(); //获得锁
        try {
            while (full) { try { producer_con.await(); } catch (InterruptedException e) { e.printStackTrace(); } }  //冻结生产者线程
            System.out.println("生产者" + Thread.currentThread().getName() + "生       产了第" + number + "只烤鸭");  //生产烤鸭
            number++;
            full = true;  //生产完毕将标志位设为true
            consumer_con.signal(); //生产完毕唤醒消费线程
        } finally {
            lock.unlock(); //释放锁
        }
    }

    //消费方法
    public synchronized void consume() {
        lock.lock(); //获得锁
        try {
            while (!full) {
                try { consumer_con.await(); } catch (InterruptedException e) { e.printStackTrace(); } //冻结消费者线程
            }
            System.out.println("消费者" + Thread.currentThread().getName() + "消费了第" + number + "只烤鸭");
            full = false;  //消费完毕标志位设为false
            producer_con.signal(); //消费完毕唤醒生产线程
        } finally {
            lock.unlock(); //释放锁
        }
    }

}

//生产线程
class Producer implements Runnable {
    private Duck d;

    public Producer(Duck d) {
        this.d = d;
    }

    @Override
    public void run() {
        while (true) {
           d.produce();
       }
    }
}

//消费线程
class Consumer implements Runnable {
    private Duck d;

    public Consumer(Duck d) {
        this.d= d;
    }

    @Override
    public void run() {
        while (true) {
            d.consume();
        }
    }
}

代码除了用Lock和Condition替换了synchronized之外,还用while循环替代了if来判断标志位,这是为了保证每次线程被唤醒之后都要重新判断标志位。

4、 关于锁的讨论

首先考虑一个问题,wait和sleep方法都能冻结线程,它们的区别是什么呢?

  1. wait可以指定时间也可以不指定,但是sleep必须要指定时间
  2. wait方法释放了当前线程的执行权,同时释放锁;sleep方法只是释放了线程的执行权,但并不释放锁。

再来考虑一个问题,代码如下(为了说明问题,只是伪代码):

class Demo {
    void show(){
        synchronized (this) {
            wait();  //t0,t1,t2进来了
            //一些逻辑
        }
    }

    void method(){
        synchronized (this) {
            notifyAll(); //t3唤醒其他线程
            //一些逻辑
        }
    }
}

比如说线程t0进入了show方法拿到锁,之后被冻结锁释放,接着t1进入show方法拿到锁同样被冻结释放锁,t2也是同理进入show并冻结。然后线程t3进入了method拿到锁唤醒了t0,t1,t2,这时问题来了,此时这3个线程都在同步代码中,到底谁执行?

答案是它们3个都不会执行。这里就要强调锁的概念了,结论就是谁拿到锁谁执行。那么锁在哪里呢?在t3手里。必须要等到t3执行完method方法后将锁释放,之后t0,t1,t2中的一个获取到cpu执行权,比如说是t1,那么这时候t1才能拿到锁继续执行show中的剩余同步代码。

猜你喜欢

转载自blog.csdn.net/weixin_44965650/article/details/107065339