彻底弄懂CompletableFuture(缝补长文)

从创建线程的三种方式说起

方式一:继承Thread类实现多线程:

  1. 在Java中负责实现线程功能的类是java.lang.Thread 类。

  2. 可以通过创建 Thread的实例来创建新的线程。

  3. 每个线程都是通过某个特定的Thread对象所对应的方法run( )来完成其操作的,方法run( )称为线程体。

  4. 通过调用Thread类的start()方法来启动一个线程(只是将线程由新生态转为就绪态,而不是运行态)。

public class TestThread extends Thread {//继承Thread类
    //run()方法里是线程体
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println(this.getName() + ":" + i);
        }
    }
​
    public static void main(String[] args) {
        TestThread thread1 = new TestThread();//创建线程对象
        thread1.start();//启动线程
        TestThread thread2 = new TestThread();
        thread2.start();
    }
}

此种方式的缺点:因为Java只支持单继承多实现,所以当我们的类已经继承了一个类(如小程序必须继承自 Applet 类),则无法再继承 Thread 类。

方式二:通过Runnable接口实现多线程

在开发中,我们应用更多的是通过Runnable接口实现多线程。这种方式克服了实现Thread类的缺点,即在实现Runnable接口的同时还可以继承某个类。

public class TestThread2 implements Runnable {//自定义类实现Runnable接口;
    //run()方法里是线程体;
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + ":" + i);
        }
    }
    public static void main(String[] args) {
        //创建线程对象,把实现了Runnable接口的对象作为参数传入;
        Thread thread1 = new Thread(new TestThread2());
        thread1.start();//启动线程;
        Thread thread2 = new Thread(new TestThread2());
        thread2.start();
    }
}

原理: Thread中的public Thread(Runnable target)初始化方法将传入的target保存,在调用start方法的时候,实际上运行target的run();

方式三:通过Callable接口实现多线程

前2种方式都有一个缺陷就是:在执行完任务之后无法获取执行结果。如果需要获取执行结果,就必须通过共享变量或者使用线程通信的方式来达到效果,这样使用起来就比较麻烦。而自从Java 1.5开始,就提供了Callable和Future,通过它们可以在任务执行完毕之后得到任务执行结果。

代码很简单,具体原理稍后介绍。

public static void main(String[] args) throws ExecutionException, InterruptedException {
    ExecutorService executorService = Executors.newSingleThreadExecutor();
    Future<String> submit = executorService.submit(() -> {
        System.out.println(Thread.currentThread().getName() + "========>正在执行");
        Thread.sleep(3 * 1000L);
        return "success";
    });
    String result = submit.get();
    System.out.println("result=======>" + result);
    // 关闭线程池
    executorService.shutdown();
}

返回了Future类型的对象,通过这个对象,可以调用get方法获取返回值。(目前先知道这些就可以了,下文会详细解释。)

由几个类与接口间的关系引入CompletableFuture

Runable 接口

public interface Runnable {
    public abstract void run();
}

Future 接口

Future接口表示异步任务,是还没有完成的任务给出的未来结果。

public interface Future<V> {
    // 尝试取消执行任务。
    boolean cancel(boolean mayInterruptIfRunning);
    // 判断任务是否被取消。
    boolean isCancelled();
    // 判断任务是否已经被执行完成。
    boolean isDone();
    // 等待任务执行完成并获取运算结果。
    V get() throws InterruptedException, ExecutionException;
    // 多了一个超时时间。
    V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException;
}

RunableFuture 接口

RunableFuture 接口继承了Runable 接口和Future接口。

public interface RunnableFuture<V> extends Runnable, Future<V> {
    void run();
}

FutureTask类

FutureTask实现了RunnableFuture接口,RunnableFuture接口实现了Runnable和Future接口,接口中的具体实现由FutureTask来实现。这个类的两个构造方法如下 :

public FutureTask(Callable<V> callable) {  
    if (callable == null)
        throw new NullPointerException();
    sync = new Sync(callable);  
}  
public FutureTask(Runnable runnable, V result) {  
    sync = new Sync(Executors.callable(runnable, result));  
}

用于创建可以有返回值也可以没有返回值的线程

如上提供了两个构造函数,一个以Callable为参数,另外一个以Runnable为参数。这些类之间的关联对于任务建模的办法非常灵活,允许你基于FutureTask的Runnable特性(因为它实现了Runnable接口),把任务写成Callable,然后封装进一个由执行者调度并在必要时可以取消的FutureTask。

FutureTask可以由执行者调度,这一点很关键。它对外提供的方法基本上就是Future和Callable、Runnable接口的组合:get()、cancel、isDone()、isCancelled()和run(),而run()方法通常都是由执行者调用,我们基本上不需要直接调用它。

换句话说:创建线程需要使用new Thread(Runable),而FutureTask实现了Runable接口,可以用来作为Thread创建的参数。FutureTask既有Callable的构造函数,也有Runable的构造函数,也就是说无论是有返回值的还是无返回值的,都可以先封装成一个FutureTask对象,然后创建线程。同时,由于实现了Future接口,所以Future接口中的方法也可以使用。

总结一下,至此完成了一件事情,创建一个线程,这个线程可以有返回值也可以没有。FutureTask内部维护Callable类型的成员变量,对于Callable任务,直接赋值即可,而对于Runnable任务,需要先调用Executors#callable()把Runnable先包装成Callable,Executors#callable()用到了适配器模式,而RunnableAdapter实现了Callable接口,所以包装后的RunnableAdapter可以赋值给FutureTask.callable。

  • Runnable --> Executors.callable() --> RunnableAdapter implements Callable --> FutureTask.callable

  • Callable --> FutureTask.callable

代码分析见这里:Java多线程(三)——FutureTask/CompletableFuture - iwehdio - 博客园 (cnblogs.com)

举例:

public class FutureTaskTest {
    public static void main () throws InterruptedException, ExecutionException {
        FutureTask<String> task1 = new FutureTask<>(() -> {
            return "Callable";
        });
        new Thread(task1).start();
​
        FutureTask<String> task2 = new FutureTask<>(() -> {
        },"Runable");
        new Thread(task2).start();
​
        Thread.sleep(1000);
        System.out.println(task1.get());
        System.out.println(task2.get());
    }
}

返回值

Callable
Runable

为什么get()是阻塞的?

在FutureTask中定义了很多任务状态:

一个任务,有时可能非常耗时。而当用户使用futureTask.get()时,必然是希望获取最终结果的。如果FutureTask不帮我们阻塞,就有可能获取空结果。此时为了获取最终结果,用户不得不在外部自己写阻塞程序。所以,get()内部会判断当前任务的状态,只有当任务完成才返回。

线程从阻塞到获取结果,中间必然经历类似唤醒的操作,怎么做到的?

秘密就在awaitDone():核心的就是 for循环 + LockSupport。

LockSupport是一个线程阻塞工具类,所有的方法都是静态方法,可以让线程在任意位置阻塞,当然也有唤醒的方法。

LockSupport主要有两类方法:parkunpark。即让线程停下和启动。

举个栗子(我抄的):

public class ParkTest {
    @Test
    public void testPark() throws InterruptedException {
        // 存储线程
        List<Thread> threadList = new ArrayList<>();
        // 创建5个线程
        for (int i = 0; i < 5; i++) {
            Thread thread = new Thread(() -> {
                System.out.println("我是" + Thread.currentThread().getName() + ", 我开始工作了~");
                LockSupport.park(this);
                System.out.println("我是" + Thread.currentThread().getName() + ", 我又活过来了~");
            });
            thread.start();
            threadList.add(thread);
        }
        Thread.sleep(3 * 1000L);
        System.out.println("====== 所有线程都阻塞了,3秒后全部恢复了 ======");
        // unPark()所有线程
        for (Thread thread : threadList) {
            LockSupport.unpark(thread);
        }
        // 等所有线程执行完毕
        Thread.sleep(3 * 1000L);
    }
}

也就是说,调用get()后,如果当前没有结果,就会被park(),等有了结果再unpark()并往下走,最后取出outcome返回。

何时调用futureTask.get()?

    1. JDK把FutureTask#get()设计成阻塞的,建议不要立即调用get(),否则程序完全没有发挥异步优势,由异步阻塞变成同步阻塞。

    2. 开启多线程,当然应该发挥多线程的优势:

  • isDone() + get():

    实际开发时,异步线程具体会耗时多久有时很难预估,受网络、数据库等各方面影响。所以很难做到在合适的地方get()然后一击即中。FutureTask提供了isDone()方法, 当然,这种做法也不是很优雅。JDK1.8提供了CompletableFuture(实现了CompletionStage接口)解决这个问题。

CompletionStage接口

参考资料Java并发包之阶段执行之CompletionStage接口 - 莫待樱开春来踏雪觅芳踪 - 博客园 (cnblogs.com)

CompletionStage是一个“很简单”的接口。完全独立,没有继承任何其他接口,所有方法都是它自己定义的。

CompletionStage是Java8新增得一个接口,用于异步执行中的阶段处理,其大量用在Lambda表达式计算过程中,目前只有CompletableFuture一个实现类。

为了举例说明这些接口方法的使用,会用到部分CompletableFuture的方法,下一步再详细的介绍CompletableFuture。CompletionStage定义了一组接口用于在一个阶段执行结束之后,要么继续执行下一个阶段,要么对结果进行转换产生新的结果等等,一般来说要执行下一个阶段都需要上一个阶段正常完成,当然这个类也提供了对异常结果的处理接口。CompletionStage只定义了一组基本的接口,其实现类还可据此扩展出更丰富的方法。

CompletionStage的接口方法可以从多种角度进行分类,从最宏观的横向划分,CompletionStage的接口主要分三类:

  1. 产出型或者函数型:就是用上一个阶段的结果作为指定函数的参数执行函数产生新的结果。这一类接口方法名中基本都有apply字样,接口的参数是(Bi)Function类型。

  2. 消耗型或者消费型:就是用上一个阶段的结果作为指定操作的参数执行指定的操作,但不对阶段结果产生影响。这一类接口方法名中基本都有accept字样,接口的参数是(Bi)Consumer类型。

  3. 不消费也不产出型:就是不依据上一个阶段的执行结果,只要上一个阶段完成(但一般要求正常完成),就执行指定的操作,且不对阶段的结果产生影响。这一类接口方法名中基本都有run字样,接口的参数是Runnable类型。

在以上三类横向划分方法的基础上,又可以按照以下的规则对这些接口方法进行纵向的划分:

一、多阶段的依赖:一个阶段的执行可以由一个阶段的完成触发,或者两个阶段的同时完成,或者两个阶段中的任何一个完成。

  1. 方法前缀为then的方法安排了对单个阶段的依赖。

  2. 那些由完成两个阶段而触发的,可以结合他们的结果或产生的影响,这一类方法带有combine或者both字样。

  3. 那些由两个阶段中任意一个完成触发的,不能保证哪个的结果或效果用于相关阶段的计算,这类方法带有either字样。

二、按执行的方式:阶段之间的依赖关系控制计算的触发,但不保证任何特定的顺序。因为一个阶段的执行可以采用以下三种方式之一安排:

  1. 默认的执行方式。所有方法名没有async后缀的方法都按这种默认执行方式执行。

  2. 默认的异步执行。所有方法名以async为后缀,但没有Executor参数的方法都属于此类。

  3. 自定义执行方式。所有方法名以async为后缀,并且具有Executor参数的方法都属于此类。

默认的执行方式(包括默认的异步执行)的执行属性由CompletionStage的实现类指定例如CompletableFuture,而自定义的执行方式的执行属性由传入的Executor指定,这可能具有任意的执行属性,甚至可能不支持并发执行,但还是被安排异步执行。

三、按上一个阶段的完成状态:无论触发阶段是正常完成还是异常完成,都有两种形式的方法支持处理。

  1. 不论上一个阶段是正常还是异常完成:

    1. whenComplete方法可以在上一个阶段不论以何种方式完成的处理,但它是一个消费型接口,即不对整个阶段的结果产生影响。

    2. handle前缀的方法也可以在上一个阶段不论以何种方式完成的处理,它是一个产出型(或函数型)接口,既可以由上一个阶段的异常产出新结果,也可以其正常结果产出新结果,使该结果可以由其他相关阶段继续进一步处理。

  2. 上一个阶段是异常完成的时候执行:exceptionally方法可以在上一个阶段以异常完成时进行处理,它可以根据上一个阶段的异常产出新的结果,使该结果可以由其他相关阶段继续进一步处理。

CompletionStage类

CompletableFuture的简单使用:

上代码

@Test
public void testCallBack() throws InterruptedException, ExecutionException {
    // 提交一个任务,返回CompletableFuture
    CompletableFuture<String> completableFuture = CompletableFuture.supplyAsync(()-> {
        System.out.println("=============>异步线程开始...");
        System.out.println("=============>异步线程为:" + Thread.currentThread().getName());
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("=============>异步线程结束...");
        return "supplierResult";
    });
    // 阻塞获取结果
    System.out.println("异步结果是:" + completableFuture.get());
    System.out.println("main结束");
}

整个过程看起来和同步没啥区别,因为我们在main线程中使用了CompletableFuture#get(),直接阻塞了。

CompletableFuture和FutureTask的异同点:

  • 相同:都实现了Future接口,所以都可以使用诸如Future#get()、Future#isDone()、Future#cancel()等方法

  • 不同:

    • FutureTask实现了Runnable,所以它可以作为任务被执行,且内部维护outcome,可以存储结果

    • CompletableFuture没有实现Runnable,无法作为任务被执行,所以你无法把它直接丢给线程池执行,相反地,你可以把Supplier#get()这样的函数式接口实现类丢给它执行

    • CompletableFuture实现了CompletionStage,支持异步回调

FutureTask和CompletableFuture最大的区别在于,FutureTask需要我们主动阻塞获取,而CompletableFuture支持异步回调。CompletableFuture好像承担的其实是线程池的角色,而Supplier#get()则对应Runnable#run()、Callable#call()。

重新举个栗子

@Test
public void testCallBack() throws InterruptedException, ExecutionException {
    // 提交一个任务,返回CompletableFuture(注意,并不是把CompletableFuture提交到线程池,它没有实现Runnable)
    CompletableFuture<String> completableFuture = CompletableFuture.supplyAsync(new Supplier<String>() {
        @Override
        public String get() {
            System.out.println("=============>异步线程开始...");
            System.out.println("=============>异步线程为:" + Thread.currentThread().getName());
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("=============>异步线程结束...");
            return "supplierResult";
        }
    });
​
    // 异步回调:上面的Supplier#get()返回结果后,异步线程会回调BiConsumer#accept()
    completableFuture.whenComplete(new BiConsumer<String, Throwable>() {
        @Override
        public void accept(String s, Throwable throwable) {
            System.out.println("=============>异步任务结束回调...");
            System.out.println("=============>回调线程为:" + Thread.currentThread().getName());
        }
    });
​
    // CompletableFuture的异步线程是守护线程,一旦main结束就没了,为了看到打印结果,需要让main休眠一会儿
    System.out.println("main结束");
    TimeUnit.SECONDS.sleep(5);
}
​

总而言之,CompletionStage是一个接口,定义了一些方法,CompletableFuture实现了这些方法并设计出了异步回调的机制

异步线程会回调BiConsumer#accept(),而CompletableFuture#whenComplete()是主线程调用的。即CompletionStage中定义的诸如whenComplete()等方法虽然和异步回调有关系,但并不是最终被回调的方法,最终被回调的其实是whenComplete(BiConsumer)传进去的BiConsumer#accept()。

异步线程哪来的,Supplier如何被执行(CompletableFuture内部原理是怎么样的)?

TODO   今天太晚了,明天再写

总结一下

创建Thread时需要传入实现了Runable接口的类,但是对于有返回值的类,无法直接创建。为了解决这个问题,RunableFuture接口继承了Runable和Future,又有FutureTask对其进行了实现。FutureTask的构造函数既可以输入Runable对象也可以输入Callable对象,因此,可以先将要执行的函数转换成FutureTask对象,然后再用其创建线程执行,最后调用get方法获取返回值。

但是,由于FutureTask的get方法是阻塞的,使用起来不方便,于是CompletableFuture产生了,FutureTask和CompletableFuture最大的区别在于,FutureTask需要我们主动阻塞获取,而CompletableFuture好像承担的其实是线程池的角色,支持异步回调。

 

参考资料

Java多线程(三)——FutureTask/CompletableFuture - iwehdio - 博客园 (cnblogs.com)

创建线程的三种方式(Thread、Runnable、Callable) - 大盘鸡嘹咋咧 - 博客园 (cnblogs.com)

Java并发包之阶段执行之CompletionStage接口 - 莫待樱开春来踏雪觅芳踪 - 博客园 (cnblogs.com)

【JAVA8】快速理解Consumer、Supplier、Predicate与Function_consumer supplier_SunAlwaysOnline的博客-CSDN博客

CompletableFuture原理与实践-外卖商家端API的异步化 - 美团技术团队 (meituan.com)

posted @ 2023-06-16 23:53  Yeahchen  阅读(39)  评论(0编辑  收藏  举报