把之前CompletableFuture留下的坑給填上。

why技術發表於2021-10-11

你好呀,我是歪歪。

填個坑吧,把之前一直欠著的 CompletableFuture 給寫了,因為後臺已經收到過好幾次催更的留言了。

這玩意我在之前寫的這篇文章中提到過:《面試官問我知不知道非同步程式設計的Future》

因為是重點寫 Future 的,所以 CompletableFuture 只是在最後一小節的時候簡單的寫了一下:

我就直接把當時的例子拿過來改一下吧,先把程式碼放在這裡了:

public class MainTest {

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

核心需求就是女神化妝的時候,我可以先去幹點自己的事情。

上面的程式執行結果是這樣的:

符合我們的預期,沒有任何毛病。

但是當你自己去編寫程式的時候,有可能會遇到這樣的情況:

什麼情況,女神還在化妝呢,程式就執行完了?

是的,這就是我要說的第一個關於 CompletableFuture 的知識點:守護執行緒。

守護執行緒

你仔細觀察前面提到的兩個截圖,對比一下他們的第 28 行,第二個截圖少了一行程式碼:

Thread.currentThread().join();

這行程式碼是在幹啥事呢?

目的就是阻塞主執行緒,哪怕你讓主執行緒睡眠也行,反正目的就是把主執行緒阻塞住。

如果沒有這行程式碼,出現的情況就是主執行緒直接執行完了,程式也就結束了。

你想想,會是什麼原因?

這個時候你腦海裡面應該啪的一下,很快就想到“守護執行緒”這個概念。

主執行緒是使用者執行緒,這個沒啥說的。

所有的使用者執行緒執行完成後, JVM 也就退出了。

因此,出現上面問題的原因我有合理的理由猜測:CompletableFuture 裡面執行的任務屬於守護執行緒。

有了理論知識的支撐,並推出這個假設之後,就有了證實的方向,問題就很簡單了。

啪的一下在這裡打上一個斷點,然後 Debug 起來,表示式一寫就看出來了,確實是守護執行緒:

我一般是想要看到具體的程式碼的,就是得看到把這個執行緒設定為守護執行緒的那一行程式碼,我才會死心。

所以我就去找了一下,還是稍微花了點時間,過程就不描述了,直接說結論吧。

首先 CompletableFuture 預設的執行緒池是 ForkJoinPool,這個是很容易就能在原始碼裡面找到的:

在 ForkJoinPool 裡面,把執行緒都設定為守護執行緒的地方就在這裡:

java.util.concurrent.ForkJoinPool#registerWorker

你若是想要自己除錯的話,那麼在這裡打上斷點之後,可以看一下呼叫棧,很快就摸清楚這個呼叫流程了:

另外,我在寫文章的過程中還注意到了這個註釋:

前面大概就是說 shutdown 和 shutdownNow 對於這個執行緒池來說沒用。

如果,執行緒池裡面的任務需要在程式終止前完成,那麼應該在退出前呼叫 commonPool().awaitQuiescence。

所以,我的程式應該改成這樣:

可以,不錯,很優雅。

如果,你的非同步任務非常重要,必須要執行完成,那麼 ForkJoinPool 也給你封裝好了一個方法:

java.util.concurrent.ForkJoinPool#quiesceCommonPool

另外,其實 CompletableFuture 也是支援傳一個自定義執行緒池的:

比如,我把前面的程式改成下面這樣:

加入指定執行緒池的邏輯,註釋掉主執行緒 join 的程式碼,跑起來之後。誒,JVM 一直都在。

你說神奇不神奇?

我想這個原因就不用我來分析了吧?

和 Future 對比

CompletableFuture 其實就是 Future 的升級版。

Future 有的,它都有。

Future 的短板,它補上了。

畢竟一個是 JDK 1.5 時代的產物,另一個是 1.8 時代的作品:

中間跨度了整整 10 年,10 年啊!

所以,後來居上。

給大家對比一下 Future 和 CompletableFuture。

首先對於我個人而言,第一個最直觀的感受是獲取結果的姿勢舒服多了。

我不得不又把這張圖拿出來說說了,主要關注下面的兩種 future 和 callback:

當我們用 Future 去實現非同步,要獲取非同步結果的時候,是怎麼樣操作的?

是不是得呼叫 future.get() 方法去取值。

如果這個時候值已經準備就緒,在 future 裡面封裝好了,那麼萬事大吉,直接拿出來就可以用。

但是如果值還沒有準備好呢?

是不是就阻塞等待了?

所以我常常說 Future 是一種閹割版的非同步模式。

比如還是最開始的例子,如果我用 Future 來做,那麼是這樣的:

你仔細看我框起來的地方,是 main 執行緒開始獲取結果,獲取結果的這個動作把 main 執行緒給阻塞住了。

你就去洗不了頭了,老弟。

好,你說你把獲取結果的操作放到最後,沒問題。

但是,無論你放在哪裡,你都有一個 get 的動作,且你執行這個動作的時候,你也不知道值到底準備好了沒,所以有可能出現阻塞等待的情況。

好,那麼問題來了:如果消除這個阻塞等待呢?

很簡單,換個思路,我們從主動問詢,變成等待通知。

女神化妝好了之後,主動通知一下我不就好了嗎?

用程式設計師的話說就是:執行結果出來了,你執行一下我留給你的回撥函式不就好了嗎?

CompletableFuture 就可以幹這個事兒。

用 CompletableFuture 寫一遍上面的程式就是這樣的:

pool-1-thread-1,女神化妝的這個執行緒,她好了之後會主動叫你,你明白嗎?

這就是我第一次接觸到 CompletableFuture 後,學到的第一個讓我感到舒服的地方。

這種寫法你注意,whenComplete(returnStr, exception) 返回資訊和異常資訊在這裡都有了。

除此之外,這個方法還是帶返回值的,你也完全可以像是用 Future 那樣通過 get 獲取其返回值:

按理來說也就是可以用了。

但是如果你不需要返回值,它還提供了這樣的寫法:

正常情況和異常情況分開處理。

優雅,非常優雅。

還有更牛的。

前面我們化妝的執行緒和化妝完成的執行緒不是同一個執行緒嗎:

假設我們需要兩個不同的執行緒,一個只負責化妝,一個只負責通知。畢竟女神化完妝之後,更加女神了,搞兩個執行緒我尋思也不過分。

改動點小到令人髮指:

只需要把呼叫的方法從 whenComplete 改為 whenCompleteAysn 即可。

同樣,這個方法也支援指定執行緒池:

你可以去看 CompletableFuture 裡面有非常多的 Aysn 結尾的方法,大多都是幹這個事兒的,以非同步的形式線上程池中執行。

如果說上面的介紹讓你覺得不過如此,那麼再介紹一個 Future 沒有的東西。

假設現在需求是這樣的。

女神化完妝之後,還要花個一小會選衣服,不過分吧。

也就是說我們現在有兩個非同步任務,第一個是化妝,第二個是選衣服。

選衣服要在化妝完成之後進行,這兩個任務是序列的,用 CompletableFuture 怎麼實現呢?

我把程式碼貼一下,為了看起來更加直觀,我沒有用鏈式呼叫:

public class MainTest {

    public static void main(String[] args) throws Exception {
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        //任務一
        CompletableFuture<String> makeUpFuture = CompletableFuture.supplyAsync(() -> {
            System.out.println(Thread.currentThread().getName() + "-女神:我開始化妝了。");
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return "化妝完畢了。";
        }, executorService);

        //任務二(makeUpFuture是方法呼叫方,意思是等makeUpFuture執行完成後執行再執行)
        CompletableFuture<String> dressFuture = makeUpFuture.thenApply(makeUp -> {
            System.out.println(Thread.currentThread().getName() + "-女神:" + makeUp + "我開始選衣服啦,好了我叫你。");
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return makeUp + "衣服也選好啦。靚仔,走去玩兒吧。";
        });

        //獲取結果
        dressFuture.thenAccept(result -> {
            System.out.println(Thread.currentThread().getName() + "-" + result);
        });
    }
}

這樣輸出結果就是這樣的:

符合我們的預期。

假設我們想要選衣服的時候換另外一個執行緒怎麼辦呢?

別說不知道,這不剛才教你了嗎,Async 結尾的方法,得活學活用起來:

前面講的是多個非同步任務序列執行,接下來再說一下並行。

CompletableFuture 裡面提供了兩個並行的方法:

兩個方法的入參都是可變引數,就是一個個非同步任務。

allOf 顧名思義就是入參的多個 CompletableFuture 都必須成功,才能繼續執行。

而 anyOf 就是入參的多個 CompletableFuture 只要有一個成功就行。

還是舉個例子。

假設,我是說假設啊,我是一個海王。

算了,我假設我有一個朋友吧。

他同時追求好幾個女朋友。今天他打算約小美和小乖中的一個出門玩,隨便哪個都行。誰先化妝完成,就約誰。另外一個就放她鴿子。

這個場景,我們就可以用 anyOf 來模擬,於是就出現了這樣的程式碼:

從輸出結果來看,最後和朋友約會的是小美。

都把小美約出來了,必須要一起吃個飯才行,對吧。

那麼這個時候朋友問:小美,你想吃點什麼呢?

小美肯定會回答:隨便,就行,無所謂。

聽到這樣的回答,朋友心裡就有底了,馬上給出了一個方案:我們去吃沙縣小吃或者黃燜雞吧,哪一家店等的時間短,我們就去吃哪一家。

於是上面的程式碼,就變成了這樣:

輸出結果是這樣的:

我把程式碼都放這裡,你粘過去就能跑起來:

public class MainTest {

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(10);

        CompletableFuture<String> xiaoMei = CompletableFuture.supplyAsync(() -> {
            System.out.println(Thread.currentThread().getName() + "-小美:我開始化妝了,好了我叫你。");
            try {
                int time = ThreadLocalRandom.current().nextInt(5);
                TimeUnit.SECONDS.sleep(time);
                System.out.println(Thread.currentThread().getName() + "-小美,化妝耗時:" + time);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return "小美:化妝完畢了。";
        }, executorService);

        CompletableFuture<String> xiaoGuai = CompletableFuture.supplyAsync(() -> {
            System.out.println(Thread.currentThread().getName() + "-小乖:我開始化妝了,好了我叫你。");
            try {
                int time = ThreadLocalRandom.current().nextInt(5);
                TimeUnit.SECONDS.sleep(time);
                System.out.println(Thread.currentThread().getName() + "-小乖,化妝耗時:" + time);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return "小乖:化妝完畢了。";
        }, executorService);

        CompletableFuture<Object> girl = CompletableFuture.anyOf(xiaoMei, xiaoGuai);
        girl.thenAccept(result -> {
            System.out.println("我看最後是誰先畫完呢 = " + result);
        });

        CompletableFuture<String> eatChooseOne = girl.thenApplyAsync((result) -> {
            try {
                TimeUnit.SECONDS.sleep(ThreadLocalRandom.current().nextInt(10));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return result + "這裡人少,我們去吃沙縣小吃吧!";
        }, executorService);

        CompletableFuture<String> eatChooseTwo = girl.thenApplyAsync((result) -> {
            try {
                TimeUnit.SECONDS.sleep(ThreadLocalRandom.current().nextInt(10));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return result + "這裡人少,我們去吃黃燜雞吧!";
        }, executorService);

        CompletableFuture.allOf(eatChooseOne, eatChooseTwo).thenAccept(result -> {
            System.out.println("最終結果:" + result);
        });
    }
}

如果你說,小孩子才做選擇,大人是全部都要。

那麼,你可以試著用一下 allOf,只是需要注意的是,allOf 是不帶返回值的。

好了,寫到這裡我都感覺有點像是 API 教學了,沒啥勁。所以 CompletableFuture 還有很多很多的方法,我就不一一介紹了。

再說說 get 方法

最後,再看看 get 方法吧。之前釋出的《看完JDK併發包原始碼的這個效能問題,我驚了!》這篇文章,有朋友看了之後有幾個問題,我再串起來講一下。

CompletableFuture 提交任務的方式有兩種:

一種是 supplyAsync 帶返回值的。

一種是 runAsync 返回值是 void 的,相當於沒有返回值。

比如,我們用 supplyAsync 的時候:

就刻意返回一個 null。

我還可以擴充套件一下,假設我們的方法用的是 runAsync,本來就沒有返回值的。

比如這樣:

我們再看一下 get 方法:

你看這裡的判斷條件是 (r = result) == null

那麼問題就來了,假設這個方法的返回值本來就是 null,也就是我們上面的情況,怎麼辦呢?

為 null 就有三種情況了:

  • 1.是 runAsync 這種,真的沒有返回值,所以就算任務執行完成了,get 出來的確實就是 null。
  • 2.是有返回值的,只是目前任務還沒執行完成,所以 result 還是 null。
  • 3.是有返回值的,返回的值就是 null。

怎麼去分別出這三種情況呢?

那麼就要看看這個 result 賦值的地方了,用腳指頭猜也知道在這裡搞了一些事情。

所以簡單的找尋一番之後,可以找到這個關鍵的地方:

框起來的程式碼,目的是為了獲取 CompletableFuture 類中的 result 欄位的偏移量,並用大寫的 RESULT 儲存起來。

有經驗的朋友看到這裡大概就知道它要用 compareAndSwapObject 這個騷操作了:

然後就能找到這幾個和 null 相關的地方:

答案就是我框起來的部分:在 CompletableFuture 裡面,把 null 也封裝到 AltResult 物件裡面了。

基於此,可以區分出前面我說的那三種情況。

你看這裡有一個專門的 completeNull 方法,其中的呼叫者就有 AysncRun 方法:

你可以在其呼叫的地方打上斷點,然後把我前面用 runAsync 提交方式的程式碼跑起來:

再去看看呼叫棧,除錯一下,你就知道 runAsync 這種,真的沒有返回值的是怎麼處理的了。

核心技術就是把 null 封裝到 AltResult 物件裡面。

然後如何分別返回值就是 null 的情況呢?

都有一個代表 null 的物件了,那還不簡單嗎,一個小小的判斷就搞定了:

最後,再提一下這個方法:

java.util.concurrent.CompletableFuture#waitingGet

我之前那篇文章裡面寫了這樣一句話:

加入這個自旋,是為了稍晚一點執行後續邏輯中的 park 程式碼,這個稍重一點的操作。但是我覺得這個 “brief spin-wait” 的收益其實是微乎其微的。

有小夥伴問我 park 的邏輯在哪?

其實就在 waitingGet 的 while 迴圈的最後一個分支裡面,也就是我框起來的部分:

最後你順著往下 Debug ,就能找到這個地方:

java.util.concurrent.CompletableFuture.Signaller#block

這裡不就是 park 的邏輯嗎:

打上斷點自己玩去吧。

其實還有一種騷操作,我一般不告訴別人,也簡單的分享一下吧。

還是拿前面的程式碼做演示,這個程式碼你跑起來之後,主執行緒由於呼叫了 get 方法,那麼勢必會阻塞等待非同步任務的結果:

你就把它給跑起來,然後點一下這個照相機的圖示:

就可以看到這樣的畫面:

主執行緒是 park 起來的,在哪被 park 起來的呢?

at java.util.concurrent.CompletableFuture$Signaller.block(CompletableFuture.java:1707)

這不就是我剛剛給你說的方法嗎?

然後你在這裡打上斷點,看一下呼叫堆疊,不就把主鏈路玩得明明白白的嘛:

怎麼樣,這波逆向操作,溜不溜,分分鐘就學會了。

找到了 park 的地方,那麼在哪兒被 unpark 的呢?

這還不簡單嗎?

反正我一搜就搜出來了:

然後再在 unpark 這裡打上一個斷點:

喚醒流程也可以除錯的明明白白。

好了,掛起和喚醒都給你定位到關鍵地方了,就到這,玩去吧。

本文已收錄自個人部落格,歡迎大家來玩。

https://www.whywhy.vip/

相關文章