溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊(cè)×
其他方式登錄
點(diǎn)擊 登錄注冊(cè) 即表示同意《億速云用戶服務(wù)條款》

Java之JDK19虛擬線程的知識(shí)點(diǎn)有哪些

發(fā)布時(shí)間:2022-10-10 14:12:36 來(lái)源:億速云 閱讀:154 作者:iii 欄目:編程語(yǔ)言

這篇“Java之JDK19虛擬線程的知識(shí)點(diǎn)有哪些”文章的知識(shí)點(diǎn)大部分人都不太理解,所以小編給大家總結(jié)了以下內(nèi)容,內(nèi)容詳細(xì),步驟清晰,具有一定的借鑒價(jià)值,希望大家閱讀完這篇文章能有所收獲,下面我們一起來(lái)看看這篇“Java之JDK19虛擬線程的知識(shí)點(diǎn)有哪些”文章吧。

介紹

虛擬線程具有和 Go 語(yǔ)言的 goroutines 和 Erlang 語(yǔ)言的進(jìn)程類似的實(shí)現(xiàn)方式,它們是用戶模式(user-mode)線程的一種形式。

在過(guò)去 Java 中常常使用線程池來(lái)進(jìn)行平臺(tái)線程的共享以提高對(duì)計(jì)算機(jī)硬件的使用率,但在這種異步風(fēng)格中,請(qǐng)求的每個(gè)階段可能在不同的線程上執(zhí)行,每個(gè)線程以交錯(cuò)的方式運(yùn)行屬于不同請(qǐng)求的階段,與 Java 平臺(tái)的設(shè)計(jì)不協(xié)調(diào)從而導(dǎo)致:

  • 堆棧跟蹤不提供可用的上下文

  • 調(diào)試器不能單步執(zhí)行請(qǐng)求處理邏輯

  • 分析器不能將操作的成本與其調(diào)用方關(guān)聯(lián)。

而虛擬線程既保持與平臺(tái)的設(shè)計(jì)兼容,同時(shí)又能最佳地利用硬件從而不影響可伸縮性。虛擬線程是由 JDK 而非操作系統(tǒng)提供的線程的輕量級(jí)實(shí)現(xiàn):

  • 虛擬線程是沒(méi)有綁定到特定操作系統(tǒng)線程的線程。

  • 平臺(tái)線程是以傳統(tǒng)方式實(shí)現(xiàn)的線程,作為圍繞操作系統(tǒng)線程的簡(jiǎn)單包裝。

摘要

向 Java 平臺(tái)引入虛擬線程。虛擬線程是輕量級(jí)線程,它可以大大減少編寫、維護(hù)和觀察高吞吐量并發(fā)應(yīng)用程序的工作量。

目標(biāo)

  • 允許以簡(jiǎn)單的每個(gè)請(qǐng)求一個(gè)線程的方式編寫的服務(wù)器應(yīng)用程序以接近最佳的硬件利用率進(jìn)行擴(kuò)展。

  • 允許使用 java.lang.ThreadAPI 的現(xiàn)有代碼采用虛擬線程,并且只做最小的更改。

  • 使用現(xiàn)有的 JDK 工具可以方便地對(duì)虛擬線程進(jìn)行故障排除、調(diào)試和分析。

非目標(biāo)

  • 移除線程的傳統(tǒng)實(shí)現(xiàn)或遷移現(xiàn)有應(yīng)用程序以使用虛擬線程并不是目標(biāo)。

  • 改變 Java 的基本并發(fā)模型。

  • 我們的目標(biāo)不是在 Java 語(yǔ)言或 Java 庫(kù)中提供新的資料平行結(jié)構(gòu)。StreamAPI 仍然是并行處理大型數(shù)據(jù)集的首選方法。

動(dòng)機(jī)

近30年來(lái),Java 開發(fā)人員一直依賴線程作為并發(fā)服務(wù)器應(yīng)用程序的構(gòu)件。每個(gè)方法中的每個(gè)語(yǔ)句都在一個(gè)線程中執(zhí)行,而且由于 Java 是多線程的,因此執(zhí)行的多個(gè)線程同時(shí)發(fā)生。

線程是 Java 的并發(fā)單元: 一段順序代碼,與其他這樣的單元并發(fā)運(yùn)行,并且在很大程度上獨(dú)立于這些單元。

每個(gè)線程都提供一個(gè)堆棧來(lái)存儲(chǔ)本地變量和協(xié)調(diào)方法調(diào)用,以及出錯(cuò)時(shí)的上下文: 異常被同一個(gè)線程中的方法拋出和捕獲,因此開發(fā)人員可以使用線程的堆棧跟蹤來(lái)查找發(fā)生了什么。

線程也是工具的一個(gè)核心概念: 調(diào)試器遍歷線程方法中的語(yǔ)句,分析器可視化多個(gè)線程的行為,以幫助理解它們的性能。

兩種并發(fā) style

thread-per-request style

  • 服務(wù)器應(yīng)用程序通常處理彼此獨(dú)立的并發(fā)用戶請(qǐng)求,因此應(yīng)用程序通過(guò)在整個(gè)請(qǐng)求持續(xù)期間為該請(qǐng)求分配一個(gè)線程來(lái)處理請(qǐng)求是有意義的。這種按請(qǐng)求執(zhí)行線程的 style 易于理解、易于編程、易于調(diào)試和配置,因?yàn)樗褂闷脚_(tái)的并發(fā)單元來(lái)表示應(yīng)用程序的并發(fā)單元。

  • 服務(wù)器應(yīng)用程序的可伸縮性受到利特爾定律(Little's Law)的支配,該定律關(guān)系到延遲、并發(fā)性和吞吐量: 對(duì)于給定的請(qǐng)求處理持續(xù)時(shí)間(延遲) ,應(yīng)用程序同時(shí)處理的請(qǐng)求數(shù)(并發(fā)性) 必須與到達(dá)速率(吞吐量) 成正比增長(zhǎng)。

  • 例如,假設(shè)一個(gè)平均延遲為 50ms 的應(yīng)用程序通過(guò)并發(fā)處理 10 個(gè)請(qǐng)求實(shí)現(xiàn)每秒 200 個(gè)請(qǐng)求的吞吐量。為了使該應(yīng)用程序的吞吐量達(dá)到每秒 2000 個(gè)請(qǐng)求,它將需要同時(shí)處理 100 個(gè)請(qǐng)求。如果在請(qǐng)求持續(xù)期間每個(gè)請(qǐng)求都在一個(gè)線程中處理,那么為了讓應(yīng)用程序跟上,線程的數(shù)量必須隨著吞吐量的增長(zhǎng)而增長(zhǎng)。

  • 不幸的是,可用線程的數(shù)量是有限的,因?yàn)?JDK 將線程實(shí)現(xiàn)為操作系統(tǒng)(OS)線程的包裝器。操作系統(tǒng)線程代價(jià)高昂,因此我們不能擁有太多線程,這使得實(shí)現(xiàn)不適合每個(gè)請(qǐng)求一個(gè)線程的 style 。

  • 如果每個(gè)請(qǐng)求在其持續(xù)時(shí)間內(nèi)消耗一個(gè)線程,從而消耗一個(gè) OS 線程,那么線程的數(shù)量通常會(huì)在其他資源(如 CPU 或網(wǎng)絡(luò)連接)耗盡之前很久成為限制因素。JDK 當(dāng)前的線程實(shí)現(xiàn)將應(yīng)用程序的吞吐量限制在遠(yuǎn)低于硬件所能支持的水平。即使在線程池中也會(huì)發(fā)生這種情況,因?yàn)槌赜兄诒苊鈫?dòng)新線程的高成本,但不會(huì)增加線程的總數(shù)。

asynchronous style

一些希望充分利用硬件的開發(fā)人員已經(jīng)放棄了每個(gè)請(qǐng)求一個(gè)線程(thread-per-request) 的 style ,轉(zhuǎn)而采用線程共享(thread-sharing ) 的 style 。

請(qǐng)求處理代碼不是從頭到尾處理一個(gè)線程上的請(qǐng)求,而是在等待 I/O 操作完成時(shí)將其線程返回到一個(gè)池中,以便該線程能夠處理其他請(qǐng)求。這種細(xì)粒度的線程共享(其中代碼只在執(zhí)行計(jì)算時(shí)保留一個(gè)線程,而不是在等待 I/O 時(shí)保留該線程)允許大量并發(fā)操作,而不需要消耗大量線程。

雖然它消除了操作系統(tǒng)線程的稀缺性對(duì)吞吐量的限制,但代價(jià)很高: 它需要一種所謂的異步編程 style ,采用一組獨(dú)立的 I/O 方法,這些方法不等待 I/O 操作完成,而是在以后將其完成信號(hào)發(fā)送給回調(diào)。如果沒(méi)有專門的線程,開發(fā)人員必須將請(qǐng)求處理邏輯分解成小的階段,通常以 lambda 表達(dá)式的形式編寫,然后將它們組合成帶有 API 的順序管道(例如,參見 CompletableFuture,或者所謂的“反應(yīng)性”框架)。因此,它們放棄了語(yǔ)言的基本順序組合運(yùn)算符,如循環(huán)和 try/catch 塊。

在異步樣式中,請(qǐng)求的每個(gè)階段可能在不同的線程上執(zhí)行,每個(gè)線程以交錯(cuò)的方式運(yùn)行屬于不同請(qǐng)求的階段。這對(duì)于理解程序行為有著深刻的含義:

  • 堆棧跟蹤不提供可用的上下文

  • 調(diào)試器不能單步執(zhí)行請(qǐng)求處理邏輯

  • 分析器不能將操作的成本與其調(diào)用方關(guān)聯(lián)。

當(dāng)使用 Java 的流 API 在短管道中處理數(shù)據(jù)時(shí),組合 lambda 表達(dá)式是可管理的,但是當(dāng)應(yīng)用程序中的所有請(qǐng)求處理代碼都必須以這種方式編寫時(shí),就有問(wèn)題了。這種編程 style 與 Java 平臺(tái)不一致,因?yàn)閼?yīng)用程序的并發(fā)單元(異步管道)不再是平臺(tái)的并發(fā)單元。

對(duì)比

Java之JDK19虛擬線程的知識(shí)點(diǎn)有哪些

使用虛擬線程保留thread-per-request style

為了使應(yīng)用程序能夠在與平臺(tái)保持和諧的同時(shí)進(jìn)行擴(kuò)展,我們應(yīng)該通過(guò)更有效地實(shí)現(xiàn)線程來(lái)努力保持每個(gè)請(qǐng)求一個(gè)線程的 style ,以便它們能夠更加豐富。

操作系統(tǒng)無(wú)法更有效地實(shí)現(xiàn) OS 線程,因?yàn)椴煌恼Z(yǔ)言和運(yùn)行時(shí)以不同的方式使用線程堆棧。然而,Java 運(yùn)行時(shí)實(shí)現(xiàn) Java 線程的方式可以切斷它們與操作系統(tǒng)線程之間的一一對(duì)應(yīng)關(guān)系。正如操作系統(tǒng)通過(guò)將大量虛擬地址空間映射到有限數(shù)量的物理 RAM 而給人一種內(nèi)存充足的錯(cuò)覺(jué)一樣,Java 運(yùn)行時(shí)也可以通過(guò)將大量虛擬線程映射到少量操作系統(tǒng)線程而給人一種線程充足的錯(cuò)覺(jué)。

  • 虛擬線程是沒(méi)有綁定到特定操作系統(tǒng)線程的線程。

  • 平臺(tái)線程是以傳統(tǒng)方式實(shí)現(xiàn)的線程,作為圍繞操作系統(tǒng)線程的簡(jiǎn)單包裝。

thread-per-request 樣式的應(yīng)用程序代碼可以在整個(gè)請(qǐng)求期間在虛擬線程中運(yùn)行,但是虛擬線程只在 CPU 上執(zhí)行計(jì)算時(shí)使用操作系統(tǒng)線程。其結(jié)果是與異步樣式相同的可伸縮性,除了它是透明實(shí)現(xiàn)的:

當(dāng)在虛擬線程中運(yùn)行的代碼調(diào)用 Java.* API 中的阻塞 I/O 操作時(shí),運(yùn)行時(shí)執(zhí)行一個(gè)非阻塞操作系統(tǒng)調(diào)用,并自動(dòng)掛起虛擬線程,直到稍后可以恢復(fù)。

對(duì)于 Java 開發(fā)人員來(lái)說(shuō),虛擬線程是創(chuàng)建成本低廉、數(shù)量幾乎無(wú)限多的線程。硬件利用率接近最佳,允許高水平的并發(fā)性,從而提高吞吐量,而應(yīng)用程序仍然與 Java 平臺(tái)及其工具的多線程設(shè)計(jì)保持協(xié)調(diào)。

虛擬線程的意義

虛擬線程是廉價(jià)和豐富的,因此永遠(yuǎn)不應(yīng)該被共享(即使用線程池) : 應(yīng)該為每個(gè)應(yīng)用程序任務(wù)創(chuàng)建一個(gè)新的虛擬線程。

因此,大多數(shù)虛擬線程的壽命都很短,并且具有淺層調(diào)用堆棧,執(zhí)行的操作只有單個(gè) HTTP 客戶機(jī)調(diào)用或單個(gè) JDBC 查詢那么少。相比之下,平臺(tái)線程是重量級(jí)和昂貴的,因此經(jīng)常必須共享。它們往往是長(zhǎng)期存在的,具有深度調(diào)用堆棧,并且在許多任務(wù)之間共享。

總之,虛擬線程保留了可靠的 thread-per-request style ,這種 style 與 Java 平臺(tái)的設(shè)計(jì)相協(xié)調(diào),同時(shí)又能最佳地利用硬件。使用虛擬線程并不需要學(xué)習(xí)新的概念,盡管它可能需要為應(yīng)對(duì)當(dāng)今線程的高成本而養(yǎng)成的忘卻習(xí)慣。虛擬線程不僅可以幫助應(yīng)用程序開發(fā)人員ーー它們還可以幫助框架設(shè)計(jì)人員提供易于使用的 API,這些 API 與平臺(tái)的設(shè)計(jì)兼容,同時(shí)又不影響可伸縮性。

說(shuō)明

如今,java.lang 的每一個(gè)實(shí)例。JDK 中的線程是一個(gè)平臺(tái)線程。平臺(tái)線程在底層操作系統(tǒng)線程上運(yùn)行 Java 代碼,并在代碼的整個(gè)生命周期中捕獲操作系統(tǒng)線程。平臺(tái)線程的數(shù)量?jī)H限于操作系統(tǒng)線程的數(shù)量。

虛擬線程是 java.lang 的一個(gè)實(shí)例。在基礎(chǔ)操作系統(tǒng)線程上運(yùn)行 Java 代碼,但在代碼的整個(gè)生命周期中不捕獲該操作系統(tǒng)線程的線程。這意味著許多虛擬線程可以在同一個(gè) OS 線程上運(yùn)行它們的 Java 代碼,從而有效地共享它們。平臺(tái)線程壟斷了一個(gè)珍貴的操作系統(tǒng)線程,而虛擬線程卻沒(méi)有。虛擬線程的數(shù)量可能比操作系統(tǒng)線程的數(shù)量大得多。

虛擬線程是由 JDK 而非操作系統(tǒng)提供的線程的輕量級(jí)實(shí)現(xiàn)。它們是用戶模式(user-mode)線程的一種形式,已經(jīng)在其他多線程語(yǔ)言中取得了成功(例如,Go 中的 goroutines 和 Erlang 的進(jìn)程)。在 Java 的早期版本中,用戶模式線程甚至以所謂的“綠線程”為特色,當(dāng)時(shí) OS 線程還不成熟和普及。然而,Java 的綠色線程都共享一個(gè) OS 線程(M: 1調(diào)度) ,并最終被平臺(tái)線程超越,實(shí)現(xiàn)為 OS 線程的包裝器(1:1調(diào)度)。虛擬線程采用 M: N 調(diào)度,其中大量(M)虛擬線程被調(diào)度在較少(N)操作系統(tǒng)線程上運(yùn)行。

虛擬線程 VS 平臺(tái)線程

簡(jiǎn)單示例

開發(fā)人員可以選擇使用虛擬線程還是平臺(tái)線程。下面是一個(gè)創(chuàng)建大量虛擬線程的示例程序。該程序首先獲得一個(gè) ExecutorService,它將為每個(gè)提交的任務(wù)創(chuàng)建一個(gè)新的虛擬線程。然后,它提交10000項(xiàng)任務(wù),等待所有任務(wù)完成:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}  // executor.close() is called implicitly, and waits

本例中的任務(wù)是簡(jiǎn)單的代碼(休眠一秒鐘) ,現(xiàn)代硬件可以輕松支持10,000個(gè)虛擬線程并發(fā)運(yùn)行這些代碼。在幕后,JDK 在少數(shù)操作系統(tǒng)線程上運(yùn)行代碼,可能只有一個(gè)線程。

如果這個(gè)程序使用 ExecutorService 為每個(gè)任務(wù)創(chuàng)建一個(gè)新的平臺(tái)線程,比如 Executors.newCachedThreadPool () ,那么情況就會(huì)大不相同。ExecutorService 將嘗試創(chuàng)建10,000個(gè)平臺(tái)線程,從而創(chuàng)建10,000個(gè) OS 線程,程序可能會(huì)崩潰,這取決于計(jì)算機(jī)和操作系統(tǒng)。

相反,如果程序使用從池中獲取平臺(tái)線程的 ExecutorService (例如 Executors.newFixedThreadPool (200)) ,情況也不會(huì)好到哪里去。ExecutorService 將創(chuàng)建200個(gè)平臺(tái)線程,由所有10,000個(gè)任務(wù)共享,因此許多任務(wù)將按順序運(yùn)行,而不是并發(fā)運(yùn)行,而且程序?qū)⑿枰荛L(zhǎng)時(shí)間才能完成。對(duì)于這個(gè)程序,一個(gè)有200個(gè)平臺(tái)線程的池只能達(dá)到每秒200個(gè)任務(wù)的吞吐量,而虛擬線程達(dá)到每秒10,000個(gè)任務(wù)的吞吐量(在充分預(yù)熱之后)。此外,如果示例程序中的10000被更改為1000000,那么該程序?qū)⑻峤?,000,000個(gè)任務(wù),創(chuàng)建1,000,000個(gè)并發(fā)運(yùn)行的虛擬線程,并且(在足夠的預(yù)熱之后)實(shí)現(xiàn)大約1,000,000任務(wù)/秒的吞吐量。

如果這個(gè)程序中的任務(wù)執(zhí)行一秒鐘的計(jì)算(例如,對(duì)一個(gè)巨大的數(shù)組進(jìn)行排序)而不僅僅是休眠,那么增加超出處理器核心數(shù)量的線程數(shù)量將無(wú)濟(jì)于事,無(wú)論它們是虛擬線程還是平臺(tái)線程。

虛擬線程并不是更快的線程ーー它們運(yùn)行代碼的速度并不比平臺(tái)線程快。它們的存在是為了提供規(guī)模(更高的吞吐量) ,而不是速度(更低的延遲) 。它們的數(shù)量可能比平臺(tái)線程多得多,因此根據(jù) Little’s Law,它們能夠?qū)崿F(xiàn)更高吞吐量所需的更高并發(fā)性。

換句話說(shuō),虛擬線程可以顯著提高應(yīng)用程序的吞吐量,在如下情況時(shí):

  • 并發(fā)任務(wù)的數(shù)量很多(超過(guò)幾千個(gè))

  • 工作負(fù)載不受 CPU 限制,因?yàn)樵谶@種情況下,比處理器核心擁有更多的線程并不能提高吞吐量

虛擬線程有助于提高典型服務(wù)器應(yīng)用程序的吞吐量,因?yàn)檫@類應(yīng)用程序由大量并發(fā)任務(wù)組成,這些任務(wù)花費(fèi)了大量時(shí)間等待。

虛擬線程可以運(yùn)行平臺(tái)線程可以運(yùn)行的任何代碼。特別是,虛擬線程支持線程本地變量和線程中斷,就像平臺(tái)線程一樣。這意味著處理請(qǐng)求的現(xiàn)有 Java 代碼很容易在虛擬線程中運(yùn)行。許多服務(wù)器框架將選擇自動(dòng)執(zhí)行此操作,為每個(gè)傳入請(qǐng)求啟動(dòng)一個(gè)新的虛擬線程,并在其中運(yùn)行應(yīng)用程序的業(yè)務(wù)邏輯。

下面是一個(gè)服務(wù)器應(yīng)用程序示例,它聚合了另外兩個(gè)服務(wù)的結(jié)果。假設(shè)的服務(wù)器框架(未顯示)為每個(gè)請(qǐng)求創(chuàng)建一個(gè)新的虛擬線程,并在該虛擬線程中運(yùn)行應(yīng)用程序的句柄代碼。然后,應(yīng)用程序代碼創(chuàng)建兩個(gè)新的虛擬線程,通過(guò)與第一個(gè)示例相同的 ExecutorService 并發(fā)地獲取資源:

void handle(Request request, Response response) {
    var url1 = ...
    var url2 = ...
 
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var future1 = executor.submit(() -> fetchURL(url1));
        var future2 = executor.submit(() -> fetchURL(url2));
        response.send(future1.get() + future2.get());
    } catch (ExecutionException | InterruptedException e) {
        response.fail(e);
    }
}
 
String fetchURL(URL url) throws IOException {
    try (var in = url.openStream()) {
        return new String(in.readAllBytes(), StandardCharsets.UTF_8);
    }
}

這樣的服務(wù)器應(yīng)用程序使用簡(jiǎn)單的阻塞代碼,可以很好地?cái)U(kuò)展,因?yàn)樗梢允褂么罅刻摂M線程。

NewVirtualThreadPerTaskExector ()并不是創(chuàng)建虛擬線程的唯一方法。新的 java.lang.Thread.Builder??梢詣?chuàng)建和啟動(dòng)虛擬線程。此外,結(jié)構(gòu)化并發(fā)提供了一個(gè)更強(qiáng)大的 API 來(lái)創(chuàng)建和管理虛擬線程,特別是在類似于這個(gè)服務(wù)器示例的代碼中,通過(guò)這個(gè) API,平臺(tái)及其工具可以了解線程之間的關(guān)系。

虛擬線程是一個(gè)預(yù)覽 API,默認(rèn)情況下是禁用的

上面的程序使用 Executors.newVirtualThreadPerTaskExector ()方法,因此要在 JDK 19上運(yùn)行它們,必須啟用以下預(yù)覽 API:

  • 使用javac --release 19 --enable-preview Main.java編譯該程序,并使用 java --enable-preview Main 運(yùn)行該程序;或者:

  • 在使用源代碼啟動(dòng)程序時(shí),使用 java --source 19 --enable-preview Main.java 運(yùn)行程序; 或者:

  • 在使用 jshell 時(shí),使用 jshell --enable-preview 啟動(dòng)它。

不要共享(pool)虛擬線程

開發(fā)人員通常會(huì)將應(yīng)用程序代碼從傳統(tǒng)的基于線程池的 ExecutorService 遷移到每個(gè)任務(wù)一個(gè)虛擬線程的 ExecutorService。與所有資源池一樣,線程池旨在共享昂貴的資源,但虛擬線程并不昂貴,而且從不需要共享它們。

開發(fā)人員有時(shí)使用線程池來(lái)限制對(duì)有限資源的并發(fā)訪問(wèn)。例如,如果一個(gè)服務(wù)不能處理超過(guò)20個(gè)并發(fā)請(qǐng)求,那么通過(guò)提交給大小為 20 的池的任務(wù)將確保執(zhí)行對(duì)該服務(wù)的所有訪問(wèn)。因?yàn)槠脚_(tái)線程的高成本使得線程池?zé)o處不在,所以這個(gè)習(xí)慣用法也變得無(wú)處不在,但是開發(fā)人員不應(yīng)該為了限制并發(fā)性而將虛擬線程集中起來(lái)。應(yīng)該使用專門為此目的設(shè)計(jì)的構(gòu)造(如信號(hào)量semaphores)來(lái)保護(hù)對(duì)有限資源的訪問(wèn)。這比線程池更有效、更方便,也更安全,因?yàn)椴淮嬖诰€程本地?cái)?shù)據(jù)從一個(gè)任務(wù)意外泄漏到另一個(gè)任務(wù)的風(fēng)險(xiǎn)。

觀測(cè)

編寫清晰的代碼并不是故事的全部。對(duì)于故障排除、維護(hù)和優(yōu)化來(lái)說(shuō),清晰地表示正在運(yùn)行的程序的狀態(tài)也是必不可少的,JDK 長(zhǎng)期以來(lái)一直提供調(diào)試、概要分析和監(jiān)視線程的機(jī)制。這樣的工具對(duì)虛擬線程也應(yīng)該這樣做ーー也許要適應(yīng)它們的大量數(shù)據(jù)ーー因?yàn)樗鼈儺吘故?java.lang.Thread 的實(shí)例。

Java 調(diào)試器可以單步執(zhí)行虛擬線程、顯示調(diào)用堆棧和檢查堆棧幀中的變量。JDK Flight Recorder (JFR) 是 JDK 的低開銷分析和監(jiān)視機(jī)制,可以將來(lái)自應(yīng)用程序代碼的事件(比如對(duì)象分配和 I/O 操作)與正確的虛擬線程關(guān)聯(lián)起來(lái)。

這些工具不能為以異步樣式編寫的應(yīng)用程序做這些事情。在這種風(fēng)格中,任務(wù)與線程無(wú)關(guān),因此調(diào)試器不能顯示或操作任務(wù)的狀態(tài),分析器也不能告訴任務(wù)等待 I/O 所花費(fèi)的時(shí)間。

線程轉(zhuǎn)儲(chǔ)( thread dump) 是另一種流行的工具,用于以每個(gè)請(qǐng)求一個(gè)線程的樣式編寫的應(yīng)用程序的故障排除。遺憾的是,通過(guò) jstack 或 jcmd 獲得的 JDK 傳統(tǒng)線程轉(zhuǎn)儲(chǔ)提供了一個(gè)扁平的線程列表。這適用于數(shù)十或數(shù)百個(gè)平臺(tái)線程,但不適用于數(shù)千或數(shù)百萬(wàn)個(gè)虛擬線程。因此,我們將不會(huì)擴(kuò)展傳統(tǒng)的線程轉(zhuǎn)儲(chǔ)以包含虛擬線程,而是在 jcmd 中引入一種新的線程轉(zhuǎn)儲(chǔ),以顯示平臺(tái)線程旁邊的虛擬線程,所有這些線程都以一種有意義的方式進(jìn)行分組。當(dāng)程序使用結(jié)構(gòu)化并發(fā)時(shí),可以顯示線程之間更豐富的關(guān)系。

因?yàn)榭梢暬头治龃罅康木€程可以從工具中受益,所以 jcmd 除了純文本之外,還可以發(fā)布 JSON 格式的新線程轉(zhuǎn)儲(chǔ):

$ jcmdThread.dump_to_file -format=json

新的線程轉(zhuǎn)儲(chǔ)格式列出了在網(wǎng)絡(luò) I/O 操作中被阻塞的虛擬線程,以及由上面所示的 new-thread-per-task ExecutorService 創(chuàng)建的虛擬線程。它不包括對(duì)象地址、鎖、 JNI 統(tǒng)計(jì)信息、堆統(tǒng)計(jì)信息以及傳統(tǒng)線程轉(zhuǎn)儲(chǔ)中出現(xiàn)的其他信息。此外,由于可能需要列出大量線程,因此生成新的線程轉(zhuǎn)儲(chǔ)并不會(huì)暫停應(yīng)用程序。

下面是這樣一個(gè)線程轉(zhuǎn)儲(chǔ)的示例,它取自類似于上面第二個(gè)示例的應(yīng)用程序,在 JSON 查看器中呈現(xiàn) :

Java之JDK19虛擬線程的知識(shí)點(diǎn)有哪些

由于虛擬線程是在 JDK 中實(shí)現(xiàn)的,并且不綁定到任何特定的操作系統(tǒng)線程,因此它們對(duì)操作系統(tǒng)是不可見的,操作系統(tǒng)不知道它們的存在。操作系統(tǒng)級(jí)別的監(jiān)視將觀察到,JDK 進(jìn)程使用的操作系統(tǒng)線程比虛擬線程少。

調(diào)度

為了完成有用的工作,需要調(diào)度一個(gè)線程,也就是分配給處理器核心執(zhí)行。對(duì)于作為 OS 線程實(shí)現(xiàn)的平臺(tái)線程,JDK 依賴于 OS 中的調(diào)度程序。相比之下,對(duì)于虛擬線程,JDK 有自己的調(diào)度程序。JDK 的調(diào)度程序不直接將虛擬線程分配給處理器,而是將虛擬線程分配給平臺(tái)線程(這是前面提到的虛擬線程的 M: N 調(diào)度)。然后,操作系統(tǒng)像往常一樣調(diào)度平臺(tái)線程。

JDK 的虛擬線程調(diào)度程序是一個(gè)在 FIFO 模式下運(yùn)行的工作竊取(work-stealing) 的 ForkJoinPool。調(diào)度程序的并行性是可用于調(diào)度虛擬線程的平臺(tái)線程的數(shù)量。默認(rèn)情況下,它等于可用處理器的數(shù)量,但是可以使用系統(tǒng)屬性 jdk.viralThreadScheduler.allelism 對(duì)其進(jìn)行調(diào)優(yōu)。注意,這個(gè) ForkJoinPool 不同于公共池,例如,公共池用于并行流的實(shí)現(xiàn),公共池以 LIFO 模式運(yùn)行。

  • 虛擬線程無(wú)法獲得載體(即負(fù)責(zé)調(diào)度虛擬線程的平臺(tái)線程)的標(biāo)識(shí)。由 Thread.currentThread ()返回的值始終是虛擬線程本身。

  • 載體和虛擬線程的堆棧跟蹤是分離的。在虛擬線程中拋出的異常將不包括載體的堆棧幀。線程轉(zhuǎn)儲(chǔ)不會(huì)顯示虛擬線程堆棧中其載體的堆棧幀,反之亦然。

  • 虛擬線程不能使用載體的線程本地變量,反之亦然。

此外,從 Java 代碼的角度來(lái)看,虛擬線程及其載體平臺(tái)線程臨時(shí)共享操作系統(tǒng)線程的事實(shí)是不存在的。相比之下,從本機(jī)代碼的角度來(lái)看,虛擬線程及其載體都在同一個(gè)本機(jī)線程上運(yùn)行。因此,在同一虛擬線程上多次調(diào)用的本機(jī)代碼可能會(huì)在每次調(diào)用時(shí)觀察到不同的 OS 線程標(biāo)識(shí)符。

調(diào)度程序當(dāng)前沒(méi)有實(shí)現(xiàn)虛擬線程的時(shí)間共享。分時(shí)是對(duì)消耗了分配的 CPU 時(shí)間的線程的強(qiáng)制搶占。雖然在平臺(tái)線程數(shù)量相對(duì)較少且 CPU 利用率為100% 的情況下,分時(shí)可以有效地減少某些任務(wù)的延遲,但是對(duì)于一百萬(wàn)個(gè)虛擬線程來(lái)說(shuō),分時(shí)是否有效尚不清楚。

執(zhí)行

要利用虛擬線程,不必重寫程序。虛擬線程不需要或期望應(yīng)用程序代碼顯式地將控制權(quán)交還給調(diào)度程序; 換句話說(shuō),虛擬線程不是可協(xié)作的。用戶代碼不能假設(shè)如何或何時(shí)將虛擬線程分配給平臺(tái)線程,就像它不能假設(shè)如何或何時(shí)將平臺(tái)線程分配給處理器核心一樣。

為了在虛擬線程中運(yùn)行代碼,JDK 的虛擬線程調(diào)度程序通過(guò)將虛擬線程掛載到平臺(tái)線程上來(lái)分配要在平臺(tái)線程上執(zhí)行的虛擬線程。這使得平臺(tái)線程成為虛擬線程的載體。稍后,在運(yùn)行一些代碼之后,虛擬線程可以從其載體卸載。此時(shí)平臺(tái)線程是空閑的,因此調(diào)度程序可以在其上掛載不同的虛擬線程,從而使其再次成為載體。

通常,當(dāng)虛擬線程阻塞 I/O 或 JDK 中的其他阻塞操作(如 BlockingQueue.take ())時(shí),它將卸載。當(dāng)阻塞操作準(zhǔn)備完成時(shí)(例如,在套接字上已經(jīng)接收到字節(jié)) ,它將虛擬線程提交回調(diào)度程序,調(diào)度程序?qū)⒃谶\(yùn)營(yíng)商上掛載虛擬線程以恢復(fù)執(zhí)行。

虛擬線程的掛載和卸載頻繁且透明,并且不會(huì)阻塞任何 OS 線程。例如,前面顯示的服務(wù)器應(yīng)用程序包含以下代碼行,其中包含對(duì)阻塞操作的調(diào)用:

response.send(future1.get() + future2.get());

這些操作將導(dǎo)致虛擬線程多次掛載和卸載,通常每個(gè) get ()調(diào)用一次,在 send (...)中執(zhí)行 I/O 過(guò)程中可能多次掛載和卸載。

JDK 中的絕大多數(shù)阻塞操作將卸載虛擬線程,從而釋放其載體和底層操作系統(tǒng)線程,使其承擔(dān)新的工作。但是,JDK 中的一些阻塞操作不會(huì)卸載虛擬線程,因此阻塞了其載體和底層 OS 線程。這是由于操作系統(tǒng)級(jí)別(例如,許多文件系統(tǒng)操作)或 JDK 級(jí)別(例如,Object.wait ())的限制造成的。這些阻塞操作的實(shí)現(xiàn)將通過(guò)暫時(shí)擴(kuò)展調(diào)度程序的并行性來(lái)補(bǔ)償對(duì) OS 線程的捕獲。因此,調(diào)度程序的 ForkJoinPool 中的平臺(tái)線程的數(shù)量可能會(huì)暫時(shí)超過(guò)可用處理器的數(shù)量??梢允褂孟到y(tǒng)屬性 jdk.viralThreadScheduler.maxPoolSize 調(diào)優(yōu)調(diào)度程序可用的最大平臺(tái)線程數(shù)。

有兩種情況下,在阻塞操作期間無(wú)法卸載虛擬線程,因?yàn)樗还潭ㄔ谄漭d體上:

  • 當(dāng)它在同步塊或方法內(nèi)執(zhí)行代碼時(shí),或

  • 當(dāng)它執(zhí)行本機(jī)方法或外部函數(shù)時(shí)。

固定并不會(huì)導(dǎo)致應(yīng)用程序不正確,但它可能會(huì)妨礙應(yīng)用程序的可伸縮性。如果虛擬線程在固定時(shí)執(zhí)行阻塞操作(如 I/O 或 BlockingQueue.take () ) ,那么它的載體和底層操作系統(tǒng)線程將在操作期間被阻塞。長(zhǎng)時(shí)間的頻繁固定會(huì)通過(guò)捕獲運(yùn)營(yíng)商而損害應(yīng)用程序的可伸縮性。

調(diào)度程序不會(huì)通過(guò)擴(kuò)展其并行性來(lái)補(bǔ)償固定。相反,可以通過(guò)修改頻繁運(yùn)行的同步塊或方法來(lái)避免頻繁和長(zhǎng)時(shí)間的固定,并保護(hù)潛在的長(zhǎng) I/O 操作來(lái)使用 java.util.concurrent.locks.ReentrantLock。不需要替換不常使用的同步塊和方法(例如,只在啟動(dòng)時(shí)執(zhí)行)或保護(hù)內(nèi)存操作的同步塊和方法。一如既往,努力保持鎖定策略的簡(jiǎn)單明了。

新的診斷有助于將代碼遷移到虛擬線程,以及評(píng)估是否應(yīng)該使用 java.util.concurrent lock 替換同步的特定用法:

  • 當(dāng)線程在固定時(shí)阻塞時(shí),會(huì)發(fā)出 JDK JFR事件。

  • 當(dāng)線程在固定時(shí)阻塞時(shí),系統(tǒng)屬性 jdk.tracePinnedThreads 觸發(fā)堆棧跟蹤。使用-Djdk.tracePinnedThreads = full 運(yùn)行會(huì)在線程被固定時(shí)打印一個(gè)完整的堆棧跟蹤,并突出顯示保存監(jiān)視器的本機(jī)框架和框架。使用-Djdk.tracePinnedThreads = short 將輸出限制為有問(wèn)題的幀。

內(nèi)存使用和垃圾回收

虛擬線程的堆棧作為堆棧塊對(duì)象存儲(chǔ)在 Java 的垃圾回收堆中。堆棧隨著應(yīng)用程序的運(yùn)行而增長(zhǎng)和縮小,這既是為了提高內(nèi)存效率,也是為了容納任意深度的堆棧(直到 JVM 配置的平臺(tái)線程堆棧大小)。這種效率支持大量的虛擬線程,因此服務(wù)器應(yīng)用程序中每個(gè)請(qǐng)求一個(gè)線程的風(fēng)格可以繼續(xù)存在。

在上面的第二個(gè)例子中,回想一下,一個(gè)假設(shè)的框架通過(guò)創(chuàng)建一個(gè)新的虛擬線程并調(diào)用 handle 方法來(lái)處理每個(gè)請(qǐng)求; 即使它在深度調(diào)用堆棧的末尾調(diào)用 handle (在身份驗(yàn)證、事務(wù)處理等之后) ,handle 本身也會(huì)產(chǎn)生多個(gè)虛擬線程,這些虛擬線程只執(zhí)行短暫的任務(wù)。因此,對(duì)于每個(gè)具有深層調(diào)用堆棧的虛擬線程,都會(huì)有多個(gè)具有淺層調(diào)用堆棧的虛擬線程,這些虛擬線程消耗的內(nèi)存很少。

通常,虛擬線程所需的堆空間和垃圾收集器活動(dòng)的數(shù)量很難與異步代碼的數(shù)量相比較。一百萬(wàn)個(gè)虛擬線程至少需要一百萬(wàn)個(gè)對(duì)象,但是共享一個(gè)平臺(tái)線程池的一百萬(wàn)個(gè)任務(wù)也需要一百萬(wàn)個(gè)對(duì)象。此外,處理請(qǐng)求的應(yīng)用程序代碼通???I/O 操作維護(hù)數(shù)據(jù)。每個(gè)請(qǐng)求一個(gè)線程的代碼可以將這些數(shù)據(jù)保存在本地變量中:

  • 這些本地變量存儲(chǔ)在堆中的虛擬線程堆棧中

  • 異步代碼必須將這些數(shù)據(jù)保存在從管道的一個(gè)階段傳遞到下一個(gè)階段的堆對(duì)象中

一方面,虛擬線程需要的堆棧幀布局比緊湊對(duì)象更浪費(fèi); 另一方面,虛擬線程可以在許多情況下變異和重用它們的堆棧(取決于低級(jí) GC 交互) ,而異步管道總是需要分配新對(duì)象,因此虛擬線程可能需要更少的分配。

總的來(lái)說(shuō),每個(gè)請(qǐng)求線程與異步代碼的堆消耗和垃圾收集器活動(dòng)應(yīng)該大致相似。隨著時(shí)間的推移,我們希望使虛擬線程堆棧的內(nèi)部表示更加緊湊。

與平臺(tái)線程堆棧不同,虛擬線程堆棧不是 GC 根,所以它們中包含的引用不會(huì)被執(zhí)行并發(fā)堆掃描的垃圾收集器(比如 G1)在 stop-the-world 暫停中遍歷。這也意味著,如果一個(gè)虛擬線程被阻塞,例如 BlockingQueue.take () ,并且沒(méi)有其他線程可以獲得對(duì)虛擬線程或隊(duì)列的引用,那么線程就可以被垃圾收集ーー這很好,因?yàn)樘摂M線程永遠(yuǎn)不會(huì)被中斷或解除阻塞。當(dāng)然,如果虛擬線程正在運(yùn)行,或者它被阻塞并且可能被解除阻塞,那么它將不會(huì)被垃圾收集。

當(dāng)前虛擬線程的一個(gè)限制是 G1 GC 不支持大型堆棧塊對(duì)象。如果虛擬線程的堆棧達(dá)到區(qū)域大小的一半(可能小到512KB) ,那么可能會(huì)拋出 StackOverfloError。

具體變化

java.lang.Thread

  • Thread.Builder, Thread.ofVirtual(), 和 Thread.ofPlatform() 是創(chuàng)建虛擬線程和平臺(tái)線程的新 API,例如:

Thread thread = Thread.ofVirtual().name("duke").unstarted(runnable);

創(chuàng)建一個(gè)新的未啟動(dòng)的虛擬線程“ duke”。

  • Thread.startVirtualThread(Runnable) 是創(chuàng)建然后啟動(dòng)虛擬線程的一種方便的方法。

  • Thread.Builder 可以創(chuàng)建線程或 ThreadFactory, 后者可以創(chuàng)建具有相同屬性的多個(gè)線程。

  • Thread.isVirtual() 測(cè)試是否一個(gè)線程是一個(gè)虛擬的線程。

  • Thread.join 和 Thread.sleep 的新重載接受等待和睡眠時(shí)間作為java.time.Duration的實(shí)例。

  • 新的 final 方法 Thread.threadId() 返回線程的標(biāo)識(shí)符?,F(xiàn)在不推薦使用現(xiàn)有的非 final 方法 Thread.getId() 。

  • Thread.getAllStackTraces() 現(xiàn)在返回所有平臺(tái)線程的映射,而不是所有線程的映射。

java.lang.Thread API其他方面沒(méi)有改變。構(gòu)造器也無(wú)新變化。

虛擬線程和平臺(tái)線程之間的主要 API 差異是:

  • 公共線程構(gòu)造函數(shù)不能創(chuàng)建虛擬線程。

  • 虛擬線程始終是守護(hù)進(jìn)程線程,Thread.setDaemon (boolean)方法不能將虛擬線程更改為非守護(hù)進(jìn)程線程。

  • 虛擬線程有一個(gè)固定的 Thread.NORM_PRIORITY 優(yōu)先級(jí)。Thread.setPriority(int)方法對(duì)虛擬線程沒(méi)有影響。在將來(lái)的版本中可能會(huì)重新討論這個(gè)限制。

  • 虛擬線程不是線程組的活動(dòng)成員。在虛擬線程上調(diào)用時(shí),Thread.getThreadGroup() 返回一個(gè)名為“ VirtualThreads”的占位符線程組。The Thread.Builder API 不定義設(shè)置虛擬線程的線程組的方法。

  • 使用 SecurityManager 集運(yùn)行時(shí),虛擬線程沒(méi)有權(quán)限。

  • 虛擬線程不支持 stop(), suspend(), 或 resume()方法。這些方法在虛擬線程上調(diào)用時(shí)引發(fā)異常。

Thread-local variables

虛擬線程支持線程局部變量(ThreadLocal)和可繼承的線程局部變量(InheritableThreadLocal) ,就像平臺(tái)線程一樣,因此它們可以運(yùn)行使用線程局部變量的現(xiàn)有代碼。但是,由于虛擬線程可能非常多,所以應(yīng)該在仔細(xì)考慮之后使用線程局部變量。

特別是,不要使用線程局部變量在線程池中共享同一線程的多個(gè)任務(wù)之間共享昂貴的資源。虛擬線程永遠(yuǎn)不應(yīng)該被共享,因?yàn)槊總€(gè)線程在其生存期內(nèi)只能運(yùn)行一個(gè)任務(wù)。我們已經(jīng)從 java.base 模塊中移除了許多線程局部變量的使用,以便為虛擬線程做準(zhǔn)備,從而減少在使用數(shù)百萬(wàn)個(gè)線程運(yùn)行時(shí)的內(nèi)存占用。

此外:

  • The Thread.Builder API 定義了一個(gè)在創(chuàng)建線程時(shí)選擇不使用線程局部變量的方法(a method to opt-out of thread locals when creating a thread)。它還定義了一個(gè)方法來(lái)選擇不繼承可繼承線程局部變量的初始值( a method to opt-out of inheriting the initial value of inheritable thread-locals)。當(dāng)從不支持線程局部變量的線程調(diào)用時(shí), ThreadLocal.get()返回初始值,ThreadLocal.set(T) 拋出異常。

  • 遺留上下文類加載器( context class loader)現(xiàn)在被指定為像可繼承的線程本地一樣工作。如果在不支持線程局部變量的線程上調(diào)用 Thread.setContextClassLoader(ClassLoader),那么它將引發(fā)異常。

Networking

網(wǎng)絡(luò) API 在java.net 和java.nio.channels 包中的實(shí)現(xiàn)現(xiàn)在與虛擬線程一起工作: 虛擬線程上的一個(gè)操作阻塞,例如,建立網(wǎng)絡(luò)連接或從套接字讀取,釋放底層平臺(tái)線程來(lái)做其他工作。

為了允許中斷和取消, java.net.Socket定義的阻塞 I/O 方法、ServerSocket 和 DatagramSocket 現(xiàn)在被指定為在虛擬線程中調(diào)用時(shí)是可中斷的: 中斷套接字上被阻塞的虛擬線程將釋放線程并關(guān)閉套接字。

當(dāng)從 InterruptibleChannel 獲取時(shí),這些類型套接字上的阻塞 I/O 操作總是可中斷的,因此這種更改使這些 API 在創(chuàng)建時(shí)的行為與從通道獲取時(shí)的構(gòu)造函數(shù)的行為保持一致。

java.io

The java.io 包為字節(jié)和字符流提供 API。這些 API 的實(shí)現(xiàn)是高度同步的,需要進(jìn)行更改以避免在虛擬線程中使用被固定。

在底層中,面向字節(jié)的輸入/輸出流沒(méi)有指定為線程安全的,也沒(méi)有指定在讀或?qū)懛椒ㄖ凶枞€程時(shí)調(diào)用 close() 時(shí)的預(yù)期行為。在大多數(shù)情況下,使用來(lái)自多個(gè)并發(fā)線程的特定輸入或輸出流是沒(méi)有意義的。面向字符的讀/寫器也沒(méi)有被指定為線程安全的,但是它們確實(shí)為子類公開了一個(gè)鎖對(duì)象。除了固定外,這些類中的同步還存在問(wèn)題和不一致; 例如, InputStreamReader 和 OutputStreamWriter 使用的流解碼器和編碼器在流對(duì)象而不是鎖對(duì)象上進(jìn)行同步。

為了防止固定,現(xiàn)在的實(shí)現(xiàn)如下:

  • BufferedInputStream, BufferedOutputStream, BufferedReader, BufferedWriter, PrintStream, 和 PrintWriter 現(xiàn)在在直接使用時(shí)使用顯式鎖而不是監(jiān)視器。當(dāng)這些類被子類化時(shí),它們與以前一樣進(jìn)行同步。

  • InputStreamReader 和 OutputStreamWriter 使用的流解碼器和編碼器現(xiàn)在使用與封閉的 InputStreamReader 或 OutputStreamWriter 相同的鎖。

更進(jìn)一步并消除所有這些常常不必要的鎖定超出了本文的范圍。

此外,BufferedOutputStream、 BufferedWriter 和 OutputStreamWriter 的流編碼器使用的緩沖區(qū)的初始大小現(xiàn)在更小了,以便在堆中有許多流或?qū)懭肫鲿r(shí)減少內(nèi)存使用ーー如果有一百萬(wàn)個(gè)虛擬線程,每個(gè)線程在套接字連接上都有一個(gè)緩沖流,就可能出現(xiàn)這種情況。

以上就是關(guān)于“Java之JDK19虛擬線程的知識(shí)點(diǎn)有哪些”這篇文章的內(nèi)容,相信大家都有了一定的了解,希望小編分享的內(nèi)容對(duì)大家有幫助,若想了解更多相關(guān)的知識(shí)內(nèi)容,請(qǐng)關(guān)注億速云行業(yè)資訊頻道。

向AI問(wèn)一下細(xì)節(jié)

免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場(chǎng),如果涉及侵權(quán)請(qǐng)聯(lián)系站長(zhǎng)郵箱:is@yisu.com進(jìn)行舉報(bào),并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。

AI