Java知識點總結之JDK19虛擬執行緒

2022-10-09 18:01:24
本篇文章給大家帶來了關於的相關知識,其中主要介紹了關於jdk19中虛擬執行緒的相關內容,虛擬執行緒是具有和go語言的goroutines 和 Erlang 語言的程序類似的實現方式,它們是使用者模式執行緒的一種形式,下面一起來看一下,希望對大家有幫助。

程式設計師必備介面測試偵錯工具:

推薦學習:《》

介紹

虛擬執行緒具有和 Go 語言的 goroutines 和 Erlang 語言的程序類似的實現方式,它們是使用者模式(user-mode)執行緒的一種形式。

在過去 Java 中常常使用執行緒池來進行平臺執行緒的共用以提高對計算機硬體的使用率,但在這種非同步風格中,請求的每個階段可能在不同的執行緒上執行,每個執行緒以交錯的方式執行屬於不同請求的階段,與 Java 平臺的設計不協調從而導致:

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

  • 偵錯程式不能單步執行請求處理邏輯

  • 分析器不能將操作的成本與其呼叫方關聯。

而虛擬執行緒既保持與平臺的設計相容,同時又能最佳地利用硬體從而不影響可伸縮性。虛擬執行緒是由 JDK 而非作業系統提供的執行緒的輕量級實現:

  • 虛擬執行緒是沒有繫結到特定作業系統執行緒的執行緒。

  • 平臺執行緒是以傳統方式實現的執行緒,作為圍繞作業系統執行緒的簡單包裝。

摘要

向 Java 平臺引入虛擬執行緒。虛擬執行緒是輕量級執行緒,它可以大大減少編寫、維護和觀察高吞吐量並行應用程式的工作量。

目標

  • 允許以簡單的每個請求一個執行緒的方式編寫的伺服器應用程式以接近最佳的硬體利用率進行擴充套件。

  • 允許使用 java.lang.ThreadAPI 的現有程式碼採用虛擬執行緒,並且只做最小的更改。

  • 使用現有的 JDK 工具可以方便地對虛擬執行緒進行故障排除、偵錯和分析。

非目標

  • 移除執行緒的傳統實現或遷移現有應用程式以使用虛擬執行緒並不是目標。

  • 改變 Java 的基本並行模型。

  • 我們的目標不是在 Java 語言或 Java 庫中提供新的資料平行結構。StreamAPI 仍然是並行處理大型資料集的首選方法。

動機

近30年來,Java 開發人員一直依賴執行緒作為並行伺服器應用程式的構件。每個方法中的每個語句都在一個執行緒中執行,而且由於 Java 是多執行緒的,因此執行的多個執行緒同時發生。

執行緒是 Java 的並行單元: 一段順序程式碼,與其他這樣的單元並行執行,並且在很大程度上獨立於這些單元。

每個執行緒都提供一個堆疊來儲存本地變數和協調方法呼叫,以及出錯時的上下文: 異常被同一個執行緒中的方法丟擲和捕獲,因此開發人員可以使用執行緒的堆疊跟蹤來查詢發生了什麼。

執行緒也是工具的一個核心概念: 偵錯程式遍歷執行緒方法中的語句,分析器視覺化多個執行緒的行為,以幫助理解它們的效能。

兩種並行 style

thread-per-request style

  • 伺服器應用程式通常處理彼此獨立的並行使用者請求,因此應用程式通過在整個請求持續期間為該請求分配一個執行緒來處理請求是有意義的。這種按請求執行執行緒的 style 易於理解、易於程式設計、易於偵錯和設定,因為它使用平臺的並行單元來表示應用程式的並行單元。

  • 伺服器應用程式的可伸縮性受到利特爾定律(Little's Law)的支配,該定律關係到延遲、並行性和吞吐量: 對於給定的請求處理持續時間(延遲) ,應用程式同時處理的請求數(並行性) 必須與到達速率(吞吐量) 成正比增長。

  • 例如,假設一個平均延遲為 50ms 的應用程式通過並行處理 10 個請求實現每秒 200 個請求的吞吐量。為了使該應用程式的吞吐量達到每秒 2000 個請求,它將需要同時處理 100 個請求。如果在請求持續期間每個請求都在一個執行緒中處理,那麼為了讓應用程式跟上,執行緒的數量必須隨著吞吐量的增長而增長。

  • 不幸的是,可用執行緒的數量是有限的,因為 JDK 將執行緒實現為作業系統(OS)執行緒的包裝器。作業系統執行緒代價高昂,因此我們不能擁有太多執行緒,這使得實現不適合每個請求一個執行緒的 style 。

  • 如果每個請求在其持續時間內消耗一個執行緒,從而消耗一個 OS 執行緒,那麼執行緒的數量通常會在其他資源(如 CPU 或網路連線)耗盡之前很久成為限制因素。JDK 當前的執行緒實現將應用程式的吞吐量限制在遠低於硬體所能支援的水平。即使線上程池中也會發生這種情況,因為池有助於避免啟動新執行緒的高成本,但不會增加執行緒的總數。

asynchronous style

一些希望充分利用硬體的開發人員已經放棄了每個請求一個執行緒(thread-per-request) 的 style ,轉而採用執行緒共用(thread-sharing ) 的 style 。

請求處理程式碼不是從頭到尾處理一個執行緒上的請求,而是在等待 I/O 操作完成時將其執行緒返回到一個池中,以便該執行緒能夠處理其他請求。這種細粒度的執行緒共用(其中程式碼只在執行計算時保留一個執行緒,而不是在等待 I/O 時保留該執行緒)允許大量並行操作,而不需要消耗大量執行緒。

雖然它消除了作業系統執行緒的稀缺性對吞吐量的限制,但代價很高: 它需要一種所謂的非同步程式設計 style ,採用一組獨立的 I/O 方法,這些方法不等待 I/O 操作完成,而是在以後將其完成訊號傳送給回撥。如果沒有專門的執行緒,開發人員必須將請求處理邏輯分解成小的階段,通常以 lambda 表示式的形式編寫,然後將它們組合成帶有 API 的順序管道(例如,參見 CompletableFuture,或者所謂的「反應性」框架)。因此,它們放棄了語言的基本順序組合運運算元,如迴圈和 try/catch 塊。

在非同步樣式中,請求的每個階段可能在不同的執行緒上執行,每個執行緒以交錯的方式執行屬於不同請求的階段。這對於理解程式行為有著深刻的含義:

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

  • 偵錯程式不能單步執行請求處理邏輯

  • 分析器不能將操作的成本與其呼叫方關聯。

當使用 Java 的流 API 在短管道中處理資料時,組合 lambda 表示式是可管理的,但是當應用程式中的所有請求處理程式碼都必須以這種方式編寫時,就有問題了。這種程式設計 style 與 Java 平臺不一致,因為應用程式的並行單元(非同步管道)不再是平臺的並行單元。

對比

18.png

使用虛擬執行緒保留thread-per-request style

為了使應用程式能夠在與平臺保持和諧的同時進行擴充套件,我們應該通過更有效地實現執行緒來努力保持每個請求一個執行緒的 style ,以便它們能夠更加豐富。

作業系統無法更有效地實現 OS 執行緒,因為不同的語言和執行時以不同的方式使用執行緒堆疊。然而,Java 執行時實現 Java 執行緒的方式可以切斷它們與作業系統執行緒之間的一一對應關係。正如作業系統通過將大量虛擬地址空間對映到有限數量的物理 RAM 而給人一種記憶體充足的錯覺一樣,Java 執行時也可以通過將大量虛擬執行緒對映到少量作業系統執行緒而給人一種執行緒充足的錯覺。

  • 虛擬執行緒是沒有繫結到特定作業系統執行緒的執行緒。

  • 平臺執行緒是以傳統方式實現的執行緒,作為圍繞作業系統執行緒的簡單包裝。

thread-per-request 樣式的應用程式程式碼可以在整個請求期間在虛擬執行緒中執行,但是虛擬執行緒只在 CPU 上執行計算時使用作業系統執行緒。其結果是與非同步樣式相同的可伸縮性,除了它是透明實現的:

當在虛擬執行緒中執行的程式碼呼叫 Java.* API 中的阻塞 I/O 操作時,執行時執行一個非阻塞作業系統呼叫,並自動掛起虛擬執行緒,直到稍後可以恢復。

對於 Java 開發人員來說,虛擬執行緒是建立成本低廉、數量幾乎無限多的執行緒。硬體利用率接近最佳,允許高水平的並行性,從而提高吞吐量,而應用程式仍然與 Java 平臺及其工具的多執行緒設計保持協調。

虛擬執行緒的意義

虛擬執行緒是廉價和豐富的,因此永遠不應該被共用(即使用執行緒池) : 應該為每個應用程式任務建立一個新的虛擬執行緒。

因此,大多數虛擬執行緒的壽命都很短,並且具有淺層呼叫堆疊,執行的操作只有單個 HTTP 客戶機呼叫或單個 JDBC 查詢那麼少。相比之下,平臺執行緒是重量級和昂貴的,因此經常必須共用。它們往往是長期存在的,具有深度呼叫堆疊,並且在許多工之間共用。

總之,虛擬執行緒保留了可靠的 thread-per-request style ,這種 style 與 Java 平臺的設計相協調,同時又能最佳地利用硬體。使用虛擬執行緒並不需要學習新的概念,儘管它可能需要為應對當今執行緒的高成本而養成的忘卻習慣。虛擬執行緒不僅可以幫助應用程式開發人員ーー它們還可以幫助框架設計人員提供易於使用的 API,這些 API 與平臺的設計相容,同時又不影響可伸縮性。

說明

如今,java.lang 的每一個範例。JDK 中的執行緒是一個平臺執行緒。平臺執行緒在底層作業系統執行緒上執行 Java 程式碼,並在程式碼的整個生命週期中捕獲作業系統執行緒。平臺執行緒的數量僅限於作業系統執行緒的數量。

虛擬執行緒是 java.lang 的一個範例。在基礎作業系統執行緒上執行 Java 程式碼,但在程式碼的整個生命週期中不捕獲該作業系統執行緒的執行緒。這意味著許多虛擬執行緒可以在同一個 OS 執行緒上執行它們的 Java 程式碼,從而有效地共用它們。平臺執行緒壟斷了一個珍貴的作業系統執行緒,而虛擬執行緒卻沒有。虛擬執行緒的數量可能比作業系統執行緒的數量大得多。

虛擬執行緒是由 JDK 而非作業系統提供的執行緒的輕量級實現。它們是使用者模式(user-mode)執行緒的一種形式,已經在其他多執行緒語言中取得了成功(例如,Go 中的 goroutines 和 Erlang 的程序)。在 Java 的早期版本中,使用者模式執行緒甚至以所謂的「綠執行緒」為特色,當時 OS 執行緒還不成熟和普及。然而,Java 的綠色執行緒都共用一個 OS 執行緒(M: 1排程) ,並最終被平臺執行緒超越,實現為 OS 執行緒的包裝器(1:1排程)。虛擬執行緒採用 M: N 排程,其中大量(M)虛擬執行緒被排程在較少(N)作業系統執行緒上執行。

虛擬執行緒 VS 平臺執行緒

簡單範例

開發人員可以選擇使用虛擬執行緒還是平臺執行緒。下面是一個建立大量虛擬執行緒的範例程式。該程式首先獲得一個 ExecutorService,它將為每個提交的任務建立一個新的虛擬執行緒。然後,它提交10000項任務,等待所有任務完成:

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
登入後複製

本例中的任務是簡單的程式碼(休眠一秒鐘) ,現代硬體可以輕鬆支援10,000個虛擬執行緒並行執行這些程式碼。在幕後,JDK 在少數作業系統執行緒上執行程式碼,可能只有一個執行緒。

如果這個程式使用 ExecutorService 為每個任務建立一個新的平臺執行緒,比如 Executors.newCachedThreadPool () ,那麼情況就會大不相同。ExecutorService 將嘗試建立10,000個平臺執行緒,從而建立10,000個 OS 執行緒,程式可能會崩潰,這取決於計算機和作業系統。

相反,如果程式使用從池中獲取平臺執行緒的 ExecutorService (例如 Executors.newFixedThreadPool (200)) ,情況也不會好到哪裡去。ExecutorService 將建立200個平臺執行緒,由所有10,000個任務共用,因此許多工將按順序執行,而不是並行執行,而且程式將需要很長時間才能完成。對於這個程式,一個有200個平臺執行緒的池只能達到每秒200個任務的吞吐量,而虛擬執行緒達到每秒10,000個任務的吞吐量(在充分預熱之後)。此外,如果範例程式中的10000被更改為1000000,那麼該程式將提交1,000,000個任務,建立1,000,000個並行執行的虛擬執行緒,並且(在足夠的預熱之後)實現大約1,000,000任務/秒的吞吐量。

如果這個程式中的任務執行一秒鐘的計算(例如,對一個巨大的陣列進行排序)而不僅僅是休眠,那麼增加超出處理器核心數量的執行緒數量將無濟於事,無論它們是虛擬執行緒還是平臺執行緒。

虛擬執行緒並不是更快的執行緒ーー它們執行程式碼的速度並不比平臺執行緒快。它們的存在是為了提供規模(更高的吞吐量) ,而不是速度(更低的延遲) 。它們的數量可能比平臺執行緒多得多,因此根據 Little’s Law,它們能夠實現更高吞吐量所需的更高並行性。

換句話說,虛擬執行緒可以顯著提高應用程式的吞吐量,在如下情況時:

  • 並行任務的數量很多(超過幾千個)

  • 工作負載不受 CPU 限制,因為在這種情況下,比處理器核心擁有更多的執行緒並不能提高吞吐量

虛擬執行緒有助於提高典型伺服器應用程式的吞吐量,因為這類應用程式由大量並行任務組成,這些任務花費了大量時間等待。

虛擬執行緒可以執行平臺執行緒可以執行的任何程式碼。特別是,虛擬執行緒支援執行緒本地變數和執行緒中斷,就像平臺執行緒一樣。這意味著處理請求的現有 Java 程式碼很容易在虛擬執行緒中執行。許多伺服器框架將選擇自動執行此操作,為每個傳入請求啟動一個新的虛擬執行緒,並在其中執行應用程式的業務邏輯。

下面是一個伺服器應用程式範例,它聚合了另外兩個服務的結果。假設的伺服器框架(未顯示)為每個請求建立一個新的虛擬執行緒,並在該虛擬執行緒中執行應用程式的控制程式碼程式碼。然後,應用程式程式碼建立兩個新的虛擬執行緒,通過與第一個範例相同的 ExecutorService 並行地獲取資源:

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);
    }
}
登入後複製

這樣的伺服器應用程式使用簡單的阻塞程式碼,可以很好地擴充套件,因為它可以使用大量虛擬執行緒。

NewVirtualThreadPerTaskExector ()並不是建立虛擬執行緒的唯一方法。新的 java.lang.Thread.Builder。可以建立和啟動虛擬執行緒。此外,結構化並行提供了一個更強大的 API 來建立和管理虛擬執行緒,特別是在類似於這個伺服器範例的程式碼中,通過這個 API,平臺及其工具可以瞭解執行緒之間的關係。

虛擬執行緒是一個預覽 API,預設情況下是禁用的

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

  • 使用javac --release 19 --enable-preview Main.java編譯該程式,並使用 java --enable-preview Main 執行該程式;或者:

  • 在使用原始碼啟動程式時,使用 java --source 19 --enable-preview Main.java 執行程式; 或者:

  • 在使用 jshell 時,使用 jshell --enable-preview 啟動它。

不要共用(pool)虛擬執行緒

開發人員通常會將應用程式程式碼從傳統的基於執行緒池的 ExecutorService 遷移到每個任務一個虛擬執行緒的 ExecutorService。與所有資源池一樣,執行緒池旨在共用昂貴的資源,但虛擬執行緒並不昂貴,而且從不需要共用它們。

開發人員有時使用執行緒池來限制對有限資源的並行存取。例如,如果一個服務不能處理超過20個並行請求,那麼通過提交給大小為 20 的池的任務將確保執行對該服務的所有存取。因為平臺執行緒的高成本使得執行緒池無處不在,所以這個習慣用法也變得無處不在,但是開發人員不應該為了限制並行性而將虛擬執行緒集中起來。應該使用專門為此目的設計的構造(如號誌semaphores)來保護對有限資源的存取。這比執行緒池更有效、更方便,也更安全,因為不存線上程本地資料從一個任務意外洩漏到另一個任務的風險。

觀測

編寫清晰的程式碼並不是故事的全部。對於故障排除、維護和優化來說,清晰地表示正在執行的程式的狀態也是必不可少的,JDK 長期以來一直提供偵錯、概要分析和監視執行緒的機制。這樣的工具對虛擬執行緒也應該這樣做ーー也許要適應它們的大量資料ーー因為它們畢竟是 java.lang.Thread 的範例。

Java 偵錯程式可以單步執行虛擬執行緒、顯示呼叫堆疊和檢查堆疊幀中的變數。JDK Flight Recorder (JFR) 是 JDK 的低開銷分析和監視機制,可以將來自應用程式程式碼的事件(比如物件分配和 I/O 操作)與正確的虛擬執行緒關聯起來。

這些工具不能為以非同步樣式編寫的應用程式做這些事情。在這種風格中,任務與執行緒無關,因此偵錯程式不能顯示或操作任務的狀態,分析器也不能告訴任務等待 I/O 所花費的時間。

執行緒轉儲( thread dump) 是另一種流行的工具,用於以每個請求一個執行緒的樣式編寫的應用程式的故障排除。遺憾的是,通過 jstack 或 jcmd 獲得的 JDK 傳統執行緒轉儲提供了一個扁平的執行緒列表。這適用於數十或數百個平臺執行緒,但不適用於數千或數百萬個虛擬執行緒。因此,我們將不會擴充套件傳統的執行緒轉儲以包含虛擬執行緒,而是在 jcmd 中引入一種新的執行緒轉儲,以顯示平臺執行緒旁邊的虛擬執行緒,所有這些執行緒都以一種有意義的方式進行分組。當程式使用結構化並行時,可以顯示執行緒之間更豐富的關係。

因為視覺化和分析大量的執行緒可以從工具中受益,所以 jcmd 除了純文字之外,還可以釋出 JSON 格式的新執行緒轉儲:

$ jcmd  Thread.dump_to_file -format=json 
登入後複製

新的執行緒轉儲格式列出了在網路 I/O 操作中被阻塞的虛擬執行緒,以及由上面所示的 new-thread-per-task ExecutorService 建立的虛擬執行緒。它不包括物件地址、鎖、 JNI 統計資訊、堆統計資訊以及傳統執行緒轉儲中出現的其他資訊。此外,由於可能需要列出大量執行緒,因此生成新的執行緒轉儲並不會暫停應用程式。

下面是這樣一個執行緒轉儲的範例,它取自類似於上面第二個範例的應用程式,在 JSON 檢視器中呈現 :

19.png

由於虛擬執行緒是在 JDK 中實現的,並且不繫結到任何特定的作業系統執行緒,因此它們對作業系統是不可見的,作業系統不知道它們的存在。作業系統級別的監視將觀察到,JDK 程序使用的作業系統執行緒比虛擬執行緒少。

排程

為了完成有用的工作,需要排程一個執行緒,也就是分配給處理器核心執行。對於作為 OS 執行緒實現的平臺執行緒,JDK 依賴於 OS 中的排程程式。相比之下,對於虛擬執行緒,JDK 有自己的排程程式。JDK 的排程程式不直接將虛擬執行緒分配給處理器,而是將虛擬執行緒分配給平臺執行緒(這是前面提到的虛擬執行緒的 M: N 排程)。然後,作業系統像往常一樣排程平臺執行緒。

JDK 的虛擬執行緒排程程式是一個在 FIFO 模式下執行的工作竊取(work-stealing) 的 ForkJoinPool。排程程式的並行性是可用於排程虛擬執行緒的平臺執行緒的數量。預設情況下,它等於可用處理器的數量,但是可以使用系統屬性 jdk.viralThreadScheduler.allelism 對其進行調優。注意,這個 ForkJoinPool 不同於公共池,例如,公共池用於並行流的實現,公共池以 LIFO 模式執行。

  • 虛擬執行緒無法獲得載體(即負責排程虛擬執行緒的平臺執行緒)的標識。由 Thread.currentThread ()返回的值始終是虛擬執行緒本身。

  • 載體和虛擬執行緒的堆疊跟蹤是分離的。在虛擬執行緒中丟擲的異常將不包括載體的堆疊幀。執行緒轉儲不會顯示虛擬執行緒堆疊中其載體的堆疊幀,反之亦然。

  • 虛擬執行緒不能使用載體的執行緒本地變數,反之亦然。

此外,從 Java 程式碼的角度來看,虛擬執行緒及其載體平臺執行緒臨時共用作業系統執行緒的事實是不存在的。相比之下,從本機程式碼的角度來看,虛擬執行緒及其載體都在同一個本機執行緒上執行。因此,在同一虛擬執行緒上多次呼叫的本機程式碼可能會在每次呼叫時觀察到不同的 OS 執行緒識別符號。

排程程式當前沒有實現虛擬執行緒的時間共用。分時是對消耗了分配的 CPU 時間的執行緒的強制搶佔。雖然在平臺執行緒數量相對較少且 CPU 利用率為100% 的情況下,分時可以有效地減少某些任務的延遲,但是對於一百萬個虛擬執行緒來說,分時是否有效尚不清楚。

執行

要利用虛擬執行緒,不必重寫程式。虛擬執行緒不需要或期望應用程式程式碼顯式地將控制權交還給排程程式; 換句話說,虛擬執行緒不是可共同作業的。使用者程式碼不能假設如何或何時將虛擬執行緒分配給平臺執行緒,就像它不能假設如何或何時將平臺執行緒分配給處理器核心一樣。

為了在虛擬執行緒中執行程式碼,JDK 的虛擬執行緒排程程式通過將虛擬執行緒掛載到平臺執行緒上來分配要在平臺執行緒上執行的虛擬執行緒。這使得平臺執行緒成為虛擬執行緒的載體。稍後,在執行一些程式碼之後,虛擬執行緒可以從其載體解除安裝。此時平臺執行緒是空閒的,因此排程程式可以在其上掛載不同的虛擬執行緒,從而使其再次成為載體。

通常,當虛擬執行緒阻塞 I/O 或 JDK 中的其他阻塞操作(如 BlockingQueue.take ())時,它將解除安裝。當阻塞操作準備完成時(例如,在通訊端上已經接收到位元組) ,它將虛擬執行緒提交回撥度程式,排程程式將在運營商上掛載虛擬執行緒以恢復執行。

虛擬執行緒的掛載和解除安裝頻繁且透明,並且不會阻塞任何 OS 執行緒。例如,前面顯示的伺服器應用程式包含以下程式碼行,其中包含對阻塞操作的呼叫:

response.send(future1.get() + future2.get());
登入後複製

這些操作將導致虛擬執行緒多次掛載和解除安裝,通常每個 get ()呼叫一次,在 send (...)中執行 I/O 過程中可能多次掛載和解除安裝。

JDK 中的絕大多數阻塞操作將解除安裝虛擬執行緒,從而釋放其載體和底層作業系統執行緒,使其承擔新的工作。但是,JDK 中的一些阻塞操作不會解除安裝虛擬執行緒,因此阻塞了其載體和底層 OS 執行緒。這是由於作業系統級別(例如,許多檔案系統操作)或 JDK 級別(例如,Object.wait ())的限制造成的。這些阻塞操作的實現將通過暫時擴充套件排程程式的並行性來補償對 OS 執行緒的捕獲。因此,排程程式的 ForkJoinPool 中的平臺執行緒的數量可能會暫時超過可用處理器的數量。可以使用系統屬性 jdk.viralThreadScheduler.maxPoolSize 調優排程程式可用的最大平臺執行緒數。

有兩種情況下,在阻塞操作期間無法解除安裝虛擬執行緒,因為它被固定在其載體上:

  • 當它在同步塊或方法內執行程式碼時,或

  • 當它執行本機方法或外部函數時。

固定並不會導致應用程式不正確,但它可能會妨礙應用程式的可伸縮性。如果虛擬執行緒在固定時執行阻塞操作(如 I/O 或 BlockingQueue.take () ) ,那麼它的載體和底層作業系統執行緒將在操作期間被阻塞。長時間的頻繁固定會通過捕獲運營商而損害應用程式的可伸縮性。

排程程式不會通過擴充套件其並行性來補償固定。相反,可以通過修改頻繁執行的同步塊或方法來避免頻繁和長時間的固定,並保護潛在的長 I/O 操作來使用 java.util.concurrent.locks.ReentrantLock。不需要替換不常使用的同步塊和方法(例如,只在啟動時執行)或保護記憶體操作的同步塊和方法。一如既往,努力保持鎖定策略的簡單明瞭。

新的診斷有助於將程式碼遷移到虛擬執行緒,以及評估是否應該使用 java.util.concurrent lock 替換同步的特定用法:

  • 當執行緒在固定時阻塞時,會發出 JDK JFR事件。

  • 當執行緒在固定時阻塞時,系統屬性 jdk.tracePinnedThreads 觸發堆疊跟蹤。使用-Djdk.tracePinnedThreads = full 執行會線上程被固定時列印一個完整的堆疊跟蹤,並突出顯示儲存監視器的本機框架和框架。使用-Djdk.tracePinnedThreads = short 將輸出限制為有問題的幀。

記憶體使用和垃圾回收

虛擬執行緒的堆疊作為堆疊塊物件儲存在 Java 的垃圾回收堆中。堆疊隨著應用程式的執行而增長和縮小,這既是為了提高記憶體效率,也是為了容納任意深度的堆疊(直到 JVM 設定的平臺執行緒堆疊大小)。這種效率支援大量的虛擬執行緒,因此伺服器應用程式中每個請求一個執行緒的風格可以繼續存在。

在上面的第二個例子中,回想一下,一個假設的框架通過建立一個新的虛擬執行緒並呼叫 handle 方法來處理每個請求; 即使它在深度呼叫堆疊的末尾呼叫 handle (在身份驗證、事務處理等之後) ,handle 本身也會產生多個虛擬執行緒,這些虛擬執行緒只執行短暫的任務。因此,對於每個具有深層呼叫堆疊的虛擬執行緒,都會有多個具有淺層呼叫堆疊的虛擬執行緒,這些虛擬執行緒消耗的記憶體很少。

通常,虛擬執行緒所需的堆空間和垃圾收集器活動的數量很難與非同步程式碼的數量相比較。一百萬個虛擬執行緒至少需要一百萬個物件,但是共用一個平臺執行緒池的一百萬個任務也需要一百萬個物件。此外,處理請求的應用程式程式碼通常跨 I/O 操作維護資料。每個請求一個執行緒的程式碼可以將這些資料儲存在本地變數中:

  • 這些本地變數儲存在堆中的虛擬執行緒堆疊中

  • 非同步程式碼必須將這些資料儲存在從管道的一個階段傳遞到下一個階段的堆物件中

一方面,虛擬執行緒需要的堆疊幀佈局比緊湊物件更浪費; 另一方面,虛擬執行緒可以在許多情況下變異和重用它們的堆疊(取決於低階 GC 互動) ,而非同步管道總是需要分配新物件,因此虛擬執行緒可能需要更少的分配。

總的來說,每個請求執行緒與非同步程式碼的堆消耗和垃圾收集器活動應該大致相似。隨著時間的推移,我們希望使虛擬執行緒堆疊的內部表示更加緊湊。

與平臺執行緒堆疊不同,虛擬執行緒堆疊不是 GC 根,所以它們中包含的參照不會被執行並行堆掃描的垃圾收集器(比如 G1)在 stop-the-world 暫停中遍歷。這也意味著,如果一個虛擬執行緒被阻塞,例如 BlockingQueue.take () ,並且沒有其他執行緒可以獲得對虛擬執行緒或佇列的參照,那麼執行緒就可以被垃圾收集ーー這很好,因為虛擬執行緒永遠不會被中斷或解除阻塞。當然,如果虛擬執行緒正在執行,或者它被阻塞並且可能被解除阻塞,那麼它將不會被垃圾收集。

當前虛擬執行緒的一個限制是 G1 GC 不支援大型堆疊塊物件。如果虛擬執行緒的堆疊達到區域大小的一半(可能小到512KB) ,那麼可能會丟擲 StackOverfloError。

具體變化

java.lang.Thread

  • Thread.Builder, Thread.ofVirtual(), 和 Thread.ofPlatform() 是建立虛擬執行緒和平臺執行緒的新 API,例如:

Thread thread = Thread.ofVirtual().name("duke").unstarted(runnable);
登入後複製

建立一個新的未啟動的虛擬執行緒「 duke」。

  • Thread.startVirtualThread(Runnable) 是建立然後啟動虛擬執行緒的一種方便的方法。

  • Thread.Builder 可以建立執行緒或 ThreadFactory, 後者可以建立具有相同屬性的多個執行緒。

  • Thread.isVirtual() 測試是否一個執行緒是一個虛擬的執行緒。

  • Thread.join 和 Thread.sleep 的新過載接受等待和睡眠時間作為java.time.Duration的範例。

  • 新的 final 方法 Thread.threadId() 返回執行緒的識別符號。現在不推薦使用現有的非 final 方法 Thread.getId() 。

  • Thread.getAllStackTraces() 現在返回所有平臺執行緒的對映,而不是所有執行緒的對映。

java.lang.Thread API其他方面沒有改變。構造器也無新變化。

虛擬執行緒和平臺執行緒之間的主要 API 差異是:

  • 公共執行緒建構函式不能建立虛擬執行緒。

  • 虛擬執行緒始終是守護行程執行緒,Thread.setDaemon (boolean)方法不能將虛擬執行緒更改為非守護行程執行緒。

  • 虛擬執行緒有一個固定的 Thread.NORM_PRIORITY 優先順序。Thread.setPriority(int)方法對虛擬執行緒沒有影響。在將來的版本中可能會重新討論這個限制。

  • 虛擬執行緒不是執行緒組的活動成員。在虛擬執行緒上呼叫時,Thread.getThreadGroup() 返回一個名為「 VirtualThreads」的預留位置執行緒組。The Thread.Builder API 不定義設定虛擬執行緒的執行緒組的方法。

  • 使用 SecurityManager 集執行時,虛擬執行緒沒有許可權。

  • 虛擬執行緒不支援 stop(), suspend(), 或 resume()方法。這些方法在虛擬執行緒上呼叫時引發異常。

Thread-local variables

虛擬執行緒支援執行緒區域性變數(ThreadLocal)和可繼承的執行緒區域性變數(InheritableThreadLocal) ,就像平臺執行緒一樣,因此它們可以執行使用執行緒區域性變數的現有程式碼。但是,由於虛擬執行緒可能非常多,所以應該在仔細考慮之後使用執行緒區域性變數。

特別是,不要使用執行緒區域性變數線上程池中共用同一執行緒的多個任務之間共用昂貴的資源。虛擬執行緒永遠不應該被共用,因為每個執行緒在其生存期內只能執行一個任務。我們已經從 java.base 模組中移除了許多執行緒區域性變數的使用,以便為虛擬執行緒做準備,從而減少在使用數百萬個執行緒執行時的記憶體佔用。

此外:

  • The Thread.Builder API 定義了一個在建立執行緒時選擇不使用執行緒區域性變數的方法(a method to opt-out of thread locals when creating a thread)。它還定義了一個方法來選擇不繼承可繼承執行緒區域性變數的初始值( a method to opt-out of inheriting the initial value of inheritable thread-locals)。當從不支援執行緒區域性變數的執行緒呼叫時, ThreadLocal.get()返回初始值,ThreadLocal.set(T) 丟擲異常。

  • 遺留上下文類載入器( context class loader)現在被指定為像可繼承的執行緒本地一樣工作。如果在不支援執行緒區域性變數的執行緒上呼叫 Thread.setContextClassLoader(ClassLoader),那麼它將引發異常。

Networking

網路 API 在java.net 和java.nio.channels 包中的實現現在與虛擬執行緒一起工作: 虛擬執行緒上的一個操作阻塞,例如,建立網路連線或從通訊端讀取,釋放底層平臺執行緒來做其他工作。

為了允許中斷和取消, java.net.Socket定義的阻塞 I/O 方法、ServerSocket 和 DatagramSocket 現在被指定為在虛擬執行緒中呼叫時是可中斷的: 中斷通訊端上被阻塞的虛擬執行緒將釋放執行緒並關閉通訊端。

當從 InterruptibleChannel 獲取時,這些型別通訊端上的阻塞 I/O 操作總是可中斷的,因此這種更改使這些 API 在建立時的行為與從通道獲取時的建構函式的行為保持一致。

java.io

The java.io 包為位元組和字元流提供 API。這些 API 的實現是高度同步的,需要進行更改以避免在虛擬執行緒中使用被固定。

在底層中,面向位元組的輸入/輸出流沒有指定為執行緒安全的,也沒有指定在讀或寫方法中阻塞執行緒時呼叫 close() 時的預期行為。在大多數情況下,使用來自多個並行執行緒的特定輸入或輸出流是沒有意義的。面向字元的讀/寫器也沒有被指定為執行緒安全的,但是它們確實為子類公開了一個鎖物件。除了固定外,這些類中的同步還存在問題和不一致; 例如, InputStreamReader 和 OutputStreamWriter 使用的流解碼器和編碼器在流物件而不是鎖物件上進行同步。

為了防止固定,現在的實現如下:

  • BufferedInputStream, BufferedOutputStream, BufferedReader, BufferedWriter, PrintStream, 和 PrintWriter 現在在直接使用時使用顯式鎖而不是監視器。當這些類被子類化時,它們與以前一樣進行同步。

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

更進一步並消除所有這些常常不必要的鎖定超出了本文的範圍。

此外,BufferedOutputStream、 BufferedWriter 和 OutputStreamWriter 的流編碼器使用的緩衝區的初始大小現在更小了,以便在堆中有許多流或寫入器時減少記憶體使用ーー如果有一百萬個虛擬執行緒,每個執行緒在通訊端連線上都有一個緩衝流,就可能出現這種情況

推薦學習:《》

以上就是Java知識點總結之JDK19虛擬執行緒的詳細內容,更多請關注TW511.COM其它相關文章!