多线程

每天学习新知识,每天进步一点点。

在程序设计中,多线程就是指一个应用程序中有多条并发执行的线索,每条线索都被称作一个线程。他们会交替执行,彼此间可以进行通信。本文将对Java中的多线程知识进行介绍。

1. 线程概述

1.1 进程

在学习线程之前,需要先了解一下什么是进程。在一个操作系统中,每个独立执行的程序都可称之为一个进程,也就是“正在运行的程序”。例如同时运行的QQ、火绒安全卫士、IDEA开发工具等。

多任务操作系统中(即能同时执行多个应用程序),可以查看当前系统中所有的进程,这里以Windows操作系统为例,打开任务管理器窗口,在“进程”选项卡中查看当前系统中的进程,如图1所示。
进程
图1 任务管理器

在多任务操作系统中,表面上看是支持进程并发执行的,例如可以一边听音乐一边聊天,但实际上这些进程并不是在同一时刻运行的。在计算机中,所有的应用程序都是由CPU执行的,对于一个CPU而言,在某个时间点只能运行一个程序,也就是说只能执行一个进程,操作系统会为每一个进程分配一段有限的CPU使用时间,CPU在这段时间中执行某个进程,然后会在下一段时间切换到另一个进程中去执行。由于CPU运行速度非常快,能在极短的时间内在不同的进程之间进行切换,所以给人以同时执行多个程序的感觉。

1.2 线程

在多任务操作系统中,每个运行的程序都是一个进程,用来执行不同的任务,而在一个进程中还可以有多个执行单元同时运行,来同时完成一个或多个程序任务,这些执行单元可以看做程序执行的一条条线索,被称为线程。操作系统中的每一个进程中都至少存在一个线程,当一个Java程序启动时,就会产生一个进程,该进程中会默认创建一个线程,在这个线程上会运行main()方法中的代码。

在前面文章所接触过的程序中,代码都是按照调用顺序依次往下执行,没有出现多段程序代码交替运行的效果,这样的程序称作单线程程序。如果希望程序中实现多段程序代码交替运行的效果,则需要创建多个线程,即多线程程序。多线程程序在运行时,每个线程之间都是独立的,它们可以并发执行。程序中的单线程和多线程的主要区别可以通过一张图示来简单说明,如图1-2所示。

线程
图1-2 线程

从图1-2可以看出,单线程就是一条顺序执行线索,而多线程则是并发执行的多条线索,这样就可以充分利用CUP资源,进一步提升程序执行效率。从表面上看,多线程看似是同时并发执行的,其实不然,它们和进程一样,也是由CPU控制并轮流执行的,只不过CPU运行速度非常快,故而给人同时执行的感觉。

2. 线程的创建

Java为多线程开发提供了非常优秀的技术支持。在Java中,可以通过三种方式来实现多线程,第一种是继承Thread类,重写run()方法。第二种是实现Runnable接口,重写run()方法。第三种是实现Callable接口,并重写call()方法,并使用Future来获取call()方法的返回值。

2.1 Thread类实现多线程

Thread类是java.lang包下的一个线程类,用来实现Java多线程。通过继承Thread类的方式来实现多线程非常简单,主要步骤如下:

  1. 创建一个Thread线程类的子类(子线程),同时重写Thread类的run()方法;

  2. 创建该子类的实例对象,并通过调用start()方法启动线程。

接下来通过一个案例来演示如何通过继承Thread类的方式来实现多线程,如下所示。
例2-1 Demo1.java

// 定义一个继承自Thread类的MyThread类
class MyThread extends Thread {

    // 用于表示剩余票数
    private int tickets = 10;

    // 构造函数,接收线程名称
    public MyThread(String name) {
        super(name); // 调用父类构造函数,设置线程名称
    }

    // 重写run方法,定义线程的执行内容
    public void run() {
        // 当还有票时,持续售票
        while (tickets > 0) {
            // 输出当前线程的名称和正在售卖的票号
            System.out.println(Thread.currentThread().getName() + "正在售卖第" + tickets-- + "张票");
        }
    }
}

// 主类
public class Demo1 {
    public static void main(String[] args) {
        // 创建两个线程实例,分别代表不同的售票窗口
        MyThread t = new MyThread("窗口1");
        MyThread t1 = new MyThread("窗口2");

        // 启动线程
        t.start(); // 启动窗口1线程
        t1.start(); // 启动窗口2线程
    }
}

输出:

窗口1正在售卖第10张票
窗口2正在售卖第10张票
窗口1正在售卖第9张票
窗口2正在售卖第9张票
窗口1正在售卖第8张票
窗口2正在售卖第8张票
窗口1正在售卖第7张票
窗口2正在售卖第7张票
窗口1正在售卖第6张票
窗口2正在售卖第6张票
窗口1正在售卖第5张票
窗口2正在售卖第5张票
窗口1正在售卖第4张票
窗口2正在售卖第4张票
窗口1正在售卖第3张票
窗口2正在售卖第3张票
窗口1正在售卖第2张票
窗口2正在售卖第2张票
窗口1正在售卖第1张票
窗口2正在售卖第1张票

案例中,定义了一个继承Thread线程类的子类MyThread,并重写了run()方法,其中currentThread()是Thread类的静态方法,用来获取当前线程对象getName()方法用来获取线程名称。然后在main()方法中分别创建了两个线程实例,并指定线程名称为窗口1和窗口2,最后通过start()方法启动线程。

从输出结果可以看出,两个线程对象交互执行了各自重写的run()方法,并打印出对应的输出信息,而不是按照编程顺序先执行完第一个线程方法后才执行第二个线程方法,这就说明程序实现了多线程功能。

2.2 Runnable接口实现多线程

在上一节案例中,通过继承Thread类的方式实现了多线程,但这种方式有一定的局限性,因为Java只支持类的单继承,如果某个类已经继承了其他父类,就无法再继承Thread类来实现多线程。在现实生活中,买票的时候剩余票数是共享的,不可能说共享的票数,窗口1售完票后,窗口2还有余票。通过案例的输出结果可以看出,两个线程之间并没有共享数据,所以这种方式实现的多线程并不是真正的多线程。这是因为我们创建了两个Thread对象,两个对象中的票数是相互独立的。在这种情况下,就可以考虑通过实现Runnable接口的方式来实现多线程。

使用实现Runnable接口的方式来实现多线程的主要步骤如下:

  1. 创建一个Runnable接口实现类,同时重写接口中的run()方法;

  2. 创建Runnable接口的实现类对象

  3. 使用Thread有参构造方法创建线程实例,并将Runnable接口的实现类的实例对象作为参数传入

  4. 调用线程实例的start()方法启动线程。

接下来通过修改Demo1案例来演示如何通过实现Runnable接口的方式来实现多线程,如下所示。
例2-2 Demo2.java

// 实现Runnable接口的MyThread类
class MyThread implements Runnable {
    // 用于表示剩余票数
    private int tickets = 10;

    // 重写run方法,定义线程的执行内容
    public void run() {
        // 当还有票时,持续售票
        while (tickets > 0) {
            // 输出当前线程的名称和正在售卖的票号
            System.out.println(Thread.currentThread().getName() + "正在售卖第" + tickets-- + "张票");
        }
    }
}

// 主类
public class Demo2 {
    public static void main(String[] args) {
        // 创建MyThread实例
        MyThread mt = new MyThread();

        // 创建两个线程实例,分别代表不同的售票窗口
        Thread t = new Thread(mt, "窗口1");
        Thread t1 = new Thread(mt, "窗口2");

        // 启动线程
        t.start();  // 启动窗口1线程
        t1.start(); // 启动窗口2线程
    }
}

输出

窗口2正在售卖第9张票
窗口2正在售卖第8张票
窗口1正在售卖第10张票
窗口2正在售卖第7张票
窗口1正在售卖第6张票
窗口2正在售卖第5张票
窗口1正在售卖第4张票
窗口2正在售卖第3张票
窗口1正在售卖第2张票
窗口2正在售卖第1张票

案例中,定义了一个实现Runnable接口的实现类MyThread,并重写了run()方法,然后在main()方法中先后创建并启动了两个线程实例。在main()方法中创建线程实例时,先创建了Runnable接口的实现类对象mt,然后将mt作为Thread构造方法的参数来创建线程实例,最后同样通过start()方法启动了线程实例。

从结果可以看出,两个线程对象同样交互执行了各自重写的run()方法,并打印出对应的输出信息,并且共享了同一个票数数据,这就说明案例通过实现Runnable接口的方式实现了多线程。

tip:Runnable接口中只有一个抽象的run()方法,那么该接口就属于JDK 8中定义的函数式接口,在使用时,可以直接通过Lambda表达式的方式更简洁的来实现线程实例。同样,在下一小节将要讲解的Callable接口也属于函数式接口。

2.3 Callable接口实现多线程

通过Thread类和Runnable接口实现多线程时,需要重写run()方法,但是由于该方法没有返回值,因此无法从多个线程中获取返回结果。为了解决这个问题,从JDK 5开始,Java提供了一个新的Callable接口,来满足这种既能创建多线程又可以有返回值的需求。

通过Callable接口实现多线程的方式与Runnable接口实现多线程的方式一样,都是通过Thread类的有参构造方法传入Runnable接口类型的参数来实现多线程,不同的是,这里传入的是Runnable接口的子类FutureTask对象作为参数,而FutureTask对象中则封装带有返回值的Callable接口实现类。

使用实现Callable接口的方式来创建并启动线程实例的主要步骤如下:

  1. 创建一个Callable接口实现类,同时重写Callable接口的call()方法;

  2. 创建Callable接口的实现类对象

  3. 通过FutureTask线程结果处理类的有参构造方法来封装Callable接口实现类对象;

  4. 使用参数为FutureTask类对象的Thread有参构造方法创建Thread线程实例;

  5. 调用线程实例的start()方法启动线程。

接下来通过一个案例来演示如何通过实现Callable接口的方式来实现多线程,如下所示。
例2-3 Demo3.java

import java.util.concurrent.*; // 引入并发包

// 实现Callable接口的MyThread类
class MyThread implements Callable<Object> {
    // 表示剩余票数
    private int tickets = 10;

    // 重写call方法,定义线程的执行内容
    public Object call() {
        // 当还有票时,持续售票
        while (tickets > 0) {
            // 输出当前线程的名称和正在售卖的票号
            System.out.println(Thread.currentThread().getName() + "正在售卖第" + tickets-- + "张票");
        }
        // 返回剩余票数
        return tickets;
    }
}

// 主类
public class Demo3 {
    public static void main(String[] args) throws InterruptedException, ExecutionException {
        // 创建MyThread实例
        MyThread mt = new MyThread();

        // 创建FutureTask实例,并将MyThread对象作为目标任务
        FutureTask<Object> ft = new FutureTask<>(mt);
        FutureTask<Object> ft1 = new FutureTask<>(mt);

        // 创建两个线程实例,分别代表不同的售票窗口
        Thread t = new Thread(ft, "窗口1");
        Thread t1 = new Thread(ft1, "窗口2");

        // 启动线程
        t.start();  // 启动窗口1线程
        t1.start(); // 启动窗口2线程
        
        // 获取并输出窗口1的剩余票数
        System.out.println("窗口1现在剩余票数:" + ft.get());
        // 获取并输出窗口2的剩余票数
        System.out.println("窗口2现在剩余票数:" + ft1.get());
    }
}

输出

窗口1正在售卖第10张票
窗口1正在售卖第8张票
窗口2正在售卖第9张票
窗口1正在售卖第7张票
窗口1正在售卖第5张票
窗口1正在售卖第4张票
窗口1正在售卖第3张票
窗口1正在售卖第2张票
窗口1正在售卖第1张票
窗口2正在售卖第6张票
窗口1现在剩余票数:0
窗口2现在剩余票数:0

从输出结果可以看出,所示案例通过实现Callable接口的方式实现了多线程并带有返回结果。

Callable接口方式实现的多线程是通过FutureTask类来封装和管理返回结果的,该类的直接父接口是RunnableFuture,从名称上可以看出RunnableFuture是由Runnable和Future组成的结合体。下面就通过一个示意图来展示FutureTask类的继承关系,如图所示。
FutureTask类继承关系
图2-3 FutureTask类继承关系

从图2-3可以看出,FutureTask本质是Runnable接口Future接口实现类,而Future则是JDK 5提供的用来管理线程执行返回结果的。其中Future接口中共有5个方法,用来对线程结果进行管理,这些方法及说明如表所示。

表2-3 Future接口的方法

方法声明功能描述
boolean cancel(boolean mayInterruptIfRunning)用于取消任务,参数mayInterruptIfRunning表示是否允许取消正在执行却没有执行完毕的任务,如果设置true,则表示可以取消正在执行的任务
boolean isCancelled()判断任务是否被取消成功,如果在任务正常完成前被取消成功,则返回 true
boolean isDone()判断任务是否已经完成,若任务完成,则返回true
V get()用于获取执行结果,这个方法会发生阻塞,一直等到任务执行完毕才返回执行结果
V get(long timeout, TimeUnit unit)用于在指定时间内获取执行结果,如果在指定时间内,还没获取到结果,就直接返回null

2.4 三种实现多线程方式的对比分析

通过上面的售票案例,并结合实际情况进行分析,通过实现Runnable接口(或者Callable接口)相对于继承Thread类实现多线程来说,有如下显著的好处:

  1. 适合多个线程去处理同一个共享资源的情况,把线程同程序代码、数据有效的分离,很好的体现了面向对象的设计思想。

  2. 可以避免Java单继承带来的局限性,由于一个类不能同时有两个父类,所以在当前类已经有一个父类的基础上,那么就只能采用实现Runnable接口或者Callable接口的方式来实现多线程。

tip:事实上,实际开发中大部分的多线程应用都会采用Runnable接口或者Callable接口的方式实现多线程。

2.5 后台线程

在上述的售票案例中,当main()方法中创建并启动的两个新线程的代码执行完毕后,主线程也就随之结束了。通过程序的运行结果可以看出,虽然主线程结束了,但整个Java程序却没有随之结束,仍然在执行售票的代码。对Java程序来说,只要还有一个前台线程在运行,这个进程就不会结束,如果一个进程中只有后台线程运行,这个进程就会结束。这里提到的前台线程和后台线程是一种相对的概念,新创建的线程默认都是前台线程,如果某个线程对象在启动之前调用了setDaemon(true)语句,这个线程就变成一个后台线程。接下来通过一个案例来演示当程序只有后台线程时就会结束的情况,如下所示。
例2-4 Demo4.java

// 实现Runnable接口的DaemonThread类
class DaemonThread implements Runnable {
    // 重写run方法,定义线程的执行内容
    public void run() {
        // 循环执行,输出当前线程的名称
        while (true) {
            System.out.println(Thread.currentThread().getName() + "正在执行中");
        }
    }
}

// 主类
public class Demo4 {
    public static void main(String[] args) {
        // 输出主线程是否为后台线程,默认情况下主线程不是后台线程
        System.out.println("main线程是后台线程吗-" + Thread.currentThread().isDaemon());

        // 创建DaemonThread的实例
        DaemonThread t = new DaemonThread();
        // 创建一个线程实例,传入DaemonThread对象,并设置线程名称
        Thread t1 = new Thread(t, "线程1");
        // 输出线程1是否为后台线程(刚创建时默认不是)
        System.out.println("线程1是后台线程吗-" + t1.isDaemon());
        
        // 将线程1设为后台线程
        t1.setDaemon(true);
        // 启动线程1
        t1.start();
        // 输出线程1是否为后台线程(应该是,因为已设置为后台线程)
        System.out.println("线程1是后台线程吗-" + t1.isDaemon());

        // 模拟主线程执行方法
        int i = 0;
        // 循环10次,输出主线程正在执行的次数
        while (i++ < 10) {
            System.out.println("main正在执行第" + i + "次");
        }
    }
}

输出

main线程是后台线程吗-false
线程1是后台线程吗-false
线程1是后台线程吗-true
main正在执行第1次
main正在执行第2次
main正在执行第3次
main正在执行第4次
main正在执行第5次
main正在执行第6次
main正在执行第7次
main正在执行第8次
线程1正在执行中
线程1正在执行中
main正在执行第9次
main正在执行第10次
线程1正在执行中
线程1正在执行中
线程1正在执行中
线程1正在执行中

案例中,演示了一个后台线程结束的过程。当将线程t1设置为后台线程后,当前台主线程循环输出任务执行完毕后,整个进程就会结束,此时JVM也会通知后台线程结束。由于后台线程从接收指令,到作出响应,需要一定的时间,因此,打印了几次“线程1正在执行中”语句后,后台线程也结束了。由此说明进程中不存在前台线程时,整个进程就会结束。

tip;要将某个线程设置为后台线程,必须在该线程启动之前,也就是说setDaemon()方法必须在start()方法之前调用,否则后台线程设置无效。

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

在Java中,任何对象都有生命周期,线程也不例外,它也有自己的生命周期。当Thread对象创建完成时,线程的生命周期便开始了,当线程任务中代码正常执行完毕或者线程抛出一个未捕获的异常(Exception)或者错误(Error)时,线程的生命周期便会结束。

Java官方API将线程的整个生命周期分为六个状态,分别是NEW(新建状态)RUNNABLE(可运行状态)BLOCKED(阻塞状态)WAITING(等待状态)TIMED_WAITING(定时等待状态)TERMINATED(终止状态)。线程的不同状态表明了线程当前正在进行的活动,在程序中,通过一些操作,可以使线程在不同状态之间转换,如图所示。

线程状态转换图

图3-1 线程状态转换图

图中展示了线程各种状态的转换关系,箭头方向表示可转换的方向。接下来,针对线程生命周期中的六种状态分别进行详细讲解,具体如下:

  1. NEW(新建状态)

创建一个线程对象后,该线程对象就处于新建状态,此时它不能运行,和其他Java对象一样,仅仅由JVM为其分配了内存,没有表现出任何线程的动态特征。

  1. RUNNABLE(可运行状态)

当新建状态下的线程对象调用了start()方法,此时就会从新建状态进入可运行状态。从图10-10可以看出,在RUNNABLE状态内部又可细分成两种状态:READY(就绪状态)RUNNING(运行状态),并且线程可以在这两个状态之间相互转换。

  • 就绪状态:线程对象调用start()方法之后,等待JVM的调度,此时线程并没有运行;

  • 运行状态:线程对象获得JVM调度,如果存在多个CPU,那么允许多个线程并行运行。

  1. BLOCKED(阻塞状态)

处于运行状态的线程可能会因为某些原因失去CPU的执行权,暂时停止运行进入阻塞状态。此时,JVM不会给线程分配CPU,直到线程重新进入就绪状态,才有机会转换到运行状态。阻塞状态的线程只能先进入就绪状态,不能直接进入运行状态。
线程一般会在以下两种情况下进入阻塞状态:

  • 当线程A运行过程中,试图获取同步锁时,却被线程B获取,此时JVM把当前线程A存到对象的锁池中,线程A就会进入阻塞状态;

  • 当线程运行过程中,发出IO请求时,此时该线程也会进入阻塞状态。

  1. WAITING(等待状态)

当处于运行状态的线程调用了无时间参数限制的方法后,如wait()join()等方法,就会将当前运行中的线程转换为等待状态。

处于等待状态中的线程不能立即争夺CPU使用权,必须等待其他线程执行特定的操作后,才有机会再次争夺CPU使用权,将等待状态的线程转换为运行状态。例如,调用wait()方法而处于等待状态中的线程,必须等待其他线程调用notify()或者notifyAll()方法唤醒当前等待中的线程;调用join()方法而处于等待状态中的线程,必须等待其他加入的线程终止。

  1. TIMED_WAITING(定时等待状态)

将运行状态中的线程转换为定时等待状态中的线程与转换为等待状态中的线程操作类似,只是运行线程调用了有时间参数限制的方法,如sleep(long millis)wait(long timeout)join(long millis)等方法。

处于定时等待状态中的线程也不能立即争夺CPU使用权,必须等待其他相关线程执行完特定的操作或者限时时间结束后,才有机会再次争夺CPU使用权,将定时等待状态的线程转换为运行状态。例如,调用了wait(long timeout) 方法而处于等待状态中的线程,需要通过其他线程调用notify()或者notifyAll()方法唤醒当前等待中的线程,或者等待限时时间结束后也可以进行状态转换。

  1. TERMINATED(终止状态)

线程的run()方法、call()方法正常执行完毕或者线程抛出一个未捕获的异常(Exception)、错误(Error),线程就进入终止状态。一旦进入终止状态,线程将不再拥有运行的资格,也不能再转换到其他状态,生命周期结束。

4. 线程的调度

在计算机中,线程调度有两种模型,分别是分时调度模型抢占式调度模型。所谓分时调度模型是指让所有的线程轮流获取CPU的使用权,并且平均分配每个线程所占用的CPU时间片抢占式调度模型是指让可运行池中所有就绪状态的线程争抢CPU的使用权,而优先级高的线程获取CPU的执行权概率大于优先级低的线程。Java虚拟机默认采用抢占式调度模型

4.1 线程的优先级

在应用程序中,如果要对线程进行调度,最直接的方式就是设置线程的优先级。优先级越高的线程获得CPU执行的机会越大,而优先级越低的线程获得CPU执行的机会越小。线程的优先级用1~10之间的整数来表示,数字越大优先级越高。除了可以直接使用数字表示线程的优先级,还可以使用Thread类中提供的三个静态常量表示线程的优先级,如表所示。

表4-1 Thread类的优先级常量

Thread类的静态常量功能描述
static int MAX_PRIORITY表示线程的最高优先级,相当于值10
static int MIN_PRIORITY表示线程的最低优先级,相当于值1
static int NORM_PRIORIY表示线程的普通优先级,相当于值5

程序在运行期间,处于就绪状态的每个线程都有自己的优先级,例如main线程具有普通优先级。然而线程优先级不是固定不变的,可以通过Thread类的setPriority(int newPriority)方法对其进行设置,该方法中的参数newPriority接收的是1~10之间的整数或者Thread类的三个静态常量。接下来通过一个案例来演示不同优先级的两个线程在程序中的运行情况,如下所示。
例4-1 Demo5.java

public class Demo5 {
    public static void main(String[] args) {
        // 创建线程pt1,使用Lambda表达式定义其执行内容
        Thread pt1 = new Thread(() -> {
            int i = 0; // 计数器
            // 循环10次,输出线程的名称和执行次数
            while (i++ < 10)
                System.out.println(Thread.currentThread().getName() + "优先级较高,正在执行第" + i + "次");
        });

        // 创建线程pt2,使用Lambda表达式定义其执行内容
        Thread pt2 = new Thread(() -> {
            int i = 0; // 计数器
            // 循环10次,输出线程的名称和执行次数
            while (i++ < 10)
                System.out.println(Thread.currentThread().getName() + "优先级较低,正在执行第" + i + "次");
        });

        // 设置pt1的优先级为最大
        pt1.setPriority(Thread.MAX_PRIORITY);
        // 设置pt2的优先级为最小
        pt2.setPriority(Thread.MIN_PRIORITY);

        // 输出pt1的优先级
        System.out.println("线程0的优先级为:" + pt1.getPriority());
        // 输出pt2的优先级
        System.out.println("线程1的优先级为:" + pt2.getPriority());

        // 启动线程pt1和pt2
        pt1.start();
        pt2.start();
    }
}

输出

线程0的优先级为:10
线程1的优先级为:1
Thread-0优先级较高,正在执行第1次
Thread-0优先级较高,正在执行第2次
Thread-0优先级较高,正在执行第3次
Thread-0优先级较高,正在执行第4次
Thread-0优先级较高,正在执行第5次
Thread-0优先级较高,正在执行第6次
Thread-0优先级较高,正在执行第7次
Thread-0优先级较高,正在执行第8次
Thread-1优先级较低,正在执行第1次
Thread-0优先级较高,正在执行第9次
Thread-1优先级较低,正在执行第2次
Thread-1优先级较低,正在执行第3次
Thread-1优先级较低,正在执行第4次
Thread-1优先级较低,正在执行第5次
Thread-1优先级较低,正在执行第6次
Thread-0优先级较高,正在执行第10次
Thread-1优先级较低,正在执行第7次
Thread-1优先级较低,正在执行第8次
Thread-1优先级较低,正在执行第9次
Thread-1优先级较低,正在执行第10次

案例中,创建了两个线程pt1和pt2,分别将线程的优先级设置为1和10,从输出可以看出,优先级较高的pt1线程会获得更多的机会优先执行。需要注意的是,仅仅是有机会获得更多的执行机会,并不意味着一定会优先执行,所以可以自己多运行几次看看结果。另外,设置线程优先级并不是绝对的,在某些情况下,JVM也会对线程优先级进行调整,以提高程序的运行效率。

tip:虽然Java中提供了10个线程优先级,但是这些优先级需要操作系统的支持,不同的操作系统对优先级的支持是不一样的,不能很好的和Java中线程优先级一一对应,因此,在设计多线程应用程序时,其功能的实现一定不能依赖于线程的优先级,而只能把线程优先级作为一种提高程序效率的手段。

4.2 线程休眠

在前面过线程的优先级,优先级高的线程有更大的概率优先执行,而优先级低的线程可能会后执行。如果想要人为地控制线程执行顺序,使正在执行的线程暂停,将CPU使用权让给其他线程,这时可以使用静态方法sleep(long millis),该方法可以让当前正在执行的线程暂停一段时间,进入休眠等待状态,这样其他的线程就可以得到执行的机会。sleep(long millis)方法会声明抛出InterruptedException异常,因此在调用该方法时应该捕获异常,或者声明抛出该异常

接下来通过案例演示一下sleep()方法在程序中的使用,如下所示。
例4-2 Demo6.java

public class Demo6 {
    public static void main(String[] args) {
        // 创建线程pt1,使用Lambda表达式定义其执行内容
        Thread pt1 = new Thread(() -> {
            int i = 0; // 初始化计数器
            // 循环10次,输出线程的名称和执行次数
            while (i++ < 10) {
                // 当计数器达到5时,线程休眠500毫秒
                if (i == 5) {
                    try {
                        Thread.sleep(500); // 使当前线程暂停执行500毫秒
                    } catch (InterruptedException e) { // 捕获中断异常
                        e.printStackTrace(); // 打印异常信息
                    }
                }
                // 输出当前线程的名称及第i次执行的信息
                System.out.println(Thread.currentThread().getName() + "正在执行第" + i + "次");
            }
        });

        // 创建线程pt2,使用Lambda表达式定义其执行内容
        Thread pt2 = new Thread(() -> {
            int i = 0; // 初始化计数器
            // 循环10次,输出线程的名称和执行次数
            while (i++ < 10) {
                // 输出当前线程的名称及第i次执行的信息
                System.out.println(Thread.currentThread().getName() + "正在执行第" + i + "次");
            }
        });

        // 启动线程pt1和pt2
        pt1.start(); // 启动线程pt1
        pt2.start(); // 启动线程pt2
    }
}

输出

Thread-1正在执行第1次
Thread-0正在执行第1次
Thread-1正在执行第2次
Thread-0正在执行第2次
Thread-1正在执行第3次
Thread-0正在执行第3次
Thread-1正在执行第4次
Thread-0正在执行第4次
Thread-1正在执行第5次
Thread-1正在执行第6次
Thread-1正在执行第7次
Thread-1正在执行第8次
Thread-1正在执行第9次
Thread-1正在执行第10次
Thread-0正在执行第5次
Thread-0正在执行第6次
Thread-0正在执行第7次
Thread-0正在执行第8次
Thread-0正在执行第9次
Thread-0正在执行第10次

案例中开启了两个线程,同时在pt1线程执行过程中调用了Thread的sleep(500)方法,目的是让一个线程在执行的某一时刻休眠500毫秒,从而使另一个线程获得执行的机会。正常情况下,这两个线程会争相获取CUP执行权并交互打印输出信息。

从输出可以看出,当pt1执行到i==5时,就会进入休眠状态,此时可以看到pt2线程一直会获得CUP使用权,直到pt1线程休眠时间消耗完成才有机会获得CUP使用权。

tip:线程类Thread提供了两个线程休眠方法:sleep(long millis)和sleep(long millis, int nanos),这两个方法都带有休眠时间参数,当其他线程都终止后并不代表当前休眠的线程会立即执行,而是必须当休眠时间结束后,线程才会转换到就绪状态。

4.3 线程让步

在校园中,经常会看到很多同学一起打篮球,当某个同学抢到篮球后就可以拍一会之后传递给其他人,大家重新开始抢篮球,这个过程就相当于程序中的线程让步。线程让步可以通过yield()方法来实现,该方法和sleep(long millis)方法有点类似,都可以让当前正在运行的线程暂停,区别在于yield()方法不会阻塞该线程,它只是将线程转换成就绪状态,让系统的调度器重新调度一次。当某个线程调用yield()方法之后,与当前线程优先级相同或者更高的线程可以获得执行的机会。接下来通过一个案例来演示一下yield()方法的使用,如下所示。
例4-3 Demo7.java

public class Demo7 {
    public static void main(String[] args) {
        // 创建第一个线程t1,使用Lambda表达式定义线程执行的任务
        Thread t1 = new Thread(() -> {
            int i = 0;
            // 循环执行8次
            while (i++ < 8) {
                // 当i等于5时,进行线程让步
                if (i == 5) {
                    Thread.yield(); // 暂停当前线程的执行,让其他线程有机会执行
                    System.out.println(Thread.currentThread().getName() + "正在执行第" + i + "次,刚刚发生线程让步");
                } else {
                    // 输出当前线程的名称及执行次数
                    System.out.println(Thread.currentThread().getName() + "正在执行第" + i + "次");
                }
            }
        });

        // 创建第二个线程t2,同样使用Lambda表达式定义线程执行的任务
        Thread t2 = new Thread(() -> {
            int i = 0;
            // 循环执行8次
            while (i++ < 8)
                // 输出当前线程的名称及执行次数
                System.out.println(Thread.currentThread().getName() + "正在执行第" + i + "次");
        });

        // 启动线程t1
        t1.start();
        // 启动线程t2
        t2.start();
    }
}

输出

Thread-0正在执行第1次
Thread-1正在执行第1次
Thread-0正在执行第2次
Thread-1正在执行第2次
Thread-0正在执行第3次
Thread-1正在执行第3次
Thread-0正在执行第4次
Thread-1正在执行第4次
Thread-0正在执行第5次,发生线程让步
Thread-1正在执行第5次
Thread-0正在执行第6次
Thread-1正在执行第6次
Thread-0正在执行第7次
Thread-1正在执行第7次
Thread-0正在执行第8次
Thread-1正在执行第8次

案例中创建了两个线程thread0和thread1,它们的优先级相同。线程0在循环变量i等于5时,会调用Thread的yield()方法,使当前线程暂停,让两个线程再次争夺CUP使用权。

tip:通过yield()方法可以实现线程让步,让当前正在运行的线程失去CPU使用权,让系统的调度器重新调度一次,由于Java虚拟机默认采用抢占式调度模型,所有线程都会再次抢占CUP资源使用权,所以在执行线程让步后并不能保证立即执行其他线程

4.4 线程插队

现实生活中经常能碰到“插队”的情况,同样,在Thread类中也提供了一个join()方法来实现这个“功能”。当在某个线程中调用其他线程的join()方法时,调用的线程将被阻塞,直到被join()方法加入的线程执行完成后它才会继续运行。接下来通过一个案例来演示一下join()方法的使用,如下所示。
例4-4 Demo8.java

public class Demo8 {
    public static void main(String[] args) {
        // 创建新线程t1,使用Lambda表达式定义线程执行的任务
        Thread t1 = new Thread(() -> {
            int i = 0;
            // 循环执行8次
            while (i++ < 8)
                // 输出当前线程的名称及执行次数
                System.out.println(Thread.currentThread().getName() + "正在执行第" + i + "次");
        });

        // 启动线程t1
        t1.start();

        int i = 0;

        // 主线程循环执行10次
        while (i++ < 10) {
            try {
                // 当i大于5时,主线程将等待t1线程完成
                if (i > 5) t1.join(); // join()方法使当前线程等待t1线程完成
            } catch (InterruptedException e) {
                // 捕获并处理线程中断异常
                e.printStackTrace();
            }
            // 输出主线程的名称及执行次数
            System.out.println("main线程正在执行第" + i + "次");
        }
    }
}

输出

main线程正在执行第1次
main线程正在执行第2次
main线程正在执行第3次
Thread-0正在执行第1次
main线程正在执行第4次
Thread-0正在执行第2次
main线程正在执行第5次
Thread-0正在执行第3次
Thread-0正在执行第4次
Thread-0正在执行第5次
Thread-0正在执行第6次
Thread-0正在执行第7次
Thread-0正在执行第8次
main线程正在执行第6次
main线程正在执行第7次
main线程正在执行第8次
main线程正在执行第9次
main线程正在执行第10次

案例中,在main线程中开启了一个线程thread-0,这两个线程会相互争夺CUP使用权输出语句。当main线程中的循环变量大于5时,调用thread-0线程的join()方法,这时,thread-0线程就会“插队”优先执行,并且整个程序执行完毕后才会执行其他线程。从运行结果可以看出,当main线程输出5以后,thread-0线程就开始执行,直到执行完毕,main线程才继续执行。

Thread类中除了提供一个无参数的线程插队join()方法外,还提供了带有时间参数的线程插队方法join(long millis)。当执行带有时间参数的join(long millis)进行线程插队时,必须等待插入的线程指定时间过后才会继续执行其他线程。

5. 多线程同步

5.1 线程安全

上一节的售票案例,极有可能碰到“意外”情况,如一张票被打印多次,或者打印出的票号为0甚至负数。这些“意外”都是由多线程操作共享资源tickets所导致的线程安全问题,接下来对案例进行修改,模拟四个窗口出售10张票,并在售票的代码中每次售票时线程休眠100毫秒,如下所示。
例5-1 Demo9.java

// 实现Runnable接口的类,用于售票
class TicketThread implements Runnable {
    // 初始票数设为10
    private int tickets = 10;

    // 重写run方法,定义线程的执行逻辑
    public void run() {
        // 当还有票时,进行售票
		while(true){
			if (tickets > 0) {
				try {
					// 模拟售票的延时,睡眠100毫秒
					Thread.sleep(100);
				} catch (InterruptedException e) {
					// 捕获并处理线程中断异常
					e.printStackTrace();
				}
				// 输出当前线程的名称及售卖的票号,并减少票数
				System.out.println(Thread.currentThread().getName() + "正在售卖第" + tickets-- + "张票");
			}
		}
    }
}

public class Demo9 {
    public static void main(String[] args) {
        // 创建TicketThread实例
        TicketThread tt = new TicketThread();
        
        // 创建多个线程,分别代表不同的售票窗口
        Thread t1 = new Thread(tt, "窗口1");
        Thread t2 = new Thread(tt, "窗口2");
        Thread t3 = new Thread(tt, "窗口3");
        Thread t4 = new Thread(tt, "窗口4");

        // 启动所有线程
        t1.start();
        t2.start();
        t3.start();
        t4.start();
    }
}

输出

窗口4正在售卖第9张票
窗口3正在售卖第8张票
窗口1正在售卖第7张票
窗口2正在售卖第10张票
窗口4正在售卖第6张票
窗口3正在售卖第5张票
窗口2正在售卖第4张票
窗口1正在售卖第3张票
窗口4正在售卖第2张票
窗口3正在售卖第1张票
窗口2正在售卖第0张票
窗口1正在售卖第-1张票
窗口4正在售卖第-2张票

输出结果中,最后几行打印售出的票为0和负数,这种现象是不应该出现的,因为在售票程序中做了判断只有当票号大于0时才会进行售票。运行结果中之所以出现了负数的票号是因为多线程在售票时出现了安全问题。

在售票程序的while循环中添加了sleep()方法,这样就模拟了售票过程中线程的延迟。由于线程有延迟,当票号减为1时,假设窗口2线程此时出售1号票,对票号进行判断后,进入while循环,在售票之前通过sleep()方法模拟售票时耗时操作,这时窗口1线程会进行售票,由于此时票号仍为1,因此窗口1线程也会进入循环,同理,四个线程都会进入while循环,休眠结束后,四个线程都会进行售票,这样就相当于将票号减了四次,结果中出现了1、0、-1、-2这样的票号。

5.2 同步代码块

通过前面小节的学习,了解到线程安全问题其实就是由多个线程同时处理共享资源所导致的。要想解决线程安全问题,必须得保证处理共享资源的代码在任意时刻只能有一个线程访问。为此,Java中提供了线程同步机制。当多个线程使用同一个共享资源时,可以将处理共享资源的代码放置在一个使用synchronized关键字来修饰的代码块中,这段代码块被称作同步代码块,其语法格式如下:

    synchronized(lock){
        // 需要同步的代码
    }

上述代码中,lock是一个锁对象,它是同步代码块的关键。当线程执行同步代码块时,首先会检查锁对象的标志位,默认情况下标志位为1,此时线程会执行同步代码块,同时将锁对象的标志位置为0。当一个新的线程执行到这段同步代码块时,由于锁对象的标志位为0,新线程会发生阻塞,等待当前线程执行完同步代码块后,锁对象的标志位被置为1,新线程才能进入同步代码块执行其中的代码,这样循环往复,直到共享资源被处理完为止。这个过程就好比一个公用电话亭,只有前一个人打完电话出来后,后面的人才可以打。

接下来将售票的代码放到synchronized区域中进行修改,如下所示。
例5-2 Demo10.java

// 实现Runnable接口的类,用于售票
class TicketThread implements Runnable {
    // 初始票数设为10
    private int tickets = 10;

    // 创建一个锁对象,用于控制线程同步
    Object lock = new Object();

    // 重写run方法,定义线程的执行逻辑
    public void run() {
        // 当还有票时,进行售票
        while (true) {
            // 进入同步代码块,确保同一时刻只有一个线程能执行该代码
            synchronized (lock) {
                // 检查是否还有票可售
                if (tickets > 0) {
                    try {
                        // 输出当前线程的名称及售卖的票号,并减少票数
                        System.out.println(Thread.currentThread().getName() + "正在售卖第" + tickets-- + "张票");
                        // 模拟售票的延时,睡眠50毫秒
                        Thread.sleep(50);
                    } catch (InterruptedException e) {
                        // 捕获并处理线程中断异常
                        e.printStackTrace();
                    }
                } else {
                    // 如果票已售完,输出信息并跳出循环
                    System.out.println(Thread.currentThread().getName() + "票已售完");
                    break; // 退出循环
                }
            }
        }
    }
}

public class Demo10 {
    public static void main(String[] args) {
        // 创建TicketThread实例
        TicketThread tt = new TicketThread();
        
        // 创建多个线程,分别代表不同的售票窗口
        Thread t1 = new Thread(tt, "窗口1");
        Thread t2 = new Thread(tt, "窗口2");
        Thread t3 = new Thread(tt, "窗口3");
        Thread t4 = new Thread(tt, "窗口4");

        // 启动所有线程
        t1.start();
        t2.start();
        t3.start();
        t4.start();
    }
}

输出

窗口1正在售卖第10张票
窗口1正在售卖第9张票
窗口1正在售卖第8张票
窗口1正在售卖第7张票
窗口2正在售卖第6张票
窗口2正在售卖第5张票
窗口4正在售卖第4张票
窗口4正在售卖第3张票
窗口3正在售卖第2张票
窗口3正在售卖第1张票
窗口3票已售完
窗口4票已售完
窗口2票已售完
窗口1票已售完

案例中,将有关tickets变量的操作全部都放到同步代码块中synchronized (lock) {},从结果可以看出,售出的票不再出现0和负数的情况,这是因为售票的代码实现了同步,之前出现的线程安全问题得以解决。
tip:同步代码块中的锁对象可以是任意类型的对象,但多个线程共享的锁对象必须是相同的。“任意”说的是共享锁对象的类型。所以,锁对象的创建代码不能放到run()方法中,否则每个线程运行到run()方法都会创建一个新对象,这样每个线程都会有一个不同的锁,每个锁都有自己的标志位,线程之间便不能产生同步的效果。

5.3 同步方法

通过前面小节的学习,了解到同步代码块可以有效解决线程的安全问题,当把共享资源的操作放在synchronized定义的区域内时,便为这些操作加了同步锁。同样,在方法前面也可以使用synchronized关键字来修饰,被修饰的方法为同步方法,它能实现和同步代码块同样的功能,具体语法格式如下:

[修饰符] synchronized 返回值类型 方法名([参数1,……]){}

被synchronized修饰的方法在某一时刻只允许一个线程访问,访问该方法的其他线程都会发生阻塞,直到当前线程访问完毕后,其他线程才有机会执行。

接下来使用同步方法模拟售票系统,如下所示。
例5-3 Demo11.java

package com.demo.jdbc;

// 实现Runnable接口的类,用于售票
class TicketThread implements Runnable {
    // 初始票数设为10
    private int tickets = 10;

    // 创建一个锁对象,用于控制线程同步
    Object lock = new Object();

    // 重写run方法,定义线程的执行逻辑
    public void run() {
        // 当还有票时,反复进行售票操作
        while (true) {
            saleTicket(); // 调用售票方法
        }
    }

    // 售票方法,确保线程安全
    private synchronized void saleTicket() {
        if (tickets > 0) { // 检查是否还有票可售
            try {
                // 模拟售票的延时,睡眠50毫秒
                Thread.sleep(50);
            } catch (InterruptedException e) {
                // 捕获并处理线程中断异常
                e.printStackTrace();
            }

            // 输出当前线程的名称及售卖的票号,并减少票数
            System.out.println(Thread.currentThread().getName() + "正在售卖第" + tickets-- + "张票");
        }
    }
}

public class Demo11 {
    public static void main(String[] args) {
        // 创建TicketThread实例
        TicketThread tt = new TicketThread();

        // 创建多个线程,分别代表不同的售票窗口
        Thread t1 = new Thread(tt, "窗口1");
        Thread t2 = new Thread(tt, "窗口2");
        Thread t3 = new Thread(tt, "窗口3");
        Thread t4 = new Thread(tt, "窗口4");

        // 启动所有线程
        t1.start();
        t2.start();
        t3.start();
        t4.start();
    }
}

输出

窗口1正在售卖第10张票
窗口4正在售卖第9张票
窗口4正在售卖第8张票
窗口3正在售卖第7张票
窗口3正在售卖第6张票
窗口2正在售卖第5张票
窗口2正在售卖第4张票
窗口2正在售卖第3张票
窗口2正在售卖第2张票
窗口2正在售卖第1张票

案例中,将售票代码抽取为售票方法saleTicket(),并用synchronized关键字把saleTicket()修饰为同步方法,然后在run()方法中调用该方法。从图1可以看出,同样没有出现0号和负数号的票,说明同步方法实现了和同步代码块一样的效果。

tip:大家可能会有这样的疑问:同步代码块的锁是自己定义的任意类型的对象,那么同步方法是否也存在锁?如果有,它的锁是什么呢?答案是肯定的,同步方法也有锁,它的锁就是当前调用该方法的对象,也就是this指向的对象。这样做的好处是,同步方法被所有线程所共享,方法所在的对象相对于所有线程来说是唯一的,从而保证了锁的唯一性。当一个线程执行该方法时,其他的线程就不能进入该方法中,直到这个线程执行完该方法为止,从而达到了线程同步的效果。

有时候需要同步的方法是静态方法,静态方法不需要创建对象就可以直接用“类名.方法名()”的方式调用。这时候我们就会有一个疑问,如果不创建对象,静态同步方法的锁就不会是this,那么静态同步方法的锁是什么?Java中静态方法的锁是该方法所在类的class对象,该对象可以直接类名.class的方式获取

同步代码块和同步方法解决多线程问题有好处也有弊端。同步解决了多个线程同时访问共享数据时的线程安全问题,只要加上同一个锁,在同一时间内只能有一条线程执行,但是线程在执行同步代码时每次都会判断锁的状态,非常消耗资源,效率较低。

5.4 同步锁

synchronized同步代码块同步方法使用一种封闭式的锁机制,使用起来非常简单,也能够解决线程同步过程中出现的线程安全问题,但也有一些限制,例如它无法中断一个正在等候获得锁的线程,也无法通过轮询得到锁,如果不想等下去,也就没法得到锁。

从JDK 5开始,Java增加了一个功能更强大的Lock锁。Lock锁与synchronized隐式锁在功能上基本相同,其最大的优势在于Lock锁可以让某个线程在持续获取同步锁失败后返回,不再继续等待,另外Lock锁在使用时也更加灵活。java.util.concurrent.locks 包提供了多种锁的实现,其中最常用的类是 ReentrantLock。以下是 ReentrantLock 的构造方法及常用方法介绍。

ReentrantLock 构造方法

方法签名说明
public ReentrantLock()创建一个非公平锁的实例。
public ReentrantLock(boolean fair)创建一个锁,并指定其是否为公平锁;true 为公平锁,false 为非公平锁。公平锁按照线程请求锁的顺序进行获取,而非公平锁则可能导致某些线程饥饿。

常用方法

方法签名说明
void lock()获取锁,如果锁已被其他线程持有,调用线程会被阻塞。
void unlock()释放锁,调用线程必须持有锁,否则抛出 IllegalMonitorStateException。
boolean tryLock()尝试获取锁,如果成功返回 true,否则返回 false。
boolean tryLock(long timeout, TimeUnit unit)尝试在指定时间内获取锁,如果成功返回 true,否则返回 false。
boolean isFair()返回锁的公平性,如果是公平锁返回 true,否则返回 false。
Thread getOwner()返回当前持有锁的线程,如果没有线程持有锁,则返回 null。
int getWaitQueueLength()返回等待获取锁的线程数量。

接下来将售票案例改为使用Lock锁进行演示,如下所示。
例5-4 Demo12.java

import java.util.concurrent.locks.*; // 导入并发锁的类

// 实现Runnable接口的类,用于售票
class TicketThread implements Runnable {
    // 初始票数设为10
    private int tickets = 10;
    // 创建一个可重入锁对象
    private Lock lock = new ReentrantLock();

    // 重写run方法,定义线程的执行逻辑
    public void run() {
        // 当还有票时,进行售票
        while (true) {
            lock.lock(); // 上锁,确保线程安全
            try {
                // 检查是否还有票可售
                if (tickets > 0) {
                    // 模拟售票的延时,睡眠50毫秒
                    Thread.sleep(50);
                    // 输出当前线程的名称及售卖的票号,并减少票数
                    System.out.println(Thread.currentThread().getName() + "正在售卖第" + tickets-- + "张票");
                }
            } catch (InterruptedException e) {
                // 捕获并处理线程中断异常
                e.printStackTrace();
            } finally {
                lock.unlock(); // 确保在每次执行后解锁
            }
        }
    }
}

public class Demo12 {
    public static void main(String[] args) {
        // 创建TicketThread实例
        TicketThread tt = new TicketThread();

        // 创建多个线程,分别代表不同的售票窗口
        Thread t1 = new Thread(tt, "窗口1");
        Thread t2 = new Thread(tt, "窗口2");
        Thread t3 = new Thread(tt, "窗口3");
        Thread t4 = new Thread(tt, "窗口4");

        // 启动所有线程
        t1.start();
        t2.start();
        t3.start();
        t4.start();
    }
}

输出

窗口1正在售卖第10张票
窗口1正在售卖第9张票
窗口2正在售卖第8张票
窗口2正在售卖第7张票
窗口2正在售卖第6张票
窗口3正在售卖第5张票
窗口3正在售卖第4张票
窗口4正在售卖第3张票
窗口1正在售卖第2张票
窗口1正在售卖第1张票

案例中,通过Lock接口的实现类ReentrantLock来创建一个Lock锁对象,并通过Lock锁对象的lock()方法和unlock()方法对核心代码块进行了上锁和解锁。从结果可以看出,使用Lock同步锁也可以实现正常售票,解决线程同步过程中的安全问题。

5.5 死锁问题

有五位哲学家围坐在一张圆形餐桌旁,每位哲学家左右两边各有一只餐叉。哲学家们的生活由交替进行的思考和进餐构成,进餐时他们必须同时拿起左右两边的餐叉。然而,哲学家们从不交谈,因此他们不知道其他哲学家的状态。如果每位哲学家都试图先拿起左边的餐叉,然后再尝试拿起右边的餐叉,那么在没有适当同步机制的情况下,就可能发生死锁——每位哲学家都拿着一只餐叉等待另一只,而另一只餐叉却被其他哲学家持有,导致无人能够进餐。

例5-5 Demo13.java

public class Demo13 {
    public static void main(String[] args) {
        // 创建5个叉子对象
        Object obj1 = new Object();
        Object obj2 = new Object();
        Object obj3 = new Object();
        Object obj4 = new Object();
        Object obj5 = new Object();
        
        // 创建多个线程,分别代表不同的哲学家
        Thread t1 = new Thread(() -> {
            // 哲学家1号尝试依次拿起叉子
            synchronized (obj1) {
                System.out.println("哲学家1号拿起了叉子");
                synchronized (obj2) {
                    synchronized (obj3) {
                        synchronized (obj4) {
                            synchronized (obj5) {
                                System.out.println("哲学家1号拿起了右边的5号叉子");
                            }
                        }
                    }
                }
            }
        });
        
        Thread t2 = new Thread(() -> {
            // 哲学家2号尝试依次拿起叉子
            synchronized (obj2) {
                System.out.println("哲学家2号拿起了叉子");
                synchronized (obj3) {
                    synchronized (obj4) {
                        synchronized (obj5) {
                            synchronized (obj1) {
                                System.out.println("哲学家2号拿起了右边的1号叉子");
                            }
                        }
                    }
                }
            }
        });
        
        Thread t3 = new Thread(() -> {
            // 哲学家3号尝试依次拿起叉子
            synchronized (obj3) {
                System.out.println("哲学家3号拿起了叉子");
                synchronized (obj4) {
                    synchronized (obj5) {
                        synchronized (obj1) {
                            synchronized (obj2) {
                                System.out.println("哲学家3号拿起了右边的2号叉子");
                            }
                        }
                    }
                }
            }
        });
        
        Thread t4 = new Thread(() -> {
            // 哲学家4号尝试依次拿起叉子
            synchronized (obj4) {
                System.out.println("哲学家4号拿起了叉子");
                synchronized (obj5) {
                    synchronized (obj1) {
                        synchronized (obj2) {
                            synchronized (obj3) {
                                System.out.println("哲学家4号拿起了右边的3号叉子");
                            }
                        }
                    }
                }
            }
        });
        
        Thread t5 = new Thread(() -> {
            // 哲学家5号尝试依次拿起叉子
            synchronized (obj5) {
                System.out.println("哲学家5号拿起了叉子");
                synchronized (obj1) {
                    synchronized (obj2) {
                        synchronized (obj3) {
                            synchronized (obj4) {
                                System.out.println("哲学家5号拿起了右边的4号叉子");
                            }
                        }
                    }
                }
            }
        });

        // 启动所有线程
        t1.start();
        t2.start();
        t3.start();
        t4.start();
        t5.start();
    }
}

输出

哲学家5号拿起了叉子
哲学家4号拿起了叉子
哲学家3号拿起了叉子
哲学家1号拿起了叉子
哲学家2号拿起了叉子

案例中,创建了5个叉子对象,并创建了5个线程,分别代表不同的哲学家。每个线程都尝试依次拿起叉子。由结果可以看出,最终每个线程都会持有一个叉子,要求却是必须持有两个叉子才能够进餐,所以此时就造成了死锁。

那有没有办法可以解决这个死锁呢?答案是有的,我们可以给叉子编号,并按照编号顺序上锁,从第二个人开始,都拿起属于自己的叉子,然后到第五个人拿起属于自己的叉子后,此时就只剩下第一个人没有拿起叉子,所以第五个人可以拿起一号的叉子,进餐完成后,第四个人再拿起五号的叉子,以此类推,最终剩下一号自己拿起自己和五号的叉子进餐。简单来说,就是按照编号顺序上锁,从第二个人开始,每次都拿起自己的叉子,直到最后一个人拿起自己的叉子,然后再拿起第一个人的叉子,这样就不会发生死锁。
例5-6 Demo14.java

public class Demo14 {
    public static void main(String[] args) {
        // 创建5个叉子对象,用于哲学家进餐
        Object obj1 = new Object();
        Object obj2 = new Object();
        Object obj3 = new Object();
        Object obj4 = new Object();
        Object obj5 = new Object();
        
        // 创建多个线程,分别代表不同的哲学家
        Thread t1 = new Thread(() -> {
            synchronized(obj1) { // 哲学家1号尝试拿起左边的叉子
                System.out.println("哲学家1号拿起了叉子");
                synchronized(obj2) { // 然后尝试拿起右边的叉子
                    synchronized(obj3) {
                        synchronized(obj4) {
                            synchronized(obj5) { // 继续拿起其他叉子
                                System.out.println("哲学家1号拿起了右边的5号叉子");
                            }
                        }
                    }    
                }
            }
        });
        
        Thread t2 = new Thread(() -> {
            synchronized(obj1) { // 哲学家2号尝试拿起左边的叉子
                System.out.println("哲学家2号拿起了叉子");
                synchronized(obj2) {
                    synchronized(obj3) {
                        synchronized(obj4) {
                            synchronized(obj5) { // 继续拿起其他叉子
                                System.out.println("哲学家2号拿起了右边的1号叉子");
                            }
                        }
                    }    
                }
            }
        });
        
        Thread t3 = new Thread(() -> {
            synchronized(obj1) { // 哲学家3号尝试拿起左边的叉子
                System.out.println("哲学家3号拿起了叉子");
                synchronized(obj2) {
                    synchronized(obj3) {
                        synchronized(obj4) {
                            synchronized(obj5) { // 继续拿起其他叉子
                                System.out.println("哲学家3号拿起了右边的2号叉子");
                            }
                        }
                    }    
                }
            }
        });
        
        Thread t4 = new Thread(() -> {
            synchronized(obj1) { // 哲学家4号尝试拿起左边的叉子
                System.out.println("哲学家4号拿起了叉子");
                synchronized(obj2) {
                    synchronized(obj3) {
                        synchronized(obj4) {
                            synchronized(obj5) { // 继续拿起其他叉子
                                System.out.println("哲学家4号拿起了右边的3号叉子");
                            }
                        }
                    }    
                }
            }
        });
        
        Thread t5 = new Thread(() -> {
            synchronized(obj1) { // 哲学家5号尝试拿起左边的叉子
                System.out.println("哲学家5号拿起了叉子");
                synchronized(obj2) {
                    synchronized(obj3) {
                        synchronized(obj4) {
                            synchronized(obj5) { // 继续拿起其他叉子
                                System.out.println("哲学家5号拿起了右边的4号叉子");
                            }
                        }
                    }    
                }
            }
        });

        // 启动所有线程
        t1.start();
        t2.start();
        t3.start();
        t4.start();
        t5.start();
    }
}

输出

哲学家1号拿起了叉子
哲学家1号拿起了右边的5号叉子
哲学家2号拿起了叉子
哲学家2号拿起了右边的1号叉子
哲学家3号拿起了叉子
哲学家3号拿起了右边的2号叉子
哲学家4号拿起了叉子
哲学家4号拿起了右边的3号叉子
哲学家5号拿起了叉子
哲学家5号拿起了右边的4号叉子

案例中,创建了5个叉子对象,并创建了5个线程,分别代表不同的哲学家。每个线程都尝试依次拿起左边的叉子,然后尝试拿起右边的叉子,直到拿起了自己的叉子后,再拿起其他人的叉子。从结果可以看出,最终每个线程都拿起了自己的叉子,进餐完成后,每个线程都释放了自己的叉子,从而解决了死锁问题。

6. 多线程通信

6.1 问题引出

为了更好地理解线程间的通信,可以模拟现实生活中常见的生产者消费者场景,假设有两个线程生产者线程和消费者线程,同时去操作同一种商品,其中生产者线程负责生产商品,消费者线程负责消费商品。接下来通过一个案例来实现上述情况,具体实现如下所示。
例6-1 Demo15.java

import java.util.List;
import java.util.ArrayList;

public class Demo15 {
    public static void main(String[] args) {
        // 创建一个字符串类型的列表,用于存储产品
        List<String> goods = new ArrayList<>();
        
        // 记录程序开始时间
        long start = System.currentTimeMillis();
        
        // 创建生产者线程
        Thread t1 = new Thread(() -> {
            int num = 0; // 生产的产品数量
            // 在100毫秒内持续生产产品
            while (System.currentTimeMillis() - start <= 100) {
                goods.add("商品" + ++num); // 向列表中添加一个产品
                System.out.println("生产产品" + num); // 打印生产的产品数量
            }
        }, "生产者");
        
        // 创建消费者线程
        Thread t2 = new Thread(() -> {
            int num = 0; // 消费的产品数量
            // 在100毫秒内持续消费产品
            while (System.currentTimeMillis() - start <= 100) {
                goods.remove("商品" + ++num); // 从列表中移除一个产品
                System.out.println("消费产品" + num); // 打印消费的产品数量
            }
        }, "消费者");
        
        // 启动生产者线程
        t1.start();
        // 启动消费者线程
        t2.start();

    }
}

输出

...
...
...
消费产品619
消费产品620
消费产品621
...
...
...
...
生产产品2452
生产产品2453
生产产品2454
生产产品2455
生产产品2456
生产产品2457
生产产品2458
生产产品2459
生产产品2460
生产产品2461
生产产品2462
生产产品2463
生产产品2464
消费产品622

案例中,简单模拟了生产者线程和消费者线程,生产者线程thread1用来生产商品并存入商品集合goods中,而消费者线程thread2用来消费商品并删除集合中的该商品,同时为了保证执行数据容易查看,控制了生产者线程和消费者线程任务的共同执行时间为100毫秒,通过在该任务执行时间内来演示多线程执行过程中出现的问题。

从输出结果可以看到,在两个线程任务执行起初阶段还比较正常,生产者线程一边生成商品,消费者线程一边消费商品,但是执行到后面生产者线程和消费者线程的供需节奏不一致,生产者线程一直在生产产品而消耗者线程不再消耗产品,出现这种情况显然是不正确的。

6.2 问题如何解决

如果想解决上述问题,就需要控制多个线程按照一定的顺序轮流执行,此时就需要让线程间进行通信,保证线程任务的协调进行。为此,Java在Object类中提供了wait()notify()notifyAll()等方法用于解决线程间的通信问题,因为Java中所有类都是Object类的子类或间接子类,因此任何类的实例对象都可以直接使用这些方法。接下来针对这几个方法进行简要说明,如表6-2所示。

表6-2 线程通信的常用方法

方法声明功能描述
void wait()使当前线程放弃同步锁并进入等待,直到其他线程进入此同步锁,并调用notify()或notifyAll()方法唤醒该线程为止
void notify()唤醒此同步锁上等待的第一个调用wait()方法的线程
void notifyAll()唤醒此同步锁上调用wait()方法的所有线程

表6-2中,列出了3个与线程通信相关的方法,其中wait()方法用于使当前线程进入等待状态,notify()和notifyAll()方法用于唤醒当前处于等待状态的线程。需要注意的是,wait()、notify()和notifyAll()这三个方法的调用者都应该是同步锁对象,如果这三个方法的调用者不是同步锁对象,Java虚拟机就会抛出IllegalMonitorStateException异常。

接下来通过使用wait()和notify()方法,实现线程间的通信,如下所示。
例6-2 Demo16.java

import java.util.List;
import java.util.ArrayList;

public class Demo16 {
    public static void main(String[] args) {
        // 创建一个字符串类型的列表,用于存储产品
        List<Object> goods = new ArrayList<>();
        
        // 记录程序开始时间
        long start = System.currentTimeMillis();
        
		Object lock = new Object();
		
        // 创建生产者线程
        Thread t1 = new Thread(() -> {
            int num = 0; // 生产的产品数量
            // 在100毫秒内持续生产产品
            while (System.currentTimeMillis()-start<=100) {
				synchronized(goods){
					if(goods.size() > 0) {
						try{
							goods.wait(100);
						}catch(InterruptedException e){
							e.printStackTrace();
						}
					}else{
						goods.add("商品" + ++num); // 向列表中添加一个产品
						System.out.println("生产产品" + num); // 打印生产的产品数量
					}					
				}
            }
        }, "生产者");
        
        // 创建消费者线程
        Thread t2 = new Thread(() -> {
            int num = 0; // 消费的产品数量
            // 在100毫秒内持续消费产品
			while (System.currentTimeMillis()-start<=100) {
				synchronized(goods){
					if(goods.size() <= 0) {
						goods.notify();
					}else{
						goods.remove("商品" + ++num); // 从列表中移除一个产品
						System.out.println("消费产品" + num); // 打印消费的产品数量
					}					
				}
            }
        }, "消费者");
        
        // 启动生产者线程
        t1.start();
        // 启动消费者线程
        t2.start();
    }
}

输出

...
...
...
生产产品1207
消费产品1207
生产产品1208
消费产品1208
生产产品1209
消费产品1209
生产产品1210
消费产品1210
生产产品1211
消费产品1211
生产产品1212
消费产品1212

案例中,在生产者和消费者线程的两个执行任务中同时使用synchronized关键字同步商品生产和消费,之后每生产出商品,便调用wait()方法将当前线程置于等待状态,等待消费者线程进行消费,当消费者线程执行任务发现没有商品时便调用notify()方法唤醒对应同步锁上等待的生成者线程,让生产者线程继续生产,从而持续达到供需有序、均衡。从结果可以看出,生产者线程和消费者线程按照先生产后消费的顺序轮流执行,不再出现供需节奏不一致的问题。

tip:Java为线程等待方法wait()提供了多个重载方法,包括无参wait()方法、有等待时间的wait(long timeout)方法和wait(long timeout, int nanos)方法。其中,带有等待时间参数的wait()方法,除了会在其他线程对象调用notify()和notifyAll()方法来唤醒当前处于等待状态的线程,还会在等待时间过后自动唤醒处于等待状态的线程。比如例子中的goods.wait();就可以修改成goods.wait(100);,这样当运行时间到达后,生产者线程会在100毫秒后自动唤醒,从而退出程序。

7. 线程池

7.0 Executor 接口实现线程池管理

从JDK 5开始,在java.util.concurrent包下新增了Executor接口及其子类,允许使用线程池技术来管理线程并发问题。Executor接口提供了一个常用的ExecutorService子接口,通过该子接口可以很方便的进行线程池管理。

通过Executor接口实现线程池管理的主要步骤如下:

  1. 创建一个实现Runnable接口或者Callable接口的实现类,同时重写run()或者call()方法;

  2. 创建Runnable接口或者Callable接口的实现类对象;

  3. 使用Executors线程执行器类创建线程池;

  4. 使用ExecutorService执行器服务类的submit()方法将Runnable接口或者Callable接口的实现类对象提交到线程池进行管理;

  5. 线程任务执行完成后,可以使用shutdown()方法关闭线程池。

线程池是通过ExecutorsnewCachedThreadPool()方法创建的,Executors是JDK 5中增加的线程执行器工具类,提供了4种方法来创建用于不同需求的线程池,如表所示。

表7-1 Executors创建线程池的方法

方法声明功能描述
ExecutorService newCachedThreadPool()创建一个可扩展线程池的执行器。这个线程池执行器适用于启动许多短期任务的应用程序
ExecutorService newFixedThreadPool( int nThreads)创建一个固定线程数量线程池的执行器。这种线程池执行器可以很好的控制多线程任务,也不会导致由于响应过多导致的程序崩溃
ExecutorService newSingleThreadExecutor()在特殊需求下创建一个只执行一个任务的单个线程
ScheduledExecutorService newScheduledThreadPool(int corePoolSize)创建一个定长线程池,支持定时及周期性任务执行

接下来通过一个案例来演示如何通过Executor接口来实现线程池管理,如下所示。
例7-1 Demo17.java

import java.util.concurrent.*;

// 实现Callable接口的类
class ExecutorThread implements Callable<Object>{
    // 线程执行的任务
    public Object call() {
        int num = 0; // 计数器
        // 输出当前线程的名称,并执行5次循环
        while(num++ < 5) {
            System.out.println(Thread.currentThread().getName() + " call()方法正在执行");
        }
        // 返回计数器的值
        return num;
    }
}

public class Demo17 {
    public static void main(String[] args) throws InterruptedException, ExecutionException {
        // 创建Callable任务
        ExecutorThread t = new ExecutorThread();
        
        // 创建一个可缓存的线程池
        ExecutorService es = Executors.newCachedThreadPool();
        
        // 提交任务并获取Future对象
        Future<Object> f1 = es.submit(t); // 提交第一个任务
        Future<Object> f2 = es.submit(t); // 提交第二个任务
        
        // 关闭线程池,禁止接受新任务
        es.shutdown();
        
        // 获取线程1的返回结果
        System.out.println("线程1返回结果:" + f1.get());
        // 获取线程2的返回结果
        System.out.println("线程2返回结果:" + f2.get());
    }
}

输出

pool-1-thread-1 call()方法正在执行
pool-1-thread-1 call()方法正在执行
pool-1-thread-2 call()方法正在执行
pool-1-thread-1 call()方法正在执行
pool-1-thread-2 call()方法正在执行
pool-1-thread-1 call()方法正在执行
pool-1-thread-2 call()方法正在执行
pool-1-thread-2 call()方法正在执行
pool-1-thread-2 call()方法正在执行
pool-1-thread-1 call()方法正在执行
线程1返回结果:6
线程2返回结果:6

从结果可以看出,案例创建了一个自定义的线程池es(线程池默认生成名称为pool-1),在该线程池中管理有两个默认生成名称的线程thread-1和thread-2,同时还可以获取这两个线程的执行结果。

7.2 CompletableFuture 类实现线程池管理

在使用Callable接口实现多线程时,会用到FutureTask类对线程执行结果进行管理和获取,由于该类在获取结果时是通过阻塞或者轮询的方式,违背多线程编程的初衷且耗费过多资源。为此,JDK 8中对FutureTask存在的不足进行了改进,新增了一个强大的函数式异步编程辅助类CompletableFuture,该类同时实现了Future接口和CompletionStage接口(Java 8中新增的一个线程任务完成结果接口),并对Future进行了强大的扩展,简化异步编程的复杂性。

7.2.1 CompletableFuture 创建方式

在使用CompletableFuture类在进行线程管理时,通常会使用四个静态方法来为一段异步执行的代码创建CompletableFuture对象,具体如表所示。

表7-2-1 CompletableFuture对象创建的四个静态方法

方法声明功能描述
static CompletableFuture runAsync(Runnable runnable)以Runnable函数式接口类型为参数,并使用ForkJoinPool.commonPool()作为它的线程池执行异步代码获取CompletableFuture计算结果为空的对象
static CompletableFuture runAsync(Runnable runnable, Executor executor)以Runnable函数式接口类型为参数,并传入指定的线程池执行器executor来获取CompletableFuture计算结果为空的对象
static CompletableFuture supplyAsync(Supplier supplier)以Supplier函数式接口类型为参数,并使用ForkJoinPool.commonPool()作为它的线程池执行异步代码获取CompletableFuture计算结果非空的对象
static CompletableFuture supplyAsync(Supplier supplier, Executor executor)以Supplier函数式接口类型为参数,并传入指定的线程池执行器executor来获取CompletableFuture计算结果非空的对象

表7-2中,获取CompletableFuture对象的静态方法中,runAsync()supplyAsync()方法的本质区别就是获取的CompletableFuture对象是否带有计算结果(类似于Runnable接口和Callable接口的区别)。另外,带有Executor参数的方法用于传入指定的线程池执行器来进行多线程管理,而未带有Executor参数的方法会使用默认的ForkJoinPool.commonPool()作为它的线程池进行多线程管理。

表7-2-2 获取任务执行结果方法

方法声明功能描述
get()获取CompletableFuture计算结果,如果计算结果为空,则会阻塞当前线程直到计算结果可用
get(long timeout, TimeUnit unit)获取CompletableFuture计算结果,如果计算结果为空,则会在指定的时间内阻塞当前线程直到计算结果可用。当到了指定的时间还未获取到任务,就会抛出TimeoutException异常。
join()获取CompletableFuture计算结果,如果计算结果为空,则会阻塞当前线程直到计算结果可用
getNow(T valueIfAbsent)获取CompletableFuture计算结果,如果计算结果为空,则返回默认值valueIfAbsent

了解了创建CompletableFuture对象的常用方法后,接下来通过一个案例来演示如何通过CompletableFuture来实现线程池管理,如下所示。
例7-2 Demo18.java

import java.util.concurrent.*;

// 实现Runnable接口的类
class CompletableThread implements Runnable {
    // 线程执行的任务
    public void run() {
        int num = 0; // 计数器
        // 输出当前线程的名称,并执行5次循环
        while(num++ < 5) {
            System.out.println(Thread.currentThread().getName() + " ----run()方法正在执行");
        }
    }
}

public class Demo18 {
    public static void main(String[] args) throws InterruptedException, ExecutionException {

        CompletableThread t1 = new CompletableThread(); // 创建CompletableThread实例
        
        // 创建一个可缓存的线程池
        ExecutorService es = Executors.newCachedThreadPool();

        // 异步执行t1的任务,使用默认的ForkJoinPool
        CompletableFuture<Void> cf = CompletableFuture.runAsync(t1);

        // 异步执行t1的任务,使用自定义的线程池es
        CompletableFuture<Void> cf1 = CompletableFuture.runAsync(t1, es);
        
        // 使用supplyAsync创建一个CompletableFuture,执行计算并返回结果
        CompletableFuture<Integer> cf2 = CompletableFuture.supplyAsync(() -> {
            int num = 0; // 计数器
            int sum = 0; // 累加和
            // 输出当前线程的名称,并执行5次循环
            while(num++ < 5) {
                sum += num; // 累加num
                System.out.println(Thread.currentThread().getName() + " ----run()方法正在执行,num值为:" + num);
            }
            return sum; // 返回累加结果
        });
        
        // 使用supplyAsync创建另一个CompletableFuture,执行计算并返回结果,指定线程池
        CompletableFuture<Integer> cf3 = CompletableFuture.supplyAsync(() -> {
            int num = 5; // 计数器
            int sum = 0; // 累加和
            // 输出当前线程的名称,并执行5次循环
            while(num++ < 10) {
                sum += num; // 累加num
                System.out.println(Thread.currentThread().getName() + " ----run()方法正在执行,num值为:" + num);
            }
            return sum; // 返回累加结果
        }, es);
    
        // 将两个CompletableFuture的结果进行结合,计算总和
        CompletableFuture<Integer> cf4 = cf2.thenCombine(cf3, (result1, result2) -> result1 + result2);

        // 获取cf4的结果,输出1到10相加的结果
        System.out.println("1到10相加的结果为:" + cf4.get());

        // 关闭线程池,禁止接受新任务
        es.shutdown();
    }
}

输出

ForkJoinPool.commonPool-worker-3 ----run()方法正在执行
ForkJoinPool.commonPool-worker-3 ----run()方法正在执行
ForkJoinPool.commonPool-worker-3 ----run()方法正在执行
ForkJoinPool.commonPool-worker-3 ----run()方法正在执行
ForkJoinPool.commonPool-worker-3 ----run()方法正在执行
pool-1-thread-1 ----run()方法正在执行
pool-1-thread-1 ----run()方法正在执行
pool-1-thread-1 ----run()方法正在执行
pool-1-thread-1 ----run()方法正在执行
pool-1-thread-1 ----run()方法正在执行
pool-1-thread-2 ----run()方法正在执行,num值为:6
ForkJoinPool.commonPool-worker-5 ----run()方法正在执行,num值为:1
pool-1-thread-2 ----run()方法正在执行,num值为:7
ForkJoinPool.commonPool-worker-5 ----run()方法正在执行,num值为:2
pool-1-thread-2 ----run()方法正在执行,num值为:8
ForkJoinPool.commonPool-worker-5 ----run()方法正在执行,num值为:3
pool-1-thread-2 ----run()方法正在执行,num值为:9
ForkJoinPool.commonPool-worker-5 ----run()方法正在执行,num值为:4
pool-1-thread-2 ----run()方法正在执行,num值为:10
ForkJoinPool.commonPool-worker-5 ----run()方法正在执行,num值为:5
1到10相加的结果为:55

从结果可以看出,案例创建了两个CompletableFuture对象cf和cf1,分别使用默认的ForkJoinPool和自定义的线程池es来异步执行t1的任务。另外,还创建了两个CompletableFuture对象cf2和cf3,分别使用默认的ForkJoinPool和自定义的线程池es来异步执行计算任务,并返回计算结果。通过thenCombine()方法,可以将cf2和cf3的结果进行结合,计算总和。最后,通过get()方法获取cf4的结果,输出1到10相加的结果。

7.2.2 主动触发任务完成方法

在使用CompletableFuture类进行线程管理时,还可以主动触发任务完成的方法,具体如表所示。

表7-2-3 主动触发任务完成方法

方法声明功能描述
complete(T value)主动触发当前异步任务的完成,如果任务已完成,返回false,如果任务没完成,就会返回true,通过get()/jion()获取的值就是value 值。
completeExceptionally(Throwable ex)主动触发当前异步任务的完成,如果任务已完成,返回false,如果任务没完成,就会返回true,通过get()/jion()获取的值就是completeExceptionally设置的参数(也就是ex异常)

7.2.3 回调方法

在使用CompletableFuture类进行线程管理时,还可以设置回调方法,当任务完成时,会自动调用回调方法,具体如表所示。

表7-2-4 回调方法

方法声明功能描述
thenApply(Function<? super T,? extends U> fn)设置一个Function类型的回调函数,当任务完成时,会自动调用该回调函数,有参数有结果,参数为前置任务的结果,返回值就是当前任务的结果。
thenAccept(Consumer<? super T> action)设置一个Consumer类型的回调函数,当任务完成时,会自动调用该回调函数,有参数,无结果,参数为前置任务的结果。
thenRun(Runnable action)设置一个Runnable类型的回调函数,当任务完成时,会自动调用该回调函数,无参数,无结果。
thenApplyAsync(Function<? super T,? extends U> fn)同上,不过是异步执行
thenAcceptAsync(Consumer<? super T> action)同上,不过是异步执行
thenRunAsync(Runnable action)同上,不过是异步执行
exceptionally(Function<Throwable,? extends T> fn)设置一个Function类型的回调函数,当任务抛出异常时,会自动调用该回调函数,参数为异常,返回值就是任务的结果(如果有的话)。
whenComplete(BiConsumer<? super T,? super Throwable> action)设置一个BiConsumer类型的回调函数,当任务完成时,会自动调用该回调函数,参数分别为任务的结果和异常(如果有的话)。正常执行和出现异常都会回调,不会影响上一阶段的返回结果,无返回值。不会吞掉异常,也就是说主线程在获取执行异常任务的结果时,会抛出异常。
handle(BiFunction<? super T, Throwable, ? extends U> fn)设置一个BiFunction类型的回调函数,当任务完成时,会自动调用该回调函数,参数分别为任务的结果和异常(如果有的话),返回值就是当前任务的结果。正常执行和出现异常都会回调,有返回值。会吞掉异常

通过案例来演示这些方法的使用,如下所示。
例7-3 Demo19.java

import java.util.concurrent.*;

public class Demo19 {
    public static void main(String[] args) throws InterruptedException, ExecutionException {
 
        // 创建一个可缓存的线程池
        ExecutorService es = Executors.newCachedThreadPool();
        
        // 使用supplyAsync创建一个CompletableFuture,执行计算并返回结果
        CompletableFuture cf = CompletableFuture.supplyAsync(() -> {
            int num = 0; // 计数器
            int sum = 0; // 累加和
            // 输出当前线程的名称,并执行5次循环
            while(num++ < 5) {
                sum += num; // 累加num
                // System.out.println(Thread.currentThread().getName() + " ----run()方法正在执行,num值为:" + num);
            }
            return sum; // 返回累加结果
        }, es).thenApply(sum -> {
            // 在任务完成后处理结果
            System.out.println(Thread.currentThread().getName() + " ----调用的是thenApply()方法,sum的结果是" + sum);
            return sum; // 返回sum以便后续使用
        }).thenAccept(sum -> {
            // 不返回结果,只执行操作
            System.out.println(Thread.currentThread().getName() + " ----调用的是thenAccept()方法,sum的结果是" + sum);
        }).thenRun(() -> {
            // 不接收参数也不返回结果的操作
            System.out.println(Thread.currentThread().getName() + " ----调用的是thenRun()方法,无参数无结果");
        });
        
        // 使用supplyAsync创建另一个CompletableFuture,执行计算并返回结果
        CompletableFuture cf1 = CompletableFuture.supplyAsync(() -> {
            int num = 5; // 计数器
            int sum = 0; // 累加和
            // 输出当前线程的名称,并执行5次循环
            while(num++ < 10) {
                sum += num; // 累加num
                // System.out.println(Thread.currentThread().getName() + " ----run()方法正在执行,num值为:" + num);
            }
            return sum; // 返回累加结果
        }, es).thenApplyAsync(sum -> {
            // 在任务完成后异步处理结果
            System.out.println(Thread.currentThread().getName() + " ----调用的是thenApplyAsync()方法,cf1中sum的结果是" + sum);
            return sum; // 返回sum以便后续使用
        }, es).thenAcceptAsync(sum -> {
            // 异步执行,没有返回值
            System.out.println(Thread.currentThread().getName() + " ----调用的是thenAcceptAsync()方法,sum的结果是" + sum);
        }, es).thenRunAsync(() -> {
            // 异步执行,没有参数和返回值
            System.out.println(Thread.currentThread().getName() + " ----调用的是thenRunAsync()方法,无参数无结果");
        }, es);
        
        // 使用supplyAsync创建一个CompletableFuture,执行计算并返回结果,故意抛出异常
        CompletableFuture cf2 = CompletableFuture.supplyAsync(() -> {
            int i = 10 / 0; // 故意引发异常
            return i; // 不会到达这一步
        }, es).exceptionally(e -> {
            // 捕获异常并处理
            System.out.println("exceptionally()捕获到异常:" + e.getMessage());
            return 10; // 返回默认值
        });

        // 使用supplyAsync创建另一个CompletableFuture,执行计算并返回结果
        CompletableFuture cf3 = CompletableFuture.supplyAsync(() -> {
            int i = 10 / 2; // 正确计算
            return 20; // 返回累加结果
        }, es).handle((i, e) -> {
            // 处理结果或异常
            if (e != null) {
                System.out.println("handle()捕获到异常:" + e.getMessage());
                return 10; // 返回默认值
            }
            return i; // 返回计算结果
        });
        
        // 使用supplyAsync创建另一个CompletableFuture,故意引发异常
        CompletableFuture cf4 = CompletableFuture.supplyAsync(() -> {
            int i = 10 / 0; // 故意引发异常
            return 20; // 不会到达这一步
        }, es).whenComplete((i, e) -> {
            // 当任务完成时无论成功与否都会执行的操作
            if (e != null) {
                System.out.println("whenComplete()捕获到异常:" + e.getMessage());
            }
            System.out.println("cf4中whenComplete()最终的结果是" + i); // i可能为null,因为上面发生了异常
        });

        // 输出每个CompletableFuture的结果
        System.out.println("cf最终的结果是" + cf.get());
        System.out.println("cf1最终的结果是" + cf1.get());
        System.out.println("cf2最终的结果是" + cf2.join());
        System.out.println("cf3最终的结果是" + cf3.join());
        System.out.println("cf4最终的结果是" + cf4.join());
        
        // 关闭线程池,禁止接受新任务
        es.shutdown();
    }
}

输出

main ----调用的是thenApply()方法,sum的结果是15
main ----调用的是thenAccept()方法,sum的结果是15
main ----调用的是thenRun()方法,无参数无结果
pool-1-thread-1 ----调用的是thenApplyAsync()方法,cf1中sum的结果是40
pool-1-thread-2 ----调用的是thenAcceptAsync()方法,sum的结果是40
pool-1-thread-1 ----调用的是thenRunAsync()方法,无参数无结果
exceptionally()捕获到异常:java.lang.ArithmeticException: / by zero
whenComplete()捕获到异常:java.lang.ArithmeticException: / by zero
cf4中whenComplete()最终的结果是null
cf最终的结果是null
cf1最终的结果是null
cf2最终的结果是10
cf3最终的结果是20
Exception in thread "main" java.util.concurrent.CompletionException: java.lang.ArithmeticException: / by zero
        at java.base/java.util.concurrent.CompletableFuture.encodeThrowable(CompletableFuture.java:314)
        at java.base/java.util.concurrent.CompletableFuture.completeThrowable(CompletableFuture.java:319)
        at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1766)
        at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1130)
        at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:630)
        at java.base/java.lang.Thread.run(Thread.java:832)
Caused by: java.lang.ArithmeticException: / by zero
        at Demo19.lambda$main$12(Demo19.java:78)
        at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1764)
        ... 3 more