笑了,面試官問我知不知道非同步程式設計的Future。

why技術發表於2020-08-09

荒腔走板

大家好,我是 why,歡迎來到我連續周更優質原創文章的第 60 篇。

老規矩,先來一個簡短的荒腔走板,給冰冷的技術文注入一絲色彩。

上面這圖是我五年前,在學校宿舍拍的。

前幾天由於有點事情,開啟了多年沒有開啟的 QQ。然後突然推送了一個“那年今日”傳送的動態。

這張圖片就是那個動態裡面的。

2015 年 8 月的時候正是大三放暑假的時間,但是那個暑假我找了一個實習,所以暑假期間住在學校裡面。宿舍就我一個人。那個時候我完全沒有意識到,這是我程式猿生涯的一個真正的開端,也是我學生時代提前結束的宣告。

8 月 5 日凌晨,一隻小貓突然躥到了宿舍裡面,在宿舍裡面旁若無人的,像宿管阿姨一樣審查著一切東西。甚至直接跳到桌子上,看著我敲程式碼。完全不怕我的樣子。

於是我把它放到了我的自行車上,當模特拍了幾張照片。

初見這隻小貓時的那種驚喜我還記憶猶新,但是這波回憶殺給我的更大的衝擊是:原來,這件事已經過去五年了。

如果沒有QQ的這個提醒,你讓我想這件事是發生在什麼時候的,我的第一反應肯定是好多年前的事情了吧,慢慢咂摸之後有可能才想起,原來是大三暑假的時候的事情,然後再仔細一算,原來是僅僅五年前的事情呀。

短短的五年怎麼發生了怎麼多事情啊,把這五年塞的滿滿當當的。

不知道為什麼如果把人生求學、步入社會的各個階段分開來看,我每次回頭望的時候都感覺這好像是別人的故事啊。

幸好我自己記錄了下來,幸好這真的是我自己的故事。

好了,說迴文章。

你就是寫了個假非同步

先去我的第一篇公眾號文章中拿張圖片:《Dubbo 2.7新特性之非同步化改造》

這是 rpc 的四種呼叫方式:

文字主要分享這個 future 的呼叫方式,不講 Dubbo 框架,這裡只是一個引子。

談到 future 的時候大家都會想到非同步程式設計。但是你仔細看框起來這裡:

客戶端執行緒呼叫 future.get() 方法的時候還是會阻塞當前執行緒的。

我倒是覺得這充其量只能算一個閹割版的非同步程式設計。

本文將帶你從閹割版的 future 聊到升級版的 Google Guava 的 future,最後談談加強版的 future 。

先聊聊執行緒池的提交方式

談到 Future 的時候,我們基本上就會想到執行緒池,想到它的幾種提交方式。

先是最簡單的,execute 方式提交,不關心返回值的,直接往執行緒池裡面扔任務就完事:

public class JDKThreadPoolExecutorTest {

    public static void main(String[] args) throws Exception {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(2, 5, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(10));
        //execute(Runnable command)方法。沒有返回值
        executor.execute(() -> {
            System.out.println("關注why技術");
        });
        Thread.currentThread().join();
    }
}

可以看一下 execute 方法,接受一個 Runnable 方法,返回型別是 void:

然後是 submit 方法。你知道執行緒池有幾種 submit 方法嗎?

雖然你經常用,但是可能你從來沒有關心過人家。呸,渣男:

有三種 submit。這三種按照提交任務的型別來算分為兩個型別。

  • 提交執行 Runnable 型別的任務。

  • 提交執行 Callable 型別的任務。

但是返回值都是 Future,這才是我們關心的東西。

也許你知道執行緒池有三種 submit 方法,但是也許你根本不知道里面的任務分為兩種型別,你就只知道往執行緒池裡面扔,也不管扔的是什麼型別的任務。

我們先看一下 Callable 型別的任務是怎麼執行的:

public class JDKThreadPoolExecutorTest {

    public static void main(String[] args) throws Exception {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(2, 5, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(10));
        Future<String> future = executor.submit(() -> {
            System.out.println("關注why技術");
            return "這次一定!";
        });
        System.out.println("future的內容:" + future.get());
        Thread.currentThread().join();
    }
}

這裡利用 lambda 表示式,直接在任務體裡面帶上一個返回值,這時你看呼叫的方法就變成了這個:

執行結果也能拿到任務體裡面的返回了。輸出結果如下:

好,接下來再說說 submit 的任務為 Runable 型別的情況。

這個時候有兩個過載的形式:

標號為 ① 的方法扔進去一個 Runable 的任務,返回一個 Future,而這個返回的 Future ,相當於是返回了一個寂寞。下面我會說到原因。

標號為 ② 的方法扔進去一個 Runable 的任務的同時,再扔進去一個泛型 T ,而巧好返回的 Future 裡面的泛型也是 T,那麼我們大膽的猜測一下這就是同一個物件。如果是同一個物件,說明我們可以一個物件傳到任務體裡面去一頓操作,然後通過 Future 再次拿到這個物件的。一會就去驗證。

來,先驗證標號為 ① 的方法,我為啥說它返回了一個寂寞。

首先,還是先把測試案例放在這裡:

public class JDKThreadPoolExecutorTest {

    public static void main(String[] args) throws Exception {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(2, 5, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(10));
        Future<?> future = executor.submit(() -> {
            System.out.println("關注why技術");
        });
        System.out.println("future的內容:" + future.get());
        Thread.currentThread().join();
    }
}

可以看到,確實是呼叫的標號為 ① 的方法:

同時,我們也可以看到 future.get() 方法的返回值為 null。

你說,這不是返回了一個寂寞是幹啥?

當你想用標號為 ① 的方法時,我勸你直接用 execute 方式提交任務。還不需要構建一個寂寞的返回值,徒增無用物件。

接下來,我們看看標號為 ② 的方法是怎麼用的:

public class JDKThreadPoolExecutorTest {

    public static void main(String[] args) throws Exception {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(2, 5, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(10));
        AtomicInteger atomicInteger = new AtomicInteger();
        Future<AtomicInteger> future = executor.submit(() -> {
            System.out.println("關注why技術");
            //在這裡進行計算邏輯
            atomicInteger.set(5201314);
        }, atomicInteger);

        System.out.println("future的內容:" + future.get());
        Thread.currentThread().join();
    }
}

可以看到改造之後,確實是呼叫了標號為 ② 的方法:

future.get() 方法的輸出值也是非同步任務中我們經過計算後得出的 5201314。

你看,渣男就是這樣,明明不懂你,還非得用甜言蜜語來轟炸你。呸。

好了。綜上,執行緒池的提交方式一共有四種:一種 execute,無返回值。三種 submit,有返回值。

submit 中按照提交任務的型別又分為兩種:一個是 Callable,一個是 Runable。

submit 中 Runable 的任務型別又有兩個過載方法:一個返回了個寂寞,一個返回了個渣男。哦,不。一個返回了個寂寞,一個返回了個物件。

這個時候就有人要站出來說:你說的不對,你就是瞎說,明明就只有 execute 這一種提交方式。

是的,“只有 execute 這一種提交方式”這一種說法也是沒錯的。

請看原始碼:

三種 submit 方法裡面呼叫的都是 execute 方法。

能把前面這些方法娓娓道來,從表面談到內在的這種人,才是好人。

只有愛你,才會把你研究透。

當然,還有這幾種提交方式,用的不多,就不展開說了:

寫到這裡我不禁想起了我的第三篇文章,真是奇怪的時間線開始收縮了的感覺,《有的執行緒它死了,於是它變成一道面試題》,這篇文章裡面聊到了不同提交方式,對於異常的不同處理方式。

我就問你:一個執行緒池中的執行緒異常了,那麼執行緒池會怎麼處理這個執行緒?

你要是不知道,可以去看看這篇文章,畢竟,有可能在面試的時候遇到的:

好,上面這些東西捋清楚了之後。我們再聚焦到返回值 Future 上:

從上面的程式碼我們可以看出,當我們想要返回值的時候,都需要呼叫下面的這個 get() 方法:

而從這個方法的描述可以看出,這是一個阻塞方法。拿不到值就在那裡等著。當然,還有一個帶超時時間的 get 方法,等指定時間後就不等了。

呸,渣男。沒耐心,這點時間都捨不得等。

總之就是有可能要等的。只要等,那麼就是阻塞。只要是阻塞,就是一個假非同步。

所以總結一下這種場景下返回的 Future 的不足之處:

  • 只有主動呼叫 get 方法去獲取值,但是有可能值還沒準備好,就阻塞等待。

  • 任務處理過程中出現異常會把異常隱藏,封裝到 Future 裡面去,只有呼叫 get 方法的時候才知道異常了。

寫到這裡的時候我不禁想起一個形象的例子,我給你舉一個。

假設你想約你的女神一起去吃飯。女神嘛,肯定是要先畫個美美的妝才會出去逛街的。而女神化妝就可以類比為我們提交的一個非同步任務。

假設你是一個小屌絲,那麼女神就會對你說:我已經開始化妝了,你到樓下了就給我打電話。

然後你就收拾行頭準備出發,這就是你提交非同步任務後還可以做一些自己的事情。

你花了一小時到了女神樓下,打電話給她:女神你好,我到你樓下了。

女神說:你先等著吧,我的妝還沒畫好呢。

於是你開始等待,無盡的等待。這就是不帶超時時間的 future.get() 方法。

也有可能你硬氣一點,對女神說:我最多再等 24 小時哈,超過 24 小時不下樓,我就走了。

這就是帶超時時間的 future.get(timeout,unit) 方法:

結果 24 小時之後,女神還沒下來,你就走了。

當然,還有一種情況就是你到樓下給女神打電話,女神說:哎,今天我男神約我出去看電影,就不和你去吃飯了哈。本來我想提前給你說的,但是我又記不起你電話,只有你打過來我才能告訴你。就這樣,你自己玩去吧。

這就相當於非同步任務執行過程中丟擲了異常,而你只有在呼叫了 get 方法(打電話操作)之後才知道原來異常了。

而真正的非同步是你不用等我,我好了我就叫你。

就像女神接到男神的電話時說的:我需要一點時間準備一下,你先玩自己的吧,我一會好了給你打電話。

這讓我想起了好萊塢原則:Don't Call Us,We'll Call you!

接下來,讓我們見識一下真正的非同步。

什麼叫真正的:“你先玩自己的,我一會好了叫你。”

Guava 的 Future

女神說的:“好了叫你”。

就是一種回撥機制。說到回撥,那麼我們就需要在非同步任務提交之後,註冊一個回撥函式就行。

Google 提供的 Guava 包裡面對 JDK 的 Future 進行了擴充套件:

新增了一個 addListenter 方法,入參是一個 Runnable 的任務型別和一個執行緒池。

使用方法,先看程式碼:

public class JDKThreadPoolExecutorTest {

    public static void main(String[] args) throws Exception {
        ListeningExecutorService executor = MoreExecutors.listeningDecorator(Executors.newCachedThreadPool());
        ListenableFuture<String> listenableFuture = executor.submit(() -> {
            System.out.println(Thread.currentThread().getName()+"-女神:我開始化妝了,好了我叫你。");
            TimeUnit.SECONDS.sleep(5);
            return "化妝完畢了。";
        });

        listenableFuture.addListener(() -> {
            try {
                System.out.println(Thread.currentThread().getName()+"-future的內容:" + listenableFuture.get());
            } catch (Exception e) {
                e.printStackTrace();
            }
        }, executor);
        System.out.println(Thread.currentThread().getName()+"-等女神化妝的時候可以乾點自己的事情。");
        Thread.currentThread().join();
    }
}

首先建立執行緒池的方式變了,需要用 Guava 裡面的 MoreExecutors 方法裝飾一下:

ListeningExecutorService executor = MoreExecutors.listeningDecorator(Executors.newCachedThreadPool());

然後用裝飾後的 executor 呼叫 submit 方法(任意一種),就會返回 ListenableFuture ,拿到這個 ListenableFuture 之後,我們就可以在上面註冊監聽:

所以,上面的程式我們呼叫的是入參為 callable 型別的介面:

從執行結果可以看出來:獲取執行結果是在另外的執行緒裡面執行的,完全沒有阻塞主執行緒

和之前的“假非同步”還是有很大區別的。

除了上面的 addListener 方法外,其實我更喜歡用 FutureCallback 的方式。

可以看一下程式碼,非常的直觀:

public class JDKThreadPoolExecutorTest {

    public static void main(String[] args) throws Exception {
        ListeningExecutorService executor = MoreExecutors.listeningDecorator(Executors.newCachedThreadPool());
        ListenableFuture<String> listenableFuture = executor.submit(() -> {
            System.out.println(Thread.currentThread().getName()+"-女神:我開始化妝了,好了我叫你。");
            TimeUnit.SECONDS.sleep(5);
            return "化妝完畢了。";
        });
        Futures.addCallback(listenableFuture, new FutureCallback<String>() {
            @Override
            public void onSuccess(@Nullable String result) {
                System.out.println(Thread.currentThread().getName()+"-future的內容:" + result);
            }

            @Override
            public void onFailure(Throwable t) {
                System.out.println(Thread.currentThread().getName()+"-女神放你鴿子了。");
                t.printStackTrace();
            }
        });
        System.out.println(Thread.currentThread().getName()+"-等女神化妝的時候可以乾點自己的事情。");
        Thread.currentThread().join();
    }
}

有 onSuccess 方法和 onFailure 方法。

上面的程式輸出結果為:

如果非同步任務執行的時候丟擲了異常,比如女神被她的男神約走了,非同步任務改成這樣:

ListenableFuture<String> listenableFuture = executor.submit(() -> {
            System.out.println(Thread.currentThread().getName() + "-女神:我開始化妝了,好了我叫你。");
            TimeUnit.SECONDS.sleep(5);
            throw new Exception("男神約我看電影,就不和你吃飯了。");
        });

最終的執行結果就是這樣:

是的,女神去看電影了。她一定只是不想吃飯而已。

加強版的Future - CompletableFuture

第一小節講的 Future 是 JDK 1.5 時代的產物:

經過了這麼多年的發展,Doug Lea 在 JDK 1.8 裡面引入了新的 CompletableFuture :

到了 JDK 1.8 時代,這才是真正的非同步程式設計。

CompletableFuture 實現了兩個介面,一個是我們熟悉的 Future ,一個是 CompletionStage。

CompletionStage介面,你看這個介面的名稱中有一個 Stage :

可以把這個介面理解為一個任務的某個階段。所以多個 CompletionStage 連結在一起就是一個任務鏈。前一個任務完成後,下一個任務就會自動觸發。

CompletableFuture 裡面的方法非常的多。

由於篇幅原因,我就只演示一個方法:

public class JDKThreadPoolExecutorTest {

    public static void main(String[] args) throws Exception {
        CompletableFuture<String> completableFuture = CompletableFuture.supplyAsync(() -> {
            System.out.println(Thread.currentThread().getName() + "-女神:我開始化妝了,好了我叫你。");
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return "化妝完畢了。";
        });

        completableFuture.whenComplete((returnStr, exception) -> {
            if (exception == null) {
                System.out.println(Thread.currentThread().getName() + returnStr);
            } else {
                System.out.println(Thread.currentThread().getName() + "女神放你鴿子了。");
                exception.printStackTrace();
            }
        });
        System.out.println(Thread.currentThread().getName() + "-等女神化妝的時候可以乾點自己的事情。");
        Thread.currentThread().join();
    }
}

該方法的執行結果如下:

我們執行的時候並沒有指定用什麼執行緒池,但是從結果可以看到也是非同步的執行。

從輸出日誌中是可以看出端倪的,ForkJoinPool.commonPool() 是其預設使用的執行緒池。

當然,我們也可以自己指定。

這個方法在很多開源框架裡面使用的還是非常的多的。

接下來主要看看 CompletableFuture 對於異常的處理。我覺得非常的優雅。

不需要 try-catch 程式碼塊包裹,也不需要呼叫 Future.get() 才知道異常了,它提供了一個 handle 方法,可以處理上游非同步任務中出現的異常:

public class JDKThreadPoolExecutorTest {

    public static void main(String[] args) throws Exception {
        CompletableFuture.supplyAsync(() -> {
            System.out.println(Thread.currentThread().getName() + "-女神:我開始化妝了,好了我叫你。");
            throw new RuntimeException("男神約我看電影了,我們下次再約吧,你是個好人。");
        }).handleAsync((result, exception) -> {
            if (exception != null) {
                System.out.println(Thread.currentThread().getName() + "-女神放你鴿子了!");
                return exception.getCause();
            } else {
                return result;
            }
        }).thenApplyAsync((returnStr) -> {
            System.out.println(Thread.currentThread().getName() + "-" + returnStr);
            return returnStr;
        });
        System.out.println(Thread.currentThread().getName() + "-等女神化妝的時候可以乾點自己的事情。");
        Thread.currentThread().join();
    }
}

由於女神在化妝的時候,接到男神的電話約她看電影,就只能放你鴿子了。

所以,上面程式的輸出結果如下:

如果,你順利把女神約出來了,是這樣的:

好了,女神都約出來了,文章就到這裡了。去幹正事吧。

最後說一句(求關注)

按照我的經驗,女神約出來了你需要準備好回答一個問題:

你看我今天有什麼不同?

首先這題就是一道送命題,回答到她預期的答案的概率非常的低。有可能她今天不一樣的地方就是換了一個指甲油、換了一個美瞳、換了一個耳環之類的。

很明顯,這些非常細節的地方我們很難發現。但是別慫。

先含情脈脈的認真的盯著她,花一分鐘找答案,一分鐘後沒有找到答案,就說:

你每天都不一樣,每天都比昨天更加美麗。

好了,才疏學淺,難免會有紕漏,如果你發現了錯誤的地方,還請你在後臺留言指出來,我對其加以修改。

感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注。

我是 why,一個被程式碼耽誤的文學創作者,不是大佬,但是喜歡分享,是一個又暖又有料的四川好男人。

還有,重要的事情說三遍: 歡迎關注我呀。 歡迎關注我呀。 歡迎關注我呀。

 

相關文章