:革命性的并發(fā)模型)
摘要本文深入解析JDK 21引入的虛擬線程Virtual Threads技術(shù)從傳統(tǒng)平臺線程的資源瓶頸出發(fā)闡述虛擬線程的輕量級特性、M:N調(diào)度模型及其工作原理。詳細介紹了兩種創(chuàng)建虛擬線程的方法并通過性能對比實驗展示其在I/O密集型場景下的顯著優(yōu)勢??偨Y(jié)了虛擬線程的適用場景、使用注意事項以及與平臺線程的核心差異幫助開發(fā)者掌握這一革命性的并發(fā)編程工具。關(guān)鍵詞虛擬線程, Java并發(fā), JDK 21, I/O密集型, 線程池在之前的幾篇文章里我們從“線程的創(chuàng)建”一步步學到了“JUC并發(fā)工具”。你知道了怎么用synchronized保護共享數(shù)據(jù)怎么用wait()/notify()讓線程協(xié)作也知道了ExecutorService線程池和ConcurrentHashMap這些強大的工具。但是不知道你有沒有隱隱感覺到一絲“不對勁”我們來做一個思想實驗。假設(shè)你是一個 Web 服務(wù)的開發(fā)者你的服務(wù)需要同時處理 10,000 個用戶的請求。按照傳統(tǒng)的做法你會用線程池比如Executors.newFixedThreadPool(200)——最多同時處理 200 個請求剩下的請求在隊列里排隊。這 200 個線程每一個都對應(yīng)著一個操作系統(tǒng)線程。每個操作系統(tǒng)線程需要大約1MB 的棧內(nèi)存。200 個線程就是 200MB勉強還能接受。但如果是 10,000 個并發(fā)呢10GB 內(nèi)存光是棧就把機器撐爆了。所以傳統(tǒng)線程池的瓶頸在于線程的數(shù)量被操作系統(tǒng)限制了。即使 CPU 還有空閑你也沒法創(chuàng)建更多的線程因為每個線程的內(nèi)存開銷太大了。但你想一想這 10,000 個用戶請求大多數(shù)時間在干什么它們在等待數(shù)據(jù)庫查詢結(jié)果、等待調(diào)用下游 API、等待讀取文件……這些 I/O 操作占用了絕大部分時間而線程在這段時間里是阻塞的——它什么事都沒做卻白白占著 1MB 的內(nèi)存和寶貴的操作系統(tǒng)線程資源。這不就浪費了嗎JDK 21 引入的虛擬線程Virtual Threads就是為了解決這個問題而生的。1. 虛擬線程是什么—— 由 JVM 管理的“輕量級線程”虛擬線程你可以理解為“由 JVM 管理的輕量級用戶線程”。它和傳統(tǒng)的“平臺線程Platform Thread”有本質(zhì)的區(qū)別平臺線程直接映射到操作系統(tǒng)線程1:1 關(guān)系。創(chuàng)建、銷毀、上下文切換都需要操作系統(tǒng)介入開銷大。虛擬線程不直接映射到操作系統(tǒng)線程。JVM 在少量的平臺線程上“調(diào)度”大量的虛擬線程M:N 關(guān)系。虛擬線程的創(chuàng)建和切換由 JVM 在用戶態(tài)完成不涉及內(nèi)核態(tài)切換。打個比方平臺線程就像是“正式工”每個正式工都有自己獨立的辦公室1MB 棧內(nèi)存招聘一個正式工要走 HR 流程、要審批、要分配辦公室操作系統(tǒng)介入。而虛擬線程就像是“臨時工”他們不占獨立辦公室而是在一個大的開放工位平臺線程上輪流工作。當一個臨時工在等咖啡I/O 阻塞時他立刻讓出工位另一個臨時工馬上坐上來干活。虛擬線程的核心特性特性說明極致輕量每個虛擬線程只占幾百字節(jié)到 160KB 的內(nèi)存可以輕松創(chuàng)建數(shù)百萬個用戶態(tài)調(diào)度由 JVM 調(diào)度不經(jīng)過操作系統(tǒng)內(nèi)核切換開銷極低自動掛起/恢復遇到 I/O 阻塞時自動“讓出”載體線程I/O 完成后自動恢復兼容現(xiàn)有 API完全兼容java.lang.Thread和ExecutorServiceAPI2. 傳統(tǒng)線程的“三座大山”在深入虛擬線程之前我們先來回顧一下傳統(tǒng)平臺線程的三大痛點① 資源消耗高每個平臺線程需要分配獨立的??臻g默認 1MB。1 萬個線程就是 10GB 內(nèi)存這在大多數(shù)服務(wù)器上都是不可接受的。② 調(diào)度開銷大線程切換需要陷入操作系統(tǒng)內(nèi)核態(tài)上下文切換的成本很高。當大量線程在阻塞和就緒之間頻繁切換時CPU 大量的時間都花在了“切換”上而不是“干活”上。③ 擴展性瓶頸操作系統(tǒng)能支持的線程數(shù)量是有限的通常幾千到幾萬。即使你的 CPU 有 64 個核心你也無法創(chuàng)建 10 萬個平臺線程。為了解決這些問題傳統(tǒng)的做法是線程池——限制并發(fā)線程的數(shù)量讓線程復用。但線程池也有它的代價你需要小心翼翼地調(diào)優(yōu)池的大小池太小了吞吐量不夠池太大了內(nèi)存撐不住。而且如果一個線程在池中執(zhí)行一個阻塞 I/O 操作它仍然會占著池中的一個位置導致其他任務(wù)排隊等待。虛擬線程的思路完全不同不再限制線程的數(shù)量而是讓線程變得極其輕量輕到你可以為每個任務(wù)創(chuàng)建一個線程。3. 虛擬線程的工作原理 —— “M:N”調(diào)度模型虛擬線程最核心的設(shè)計是M:N 調(diào)度模型M 個虛擬線程運行在 N 個平臺線程上M 遠大于 N。每個平臺線程載體線程一次只能“承載”一個虛擬線程。當虛擬線程執(zhí)行到阻塞操作比如Thread.sleep()、socket.read()、數(shù)據(jù)庫查詢時它會自動從載體線程上卸載載體線程立刻去執(zhí)行另一個就緒的虛擬線程。當阻塞操作完成時虛擬線程會被重新調(diào)度到某個空閑的載體線程上繼續(xù)執(zhí)行。這意味著虛擬線程在阻塞時不會“浪費”平臺線程資源。平臺線程始終在忙碌地執(zhí)行著某些虛擬線程的任務(wù)CPU 利用率被拉滿。JVM 使用ForkJoinPool作為虛擬線程的默認調(diào)度器。它采用“工作竊取”算法讓各個載體線程之間的負載保持均衡。4. 如何創(chuàng)建虛擬線程JDK 21 提供了兩種主要方式來創(chuàng)建虛擬線程。4.1 方式一Thread.ofVirtual()這是最直接的方式和創(chuàng)建普通線程的語法幾乎一樣// 創(chuàng)建一個虛擬線程并立即啟動ThreadvtThread.ofVirtual().start(()-{System.out.println(Hello from virtual thread: Thread.currentThread());});// 等待虛擬線程執(zhí)行完畢vt.join();你還可以給虛擬線程起名字方便調(diào)試ThreadvtThread.ofVirtual().name(my-virtual-thread).start(()-{System.out.println(Running in: Thread.currentThread().getName());});vt.join();甚至可以用Thread.Builder來批量創(chuàng)建帶編號的線程varbuilderThread.ofVirtual().name(worker-,0);Threadt1builder.start(()-System.out.println(Task 1));Threadt2builder.start(()-System.out.println(Task 2));t1.join();t2.join();// 輸出worker-0 和 worker-14.2 方式二Executors.newVirtualThreadPerTaskExecutor()如果你需要提交大量任務(wù)使用虛擬線程的ExecutorService會更方便try(varexecutorExecutors.newVirtualThreadPerTaskExecutor()){// 提交 10,000 個任務(wù)每個任務(wù)都在一個新的虛擬線程中執(zhí)行for(inti0;i10_000;i){inttaskIdi;executor.submit(()-{System.out.println(Task taskId running in Thread.currentThread());});}}// try-with-resources 會自動等待所有任務(wù)完成并關(guān)閉線程池newVirtualThreadPerTaskExecutor()會為每個提交的任務(wù)創(chuàng)建一個新的虛擬線程。它和newCachedThreadPool()類似——都是“來一個任務(wù)創(chuàng)建一個線程”——但區(qū)別在于它創(chuàng)建的是虛擬線程而不是平臺線程所以你可以創(chuàng)建數(shù)百萬個而不會耗盡系統(tǒng)資源。4.3 一個直觀的性能對比我們來做一個簡單的實驗importjava.time.Duration;importjava.util.concurrent.Executors;importjava.util.stream.IntStream;publicclassVirtualThreadComparison{publicstaticvoidmain(String[]args){// 1. 虛擬線程每個任務(wù)一個虛擬線程longstart1System.currentTimeMillis();try(varexecutorExecutors.newVirtualThreadPerTaskExecutor()){IntStream.range(0,10_000).forEach(i-{executor.submit(()-{Thread.sleep(Duration.ofSeconds(1));returni;});});}System.out.println(虛擬線程耗時(System.currentTimeMillis()-start1)ms);// 2. 固定線程池20個線程任務(wù)排隊執(zhí)行l(wèi)ongstart2System.currentTimeMillis();try(varexecutorExecutors.newFixedThreadPool(20)){IntStream.range(0,10_000).forEach(i-{executor.submit(()-{Thread.sleep(Duration.ofSeconds(1));returni;});});}System.out.println(固定線程池(20)耗時(System.currentTimeMillis()-start2)ms);}}在我的機器上虛擬線程版本在幾秒內(nèi)就能完成所有任務(wù)而固定線程池20 個線程需要大約 500 秒因為 10,000 個任務(wù)排隊每次只能執(zhí)行 20 個每個任務(wù)耗時 1 秒。這個對比直觀地展示了虛擬線程在 I/O 密集型場景下的巨大優(yōu)勢。5. 虛擬線程適合什么場景? 適合I/O 密集型任務(wù)虛擬線程是 I/O 密集型任務(wù)的“天選之子”。包括Web 服務(wù)器處理 HTTP 請求微服務(wù)之間的 RPC 調(diào)用數(shù)據(jù)庫查詢JDBC文件讀寫網(wǎng)絡(luò)通信在這些場景中線程大部分時間都在等待 I/O 完成。虛擬線程讓這些等待時間不再“浪費”平臺線程資源。? 不適合CPU 密集型任務(wù)如果你的任務(wù)需要大量的 CPU 計算比如視頻編碼、加密解密、大規(guī)模矩陣運算虛擬線程并不會帶來性能提升。因為 CPU 密集型任務(wù)并不會阻塞虛擬線程不會“讓出”載體線程它和普通線程在 CPU 計算上的表現(xiàn)沒有區(qū)別。對于 CPU 密集型任務(wù)你應(yīng)該使用平臺線程并且線程數(shù)量不要超過 CPU 核心數(shù)。6. 虛擬線程的使用注意事項雖然虛擬線程用起來很簡單但有幾個地方需要特別注意6.1 不要池化虛擬線程虛擬線程的設(shè)計初衷就是“即用即毀”——創(chuàng)建成本極低用完就可以丟棄。把虛擬線程池化是反模式不僅沒有收益還會增加復雜性。// ? 錯誤池化虛擬線程ExecutorServicepoolExecutors.newFixedThreadPool(10);for(inti0;i1000;i){pool.submit(()-{/* 使用虛擬線程不這是平臺線程池 */});}// ? 正確每次創(chuàng)建新的虛擬線程try(varexecutorExecutors.newVirtualThreadPerTaskExecutor()){for(inti0;i1000;i){executor.submit(()-{/* 每個任務(wù)一個新虛擬線程 */});}}6.2 謹慎使用ThreadLocal虛擬線程的數(shù)量可能非常多百萬級如果每個虛擬線程都使用ThreadLocal存儲數(shù)據(jù)內(nèi)存消耗會迅速膨脹。在虛擬線程中ThreadLocal的生命周期應(yīng)該盡量短不要用來存儲長生命周期的數(shù)據(jù)。6.3 注意synchronized的“固定”問題Pinning當一個虛擬線程進入synchronized代碼塊或方法時它會被“固定”在當前的載體線程上——即使它在同步塊內(nèi)阻塞也不會讓出載體線程。這意味著在synchronized塊內(nèi)執(zhí)行阻塞 I/O 操作會浪費載體線程資源。建議在虛擬線程中盡量使用ReentrantLock替代synchronized。ReentrantLock不會導致固定問題。// ? 可能導致固定synchronized(lock){// 這里執(zhí)行阻塞 I/O 操作Thread.sleep(1000);}// ? 推薦使用 ReentrantLocklock.lock();try{Thread.sleep(1000);}finally{lock.unlock();}7. 虛擬線程 vs 平臺線程 —— 一個直觀的對比維度平臺線程虛擬線程管理方操作系統(tǒng)JVM棧內(nèi)存~1MB~160KB 或更少創(chuàng)建上限數(shù)千~數(shù)萬數(shù)百萬阻塞時行為占用操作系統(tǒng)線程資源自動讓出載體線程上下文切換內(nèi)核態(tài)開銷大用戶態(tài)開銷極小適用場景CPU 密集型I/O 密集型編程模型需要線程池管理一任務(wù)一線程簡單直觀8. 今天的總結(jié)今天我們初步認識了 JDK 21 最重磅的特性——虛擬線程虛擬線程是什么由 JVM 管理的輕量級用戶線程不直接映射到操作系統(tǒng)線程。為什么需要它傳統(tǒng)平臺線程資源消耗大、調(diào)度開銷高、數(shù)量受限于操作系統(tǒng)。工作原理M:N 調(diào)度模型虛擬線程在阻塞時自動讓出載體線程。如何創(chuàng)建Thread.ofVirtual().start()或Executors.newVirtualThreadPerTaskExecutor()。適用場景I/O 密集型任務(wù)Web 服務(wù)、微服務(wù)、數(shù)據(jù)庫訪問。注意事項不要池化虛擬線程、謹慎使用ThreadLocal、用ReentrantLock替代synchronized。虛擬線程讓 Java 的并發(fā)編程進入了一個新時代。它讓我們可以回歸到最簡單、最直觀的并發(fā)模型——為每個任務(wù)創(chuàng)建一個線程而不用再為線程池的大小調(diào)優(yōu)而煩惱。動手試試運行上面的性能對比代碼在你的機器上測試虛擬線程和固定線程池處理 10,000 個任務(wù)每個任務(wù)Thread.sleep(1000)的耗時差異。用Thread.ofVirtual().start()創(chuàng)建 100,000 個虛擬線程每個線程打印一句話。觀察你的機器是否還能流暢運行平臺線程做不到這一點。寫一個簡單的 HTTP 服務(wù)器用虛擬線程處理每個請求提示用com.sun.net.httpserver.HttpServer每個請求分配一個虛擬線程。然后用壓測工具如wrk或ab測試它的并發(fā)能力。嘗試在虛擬線程中使用synchronized塊執(zhí)行一個阻塞操作觀察是否有性能下降。然后用ReentrantLock替代對比兩者的表現(xiàn)。思考如果你的項目是一個 Spring Boot 微服務(wù)如何啟用虛擬線程提示Spring Boot 3.2 支持通過配置啟用虛擬線程。下一篇文章我們將深入虛擬線程的實戰(zhàn)與最佳實踐包括如何從傳統(tǒng)線程池遷移到虛擬線程、結(jié)構(gòu)化并發(fā)的概念、以及虛擬線程的調(diào)試與監(jiān)控。我們下一篇見。 獲取本系列示例代碼請訪問 GitCode。