一次慘痛的面試:“網易提前批,我被虛擬執行緒問倒了”

JavaBuild發表於2024-05-18

一、寫在開頭

昨晚收到一個粉絲在私信的留言如下:

build哥,今天參加了網易的提前批,可以說是一次慘痛的面試體驗🤣,直接被虛擬執行緒問倒了,無論是在校學習的時候還是在公司實習的時候,都使用的是Java8更多,或者Java11,比較點子背的是面試我的這一個面試官,他們團隊剛好在做Java21的切換,因此,虛擬執行緒似乎是一個逃脫不掉的重點拷問物件,雖然21出來的時候知道有虛擬執行緒這個事情,但從沒有認真研究過,被問及時說不出個123來,當場憋得臉通紅,真羞愧啊!

    確實,我們現在在國內的大部分企業中使用的Java版本還是8居多,Java21是Oracle公司於2023年9月20號釋出的版本,是一個最新且會被長期維護的穩定版本,很少有面試官會針對這部分更新內容著重拷問,但是!若你遇到了像這位粉絲一樣,面試官的專案剛好在用Java21,那它的相關特性你就必須要知道了!而Java21帶來的重磅內容就是虛擬執行緒。今天我們就抽個時間來聊一聊它。


二、虛擬執行緒的誕生背景

   虛擬執行緒 在Java19時被作為預覽特性提出,經過了2個版本的迭代後,在Java21成功上位,是一個十分重要的新增特性,對於I/O密集型程式的效能帶來了大幅度的提升!

   隨著企業應用的規模壯大,大量的網路請求或讀寫I/O場景越來越多,這種情況下,很多語言如Go、C#、Erlang、Lua等,都有“協程”來最佳化效能,曾經我們 Java 開發者面對這種平凡而又高階的技術只能乾瞪眼,遇到I/O密集型程式,我們只能透過多執行緒來最佳化,實際上這種最佳化的效果有限,使用不當還會帶來OOM問題,但在Java21推出虛擬執行緒後,一掃沉痾!虛擬執行緒的特性讓它對高IO場景得心應手。

  • I/O密集型程式: 指的是系統的CPU效能相對硬碟、記憶體要好很多,此時,系統運作,大部分的狀況是CPU在等I/O (硬碟/記憶體) 的讀/寫操作,但CPU的使用率不高。具體場景如讀檔案、寫檔案、傳輸檔案、網路請求。
  • CPU密集性程式: 也叫計算密集型,指的是系統的硬碟、記憶體效能相對CPU要好很多,此時,系統運作大部分的狀況是CPU Loading 100%,CPU要讀/寫I/O(硬碟/記憶體),I/O在很短的時間就可以完成,而CPU還有許多運算要處理,CPU Loading很高。具體場景如科學計算、影像處理和加密解密等。

三、什麼是虛擬執行緒

   那麼什麼是虛擬執行緒呢?在搞清楚這個定義之前,我們先來了解一下普通執行緒,基於過往的學習積累,我們知道JVM 是一個多執行緒環境,它透過 java.lang.Thread 為我們提供了對 作業系統執行緒(OS執行緒) 的抽象,但是 Java 中的執行緒都只是對作業系統執行緒的一種簡單封裝,我們可以稱之為 “平臺執行緒(platform thread)” ,平臺執行緒在底層 OS 執行緒上執行 Java 程式碼,並在程式碼的整個生命週期中佔用該 OS 執行緒,因此平臺執行緒的數量受限於 OS 執行緒的數量。

   而虛擬執行緒是Thread的一個例項,雖然也在OS執行緒上執行Java程式碼,但它不會在整個生命週期內都佔用該OS執行緒,換句話說,一個OS執行緒上支援多個虛擬執行緒的執行,因此,同樣的作業系統配置下,可以建立更多的虛擬執行緒數量,執行阻塞任務的整體吞吐量也就大了很多。

【一句話總結虛擬執行緒定義】

虛擬執行緒: Java 中的一種輕量級執行緒,它旨在解決傳統執行緒模型中的一些限制,提供了更高效的併發處理能力,允許建立數千甚至數萬個虛擬執行緒,而無需佔用大量作業系統資源。


四、虛擬執行緒的工作原理

   上面我們瞭解什麼是虛擬執行緒後,我們緊接著來看一下它的原理,我們知道執行緒是需要被排程分配相應的CPU時間片的。對於由作業系統執行緒實現的平臺執行緒,JDK 依賴於作業系統中的排程程式;而對於虛擬執行緒,JDK 先將虛擬執行緒分配給平臺執行緒,然後平臺執行緒按照通常的方式由作業系統進行排程。

   JDK 的虛擬執行緒排程器是一個以 FIFO 模式執行的 ForkJoinPool,排程器的預設並行度是可用於排程虛擬執行緒的平臺執行緒數量,並行度可以透過設定啟動引數調整。排程器函式程式碼如下:

private static ForkJoinPool createDefaultScheduler() {
    ForkJoinWorkerThreadFactory factory = pool -> {        
        PrivilegedAction<ForkJoinWorkerThread> pa = () -> new CarrierThread(pool);        
        return AccessController.doPrivileged(pa);    
    };    
    PrivilegedAction<ForkJoinPool> pa = () -> {        
        int parallelism, maxPoolSize, minRunnable;        
        String parallelismValue = System.getProperty("jdk.virtualThreadScheduler.parallelism");        
        String maxPoolSizeValue = System.getProperty("jdk.virtualThreadScheduler.maxPoolSize");        
        String minRunnableValue = System.getProperty("jdk.virtualThreadScheduler.minRunnable");        
        ... //略過一些賦值操作        
        Thread.UncaughtExceptionHandler handler = (t, e) -> { };
        boolean asyncMode = true; // FIFO        
        return new ForkJoinPool(parallelism, factory, handler, asyncMode,                                
                                0, maxPoolSize, minRunnable, pool -> true, 30, SECONDS);
    };    
    return AccessController.doPrivileged(pa);
}

   排程器分配給虛擬執行緒的平臺執行緒稱為虛擬執行緒的 載體執行緒(carrier),載體執行緒的資訊對虛擬執行緒不可見,Thread.currentThread() 返回的值始終是虛擬執行緒本身,載體執行緒和虛擬執行緒的堆疊跟蹤是分開的。在虛擬執行緒中丟擲的異常將不包括載體執行緒的堆疊幀。執行緒dump不會在虛擬執行緒的堆疊中顯示載體執行緒的堆疊幀,反之亦然。從 Java 程式碼的角度來看,開發者不能感知到虛擬執行緒和其載體執行緒臨時共享了一個作業系統執行緒。但從原生代碼(native code)的角度來看,虛擬執行緒和其載體在同一個本地執行緒上執行。

OS執行緒、載體執行緒、虛擬執行緒三者關係圖

image


五、如何使用虛擬執行緒

   瞭解了虛擬執行緒之後,我們最重要的一環來了,如何使用虛擬執行緒!其實,Oracle官網在這一點上做的很人性化,為了讓大家平滑的過渡到JDK21的使用上,虛擬執行緒的建立方式和之前的傳統執行緒非常相似,幾乎都是藉助Thread來構建,大致分為如下4種方式。

方法1️⃣:使用 Thread.startVirtualThread() 建立

  public void virtualThreadTest() {
        Thread.startVirtualThread(() -> {
            // 這裡放置你的任務程式碼
            System.out.println("Method ONE");
        });
    } 

方法2️⃣:使用 Thread.ofVirtual()建立

    public void virtualThreadTest() {
        Thread.ofVirtual()
                .name("virtualThreadTest")//為虛擬執行緒設定名稱
                .uncaughtExceptionHandler((t,e)-> System.out.println("執行緒[" + t.getName() + "發生了異常。message:" + e.getMessage()))//處理執行緒異常
                .start(()->{
                    System.out.println("Method TWO");
                });//建立時直接啟動
    }

   Thread.Builder是一個流式API,用於構建和配置執行緒。它提供了設定執行緒屬性(如名稱、守護狀態、優先順序、未捕獲異常處理器等)的方法。相比直接使用 Thread 來構建執行緒,Thread.Builder提供了更多的靈活性和控制力。

以上測試程式碼是建立時直接啟動,也可以建立時不啟動,透過手動呼叫 start() 來執行:

    public void virtualThreadTest() {
        var vt = Thread.ofVirtual()
                .unstarted(()->{
                    System.out.println("Method TWO");
                });
       	//建立時透過unstarted設定不啟動,手動呼叫start啟動
        vt.start();
    }

方法3️⃣:使用 ThreadFactory 建立

 public void virtualThreadTest() {
        ThreadFactory factory = Thread.ofVirtual().factory();
        factory.newThread(() -> {
            // 這裡放置你的任務程式碼
            System.out.println("Method THREE");
        }).start();
    }

方法4️⃣:使用 Executors.newVirtualThreadPerTaskExecutor()建立

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> {
    System.out.println("Method FOUR");
});
or
Future<String> future = executor.submit(() -> {
    return "Method FOUR";
});

這是透過虛擬執行緒池來構建虛擬執行緒;
注意:使用完執行緒池後,我們可以使用shutdown() 來關閉執行緒池,它會等待正在執行的任務完成,但不會接受新的任務。如果需要立即停止所有任務,可以使用 shutdownNow()。


六、IO密集型程式效能最佳化

   為了驗證虛擬執行緒的優點,我們準備了一個小測試案例,向一個固定200個執行緒的執行緒池提交1000個sleep 1s的任務並遍歷獲取結果,用平臺執行緒和虛擬執行緒分別實現,對比耗時。

6.1 平臺執行緒實現

public class TestPlatformThread {
    public static void main(String[] args) {
        AtomicInteger a = new AtomicInteger(0);
        // 建立一個固定200個執行緒的執行緒池
        try {
            ExecutorService executor =  Executors.newFixedThreadPool(200);
            List<Future<Integer>> futures = new ArrayList<>();
            long begin = System.currentTimeMillis();
            // 向執行緒池提交1000個sleep 1s的任務
            for (int i=0; i<1_000; i++) {
                   Future future = executor.submit(() -> {
                    Thread.sleep(1000);
                    return a.addAndGet(1);
                });

                futures.add(future);
            }
            // 獲取這1000個任務的結果
            for (Future<Integer> future : futures) {
                Integer integer = future.get();
                if(integer % 100 ==0){
                    System.out.println(integer + " ");
                }
            }
            // 列印總耗時
            System.out.println("Exec finish!!!");
            System.out.printf("Exec time: %dms.%n", System.currentTimeMillis() - begin);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

輸出:

100 
200 
300 
400 
500 
600 
700 
800 
900 
1000 
Exec finish!!!
Exec time: 5120ms.

這裡為什麼不採用Executors.newCachedThreadPool()而是採用固定執行緒數量的執行緒池呢?
因為當我們的併發任務數不是1000,而是1萬,甚至於10萬的時候,newCachedThreadPool會建立相應的執行緒數,而Java的平臺執行緒於作業系統執行緒又是一一對應的,不可能提供那麼多可用執行緒,會導致程式OOM。

6.2 虛擬執行緒實現

我們將上述程式碼做一個簡單的修改,採用Executors.newVirtualThreadPerTaskExecutor();建立一個虛擬執行緒池,透過虛擬執行緒進行同樣的任務處理!

// ExecutorService executor =  Executors.newFixedThreadPool(200);
//透過虛擬執行緒實現
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

輸出:

100 
200 
300 
400 
500 
600 
700 
800 
900 
1000 
Exec finis!!!
Exec time: 1025ms.

一個是5秒多,一個是1秒多,當併發任務數來到100萬的時候,虛擬執行緒耗時在27秒左右,而傳統的平臺執行緒,直接卡死,最終丟擲OOM。

100000 
200000 
300000 
400000 
500000 
600000 
700000 
800000 
900000 
1000000 
Exec finis!!!
Exec time: 27125ms.

但是,我們在測試程式中是以sleep(1s)來模擬IO處理的場景,虛擬執行緒對效能的提升十分顯著,若將程式中的sleep()換為如下程式碼:

long t0 = System .currentTimeMillis();
do {
    int x=1;
    x++;
} while (t0+1000 > System .currentTimeMillis());

以及模擬處理計算的場景,這時候耗時會反過來,下圖為本地測試結果對比
image

七、總結

最後,我們對虛擬執行緒的學習做一個言簡意賅的總結:

優點:

  1. 非常輕量級: 可以在單個執行緒中建立成百上千個虛擬執行緒而不會導致過多的執行緒建立和上下文切換;
  2. 減少資源開銷: 相比於作業系統執行緒,虛擬執行緒的資源開銷更小。本質上是提高了執行緒的執行效率,從而減少執行緒資源的建立和上下文切換。

缺點

  1. 不適用於計算密集型任務: 虛擬執行緒適用於 I/O 密集型任務,但不適用於計算密集型任務,因為密集型計算始終需要 CPU 資源作為支援。
  2. 依賴於語言或庫的支援: 協程需要程式語言或庫提供支援。不是所有程式語言都原生支援協程。比如 Java 實現的虛擬執行緒。

八、結尾彩蛋

如果本篇部落格對您有一定的幫助,大家記得留言+點贊+收藏呀。原創不易,轉載請聯絡Build哥!

image

如果您想與Build哥的關係更近一步,還可以關注“JavaBuild888”,在這裡除了看到《Java成長計劃》系列博文,還有提升工作效率的小筆記、讀書心得、大廠面經、人生感悟等等,歡迎您的加入!

image

相關文章