虛擬執行緒原理及效能分析|得物技術

架構師修行手冊發表於2023-11-08


來源:得物技術

目錄

一、背景

二、為了提升吞吐效能,我們所做的最佳化

    1. 序列模式

    2. 執行緒池 +Future 非同步呼叫

    3. 執行緒池 +CompletableFuture 非同步呼叫

三、一請求一執行緒的模型

四、虛擬執行緒

    1. 執行緒術語定義

    2. 虛擬執行緒定義

    3. 虛擬執行緒建立

    4. 虛擬執行緒實現原理

    5. 虛擬執行緒記憶體佔用評估

    6. 虛擬執行緒的侷限及使用建議

    7. 虛擬執行緒適用場景

五、虛擬執行緒壓測效能分析

    1. 測試流程

    2. 衡量指標

    3. Tomcat+普通執行緒池

    4. WebFlux

    5. Tomcat+虛擬執行緒池

六、總結

背景

JDK21 在 9 月 19 號正式釋出,帶來了較多亮點,其中虛擬執行緒備受矚目,毫不誇張的說,它改變了高吞吐程式碼的編寫方式,只需要小小的變動就可以讓目前的 IO 密集型程式的吞吐量得到提升,寫出高吞吐量的程式碼不再困難。

本文將詳細介紹虛擬執行緒的使用場景,實現原理以及在 IO 密集型服務下的效能壓測效果。

為了提升吞吐效能,我們所做的最佳化

在講虛擬執行緒之前,我們先聊聊為了提高吞吐效能,我們所做的一些最佳化方案。


序列模式

在當前的微服務架構下,處理一次使用者/上游的請求,往往需要多次呼叫下游服務、資料庫、檔案系統等,再將所有請求的資料進行處理最終的結果返回給上游。

虛擬執行緒原理及效能分析|得物技術
虛擬執行緒原理及效能分析|得物技術
在這種模式下,使用序列模式去查詢資料庫,下游 Dubbo/Http 介面,檔案系統完成一次請求,介面整體的耗時等於各個下游的返回時間之和,這種寫法雖然簡單,但是介面耗時長、效能差,無法滿足 C 端高 QPS 場景下的效能要求。

執行緒池+Future非同步呼叫

為了解決序列呼叫的低效能問題,我們會考慮使用並行非同步呼叫的方式,最簡單的方式便是使用執行緒池 +Future 去並行呼叫。

虛擬執行緒原理及效能分析|得物技術
典型程式碼如下:
虛擬執行緒原理及效能分析|得物技術
這種方式雖然解決了大部分場景下的序列呼叫低效能問題,但是也存在著嚴重的弊端,由於存在 Future 的前後依賴關係,當使用場景存在大量的前後依賴時,會使得執行緒資源和 CPU 大量浪費在阻塞等待上,導致資源利用率低。

執行緒池+CompletableFuture非同步呼叫

為了降低 CPU 的阻塞等待時間和提升資源的利用率,我們會使用CompletableFuture對呼叫流程進行編排,降低依賴之間的阻塞。

CompletableFuture 是由 Java8 引入的,在 Java8 之前一般透過 Future 實現非同步。Future 用於表示非同步計算的結果,如果存在流程之間的依賴關係,那麼只能透過阻塞或者輪詢的方式獲取結果,同時原生的 Future 不支援設定回撥方法,Java8 之前若要設定回撥可以使用 Guava 的 ListenableFuture,回撥的引入又會導致回撥地獄,程式碼基本不具備可讀性。
而 CompletableFuture 是對 Future 的擴充套件,原生支援透過設定回撥的方式處理計算結果,同時也支援組合編排操作,一定程度解決了回撥地獄的問題。
使用 CompletableFuture 的實現方式如下:
虛擬執行緒原理及效能分析|得物技術
CompletableFuture 雖然一定程度上面緩解了 CPU 資源大量浪費在阻塞等待上的問題,但是隻是緩解,核心的問題始終沒有解決。這兩個問題導致 CPU 無法充分被利用,系統吞吐量容易達到瓶頸。

  • 執行緒資源浪費瓶頸始終在 IO 等待上,導致 CPU 資源利用率較低。目前大部分服務是 IO 密集型服務,一次請求的處理耗時大部分都消耗在等待下游 RPC,資料庫查詢的 IO 等待中,此時執行緒仍然只能阻塞等待結果返回,導致 CPU 的利用率很低。
  • 執行緒數量存在限制為了增加併發度,我們會給執行緒池配置更大的執行緒數,但是執行緒的數量是有限制的,Java 的執行緒模型是 1:1 對映平臺執行緒的,導致 Java 執行緒建立的成本很高,不能無限增加。同時隨著 CPU 排程執行緒數的增加,會導致更嚴重的資源爭用,寶貴的 CPU 資源被損耗在上下文切換上。

一請求一執行緒的模型

在給出最終解決方案之前,我們先聊一聊 Web 應用中常見的一請求一執行緒的模型。

在 Web 中我們最常見的請求模型就是使用一請求一執行緒的模型,每個請求都由單獨的執行緒處理。此模型易於理解和實現,對編碼的可讀性,Debug 都非常友好,但是,它有一些缺點。當執行緒執行阻塞操作(如連線到資料庫或進行網路呼叫)時,執行緒會被阻塞,直到操作完成,這意味著執行緒在此期間將無法處理任何其他請求。
虛擬執行緒原理及效能分析|得物技術
當遇到大促或突發流量等場景導致服務承受的請求數增大時,為了保證每個請求在儘可能短的時間內返回,減少等待時間,我們經常會採用以下方案:

  • 擴大服務最大執行緒數,簡單有效,由於存在下列問題,導致平臺執行緒有最大數量限制,不能大量擴充。
    • 系統資源有限導致系統執行緒總量有限,進而導致與系統執行緒一一對應的平臺執行緒有限。
    • 平臺執行緒的排程依賴於系統的執行緒排程程式,當平臺執行緒建立過多,會消耗大量資源用於處理執行緒上下文切換。
    • 每個平臺執行緒都會開闢一塊大小約 1m 私有的棧空間,大量平臺執行緒會佔據大量記憶體。

虛擬執行緒原理及效能分析|得物技術

  • 垂直擴充套件,升級機器配置,水平擴充套件,增加服務節點,也就是俗稱的升配擴容大法,效果好,也是最常見的方案,缺點是會增加成本,同時有些場景下擴容並不能 100% 解決問題。
  • 採用非同步/響應式程式設計方案,例如 RPC NIO 非同步呼叫,WebFlux,Rx-Java 等非阻塞的基於 Ractor 模型的框架,使用事件驅動使得少量執行緒即可實現高吞吐的請求處理,擁有較好的效能與優秀的資源利用,缺點是學習成本較高相容性問題較大,編碼風格與目前的一請求一執行緒的模型差異較大,理解難度大,同時對於程式碼的除錯比較困難。

那麼有沒有一種方法可以易於編寫,方便遷移,符合日常編碼習慣,同時效能很不錯,CPU 資源利用率較高的方案呢?
JDK21 中的虛擬執行緒可能給出了答案, JDK 提供了與 Thread 完全一致的抽象 Virtual Thread 來應對這種經常阻塞的情況,阻塞仍然是會阻塞,但是換了阻塞的物件,由昂貴的平臺執行緒阻塞改為了成本很低的虛擬執行緒的阻塞,當程式碼呼叫到阻塞 API 例如 IO,同步,Sleep 等操作時,JVM 會自動把 Virtual Thread 從平臺執行緒上解除安裝,平臺執行緒就會去處理下一個虛擬執行緒,透過這種方式,提升了平臺執行緒的利用率,讓平臺執行緒不再阻塞在等待上,從底層實現了少量平臺執行緒就可以處理大量請求,提高了服務吞吐和 CPU 的利用率

虛擬執行緒

執行緒術語定義

作業系統執行緒(OS Thread):由作業系統管理,是作業系統排程的基本單位。

平臺執行緒(Platform Thread):Java.Lang.Thread 類的每個例項,都是一個平臺執行緒,是 Java 對作業系統執行緒的包裝,與作業系統是 1:1 對映。
虛擬執行緒(Virtual Thread):一種輕量級,由 JVM 管理的執行緒。對應的例項 java.lang.VirtualThread 這個類。
載體執行緒(Carrier Thread):指真正負責執行虛擬執行緒中任務的平臺執行緒。一個虛擬執行緒裝載到一個平臺執行緒之後,那麼這個平臺執行緒就被稱為虛擬執行緒的載體執行緒。

虛擬執行緒定義

JDK 中 java.lang.Thread 的每個例項都是一個平臺執行緒。平臺執行緒在底層作業系統執行緒上執行 Java 程式碼,並在程式碼的整個生命週期內獨佔作業系統執行緒,平臺執行緒例項本質是由系統核心的執行緒排程程式進行排程,並且平臺執行緒的數量受限於作業系統執行緒的數量

而虛擬執行緒(Virtual Thread)它不與特定的作業系統執行緒相繫結。它在平臺執行緒上執行 Java 程式碼,但在程式碼的整個生命週期內不獨佔平臺執行緒。這意味著許多虛擬執行緒可以在同一個平臺執行緒上執行他們的 Java 程式碼,共享同一個平臺執行緒。同時虛擬執行緒的成本很低,虛擬執行緒的數量可以比平臺執行緒的數量大得多。
虛擬執行緒原理及效能分析|得物技術

虛擬執行緒建立

方法一:直接建立虛擬執行緒

Thread vt = Thread.startVirtualThread(() -> {    System.out.println("hello wolrd virtual thread");});

方法二:建立虛擬執行緒但不自動執行,手動呼叫start()開始執行

Thread.ofVirtual().unstarted(() -> {    System.out.println("hello wolrd virtual thread");});vt.start();

方法三:透過虛擬執行緒的 ThreadFactory 建立虛擬執行緒








ThreadFactory tf = Thread.ofVirtual().factory();Thread vt = tf.newThread(() -> {    System.out.println("Start virtual thread...");    Thread.sleep(1000);    System.out.println("End virtual thread. ");});vt.start();

方法四:Executors.newVirtualThreadPer

-TaskExecutor()










ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();executor.submit(() -> {    System.out.println("Start virtual thread...");    Thread.sleep(1000);    System.out.println("End virtual thread.");    return true;});

虛擬執行緒實現原理

虛擬執行緒是由 Java 虛擬機器排程,而不是作業系統。虛擬執行緒佔用空間小,同時使用輕量級的任務佇列來排程虛擬執行緒,避免了執行緒間基於核心的上下文切換開銷,因此可以極大量地建立和使用。

簡單來看,虛擬執行緒實現如下:virtual thread =continuation+scheduler+runnable
虛擬執行緒會把任務(java.lang.Runnable例項)包裝到一個 Continuation 例項中:

  • 當任務需要阻塞掛起的時候,會呼叫 Continuation 的 yield 操作進行阻塞,虛擬執行緒會從平臺執行緒解除安裝。
  • 當任務解除阻塞繼續執行的時候,呼叫 Continuation.run 會從阻塞點繼續執行。

Scheduler 也就是執行器,由它將任務提交到具體的載體執行緒池中執行。

  • 它是 java.util.concurrent.Executor 的子類。
  • 虛擬執行緒框架提供了一個預設的 FIFO 的 ForkJoinPool 用於執行虛擬執行緒任務。

Runnable 則是真正的任務包裝器,由 Scheduler 負責提交到載體執行緒池中執行。
JVM 把虛擬執行緒分配給平臺執行緒的操作稱為 mount(掛載),取消分配平臺執行緒的操作稱為 unmount(解除安裝
mount 操作:虛擬執行緒掛載到平臺執行緒,虛擬執行緒中包裝的 Continuation 堆疊幀資料會被複製到平臺執行緒的執行緒棧,這是一個從堆複製到棧的過程。
unmount 操作:虛擬執行緒從平臺執行緒解除安裝,此時虛擬執行緒的任務還沒有執行完成,所以虛擬執行緒中包裝的 Continuation 棧資料幀會會留在堆記憶體中。
從 Java 程式碼的角度來看,其實是看不到虛擬執行緒及載體執行緒共享作業系統執行緒的,會認為虛擬執行緒及其載體都在同一個執行緒上執行,因此,在同一虛擬執行緒上多次呼叫的程式碼可能會在每次呼叫時掛載的載體執行緒都不一樣。JDK 中使用了 FIFO 模式的 ForkJoinPool 作為虛擬執行緒的排程器,從這個排程器看虛擬執行緒任務的執行流程大致如下:

  • 排程器(執行緒池)中的平臺執行緒等待處理任務。

虛擬執行緒原理及效能分析|得物技術

  • 一個虛擬執行緒被分配平臺執行緒,該平臺執行緒作為載體執行緒執行虛擬執行緒中的任務。

虛擬執行緒原理及效能分析|得物技術

  • 虛擬執行緒執行其 Continuation,Mount(掛載)平臺執行緒後,最終執行 Runnable 包裝的使用者實際任務。

虛擬執行緒原理及效能分析|得物技術

  • 虛擬執行緒任務執行完成,標記 Continuation 終結,標記虛擬執行緒為終結狀態,清空上下文,等待 GC 回收,解除掛載載體執行緒會返還到排程器(執行緒池)中等待處理下一個任務。

虛擬執行緒原理及效能分析|得物技術
上面是沒有阻塞場景的虛擬執行緒任務執行情況,如果遇到了阻塞(例如 Lock 等)場景,會觸發 Continuation 的 yield 操作讓出控制權,等待虛擬執行緒重新分配載體執行緒並且執行,具體見下面的程式碼:

 ReentrantLock lock = new ReentrantLock();        Thread.startVirtualThread(() -> {            lock.lock();            });        // 確保鎖已經被上面的虛擬執行緒持有        Thread.sleep(1000);          Thread.startVirtualThread(() -> {            System.out.println("first");            會觸發Continuation的yield操作            lock.lock();             try {                System.out.println("second");            } finally {                lock.unlock();            }            System.out.println("third");        });        Thread.sleep(Long.MAX_VALUE);    }
  • 虛擬執行緒中任務執行時候呼叫 Continuation#run() 先執行了部分任務程式碼,然後嘗試獲取鎖,該操作是阻塞操作會導致 Continuation 的 yield 操作讓出控制權,如果 yield 操作成功,會從載體執行緒 unmount,載體執行緒棧資料會移動到 Continuation 棧的資料幀中,儲存在堆記憶體中,虛擬執行緒任務完成,此時虛擬執行緒和 Continuation 還沒有終結和釋放,載體執行緒被釋放到執行器中等待新的任務;如果 Continuation 的 yield 操作失敗,則會對載體執行緒進行 Park 呼叫,阻塞在載體執行緒上,此時虛擬執行緒和載體執行緒同時會被阻塞,本地方法,Synchronized 修飾的同步方法都會導致 yield 失敗。

虛擬執行緒原理及效能分析|得物技術

  • 當鎖持有者釋放鎖之後,會喚醒虛擬執行緒獲取鎖,獲取鎖成功後,虛擬執行緒會重新進行 mount,讓虛擬執行緒任務再次執行,此時有可能是分配到另一個載體執行緒中執行,Continuation 棧會的資料幀會被恢復到載體執行緒棧中,然後再次呼叫Continuation#run() 恢復任務執行。

虛擬執行緒原理及效能分析|得物技術

  • 虛擬執行緒任務執行完成,標記 Continuation 終結,標記虛擬執行緒為終結狀態,清空上下文變數,解除載體執行緒的掛載載體執行緒返還到排程器(執行緒池)中作為平臺執行緒等待處理下一個任務

Continuation 元件十分重要,它既是使用者真實任務的包裝器,同時提供了虛擬執行緒任務暫停/繼續的能力,以及虛擬執行緒與平臺執行緒資料轉移功能,當任務需要阻塞掛起的時候,呼叫 Continuation 的 yield 操作進行阻塞。當任務需要解除阻塞繼續執行的時候,則呼叫 Continuation 的 run 恢復執行。
透過下面的程式碼可以看出 Continuation 的神奇之處,透過在編譯引數加上--add-exports java.base/jdk.internal.vm=ALL-UNNAMED 可以在本地執行。

ContinuationScope scope = new ContinuationScope("scope");Continuation continuation = new Continuation(scope, () -> {    System.out.println("before yield開始");    Continuation.yield(scope);    System.out.println("after yield 結束");});System.out.println("1 run");// 第一次執行Continuation.runcontinuation.run();System.out.println("2 run");// 第二次執行Continuation.runcontinuation.run();System.out.println("Done");

虛擬執行緒原理及效能分析|得物技術
透過上述案例可以看出,Continuation 例項進行 yield 呼叫後,再次呼叫其 run 方法就可以從 yield 的呼叫之處繼續往下執行,從而實現了程式的中斷和恢復

虛擬執行緒記憶體佔用評估

單個平臺執行緒的資源佔用

  • 根據 JVM 規範,預留 1 MB 執行緒棧空間。
  • 平臺執行緒例項,會佔據 2000+ byte 資料。

單個虛擬執行緒的資源佔用

  • Continuation 棧會佔用數百 byte 到數百 KB 記憶體空間,是作為堆疊塊物件儲存在 Java 堆中。
  • 虛擬執行緒例項會佔據 200 - 240 byte 資料。

從對比結果來看,理論上單個平臺執行緒佔用的記憶體空間至少是 KB 級別的,而單個虛擬執行緒例項佔用的記憶體空間是 byte 級別,兩者的記憶體佔用差距較大,這也是虛擬執行緒可以大批次建立的原因。
下面透過一段程式去測試平臺執行緒和虛擬執行緒的記憶體佔用:




















private static final int COUNT = 4000;
/** *  -XX:NativeMemoryTracking=detail * * @param args args */public static void main(String[] args) throws Exception {    for (int i = 0; i < COUNT; i++) {        new Thread(() -> {            try {                Thread.sleep(Long.MAX_VALUE);            } catch (Exception e) {                e.printStackTrace();            }        }, String.valueOf(i)).start();    }    Thread.sleep(Long.MAX_VALUE);}

上面的程式執行後啟動 4000 平臺執行緒,透過 -XX:NativeMemoryTracking=detail 引數和 JCMD 命令檢視所有執行緒佔據的記憶體空間如下:
虛擬執行緒原理及效能分析|得物技術
記憶體佔用大部分來自建立的平臺執行緒,匯流排程棧空間佔用約為 8096 MB,兩者加起來佔據總使用記憶體(8403MB)的 96% 以上
用類似的方式編寫執行虛擬執行緒的程式:




















private static final int COUNT = 4000;
/** * -XX:NativeMemoryTracking=detail * * @param args args */public static void main(String[] args) throws Exception {    for (int i = 0; i < COUNT; i++) {        Thread.startVirtualThread(() -> {            try {                Thread.sleep(Long.MAX_VALUE);            } catch (Exception e) {                e.printStackTrace();            }        });    }    Thread.sleep(Long.MAX_VALUE);}

上面的程式執行後啟動 4000 虛擬執行緒:
虛擬執行緒原理及效能分析|得物技術
堆記憶體的實際佔用量和總記憶體的實際佔用量都不超過 300 MB,可以證明虛擬執行緒在大量建立的前提下也不會去佔用過多的記憶體,且虛擬執行緒的堆疊是作為堆疊塊物件儲存在 Java 的堆中的,可以被 GC 回收,又降低了虛擬執行緒的佔用。

虛擬執行緒的侷限及使用建議

  • 虛擬執行緒存在 native 方法或者外部方法 (Foreign Function & Memory API,jep 424 ) 呼叫不能進行 yield 操作,此時載體執行緒會被阻塞。
  • 當執行在 synchronized 修飾的程式碼塊或者方法時,不能進行 yield 操作,此時載體執行緒會被阻塞,推薦使用 ReentrantLock。
  • ThreadLocal 相關問題,目前虛擬執行緒仍然是支援 ThreadLocal 的,但是由於虛擬執行緒的數量非常多,會導致 Threadlocal 中存的執行緒變數非常多,需要頻繁 GC 去清理,對效能會有影響,官方建議儘量少使用 ThreadLocal,同時不要在虛擬執行緒的 ThreadLocal 中放大物件,目前官方是想透過 ScopedLocal 去替換掉 ThreadLocal,但是在 21 版本還沒有正式釋出,這個可能是大規模使用虛擬執行緒的一大難題。
  • 無需池化虛擬執行緒 虛擬執行緒佔用的資源很少,因此可以大量地建立而無須考慮池化,它不需要跟平臺執行緒池一樣,平臺執行緒的建立成本比較昂貴,所以通常選擇去池化,去做共享,但是池化操作本身會引入額外開銷,對於虛擬執行緒池化反而是得不償失,使用虛擬執行緒我們拋棄池化的思維,用時建立,用完就扔。

虛擬執行緒適用場景

  • 大量的 IO 阻塞等待任務,例如下游 RPC 呼叫,DB 查詢等。
  • 大批次的處理時間較短的計算任務。
  • Thread-per-request (一請求一執行緒)風格的應用程式,例如主流的 Tomcat 執行緒模型或者基於類似執行緒模型實現的 SpringMVC 框架 ,這些應用只需要小小的改動就可以帶來巨大的吞吐提升。

虛擬執行緒壓測效能分析

在下面的測試中,我們將模擬最常使用的場景-使用 Web 容器去處理 Http 請求。

場景一:在 Spring Boot 中使用內嵌的 Tomcat 去處理 Http 請求,使用預設的平臺執行緒池作為 Tomcat 的請求處理執行緒池。
場景二:使用 Spring -WebFlux 建立基於事件迴圈模型的應用程式,進行響應式請求處理。
場景三:在 Spring Boot 中使用內嵌的 Tomcat 去處理 Http 請求,使用虛擬執行緒池作為 Tomcat 的請求處理執行緒池 (Tomcat已支援虛擬執行緒)。

測試流程

  • Jmeter 開啟 500 個執行緒去並行發起請求。每個執行緒將等待請求響應後再發起下一次請求,單次請求超時時間為 10s,測試時間持續 60s。
  • 測試的 Web Server 將接受 Jmeter 的請求,並呼叫慢速伺服器獲取響應並返回。
  • 慢速伺服器以隨機超時響應。最大響應時間為 1000ms。平均響應時間為 500ms。

虛擬執行緒原理及效能分析|得物技術

衡量指標

吞吐量和平均響應時間,吞吐量越高,平均響應時間越低,效能就越好。


Tomcat+普通執行緒池

預設情況下,Tomcat 使用一請求一執行緒模型處理請求,當 Tomcat 收到請求時,會從執行緒池中取一個執行緒去處理請求,該分配的執行緒將一直保持佔用狀態,直到請求結束才會釋放。當執行緒池中沒有執行緒時,請求會一直阻塞在佇列中,直到有請求結束釋放執行緒。預設佇列長度為 Integer.MAX。

預設執行緒池

預設情況下,執行緒池最多包含 200 個執行緒。這基本上意味著單個時間點最多處理 200 個請求。對於每個請求服務都會以阻塞的方式呼叫平均 RT500ms 的慢速伺服器。因此,可以預期每秒 400 個請求的吞吐量,最終壓測結果非常接近預期值,為 388 req/sec。

虛擬執行緒原理及效能分析|得物技術
增加執行緒池
生產環境為了吞吐考慮,一般不會使用預設值,會把執行緒池增大到 server.tomcat.threads.max=500+,調整到 500+ 之後的壓測結果如下:

虛擬執行緒原理及效能分析|得物技術

可以看出最終的吞吐量和執行緒數量呈比例上升,同時由於執行緒數的增加,請求等待減少,平均 RT 趨向於慢速伺服器的響應平均 RT。
但是需要注意的是,平臺執行緒的建立受到記憶體和 Java 執行緒對映模型的限制,不能無限擴充套件,同時大量執行緒會導致 CPU 資源大量消耗在上下文切換時,整體效能反而降低。

WebFlux

WebFlux 跟傳統的 Tomcat 執行緒模型不一樣,他不會為每個請求分配一個專用執行緒,而是使用事件迴圈模型透過非阻塞 I/O 操作同時處理多個請求,這使得它能夠用有限的執行緒數量處理大量的併發請求。

在壓測的場景下,使用 WebClient 來進行一個非阻塞的 Http 呼叫慢速處理器,並使用 RouterFunction 來做請求對映和處理。



















@Beanpublic WebClient slowServerClient() {    return WebClient.builder()            .baseUrl(")            .build();}
@Beanpublic RouterFunction<ServerResponse> routes(WebClient slowServerClient) {    return route(GET("/"), (ServerRequest req) -> ok()            .body(                    slowServerClient                            .get()                            .exchangeToFlux(resp -> resp.bodyToFlux(Object.class)),                    Object.class            ));}

WebFlux 壓測結果如下:

虛擬執行緒原理及效能分析|得物技術

可以看到,WebFlux 的請求完全沒有阻塞,僅用了 25 個執行緒就達到了 964 req/sec 的吞吐。

Tomcat+虛擬執行緒池

與平臺執行緒相比,虛擬執行緒的記憶體佔用量要低得多,執行程式大量的建立虛擬執行緒,而不會耗盡系統資源;同時當遇到 Thread.sleep(),CompletableFuture.await(),等待 I/O,獲取鎖時,虛擬執行緒會自動解除安裝,JVM 可以自動切換到另外的等待就緒的虛擬執行緒,提升單個平臺執行緒的利用率,保證平臺執行緒不會浪費在無意義的阻塞等待上。

要想使用虛擬執行緒,需要先在啟動引數中加上 --enable-preview,同時 Tomcat 在 10 版本已支援虛擬執行緒,我們只需要替換 Tomcat 的平臺執行緒池為虛擬執行緒池即可。














@Beanpublic TomcatProtocolHandlerCustomizer<?> protocolHandler() {    return protocolHandler ->            protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());}

private final RestTemplate restTemplate;
@GetMappingpublic ResponseEntity<Object> callSlowServer(){    return restTemplate.getForEntity(", Object.class);}

最終壓測結果如下:

虛擬執行緒原理及效能分析|得物技術

可以看到虛擬執行緒的壓測結果實際上與 WebFlux 的情況相同,但我們根本沒有使用任何複雜的響應式程式設計技術。同時對慢速伺服器的呼叫,也使用常規的阻塞  RestTemplate。我們所做的只是用虛擬執行緒執行器替換執行緒池就達到更復雜的 Webflux 寫法相同的效果。
總的壓測結果如下:

虛擬執行緒原理及效能分析|得物技術

透過以上壓測結果,我們可以得出以下結論:

  • 傳統的執行緒池模式效果差強人意,可以透過提高執行緒數量可以提升吞吐,但是需要考慮到系統容量和資源限制,但是對於大部分場景來說使用執行緒池去處理阻塞操作仍然是主流且不錯的選擇。
  • WebFlux 的效果非常好,但是考慮到需要完全按照響應式風格進行開發,成本及難度較大,同時 WebFlux 與現有的一些主流框架存在一些相容問題,例如 Mysql 官方 IO 庫不支援 NIO、Threadlocal 相容問題等等。現有應用的遷移基本要重寫所有程式碼,改動量和風險都不可控。
  • 虛擬執行緒的效果非常好最大的優勢就是我們沒有修改程式碼或採用任何反應式技術,唯一更改是將執行緒池替換為虛擬執行緒。雖然改動較小,但與使用執行緒池相比,效能結果得到了顯著改善。

基於上述的壓測結果,可以較為樂觀的認為虛擬執行緒會顛覆我們目前的服務和框架中的請求處理方法。

總結

過去很長時間,在編寫服務端應用時,我們對於每個請求,都使用獨佔的執行緒來處理,請求之間是相互獨立的,這就是 一請求一執行緒的模型這種方式易於理解和程式設計實現,也易於除錯和效能調優。

然而,一請求一執行緒風格並不能簡單地使用平臺執行緒來實現,因為平臺執行緒是作業系統中執行緒的封裝。作業系統的執行緒會申請成本較高,存在數量上限。對於一個要併發處理海量請求的伺服器端應用來說,對每個請求都建立一個平臺執行緒是不現實的在這種前提下,湧現出一批非阻塞 I/O 和非同步程式設計框架,如 WebFlux ,RX-Java。當某個請求在等待 I/O 操作時,它會暫時讓出執行緒,並在 I/O 操作完成之後繼續執行。透過這種方式,可以用少量執行緒同時處理大量的請求。這些框架可以提升系統的吞吐量,但是要求開發人員必須熟悉所使用的底層框架,並按照響應式的風格來編寫程式碼,響應式框架的除錯困難,學習成本,相容問題使得大部分人望而卻步 。

在使用虛擬執行緒之後,一切都將改變,開發人員可以使用目前最習慣舒服的方式來編寫程式碼,高效能和高吞吐由虛擬執行緒自動幫你完成,這極大地降低了編寫高併發服務應用的難度

參考文件

  1. %E5%89%8D%E6%8F%90




來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70027824/viewspace-2993461/,如需轉載,請註明出處,否則將追究法律責任。

相關文章