打开APP
userphoto
未登录

开通VIP,畅享免费电子书等14项超值服

开通VIP
Java并发入门原理教程(三)

1、多线程的操作原则

1. 前言

本节内容主要是对多线程的操作原则程进行讲解,具体内容点如下:

  • 了解多线程 AVO 原则,是学习本节内容的基础;

  • 了解单 CPU 时代的多线程,能够更好地理解多 CPU 诞生的原因;

  • 了解多 CPU 时代的多线程,是目前我们接触和使用到的开发场景;

  • 了解为什么要进行多线程并发,使我们学习本套课程的前提,也是多线程并发的意义所在;

  • 了解线程安全问题,这是本节课程的重点,安全问题是开发过程中非常重要且不容忽视的问题;

  • 了解共享变量内存可见性问题,是本节的重点之一。

2. 多线程 AVO 原则

A:即 Atomic,原子性操作原则。对基本数据类型的变量读和写是保证原子性的,要么都成功,要么都失败,这些操作不可中断。

V:即 volatile,可见性原则。后续的小节会对 volatile 关键字进行深入的讲解,此处只需要理解概念性问题即可。使用 volatile 关键字,保证了变量的可见性,到主存拿数据,不是到缓存里拿。

O:即 order, 就是有序性。代码的执行顺序,在代码编译前的和代码编译后的执行顺序不变。

3. 单 CPU 时代的多线程

概念:单核 CPU 上,同一时刻只能有一条线程运行,单核 CPU 上运行的单线程程序和多线程程序,从运行效率上看没有差别。换而言之,单 CPU 时代,没有真正的多线程并发效果,从这一点来看,多线程与 CPU 硬件的升级息息相关。

单 CPU 时代下的多线程:在单 CPU 时代多任务是共享一个 CPU 的,当一个任务占用 CPU 运行时,其他任务就会被挂起,当占用 CPU 的任务时间片用完后,会把 CPU 让给其他任务来使用,所以在单 CPU 时代多线程编程是没有太大意义的,并且线程间频繁的上下文切换还会带来额外开销。

上图所示为在单个 CPU 上运行两个线程,线程 A 和线程 B 是轮流使用 CPU 进行任务处理的,也就是在某个时间内单个 CPU 只执行一个线程上面的任务。当线程 A 的时间片用完后会进行线程上下文切换,也就是保存当前线程 A 的执行上下文,然后切换到线程 B 来占用 CPU 运行任务。

4. 多 CPU 时代的多线程

如下图所示为双 CPU 配置,线程 A 和线程 B 各自在自己的 CPU 上执行任务,实现了真正的并行运行。

在多线程编程实践中,线程的个数往往多于 CPU 的个数,所以一般都称多线程并发编程而不是多线程并行编程。

5. 为什么要进行多线程并发

意义:多核 CPU 时代的到来打破了单核 CPU 对多线程效能的限制。 多个 CPU 意味着每个线程可以使用自己的 CPU 运行,这减少了线程上下文切换的开销。

但随着对应用系统性能和吞吐量要求的提高,出现了处理海量数据和请求的要求,这些都对高并发编程有着迫切的需求。

6. 线程安全问题

谈到线程安全问题,我们先说说什么是共享资源。

共享资源:所谓共享资源,就是说该资源被多个线程所持有或者说多个线程都可以去访问该资源。线程安全问题是指当多个线程同时读写一个共享资源并且没有任何同步措施时,导致出现脏数据或者其他不可预见的结果和问题。

对于线程安全问题,在进行实际的开发操作过程中,我们要分析一下几点内容,确保多线程环境下的线程安全问题。

  • 确定是否是多线程环境:多线程环境下操作共享变量需要考虑线程的安全性;

  • 确定是否有增删改操作:多线程环境下,如果对共享数据有增加,删除或者修改的操作,需要谨慎。为了保证线程的同步性,必须对该共享数据进行加锁操作,保证多线程环境下,所有的线程能够获取到正确的数据。如生产者与消费者模型,售票模型;

  • 多线程下的读操作:如果是只读操作,对共享数据不需要进行锁操作,因为数据本身未发生增删改操作,不会影响获取数据的准确性。

7. 共享变量内存可见性问题

先来看下共享变量和内存可见性的定义。

共享变量:非线程私有的变量,共享变量存放于主内存中,所有的线程都有权限对变量进行增删改查操作。

内存可见性:由于数据是存放于内存中的,内存可见性意味着数据是公开的,所有线程都可对可见性的数据进行增删改查操作。

Java 内存模型规定,将所有的变量都存放在主内存(共享内存)中,当线程使用变量时,会把主内存里面的变量复制到自己的工作空间或者叫作工作内存,也就是我们所说的线程私有内存,线程读写变量时操作的是自己工作内存中的变量。

当一个线程操作共享变量时,它首先从主内存复制共享变量到自己的工作内存,然后对工作内存里的变量进行处理,处理完后将变量值更新到主内存。

总结:对于内存可见的共享变量,在对其进行操作时,一定要注意线程的安全问题,保证线程的安全以及数据的准确性,是多线程并发编程的重点。

8. 小结

多线程环境下进行并发编程,需要遵循多线程的并发原则,这是我们学习本节内容的基础所在。本节的重点在于对线程安全问题的关注以及共享变内存可见性操作问题的考虑。

本节内容为我们后续进行的多线程并发的内容奠定了良好的理论基础。

2、线程的状态详解

1. 前言

本节内容主要是对多线程的 6 种状态进行详细讲解,具体内容点如下:

  • 抛开语言,谈操作系统的线程的生命周期及线程 5 种状态,这是我们学习 Java 多线程 6 种状态的基础;

  • 掌握 Java 的线程生命周期及 6 种线程状态,是我们本节课程的重点内容;

  • 理解 Java 线程 6 种状态的定义,并且通过代码实例进行实战演练,更深入的掌握线程的 6 种不同状态,是我们本节内容的核心知识;

  • 掌握 Java 线程不同状态之间的转变关系,更好地理解线程的不同状态,是我们本节课程的重点。

2. 操作系统线程的生命周期

定义:当线程被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态。在线程的生命周期中,它要经过新建 (New)、就绪(Runnable)、运行(Running)、阻塞 (Blocked),和死亡 (Dead) 5 种状态。

从线程的新建 (New) 到死亡 (Dead),就是线程的整个生命周期。

下面我们分别对 5 种不同的状态进行概念解析。

新建 (New):操作系统在进程中新建一条线程,此时线程是初始化状态。

就绪 (Runnable):就绪状态,可以理解为随时待命状态,一切已准备就绪,随时等待运行命令。

运行 (Running):CPU 进行核心调度,对已就绪状态的线程进行任务分配,接到调度命令,进入线程运行状态。

阻塞 (Blocked):线程锁导致的线程阻塞状态。共享内存区域的共享文件,当有两个或两个以上的线程进行非读操作时,只允许一个线程进行操作,其他线程在第一个线程未释放锁之前不可进入操作,此时进入的一个线程是运行状态,其他线程为阻塞状态。

死亡 (Dead):线程工作结束,被操作系统回收。

3. Java 的线程的生命周期及状态

定义: 在 Java 线程的生命周期中,它要经过新建(New),运行(Running),阻塞(Blocked),等待(Waiting),超时等待(Timed_Waiting)和终止状态(Terminal)6 种状态。

从线程的新建(New)到终止状态(Terminal),就是线程的整个生命周期。

Tips :与操作系统相比, Java 线程是否少了 “就绪” 状态 ?其实 Java 线程依然有就绪状态,只不过 Java 线程将 “就绪(Runnable)" 和 “运行(Running)” 两种状态统一归结为 “运行(Running)” 状态。

我们来看下 Java 线程的 6 种状态的概念。

新建 (New):实现 Runnable 接口或者继承 Thead 类可以得到一个线程类,new 一个实例出来,线程就进入了初始状态。

运行 (Running):线程调度程序从可运行池中选择一个线程作为当前线程时线程所处的状态。这也是线程进入运行状态的唯一方式。

阻塞 (Blocked):阻塞状态是线程在进入 synchronized 关键字修饰的方法或者代码块时,由于其他线程正在执行,不能够进入方法或者代码块而被阻塞的一种状态。

等待 (Waiting):执行 wait () 方法后线程进入等待状态,如果没有显示的 notify () 方法或者 notifyAll () 方法唤醒,该线程会一直处于等待状态。

超时等待 (Timed_Waiting):执行 sleep(Long time)方法后,线程进入超时等待状态,时间一到,自动唤醒线程。

终止状态 (Terminal):当线程的 run () 方法完成时,或者主线程的 main () 方法完成时,我们就认为它终止了。这个线程对象也许是活的,但是,它已经不是一个单独执行的线程。线程一旦终止了,就不能复生。

4. 新建(New)状态详解

实例

public class ThreadTest implements Runnable{
    @Override
    public void run() {
        System.out.println("线程:"+Thread.currentThread()+" 正在执行...");
    }
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new ThreadTest()); //线程 创建(NEW)状态
    }
}

这里仅仅对线程进行了创建,没有执行其他方法。 此时线程的状态就是新建 (New) 状态。

Tips:新建(New)状态的线程,是没有执行 start () 方法的线程。

5. 运行(Running)状态详解

定义: 线程调度程序从可运行池中选择一个线程作为当前线程时线程所处的状态。这也是线程进入运行状态的唯一方式。

public class ThreadTest implements Runnable{
    .......
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new ThreadTest()); //线程 创建(NEW)状态
        t1. start(); //线程进入 运行(Running)状态
    }
}
代码块1234567

当线程调用 start () 方法后,线程才进入了运行(Running)状态。

6. 阻塞(Blocked)状态详解

定义: 阻塞状态是线程阻塞在进入 synchronized 关键字修饰的方法或者代码块时的状态。我们先来分析如下代码。

实例

public class DemoTest implements Runnable{
    @Override
    public void run() {
        testBolockStatus();
    }
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new DemoTest()); //线程 t1创建(NEW)状态
        t1.setName("T-one");
        Thread t2 = new Thread(new DemoTest()); //线程 t2创建(NEW)状态
        t2.setName("T-two");
        t1. start(); //线程 t1 进入 运行(Running)状态
        t2. start(); //线程 t2 进入 运行(Running)状态
    }
    public static synchronized void testBolockStatus(){ // 该方法被 synchronized修饰
        System.out.println("我是被 synchronized 修饰的同步方法, 正在有线程" +
                Thread.currentThread().getName() +
                "执行我,其他线程进入阻塞状态排队。");
    }
}

代码分析首先,请看关键代码

t1. start(); //线程 t1 进入 运行(Running)状态
t2. start(); //线程 t2 进入 运行(Running)状态
代码块12

我们将线程 t1 和 t2 进行 运行状态的启动,此时 t1 和 t2 就会执行 run () 方法下的 sync testBolockStatus () 方法。

然后,请看关键代码

public static synchronized void testBolockStatus(){ // 该方法被 synchronized修饰
代码块12

testBolockStatus () 方法是被 synchronized 修饰的同步方法。当有 2 条或者 2 条以上的线程执行该方法时, 除了进入方法的一条线程外,其他线程均处于 “阻塞” 状态。

最后,我们看下执行结果

我是被 synchronized 修饰的同步方法, 正在有线程T-one执行我,其他线程进入阻塞状态排队。
我是被 synchronized 修饰的同步方法, 正在有线程T-two执行我,其他线程进入阻塞状态排队。
代码块12

执行结果解析:我们有两条线程, 线程名称分别为: T-one 和 T-two。

  • 执行结果第一条: T-one 的状态当时为 运行(Running)状态,T-two 状态为 阻塞(Blocked)状态;

  • 执行结果第二条: T-two 的状态当时为 运行(Running)状态,T-one 状态为 阻塞(Blocked)状态。

7. 等待(Waiting)状态详解

定义: 执行 wait () 方法后线程进入等待状态,如果没有显示的 notify () 方法或者 notifyAll () 方法唤醒,该线程会一直处于等待状态。

我们通过代码来看下,等待(Waiting)状态。

实例

public class DemoTest implements Runnable{
    @Override
    public void run() {
        try {
            testBolockStatus();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new DemoTest()); //线程 t1创建(NEW)状态
        t1.setName("T-one");
        t1. start(); //线程进入 运行 状态
    }
    public synchronized void testBolockStatus() throws InterruptedException {
        System.out.println("我是线程:" + Thread.currentThread().getName() + ". 我进来了。");
        this.wait(); //线程进入 等待状态 ,没有其他线程 唤醒, 会一直等待下去
        System.out.println("我是被 synchronized 修饰的同步方法, 正在有线程" +
                Thread.currentThread().getName() +
                "执行我,其他线程进入阻塞状态排队。");
    }
}

注意看下关键代码

this.wait(); //线程进入 等待状态 ,没有其他线程 唤醒, 会一直等待下去
代码块1

这里调用了 wait () 方法。线程进入 等待(Waiting)状态。如果没有其他线程唤醒,会一直维持等待状态。

运行结果

我是线程:T-one. 我进来了。
代码块1

没有办法打印 wait () 方法后边的执行语句,因为线程已经进入了等待状态。

8. 超时等待(Timed-Waiting)状态详解

定义: 执行 sleep(Long time)方法后,线程进入超时等待状态,时间一到,自动唤醒线程。我们通过代码来看下,超时等待(Timed-Waiting)状态。

实例

public class DemoTest implements Runnable{
    @Override
    public void run() {
        .....
    }
    public static void main(String[] args) throws InterruptedException {
       .....
    }
    public synchronized void testBolockStatus() throws InterruptedException {
        System.out.println("我是线程:" + Thread.currentThread().getName() + ". 我进来了。");
        Thread.sleep(5000); //超时等待 状态 5 秒后自动唤醒线程。
        System.out.println("我是被 synchronized 修饰的同步方法, 正在有线程" +
                Thread.currentThread().getName() +
                "执行我,其他线程进入阻塞状态排队。");
    }
}

注意看下关键代码

Thread.sleep(5000); //超时等待 状态 5 秒后自动唤醒线程。
代码块1

这里调用了 sleep () 方法。线程进入超时等待(Timed-Waiting)状态。超时等待时间结束,自动唤醒线程继续执行。

运行结果:5 秒后,打印第二条语句。

我是线程:T-one. 我进来了。
我睡醒了。我是被 synchronized 修饰的同步方法, 正在有线程T-one执行我,其他线程进入阻塞状态排队。
代码块12

9. 终止(Terminal)状态定义

定义: 当线程的 run () 方法完成时,或者主线程的 main () 方法完成时,我们就认为它终止了。这个线程对象也许是活的,但是,它已经不是一个单独执行的线程。线程一旦终止了,就不能复生。

10. 小结

本节的重中之重在于线程的 6 种不同的状态,本节所有的内容都围绕这 6 种不同的状态进行的讲解,这也是本小节的核心内容,也是必须要掌握的内容。

3、synchronized 关键字

1. 前言

本节内容主要是对 synchronized 关键字的使用进行讲解,具体内容点如下:

  • 了解 synchronized 关键字的概念,从总体层面对 synchronized 关键字进行了解,是我们本节课程的基础知识;

  • 了解 synchronized 关键字的作用,知道 synchronized 关键字使用的意义,使我们学习本节内容的出发点;

  • 掌握 synchronized 关键字的 3 中使用方式,使我们本节课程的核心内容,所有的内容讲解都是围绕这一知识点进行的;

  • 了解 synchronized 关键字的内存语义,将 synchronized 关键字与 Java 的线程内存模型进行关联,更好的了解 synchronized 关键字的作用及意义,为本节重点内容。

2. synchronized 关键字介绍

概念:synchronized 同步块是 Java 提供的一种原子性内置锁,Java 中的每个对象都可以把它当作一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为内部锁,也叫作监视器锁。

线程的执行:代码在进入 synchronized 代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块内调用了该内置锁资源的 wait 系列方法时释放该内置锁。

内置锁:即排它锁,也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。

Tips:由于 Java 中的线程是与操作系统的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而 synchronized 的使用就会导致上下文切换。后续章节我们会引入 Lock 接口和 ReadWriteLock 接口,能在一定场景下很好地避免 synchronized 关键字导致的上下文切换问题。

3. synchronized 关键字的作用

作用:在并发编程中存在线程安全问题,使用 synchronized 关键字能够有效的避免多线程环境下的线程安全问题,线程安全问题主要考虑以下三点:

  • 存在共享数据,共享数据是对多线程可见的,所有的线程都有权限对共享数据进行操作;

  • 多线程共同操作共享数据。关键字 synchronized 可以保证在同一时刻,只有一个线程可以执行某个同步方法或者同步代码块,同时 synchronized 关键字可以保证一个线程变化的可见性;

  • 多线程共同操作共享数据且涉及增删改操作。如果只是查询操作,是不需要使用 synchronized 关键字的,在涉及到增删改操作时,为了保证数据的准确性,可以选择使用 synchronized 关键字。

4. synchronized 的三种使用方式

Java 中每一个对象都可以作为锁,这是 synchronized 实现同步的基础。synchronized 的三种使用方式如下:

  • 普通同步方法(实例方法):锁是当前实例对象 ,进入同步代码前要获得当前实例的锁;

  • 静态同步方法:锁是当前类的 class 对象 ,进入同步代码前要获得当前类对象的锁;

  • 同步方法块:锁是括号里面的对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

接下来会对这三种使用方式进行详细的讲解,也是本节课程的核心内容。

5. synchronized 作用于实例方法

为了更加深刻的体会 synchronized 作用于实例方法的使用,我们先来设计一个场景,并根据要求,通过代码的实例进行实现。

场景设计

  • 创建两个线程,分别设置线程名称为 threadOne 和 threadTwo;

  • 创建一个共享的 int 数据类型的 count,初始值为 0;

  • 两个线程同时对该共享数据进行增 1 操作,每次操作 count 的值增加 1;

  • 对于 count 数值加 1 的操作,请创建一个单独的 increase 方法进行实现;

  • increase 方法中,先打印进入的线程名称,然后进行 1000 毫秒的 sleep,每次加 1 操作后,打印操作的线程名称和 count 的值;

  • 运行程序,观察打印结果。

结果预期:因为 increase 方法有两个打印的语句,不会出现 threadOne 和 threadTwo 的交替打印,一个线程执行完 2 句打印之后,才能给另外一个线程执行。

实例

public class DemoTest extends Thread {
    //共享资源
    static int count = 0;
    /**
     * synchronized 修饰实例方法
     */
    public synchronized void increase() throws InterruptedException {
        sleep(1000);
        count++;
        System.out.println(Thread.currentThread().getName() + ": " + count);
    }
    @Override
    public void run() {
        try {
            increase();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        DemoTest test = new DemoTest();
        Thread t1 = new Thread(test);
        Thread t2 = new Thread(test);
        t1.setName("threadOne");
        t2.setName("threadTwo");
        t1. start();
        t2. start();
    }

结果验证

threadTwo 获取到锁,其他线程在我执行完毕之前,不可进入。
threadTwo: 1
threadOne 获取到锁,其他线程在我执行完毕之前,不可进入。
threadOne: 2
代码块1234

从结果可以看出,threadTwo 进入该方法后,休眠了 1000 毫秒,此时线程 threadOne 依然没有办法进入,因为 threadTwo 已经获取了锁,threadOne 只能等待 threadTwo 执行完毕后才可进入执行,这就是 synchronized 修饰实例方法的使用。

Tips:仔细看 DemoTest test = new DemoTest () 这就话,我们创建了一个 DemoTest 的实例对象,对于修饰普通方法,synchronized 关键字的锁即为 test 这个实例对象。

6. synchronized 作用于静态方法

Tips:对于 synchronized 作用于静态方法,锁为当前的 class,要明白与修饰普通方法的区别,普通方法的锁为创建的实例对象。为了更好地理解,我们对第 5 点讲解的代码进行微调,然后观察打印结果。

代码修改:其他代码不变,只修改如下部分代码。

  • 新增创建一个实例对象 testNew ;

  • 将线程 2 设置为 testNew 。

public static void main(String[] args) throws InterruptedException {
        DemoTest test = new DemoTest();
        DemoTest testNew = new DemoTest();
        Thread t1 = new Thread(test);
        Thread t2 = new Thread(testNew);
        t1.setName("threadOne");
        t2.setName("threadTwo");
        t1. start();
        t2. start();
    }

结果验证

threadTwo 获取到锁,其他线程在我执行完毕之前,不可进入。
threadOne 获取到锁,其他线程在我执行完毕之前,不可进入。
threadTwo: 1
threadOne: 2
代码块1234

结果分析:我们发现 threadTwo 和 threadOne 同时进入了该方法,为什么会出现这种问题呢?因为我们此次的修改是新增了 testNew 这个实例对象,也就是说,threadTwo 的锁是 testNew ,threadOne 的锁是 test。

两个线程持有两个不同的锁,不会产生互相 block。相信讲到这里,同学对实例对象锁的作用也了解了,那么我们再次将 increase 方法进行修改,将其修改成静态方法,然后输出结果。

代码修改

public static synchronized void increase() throws InterruptedException {
        System.out.println(Thread.currentThread().getName() + "获取到锁,其他线程在我执行完毕之前,不可进入。" );
        sleep(1000);
        count++;
        System.out.println(Thread.currentThread().getName() + ": " + count);
    }

结果验证

threadOne获取到锁,其他线程在我执行完毕之前,不可进入。
threadOne: 1
threadTwo获取到锁,其他线程在我执行完毕之前,不可进入。
threadTwo: 2
代码块1234

结果分析:我们看到,结果又恢复了正常,为什么会这样?关键的原因在于,synchronized 修饰静态方法,锁为当前 class,即 DemoTest.class。

public class DemoTest extends Thread {}
代码块1

无论 threadOne 和 threadTwo 如何进行 new 实例对象的创建,也不会改变锁是 DemoTest.class 的这一事实。

7. synchronized 作用于同步代码块

Tips:对于 synchronized 作用于同步代码,锁为任何我们创建的对象,只要是个对象即可,如 new Object () 可以作为锁,new String () 也可作为锁,当然如果传入 this,那么此时代表当前对象。

我们将代码恢复到第 5 点的知识,然后在第 5 点知识的基础上,再次对代码进行如下修改:

代码修改

    /**
     * synchronized 修饰实例方法
     */
    static final Object objectLock = new Object(); //创建一个对象锁
    public static void increase() throws InterruptedException {
        System.out.println(Thread.currentThread().getName() + "获取到锁,其他线程在我执行完毕之前,不可进入。" );
        synchronized (objectLock) {
            sleep(1000);
            count++;
            System.out.println(Thread.currentThread().getName() + ": " + count);
        }
    }

代码解析:我们创建了一个 objectLock 作为对象锁,除了第一句打印语句,让后三句代码加入了 synchronized 同步代码块,当 threadOne 进入时,threadTwo 不可进入后三句代码的执行。

结果验证

threadOne 获取到锁,其他线程在我执行完毕之前,不可进入。
threadTwo 获取到锁,其他线程在我执行完毕之前,不可进入。
threadOne: 1
threadTwo: 2
代码块1234

8. 小结

本节内容的核心即 synchronized 关键字的 3 种使用方式,这是必须要掌握的问题。除此之外,不同的使用方法获取到的锁的类型是不一样的,这是本节内容的重点,也是必须要掌握的知识。

对 synchronized 关键字的熟练使用,是并发编程中的一项重要技能。

4、生产者与消费者案例

1. 前言

本节内容是通过之前学习的 synchronized 关键字,实现多线程并发编程中最经典的生产者与消费者模式,这是本节课程的核心内容,所有的知识点都是围绕这一经典模型展开的。本节有如下知识点:

  • 生产者与消费者模型介绍,这是打开本节知识大门的钥匙,也是本节内容的基础;

  • 了解生产者与消费者案例实现的三种方式,我们本节以 synchronized 关键字联合 wait/notify 机制进行实现;

  • wait 方法和 notify 方法介绍,这是我们实现生产者与消费者案例的技术基础;

  • 生产者与消费者案例代码实现,这是我们本节内容的核心,一定要对此知识点进行深入的学习和掌握。

2. 生产者与消费者模型介绍

定义: 生产者消费者模式是一个十分经典的多线程并发协作的模式。

意义:弄懂生产者消费者问题能够让我们对并发编程的理解加深。

介绍:所谓生产者 - 消费者问题,实际上主要是包含了两类线程,一种是生产者线程用于生产数据,另一种是消费者线程用于消费数据,为了解耦生产者和消费者的关系,通常会采用共享的数据区域。

共享的数据区域就像是一个仓库,生产者生产数据之后直接放置在共享数据区中,并不需要关心消费者的行为。而消费者只需要从共享数据区中去获取数据,就不再需要关心生产者的行为。

3. 生产者与消费者三种实现方式

在实现生产者消费者问题时,可以采用三种方式:

  • 使用 Object 的 wait/notify 的消息通知机制,本节课程我们采用该方式结合 synchronized 关键字进行生产者与消费者模式的实现;

  • 使用 Lock 的 Condition 的 await/signal 的消息通知机制;

  • 使用 BlockingQueue 实现。本文主要将这三种实现方式进行总结归纳。

4. wait 与 notify

Java 中,可以通过配合调用 Object 对象的 wait () 方法和 notify () 方法或 notifyAll () 方法来实现线程间的通信。

wait 方法:我们之前对 wait 方法有了基础的了解,在线程中调用 wait () 方法,将阻塞当前线程,并且释放锁,直至等到其他线程调用了调用 notify () 方法或 notifyAll () 方法进行通知之后,当前线程才能从 wait () 方法出返回,继续执行下面的操作。

notify 方法:即唤醒,notify 方法使原来在该对象上 wait 的线程退出 waiting 状态,使得该线程从等待队列中移入到同步队列中去,等待下一次能够有机会获取到对象监视器锁。

notifyAll 方法:即唤醒全部 waiting 线程,与 notify 方法在效果上一致。

5. 生产者与消费者案例

为了更好地理解并掌握生产者与消费者模式的实现,我们先来进行场景设计,然后再通过实例代码进行实现并观察运行结果。

场景设计

  • 创建一个工厂类 ProductFactory,该类包含两个方法,produce 生产方法和 consume 消费方法;

  • 对于 produce 方法,当没有库存或者库存达到 10 时,停止生产。为了更便于观察结果,每生产一个产品,sleep 5000 毫秒;

  • 对于 consume 方法,只要有库存就进行消费。为了更便于观察结果,每消费一个产品,sleep 5000 毫秒;

  • 库存使用 LinkedList 进行实现,此时 LinkedList 即共享数据内存;

  • 创建一个 Producer 生产者类,用于调用 ProductFactory 的 produce 方法。生产过程中,要对每个产品从 0 开始进行编号;

  • 创建一个 Consumer 消费者类,用于调用 ProductFactory 的 consume 方法;

  • 创建一个测试类,main 函数中创建 2 个生产者和 3 个消费者,运行程序进行结果观察。

实例:创建一个工厂类 ProductFactory

class ProductFactory {
    private LinkedList<String> products; //根据需求定义库存,用 LinkedList 实现
    private int capacity = 10; // 根据需求:定义最大库存 10
    public ProductFactory() {
        products = new LinkedList<String>();
    }
    // 根据需求:produce 方法创建
    public synchronized void produce(String product) {
        while (capacity == products.size()) { //根据需求:如果达到 10 库存,停止生产
            try {
                System.out.println("警告:线程("+Thread.currentThread().getName() + ")准备生产产品,但产品池已满");
                wait(); // 库存达到 10 ,生产线程进入 wait 状态
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        products.add(product); //如果没有到 10 库存,进行产品添加
        try {
            Thread.sleep(5000); //根据需求为了便于观察结果,每生产一个产品,sleep 5000 ms
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("线程("+Thread.currentThread().getName() + ")生产了一件产品:" + product+";当前剩余商品"+products.size()+"个");
        notify(); //生产了产品,通知消费者线程从 wait 状态唤醒,进行消费
    }
    // 根据需求:consume 方法创建
    public synchronized String consume() {
        while (products.size()==0) { //根据需求:没有库存消费者进入wait状态
            try {
                System.out.println("警告:线程("+Thread.currentThread().getName() + ")准备消费产品,但当前没有产品");
                wait(); //库存为 0 ,无法消费,进入 wait ,等待生产者线程唤醒
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        String product = products.remove(0) ; //如果有库存则消费,并移除消费掉的产品
        try {
            Thread.sleep(5000);//根据需求为了便于观察结果,每消费一个产品,sleep 5000 ms
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("线程("+Thread.currentThread().getName() + ")消费了一件产品:" + product+";当前剩余商品"+products.size()+"个");
        notify();// 通知生产者继续生产
        return product;
    }
}

实例:Producer 生产者类创建

class Producer implements Runnable {
    private ProductFactory productFactory; //关联工厂类,调用 produce 方法
    public Producer(ProductFactory productFactory) {
        this.productFactory = productFactory;
    }
    public void run() {
        int i = 0 ; // 根据需求,对产品进行编号
        while (true) {
            productFactory.produce(String.valueOf(i)); //根据需求 ,调用 productFactory 的 produce 方法
            i++;
        }
    }
}

实例:Consumer 消费者类创建

class Consumer implements Runnable {
    private ProductFactory productFactory;
    public Consumer(ProductFactory productFactory) {
        this.productFactory = productFactory;
    }
    public void run() {
        while (true) {
            productFactory.consume();
        }
    }
}

实例: 创建测试类,2 个生产者,3 个消费者

public class DemoTest extends Thread{
    public static void main(String[] args) {
        ProductFactory productFactory = new ProductFactory();
        new Thread(new Producer(productFactory),"1号生产者"). start();
        new Thread(new Producer(productFactory),"2号生产者"). start();
        new Thread(new Consumer(productFactory),"1号消费者"). start();
        new Thread(new Consumer(productFactory),"2号消费者"). start();
        new Thread(new Consumer(productFactory),"3号消费者"). start();
    }
}

结果验证

线程(1号生产者)生产了一件产品:0;当前剩余商品1个
线程(3号消费者)消费了一件产品:0;当前剩余商品0个
警告:线程(2号消费者)准备消费产品,但当前没有产品
警告:线程(1号消费者)准备消费产品,但当前没有产品
线程(2号生产者)生产了一件产品:0;当前剩余商品1个
线程(2号消费者)消费了一件产品:0;当前剩余商品0个
警告:线程(1号消费者)准备消费产品,但当前没有产品
线程(2号生产者)生产了一件产品:1;当前剩余商品1个
线程(3号消费者)消费了一件产品:1;当前剩余商品0个
线程(1号生产者)生产了一件产品:1;当前剩余商品1个
线程(3号消费者)消费了一件产品:1;当前剩余商品0个
线程(2号生产者)生产了一件产品:2;当前剩余商品1个
线程(1号消费者)消费了一件产品:2;当前剩余商品0个
警告:线程(2号消费者)准备消费产品,但当前没有产品
线程(2号生产者)生产了一件产品:3;当前剩余商品1个
... 
... 

结果分析从结果来看,生产者线程和消费者线程合作无间,当没有产品时,消费者线程进入等待;当产品达到 10 个最大库存是,生产者进入等待。这就是经典的生产者 - 消费者模型。

6. 小结

实现多线程并发编程中最经典的生产者与消费者模式,这是本节课程的核心内容,所有的知识点都是围绕这一经典模型展开的。 在掌握 synchronized 关键字,wait 方法和 notify 方法的基础上,理解并掌握生产者与消费者模式是本节课程的最终目标。

5、volatile 关键字

1. 前言

本节内容主要是对 volatile 关键字进行讲解,具体内容点如下:

  • volatile 关键字概念介绍,从整体层面了解 volatile 关键字;

  • volatile 关键字与 synchronized 关键字的区别,这是本节的重点内容之一,了解 volatile 关键字与 synchronized 关键字的区别,才能更好地区分并掌握两钟关键字的使用;

  • volatile 关键字原理介绍,也是本节课程的重点之一;

  • volatile 关键字的使用,是本节课程的核心内容,所有的知识点都是围绕这一目的进行讲解的。

2. volatile 关键字介绍

概念:volatile 关键字解决内存可见性问题,是一种弱形式的同步。

介绍:该关键字可以确保当一个线程更新共享变量时,更新操作对其他线程马上可见。当一个变量被声明为 volatile 时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。

当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。

3. volatile 与 synchronized 的区别

相似处:volatile 的内存语义和 synchronized 有相似之处,具体来说就是,当线程写入了 volatile 变量值时就等价于线程退出 synchronized 同步块(把写入工作内存的变量值同步到主内存),读取 volatile 变量值时就相当于进入 synchronized 同步块( 先清空本地内存变量值,再从主内存获取最新值)。

区别:使用锁的方式可以解决共享变量内存可见性问题,但是使用锁太笨重,因为它会带来线程上下文的切换开销。具体区别如下:

  • volatile 本质是在告诉 jvm 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住;

  • volatile 仅能使用在变量级别;synchronized 则可以使用在变量、方法、和类级别的;

  • volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的修改可见性和原子性;

  • volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞;

  • volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化

4. volatile 原理

原理介绍:Java 语言提供了一种弱同步机制,即 volatile 变量,用来确保将变量的更新操作通知到其他线程。

当把变量声明为 volatile 类型后,编译器与运行时都会注意到这个变量是共享的,volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取 volatile 类型的变量时总会返回最新写入的值。

Tips:在访问 volatile 变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此 volatile 变量是一种比 sychronized 关键字更轻量级的同步机制。

我们来通过下图对非 volatile 关键字修饰的普通变量的读取方式进行理解,从而更加细致的了解 volatile 关键字修饰的变量。

当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到 CPU 缓存中。如果计算机有多个 CPU,每个线程可能在不同的 CPU 上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中。

而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache。

5. volatile 关键字的使用

为了对 volatile 关键字有着更深的使用理解,我们通过一个非常简单的场景的设计来进行学习。

场景设计

  • 创建一个 Student 类,该类有一个 String 属性,name;

  • 将 name 的 get 和 set 方法设置为同步方法;

  • 使用 synchronized 关键字实现;

  • 使用 volatile 关键字实现。

这是一个非常简单的场景,场景中只涉及到了一个类的两个同步方法,通过对两种关键字的实现,能更好的理解 volatile 关键字的使用。

实例: synchronized 关键字实现

public class Student {
    private String name;
    public synchronized String getName() {
        return name;
    }
    public synchronized void setName(String name) {
        this.name = name;
    }
}

实例: volatile 关键字实现

public class Student {
    private volatile String name;
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
}

总结:在这里使用 synchronized 和使用 volatile 是等价的,都解决了共享变量 name 的内存可见性问题。

但是前者是独占锁,同时只能有一个线程调用 get()方法,其他调用线程会被阻塞,同时会存在线程上下文切换和线程重新调度的开销,这也是使用锁方式不好的地方。

而后者是非阻塞算法,不会造成线程上下文切换的开销。

6. 小结

本节内容的核心知识点即 volatile 关键字的使用方式,在掌握核心知识之前,需要对重点内容进行理解和学习,本节内容所有的重点知识如 volatile 关键字原理,与 synchronized 关键字的区别,都是围绕核心知识进行的讲解。

本站仅提供存储服务,所有内容均由用户发布,如发现有害或侵权内容,请点击举报
打开APP,阅读全文并永久保存 查看更多类似文章
猜你喜欢
类似文章
【热】打开小程序,算一算2024你的财运
java线程同步
『互联网架构』软件架构
Java Threads 多线程10分钟参考手册
Java多线程编程从一个错误的双重校验锁代码谈一下volatile关键字
volatile关键字的原理和要避免的误区
学习Java多线程之volatile域
更多类似文章 >>
生活服务
热点新闻
分享 收藏 导长图 关注 下载文章
绑定账号成功
后续可登录账号畅享VIP特权!
如果VIP功能使用有故障,
可点击这里联系客服!

联系客服