本" />
JDK21 在 9 月 19 號正式發布,帶來了較多亮點,其中虛擬線程備受矚目,毫不夸張的說,它改變了高吞吐代碼的編寫方式,只需要小小的變動就可以讓目前的 IO 密集型程序的吞吐量得到提升,寫出高吞吐量的代碼不再困難。
本文將詳細介紹虛擬線程的使用場景,實現原理以及在 IO 密集型服務下的性能壓測效果。
在講虛擬線程之前,我們先聊聊為了提高吞吐性能,我們所做的一些優化方案。
在當前的微服務架構下,處理一次用戶/上游的請求,往往需要多次調用下游服務、數據庫、文件系統等,再將所有請求的數據進行處理最終的結果返回給上游。
圖片
圖片
在這種模式下,使用串行模式去查詢數據庫,下游 Dubbo/Http 接口,文件系統完成一次請求,接口整體的耗時等于各個下游的返回時間之和,這種寫法雖然簡單,但是接口耗時長、性能差,無法滿足 C 端高 QPS 場景下的性能要求。
為了解決串行調用的低性能問題,我們會考慮使用并行異步調用的方式,最簡單的方式便是使用線程池 +Future 去并行調用。
圖片
典型代碼如下:
圖片
這種方式雖然解決了大部分場景下的串行調用低性能問題,但是也存在著嚴重的弊端,由于存在 Future 的前后依賴關系,當使用場景存在大量的前后依賴時,會使得線程資源和 CPU 大量浪費在阻塞等待上,導致資源利用率低。
為了降低 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 私有的棧空間,大量平臺線程會占據大量內存。
圖片
那么有沒有一種方法可以易于編寫,方便遷移,符合日常編碼習慣,同時性能很不錯,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();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 實例中:
Scheduler 也就是執行器,由它將任務提交到具體的載體線程池中執行。
Runnable 則是真正的任務包裝器,由 Scheduler 負責提交到載體線程池中執行。
JVM 把虛擬線程分配給平臺線程的操作稱為 mount(掛載),取消分配平臺線程的操作稱為 unmount(卸載):
mount 操作:虛擬線程掛載到平臺線程,虛擬線程中包裝的 Continuation 堆棧幀數據會被拷貝到平臺線程的線程棧,這是一個從堆復制到棧的過程。
unmount 操作:虛擬線程從平臺線程卸載,此時虛擬線程的任務還沒有執行完成,所以虛擬線程中包裝的 Continuation 棧數據幀會會留在堆內存中。
從 Java 代碼的角度來看,其實是看不到虛擬線程及載體線程共享操作系統線程的,會認為虛擬線程及其載體都在同一個線程上運行,因此,在同一虛擬線程上多次調用的代碼可能會在每次調用時掛載的載體線程都不一樣。JDK 中使用了 FIFO 模式的 ForkJoinPool 作為虛擬線程的調度器,從這個調度器看虛擬線程任務的執行流程大致如下:
圖片
圖片
圖片
圖片
上面是沒有阻塞場景的虛擬線程任務執行情況,如果遇到了阻塞(例如 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 組件十分重要,它既是用戶真實任務的包裝器,同時提供了虛擬線程任務暫停/繼續的能力,以及虛擬線程與平臺線程數據轉移功能,當任務需要阻塞掛起的時候,調用 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 的調用之處繼續往下執行,從而實現了程序的中斷和恢復。
虛擬線程內存占用評估
單個虛擬線程的資源占用:
從對比結果來看,理論上單個平臺線程占用的內存空間至少是 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 回收,又降低了虛擬線程的占用。
虛擬線程的局限及使用建議
在下面的測試中,我們將模擬最常使用的場景-使用 Web 容器去處理 Http 請求。
場景一:在 Spring Boot 中使用內嵌的 Tomcat 去處理 Http 請求,使用默認的平臺線程池作為 Tomcat 的請求處理線程池。
場景二:使用 Spring -WebFlux 創建基于事件循環模型的應用程序,進行響應式請求處理。
場景三:在 Spring Boot 中使用內嵌的 Tomcat 去處理 Http 請求,使用虛擬線程池作為 Tomcat 的請求處理線程池 (Tomcat已支持虛擬線程)。
圖片
默認情況下,Tomcat 使用一請求一線程模型處理請求,當 Tomcat 收到請求時,會從線程池中取一個線程去處理請求,該分配的線程將一直保持占用狀態,直到請求結束才會釋放。當線程池中沒有線程時,請求會一直阻塞在隊列中,直到有請求結束釋放線程。默認隊列長度為 Integer.MAX。默認線程池默認情況下,線程池最多包含 200 個線程。這基本上意味著單個時間點最多處理 200 個請求。對于每個請求服務都會以阻塞的方式調用平均 RT500ms 的慢速服務器。因此,可以預期每秒 400 個請求的吞吐量,最終壓測結果非常接近預期值,為 388 req/sec。
增加線程池
生產環境為了吞吐考慮,一般不會使用默認值,會把線程池增大到 server.tomcat.threads.max=500+,調整到 500+ 之后的壓測結果如下:
可以看出最終的吞吐量和線程數量呈比例上升,同時由于線程數的增加,請求等待減少,平均 RT 趨向于慢速服務器的響應平均 RT。
但是需要注意的是,平臺線程的創建受到內存和 Java 線程映射模型的限制,不能無限擴展,同時大量線程會導致 CPU 資源大量消耗在上下文切換時,整體性能反而降低。
WebFlux 跟傳統的 Tomcat 線程模型不一樣,他不會為每個請求分配一個專用線程,而是使用事件循環模型通過非阻塞 I/O 操作同時處理多個請求,這使得它能夠用有限的線程數量處理大量的并發請求。
在壓測的場景下,使用 WebClient 來進行一個非阻塞的 Http 調用慢速處理器,并使用 RouterFunction 來做請求映射和處理。
@Beanpublic WebClient slowServerClient() { return WebClient.builder() .baseUrl("http://127.0.0.1:8000") .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 的吞吐。
與平臺線程相比,虛擬線程的內存占用量要低得多,運行程序大量的創建虛擬線程,而不會耗盡系統資源;同時當遇到 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("http://127.0.0.1:8000", Object.class);}
最終壓測結果如下:
圖片
可以看到虛擬線程的壓測結果實際上與 WebFlux 的情況相同,但我們根本沒有使用任何復雜的響應式編程技術。同時對慢速服務器的調用,也使用常規的阻塞 RestTemplate。我們所做的只是用虛擬線程執行器替換線程池就達到更復雜的 Webflux 寫法相同的效果。
總的壓測結果如下:
通過以上壓測結果,我們可以得出以下結論:
基于上述的壓測結果,可以較為樂觀的認為虛擬線程會顛覆我們目前的服務和框架中的請求處理方法。
過去很長時間,在編寫服務端應用時,我們對于每個請求,都使用獨占的線程來處理,請求之間是相互獨立的,這就是 一請求一線程的模型這種方式易于理解和編程實現,也易于調試和性能調優。
然而,一請求一線程風格并不能簡單地使用平臺線程來實現,因為平臺線程是操作系統中線程的封裝。操作系統的線程會申請成本較高,存在數量上限。對于一個要并發處理海量請求的服務器端應用來說,對每個請求都創建一個平臺線程是不現實的。在這種前提下,涌現出一批非阻塞 I/O 和異步編程框架,如 WebFlux ,RX-Java。當某個請求在等待 I/O 操作時,它會暫時讓出線程,并在 I/O 操作完成之后繼續執行。通過這種方式,可以用少量線程同時處理大量的請求。這些框架可以提升系統的吞吐量,但是要求開發人員必須熟悉所使用的底層框架,并按照響應式的風格來編寫代碼,響應式框架的調試困難,學習成本,兼容問題使得大部分人望而卻步 。
在使用虛擬線程之后,一切都將改變,開發人員可以使用目前最習慣舒服的方式來編寫代碼,高性能和高吞吐由虛擬線程自動幫你完成,這極大地降低了編寫高并發服務應用的難度。
本文鏈接:http://www.www897cc.com/showinfo-26-17410-0.html虛擬線程原理及性能分析
聲明:本網頁內容旨在傳播知識,若有侵權等問題請及時與本網聯系,我們將在第一時間刪除處理。郵件:2376512515@qq.com
上一篇: 好用的嵌入式設備日志輸出模塊 log.h