JDK21最終版協程實現之虛擬執行緒

發表於2023-09-27

1 全新併發程式設計模式

JDK9 後的版本你覺得沒必要折騰,我也認可,但是JDK21有必要關注。因為 JDK21 引入全新的併發程式設計模式。

一直沽名釣譽的GoLang吹得最厲害的就是協程了。JDK21 中就在這方面做了很大的改進,讓Java併發程式設計變得更簡單一點,更絲滑一點。

之前寫過JDK21 FeatureVirtual ThreadsScoped ValuesStructured Concurrency就是針對多執行緒併發程式設計的幾個功能。。

2 發展歷史

虛擬執行緒是輕量級執行緒,極大地減少了編寫、維護和觀察高吞吐量併發應用的工作量。

虛擬執行緒是由JEP 425提出的預覽功能,並在JDK 19中釋出,JDK 21中最終確定虛擬執行緒,以下是根據開發者反饋從JDK 20中的變化:

  • 現在,虛擬執行緒始終支援執行緒本地變數。與在預覽版本中允許的不同,現在不再可能建立不能具有執行緒本地變數的虛擬執行緒。對執行緒本地變數的有保障支援確保了許多現有庫可以不經修改地與虛擬執行緒一起使用,並有助於將以任務為導向的程式碼遷移到使用虛擬執行緒
  • 直接使用Thread.Builder API建立的虛擬執行緒(而不是透過Executors.newVirtualThreadPerTaskExecutor()建立的虛擬執行緒)現在預設情況下也會在其生命週期內進行監控,並且可以透過描述在"觀察虛擬執行緒"部分中的新執行緒轉儲來觀察。

基於協程的執行緒,與其他語言中的協程有相似之處,也有不同。虛擬執行緒是依附於主執行緒的,如果主執行緒銷燬了,虛擬執行緒也不復存在。

3 目標

  • 使採用簡單的 thread-per-request 模式編寫的伺服器應用程式,能以接近最佳的硬體利用率擴充套件
  • 使利用java.lang.Thread API的現有程式碼能在最小更改下采用虛擬執行緒
  • 透過現有的JDK工具輕鬆進行虛擬執行緒的故障排除、除錯和分析

4 非目標

  • 不是刪除傳統的執行緒實現,也不是悄悄將現有應用程式遷移到使用虛擬執行緒
  • 不是改變Java的基本併發模型
  • 不是在Java語言或Java庫中提供新的資料並行構造。Stream API仍是處理大型資料集的首選方式。

5 動機

Java開發人員在近30年來一直依賴執行緒作為併發服務端應用程式的構建塊。每個方法中的每個語句都在一個執行緒內執行,並且由於Java是多執行緒,多個執行緒同時執行。

執行緒是Java的併發單元:它是一段順序程式碼,與其他這樣的單元併發執行,很大程度上是獨立的。每個執行緒提供一個堆疊來儲存區域性變數和協調方法呼叫及在出現問題時的上下文:異常由同一執行緒中的方法丟擲和捕獲,因此開發可使用執行緒的堆疊跟蹤來查詢發生了啥。

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

6 thread-per-request模式

伺服器應用程式通常處理彼此獨立的併發使用者請求,因此將一個執行緒專用於處理整個請求在邏輯上是合理的。這種模式易理解、易程式設計,且易除錯和分析,因為它使用平臺的併發單元來表示應用程式的併發單元。

伺服器應用程式的可擴充套件性受到Little定律約束,該定律關聯延遲、併發性和吞吐量:對給定的請求處理持續時間(即延遲),應用程式同時處理的請求數量(併發性)必須與到達速率(吞吐量)成比例增長。如一個具有平均延遲為50ms的應用程式,透過同時處理10個請求實現每秒處理200個請求的吞吐量。為使該應用程式擴充套件到每秒處理2000個請求吞吐量,它要同時處理100個請求。如每個請求在其持續時間內都使用一個執行緒(因此使用一個os執行緒),那在其他資源(如CPU或網路連線)耗盡前,執行緒數量通常成為限制因素。JDK對執行緒的當前實現將應用程式的吞吐量限制在遠低於硬體支援水平的水平。即使執行緒進行池化,仍然發生,因為池化可避免啟動新執行緒的高成本,但並不會增加匯流排程數。

7 使用非同步模式提高可擴充套件性

一些開發人員為了充分利用硬體資源,已經放棄了採用"thread-per-request"的程式設計風格,轉而採用"共享執行緒"。這種方式,請求處理的程式碼在等待I/O操作完成時會將其執行緒返回給一個執行緒池,以便該執行緒可以為其他請求提供服務。這種對執行緒的精細共享,即只有在執行計算時才保持執行緒,而在等待I/O時釋放執行緒,允許高併發操作而不消耗大量執行緒資源。雖然它消除了由於os執行緒有限而導致的吞吐量限制,但代價高:它需要一種非同步程式設計風格,使用一組專門的I/O方法,這些方法不會等待I/O操作完成,而是稍後透過回撥通知其完成。

在沒有專用執行緒情況下,開發須將請求處理邏輯分解為小階段,通常編寫為lambda表示式,然後使用API(如CompletableFuture或響應式框架)將它們組合成順序管道。因此,他們放棄語言的基本順序組合運算子,如迴圈和try/catch塊。

非同步風格中,請求的每個階段可能在不同執行緒執行,每個執行緒交錯方式執行屬於不同請求的階段。這對於理解程式行為產生了深刻的影響:堆疊跟蹤提供不了可用的上下文,偵錯程式無法逐步執行請求處理邏輯,分析器無法將操作的成本與其呼叫者關聯起來。使用Java的流API在短管道中處理資料時,組合lambda表示式是可管理的,但當應用程式中的所有請求處理程式碼都必須以這種方式編寫時,會帶來問題。這種程式設計風格與Java平臺不符,因為應用程式的併發單位——非同步管道——不再是平臺的併發單位。

8 透過虛擬執行緒保持 thread-per-request 程式設計風格

為了在保持與平臺和諧的情況下使應用程式能擴充套件,應努力透過更高效方式實現執行緒,以便它們可更豐富存在。os無法更高效實現作業系統執行緒,因為不同程式語言和執行時以不同方式使用執行緒堆疊。然而,JRE可透過將大量虛擬執行緒對映到少量作業系統執行緒來實現執行緒的偽裝豐富性,就像os透過將大型虛擬地址空間對映到有限的實體記憶體一樣,JRE可透過將大量虛擬執行緒對映到少量作業系統執行緒來實現執行緒的偽裝豐富性。

虛擬執行緒是java.lang.Thread一個例項,不與特定os執行緒繫結。相反,平臺執行緒是java.lang.Thread的一個例項,以傳統方式實現,作為包裝在作業系統執行緒周圍的薄包裝。

採用 thread-per-request 程式設計風格的應用程式,可在整個請求的持續時間內在虛擬執行緒中執行,但虛擬執行緒僅在它在CPU上執行計算時才會消耗os執行緒。結果與非同步風格相同,只是它是透明實現:當在虛擬執行緒中執行的程式碼呼叫java.* API中的阻塞I/O操作時,執行時會執行非阻塞的os呼叫,並自動暫停虛擬執行緒,直到可稍後恢復。對Java開發,虛擬執行緒只是便宜且幾乎無限豐富的執行緒。硬體利用率接近最佳,允許高併發,因此實現高吞吐量,同時應用程式與Java平臺及其工具的多執行緒設計保持和諧一致。

9 虛擬執行緒的含義

虛擬執行緒成本低且豐富,因此永遠都不應被池化:每個應用程式任務應該建立一個新的虛擬執行緒。因此,大多數虛擬執行緒將是短暫的,且具有淺層次的呼叫棧,執行的操作可能只有一個HTTP客戶端呼叫或一個JDBC查詢。相比之下,平臺執行緒是重量級且代價昂貴,因此通常必須池化。它們傾向於具有較長的生命週期,具有深層次呼叫棧,並在許多工間共享。

總之,虛擬執行緒保留了與Java平臺設計和諧一致的可靠的 thread-per-request 程式設計風格,同時最大限度地利用硬體資源。使用虛擬執行緒無需學習新概念,儘管可能需要放棄為應對當前執行緒成本高昂而養成的習慣。虛擬執行緒不僅將幫助應用程式開發人員,還將幫助框架設計人員提供與平臺設計相容且不會犧牲可伸縮性的易於使用的API。

10 描述

如今,JDK 中的每個 java.lang.Thread 例項都是平臺執行緒。平臺執行緒在底層os執行緒上執行 Java 程式碼,並在程式碼的整個生命週期內捕獲os執行緒。平臺執行緒的數量受限於os執行緒的數量。

虛擬執行緒是 java.lang.Thread 的一個例項,它在底層os執行緒上執行 Java 程式碼,但並不在程式碼的整個生命週期內捕獲作業系統執行緒。這意味著許多虛擬執行緒可在同一個os執行緒上執行其 Java 程式碼,有效地共享它。而平臺執行緒會獨佔一個寶貴的os執行緒,虛擬執行緒則不會。虛擬執行緒的數量可 >> os執行緒的數量。

虛擬執行緒是 JDK 提供的輕量級執行緒實現,不是由os提供。它們是使用者態執行緒的一種形式,在其他多執行緒語言(如 Go 的 goroutine 和 Erlang 的程式)中取得成功。

早期版本 Java,當os執行緒尚未成熟和廣泛使用時,Java 的綠色執行緒都共享一個os執行緒(M:1 排程),最終被作為os執行緒的包裝器(1:1 排程)超越。虛擬執行緒採用 M:N 排程,其中大量(M)虛擬執行緒被排程在較少(N)的os執行緒上執行。

11 使用虛擬執行緒與平臺執行緒

開發人員可選擇使用虛擬執行緒或平臺執行緒。

11.1 建立大量虛擬執行緒demo

先獲取一個 ExecutorService,用於為每個提交的任務建立一個新的虛擬執行緒。然後,它提交 10,000 個任務並等待它們全部完成:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
              // 任務即休眠1s
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}  // executor.close() is called implicitly, and waits

現代硬體可輕鬆支援同時執行 10,000 個虛擬執行緒來執行這樣程式碼。幕後,JDK 在較少的os執行緒上執行程式碼,可能只有一個:

  • 若此程式使用一個為每個任務建立一個新的平臺執行緒的 ExecutorService,如 Executors.newCachedThreadPool(),情況將完全不同。ExecutorService 將嘗試建立 10,000 個平臺執行緒,因此會建立 10,000 個作業系統執行緒,根據計算機和os的不同,程式可能會崩潰
  • 若程式改用從池中獲取平臺執行緒的 ExecutorService,如 Executors.newFixedThreadPool(200),情況也不好多少。ExecutorService 將建立 200 個平臺執行緒供所有 10,000 個任務共享,因此許多工將順序而非併發執行,程式將要很久才能完成

該程式,具有 200 個平臺執行緒的池只能實現每秒 200 個任務的吞吐量,而虛擬執行緒在足夠熱身後,可實現每秒約 10,000 個任務的吞吐量。

若將demo中的 10_000 更改為 1_000_000,則程式將提交 1,000,000 個任務,建立 1,000,000 個同時執行的虛擬執行緒,並在足夠熱身後實現每秒約 1,000,000 個任務的吞吐量。

若此程式任務執行一個需要1s計算(如對大型陣列排序),而不僅是休眠,那增加執行緒數量超過CPU核數量將無法提高吞吐量,無論是虛擬執行緒、平臺執行緒。虛擬執行緒不是更快的執行緒 —— 它們不會比平臺執行緒執行程式碼更快。它們存在目的是提供規模(更高吞吐量),而非速度(更低的延遲)。虛擬執行緒的數量可以遠遠多於平臺執行緒的數量,因此它們可以實現更高的併發,從而實現更高的吞吐量,根據 Little 定律。

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

  1. 併發任務的數量很高(超過幾千)
  2. 工作負載不是 CPU 限制的,因為此時,比CPU核數量更多的執行緒無法提高吞吐量

虛擬執行緒有助提高典型伺服器應用程式的吞吐量,因為這種應用程式由大量併發任務組成,這些任務在大部分時間內都在等待。

虛擬執行緒可執行任何平臺執行緒可執行的程式碼。特別是,虛擬執行緒支援執行緒本地變數和執行緒中斷,就像平臺執行緒一樣。這意味著已存在的用於處理請求的 Java 程式碼可輕鬆在虛擬執行緒中執行。許多服務端框架可能會自動選擇這樣做,為每個傳入的請求啟動一個新的虛擬執行緒,並在其中執行應用程式的業務邏輯。

11.2 聚合服務demo

聚合了另外兩個服務的結果。一個假設的伺服器框架(未顯示)為每個請求建立一個新的虛擬執行緒,並在該虛擬執行緒中執行應用程式的處理程式碼。

又建立兩個新虛擬執行緒併發透過與第一個示例相同的 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);
    }
}

這程式具有直接的阻塞程式碼,因為它可以使用大量虛擬執行緒,所以能很好擴充套件。

Executor.newVirtualThreadPerTaskExecutor() 不是建立虛擬執行緒的唯一方式。下面討論新的 java.lang.Thread.Builder API 可建立和啟動虛擬執行緒。

結構化併發提供更強大 API,用於建立和管理虛擬執行緒,特別是在類似這伺服器示例的程式碼,其中執行緒之間的關係對於平臺和其工具是已知的。

12 解除預設禁用限制

虛擬執行緒是一項預覽 API,預設禁用。上面程式使用 Executors.newVirtualThreadPerTaskExecutor() 方法,所以要在 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

13 不要池化虛擬執行緒

開發通常會將應用程式程式碼從基於執行緒池的傳統 ExecutorService 遷移到基於虛擬執行緒的virtual-thread-per-task的 ExecutorService。執行緒池就像所有資源池一樣,旨在共享昂貴資源,但虛擬執行緒並不昂貴,永遠不要對它們池化。

開發人員有時使用執行緒池限制對有限資源的併發訪問。如一個服務不能處理超過 20 個併發請求,透過提交到大小為 20 的執行緒池的任務來執行對該服務的所有訪問將確保這點。由於平臺執行緒高成本已使執行緒池無處不在,這種習慣也無處不在,但開發不應誘惑自己在虛擬執行緒中進行池化以限制併發。應該使用專門設計用於此目的的構造,如訊號量來保護對有限資源的訪問。這比執行緒池更有效方便,也安全,因為不存線上程本地的資料意外洩漏給另一個任務的風險。

13 觀察虛擬執行緒

編寫清晰的程式碼還不夠,執行中程式狀態的清晰呈現對故障排除、維護和最佳化也重要,而 JDK 一直提供除錯、分析和監視執行緒的機制。這些工具應對虛擬執行緒執行相同操作,儘管可能需要適應它們的大量存在,因為它們畢竟是 java.lang.Thread 的例項。

13.1 Java 偵錯程式

可逐步執行虛擬執行緒,顯示呼叫棧,並檢查棧幀變數。JDK Flight Recorder(JFR)是 JDK 的低開銷分析和監視機制,可將來自應用程式程式碼(如物件分配和 I/O 操作)的事件與正確的虛擬執行緒關聯起來。這些工具無法為採用非同步程式設計風格編寫的應用程式執行這些操作。在該風格中,任務與執行緒無關,因此偵錯程式無法顯示或操作任務的狀態,分析器無法判斷任務等待 I/O 所花費的時間。

13.2 執行緒dump

故障排除執行緒-每請求程式設計風格應用程式的常用工具。但 JDK 的傳統執行緒轉儲(使用 jstack 或 jcmd 獲取)呈現為執行緒的扁平列表。適用於幾十或數百平臺執行緒,但不適用於數千或數百萬虛擬執行緒。因此,官方不會擴充套件傳統執行緒轉儲以包括虛擬執行緒,而是會引入一種新的執行緒轉儲型別,在 jcmd 中以有意義的方式將虛擬執行緒與平臺執行緒一起顯示。當程式使用結構化併發時,可顯示執行緒之間更豐富的關係。

由於視覺化和分析大量執行緒可受益於工具支援,jcmd 還可以 JSON 格式輸出新的執行緒轉儲,而不僅是純文字:

$ jcmd <pid> Thread.dump_to_file -format=json <file>

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

類似第二個demo程式的執行緒轉儲示例,以 JSON 呈現:

{
  "virtual_threads": [
    {
      "id": 1,
      "name": "VirtualThread-1",
      "state": "RUNNABLE",
      "stack_trace": [
        {
          "class": "java.base/java.lang.Thread",
          "method": "lambda$main$0",
          "file": "Main.java",
          "line": 10
        }
      ]
    },
    {
      "id": 2,
      "name": "VirtualThread-2",
      "state": "BLOCKED",
      "stack_trace": [
        {
          "class": "java.base/java.net.SocketInputStream",
          "method": "socketRead0",
          "file": "SocketInputStream.java",
          "line": 61
        }
      ]
    }
  ],
  "platform_threads": [
    {
      "id": 11,
      "name": "Thread-11",
      "state": "RUNNABLE",
      "stack_trace": [
        {
          "class": "java.base/java.lang.Thread",
          "method": "run",
          "file": "Thread.java",
          "line": 834
        }
      ]
    },
    {
      "id": 12,
      "name": "Thread-12",
      "state": "WAITING",
      "stack_trace": [
        {
          "class": "java.base/java.lang.Object",
          "method": "wait",
          "file": "Object.java",
          "line": 328
        }
      ]
    }
  ]
}

由於虛擬執行緒是在 JDK 實現的,不與任何特定 OS 執行緒繫結,因此它們對os不可見的,os 也不知道它們、存在。作業系統級別的監控將觀察到 JDK 程式使用的 OS 執行緒少於虛擬執行緒的數量。

14 虛擬執行緒排程

要執行有用的工作,執行緒需要被排程,即分配給一個處理器核心來執行。對於作為 OS 執行緒實現的平臺執行緒,JDK 依賴os中的排程程式。對虛擬執行緒,JDK 有自己排程程式。JDK 排程程式不是直接將虛擬執行緒分配給處理器,而是將虛擬執行緒分配給平臺執行緒(虛擬執行緒 M:N 排程)。然後,os會像往常一樣對這些平臺執行緒排程。

JDK 虛擬執行緒排程程式是以 FIFO 執行的 work-stealing ForkJoinPool。排程程式的並行度是用於排程虛擬執行緒的可用平臺執行緒數量。預設為可用處理器數量,但可使用系統屬性 jdk.virtualThreadScheduler.parallelism 調整。這 ForkJoinPool 與通常用於並行流實現等的公共池不同,後者 LIFO 執行。

排程程式分配虛擬執行緒給平臺執行緒就是虛擬執行緒的載體。虛擬執行緒可在其生命週期內被分配給不同載體,即排程程式不會在虛擬執行緒和任何特定的平臺執行緒之間保持關聯。從 Java 程式碼角度看,正在執行的虛擬執行緒邏輯上與其當前載體無關:

  • 虛擬執行緒無法獲取載體標識。Thread.currentThread() 返回值始終是虛擬執行緒本身
  • 載體和虛擬執行緒的棧軌跡是分開的。在虛擬執行緒中丟擲的異常不會包含載體棧幀。執行緒轉儲不會在虛擬執行緒的棧中顯示載體的棧幀,反之亦然
  • 載體的執行緒本地變數對虛擬執行緒不可見,反之亦然

Java程式碼角度,虛擬執行緒及其載體暫時共享一個 OS 執行緒的事實是不可見的。原生程式碼角度,與虛擬執行緒多次呼叫相同原生程式碼可能會在每次呼叫時觀察到不同的 OS 執行緒標識。

時間共享

目前,排程程式不實現虛擬執行緒的時間共享。時間共享是對已消耗的 CPU 時間進行強制搶佔的機制。雖然時間共享在某些任務的延遲降低方面可能有效,但在平臺執行緒相對較少且 CPU 利用率達 100% 時,不清楚時間共享是否同樣有效,尤其擁有百萬虛擬執行緒時。

15 執行虛擬執行緒

要利用虛擬執行緒,無需重寫程式。虛擬執行緒不需要或不期望應用程式程式碼明確將控制權交還給排程程式,即虛擬執行緒不是協作式的。使用者程式碼不應假設虛擬執行緒是如何或何時分配給平臺執行緒的,就像它不應假設平臺執行緒是如何或何時分配給處理器核。

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

通常,當虛擬執行緒在 JDK 中的某些阻塞操作(如 BlockingQueue.take())阻塞時,它會解除安裝。當阻塞操作準備完成(如在套接字上接收到位元組)時,它會將虛擬執行緒提交回撥度程式,後者將掛載虛擬執行緒到載體上以恢復執行。

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

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

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

JDK大多數阻塞操作都會解除安裝虛擬執行緒,釋放其載體和底層 OS 執行緒以承擔新工作。然而,JDK一些阻塞操作不會解除安裝虛擬執行緒,因此會阻塞其載體和底層 OS 執行緒。這是因為在 OS 級別(如許多檔案系統操作)或 JDK 級別(如Object.wait())存在一些限制。這些阻塞操作實現將透過臨時擴充套件排程程式的並行性來彌補 OS 執行緒的佔用,因此排程程式的 ForkJoinPool 中的平臺執行緒數量可能會在短時間內超過可用處理器的數量。可透過系統屬性 jdk.virtualThreadScheduler.maxPoolSize 調整排程程式可用於的最大平臺執行緒數量。

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

  • 當它執行同步塊或方法內部的程式碼時
  • 當它執行本機方法或外部函式時

固定不會使應用程式不正確,但可能會阻礙其可擴充套件性。若虛擬執行緒在固定狀態下執行阻塞操作,如 I/O 或 BlockingQueue.take(),則其載體和底層 OS 執行緒將在操作的持續時間內被阻塞。頻繁而長時間的固定可能會損害應用程式的可擴充套件性,因為它會佔用載體。

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

新的診斷工具有助於將程式碼遷移到虛擬執行緒並評估是否應該用 java.util.concurrent 鎖替換特定的 synchronized 使用:

  • 當執行緒在固定狀態下阻塞時,會發出 JDK Flight Recorder (JFR) 事件(參閱 JDK Flight Recorder)。
  • 系統屬性 jdk.tracePinnedThreads 觸發執行緒在固定狀態下阻塞時的堆疊跟蹤。使用 -Djdk.tracePinnedThreads=full 執行時會列印完整的堆疊跟蹤,突出顯示了持有監視器的本機幀和幀。使用 -Djdk.tracePinnedThreads=short 會將輸出限制為僅包含有問題的幀。

將來版本可能能夠解決上述的第一個限制(在同步塊內部固定)。第二個限制是為了與本機程式碼進行正確互動而需要的。

16 記憶體使用和與垃圾回收的互動

虛擬執行緒的堆疊儲存在 Java 的垃圾回收堆中,作為堆疊塊物件。隨應用執行,堆疊會動態增長和收縮,既能高效使用記憶體,又能夠容納任意深度的堆疊(最多達到 JVM 配置的平臺執行緒堆疊大小)。這種效率是支援大量虛擬執行緒的關鍵,因此執行緒每請求的風格在伺服器應用程式中仍然具有持續的可行性。

第二個示例中,一個假設的框架透過建立一個新的虛擬執行緒並呼叫 handle 方法來處理每個請求;即使它在深層次的調棧末尾(經過身份驗證、事務等)呼叫 handlehandle 本身也會生成多個僅執行短暫任務的虛擬執行緒。因此,對有深度呼叫棧的每個虛擬執行緒,都將有多個具有淺呼叫棧的虛擬執行緒,佔用記憶體很少。

虛擬執行緒與非同步程式碼的堆空間使用和垃圾回收活動難以比較:

  • 一百萬個虛擬執行緒需至少一百萬個物件
  • 但共享平臺執行緒池的一百萬個任務也需要一百萬個物件
  • 處理請求的應用程式程式碼通常會在 I/O 操作之間保留資料

Thread-per-request的程式碼可將這些資料保留在本地變數,這些變數儲存在堆中的虛擬執行緒棧,而非同步程式碼須將相同的資料保留在從管道的一個階段傳遞到下一個階段的堆物件。一方面,虛擬執行緒所需的棧更浪費空間,而非同步管道總是需要分配新物件,因此虛擬執行緒可能需要較少的分配。總體而言,執行緒每請求程式碼與非同步程式碼的堆消耗和垃圾回收活動應該大致相似。隨時間推移,希望將虛擬執行緒棧的內部表示大大壓縮。

與平臺執行緒棧不同,虛擬執行緒棧不是 GC root,因此不會在垃圾收集器(如 G1)進行併發堆掃描時遍歷其中的引用。這還意味著,如虛擬執行緒被阻塞在如 BlockingQueue.take(),並且沒有其他執行緒可獲取到虛擬執行緒或佇列的引用,那該執行緒可進行垃圾回收 — 這沒問題,因為虛擬執行緒永遠不會被中斷或解除阻塞。當然,如虛擬執行緒正在執行或正在阻塞且可能會被解除阻塞,那麼它將不會被垃圾回收。

16.1 當前限制

G1不支援龐大的(humongous)堆疊塊物件。如虛擬執行緒的堆疊達到region大小一半,這可能只有 512KB,那可能會拋 StackOverflowError

17 詳情變化

在Java平臺及其實現中的更改:

java.lang.Thread

API更新:

  • Thread.Builder、Thread.ofVirtual()和Thread.ofPlatform(),建立虛擬執行緒和平臺執行緒的新API。如
// 建立一個名為"duke"的新的未啟動的虛擬執行緒
Thread thread = Thread.ofVirtual().name("duke").unstarted(runnable);
  • 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在其他方面不變。Thread類定義的建構函式仍建立平臺執行緒,與以前一樣。沒有新建構函式。

虛擬執行緒和平臺執行緒API區別

  • public Thread建構函式無法建立虛擬執行緒
  • 虛擬執行緒始終是守護執行緒。Thread.setDaemon(boolean)方法無法將虛擬執行緒更改為非守護執行緒
  • 虛擬執行緒具有Thread.NORM_PRIORITY的固定優先順序。Thread.setPriority(int)方法對虛擬執行緒沒有影響。這個限制可能會在將來版本重審
  • 虛擬執行緒不是執行緒組的活躍成員。在虛擬執行緒上呼叫時,Thread.getThreadGroup()返回一個帶有名稱"VirtualThreads"的佔位符執行緒組。Thread.Builder API不定義設定虛擬執行緒執行緒組的方法
  • 設定SecurityManager時,虛擬執行緒在執行時沒有許可權
  • 虛擬執行緒不支援stop()、suspend()或resume()方法。在虛擬執行緒上呼叫這些方法會拋異常

執行緒本地變數

虛擬執行緒支援:

  • 執行緒本地變數(ThreadLocal)
  • 可繼承執行緒本地變數(InheritableThreadLocal)

就像平臺執行緒,因此它們可執行使用執行緒本地變數的現有程式碼。然而,由於虛擬執行緒可能非常多,使用執行緒本地變數時需謹慎考慮。

不要使用執行緒本地變數線上程池中共享昂貴資源,多個任務共享同一個執行緒。

虛擬執行緒不應被池化,因為每個虛擬執行緒的生命週期只用於執行單個任務。為在執行時具有數百萬個執行緒時減少記憶體佔用,已從java.base模組刪除了許多執行緒本地變數的用法。

更多的

Thread.Builder API定義了一個方法,用於在建立執行緒時選擇不使用執行緒本地變數。它還定義了一個方法,用於選擇不繼承inheritable thread-locals的初始值。在不支援執行緒本地變數的執行緒上呼叫ThreadLocal.get()將返回初始值,ThreadLocal.set(T)會拋異常。

傳統的上下文類載入器現在被指定為像inheritable thread local一樣工作。如在不支援thread locals的執行緒上呼叫Thread.setContextClassLoader(ClassLoader),則拋異常。

範圍本地變數可能對某些用例來說是執行緒本地變數的更好選擇。

JUC

支援鎖的基本API,java.util.concurrent.LockSupport,現支援虛擬執行緒:

  • 掛起虛擬執行緒會釋放底層的平臺執行緒以執行其他工作
  • 而喚醒虛擬執行緒會安排它繼續執行

這對LockSupport的更改使得所有使用它的API(鎖、訊號量、阻塞佇列等)在虛擬執行緒中呼叫時能夠優雅地掛起。

此外

Executors.newThreadPerTaskExecutor(ThreadFactory)和Executors.newVirtualThreadPerTaskExecutor()建立一個ExecutorService,它為每個任務建立一個新執行緒。這些方法允許遷移和與使用執行緒池和ExecutorService的現有程式碼進行互操作。

ExecutorService現擴充套件AutoCloseable,可使用try-with-resource構造來使用此API,如上面demo。

Future現定義了獲取已完成任務的結果或異常及獲取任務狀態的方法。它們組合可將Future物件用作流的元素,過濾包含已完成任務的流,然後map以獲取結果的流。這些方法也將對結構化併發的API新增非常有用。

18 網路

java.net和java.nio.channels包中的網路API的實現現在與虛擬執行緒一起工作:在虛擬執行緒上執行的操作,如建立網路連線或從套接字讀取時,將釋放底層平臺執行緒以執行其他工作。

為允許中斷和取消操作,java.net.Socket、ServerSocket和DatagramSocket定義的阻塞I/O方法現在在虛擬執行緒中呼叫時被規定為可中斷:中斷在套接字上阻塞的虛擬執行緒將喚醒執行緒並關閉套接字。從InterruptibleChannel獲取的這些型別套接字上的阻塞I/O操作一直是可中斷,因此這個更改使得這些API在使用它們的建構函式建立時的行為與從通道獲取時的行為保持一致。

java.io

提供了位元組和字元流的API。這些API的實現在被虛擬執行緒使用時需要進行更改以避免固定(pinning)。

作為背景,面向位元組的輸入/輸出流沒有規定是執行緒安全的,也沒有規定線上程在讀取或寫入方法中被阻塞時呼叫close()的預期行為。大多情況下,不應在多個併發執行緒中使用特定的輸入或輸出流。面向字元的讀取/寫入器也沒規定是執行緒安全的,但它們為子類公開了一個鎖物件。除了固定,這些類中的同步存在問題且不一致;例如,InputStreamReader和OutputStreamWriter使用的流解碼器和編碼器在流物件上同步,而不是在鎖物件上同步。

為了防止固定,現在實現的工作方式如下:

  • BufferedInputStream、BufferedOutputStream、BufferedReader、BufferedWriter、PrintStream和PrintWriter現在在直接使用時使用顯式鎖,而不是監視器。當它們被子類化時,這些類會像以前一樣同步
  • InputStreamReader和OutputStreamWriter使用的流解碼器和編碼器現在使用與包含它們的InputStreamReader或OutputStreamWriter相同的鎖
  • BufferedOutputStream、BufferedWriter和OutputStreamWriter使用的流編碼器的初始緩衝區大小現在更小,以減少在堆中存在許多流或編寫器時的記憶體使用——如果有百萬個虛擬執行緒,每個執行緒都有一個套接字連線上的緩衝流,這種情況可能會發生。

JNI

JNI定義了一個新的函式IsVirtualThread,用於測試一個物件是否是虛擬執行緒。

除錯

除錯架構包括三個介面:JVM工具介面(JVM TI)、Java除錯線協議(JDWP)和Java除錯介面(JDI)。這三個介面現在都支援虛擬執行緒。

JVM TI的更新包括:

  • 大多數使用jthread(即對Thread物件的JNI引用)呼叫的函式現在可以使用對虛擬執行緒的引用來呼叫。一小部分函式,即PopFrame、ForceEarlyReturn、StopThread、AgentStartFunction和GetThreadCpuTime,不支援虛擬執行緒。SetLocal*函式僅限於在中斷或單步事件時掛起的虛擬執行緒的最頂層幀中設定本地變數
  • GetAllThreads和GetAllStackTraces函式現在規定返回所有平臺執行緒,而不是所有執行緒
  • 所有事件,除了在早期VM啟動或堆迭代期間釋出的事件外,都可以在虛擬執行緒的上下文中呼叫事件回撥
  • 掛起/恢復實現允許偵錯程式掛起和恢復虛擬執行緒,以及在掛載虛擬執行緒時掛起平臺執行緒
  • 一個新的能力can_support_virtual_threads允許代理程式對虛擬執行緒的執行緒啟動和結束事件有更精細的控制

現有的JVM TI代理程式大多將像以前一樣工作,但如果呼叫不支援虛擬執行緒的函式,可能會遇到錯誤。這些錯誤將在使用不了解虛擬執行緒的代理程式與使用虛擬執行緒的應用程式時發生。將GetAllThreads更改為返回僅包含平臺執行緒的陣列可能對某些代理程式構成問題。已啟用ThreadStart和ThreadEnd事件的現有代理程式可能會遇到效能問題,因為它們無法將這些事件限制為平臺執行緒。

JDWP的更新包括:

  • 一個新的命令允許偵錯程式測試一個執行緒是否是虛擬執行緒
  • EventRequest命令上的新修飾符允許偵錯程式將執行緒啟動和結束事件限制為平臺執行緒。

JDI的更新包括:

  • com.sun.jdi.ThreadReference中的一個新方法測試一個執行緒是否是虛擬執行緒
  • com.sun.jdi.request.ThreadStartRequest和com.sun.jdi.request.ThreadDeathRequest中的新方法限制了為請求生成的事件的執行緒到平臺執行緒

如上所述,虛擬執行緒不被認為是執行緒組中的活動執行緒。因此,JVM TI函式GetThreadGroupChildren、JDWP命令ThreadGroupReference/Children和JDI方法com.sun.jdi.ThreadGroupReference.threads()返回的執行緒列表僅包含平臺執行緒。

JDK Flight Recorder(JFR)

JFR支援虛擬執行緒,並引入了幾個新的事件:

jdk.VirtualThreadStart和jdk.VirtualThreadEnd表示虛擬執行緒的啟動和結束。這些事件預設情況下是禁用的。

jdk.VirtualThreadPinned表示虛擬執行緒被固定(pinned)時的情況,即在不釋放其平臺執行緒的情況下被掛起。此事件預設情況下啟用,閾值為20毫秒。

jdk.VirtualThreadSubmitFailed表示啟動或喚醒虛擬執行緒失敗,可能是由於資源問題。此事件預設情況下啟用。

Java管理擴充套件(JMX)
java.lang.management.ThreadMXBean僅支援監視和管理平臺執行緒。findDeadlockedThreads()方法查詢處於死鎖狀態的平臺執行緒的迴圈;它不會查詢處於死鎖狀態的虛擬執行緒的迴圈。

com.sun.management.HotSpotDiagnosticsMXBean中的一個新方法生成了上面描述的新式執行緒轉儲。可以透過平臺MBeanServer從本地或遠端JMX工具間接呼叫此方法。

java.lang.ThreadGroup
java.lang.ThreadGroup是一個用於分組執行緒的遺留API,在現代應用程式中很少使用,不適合分組虛擬執行緒。我們現在將其標記為已過時並降級,預計將來將在結構化併發的一部分中引入新的執行緒組織構造。

作為背景,ThreadGroup API來自Java 1.0。最初,它的目的是提供作業控制操作,如停止組中的所有執行緒。現代程式碼更有可能使用自Java 5引入的java.util.concurrent包的執行緒池API。ThreadGroup支援早期Java版本中小程式的隔離,但Java 1.2中Java安全性架構的演進顯著,執行緒組不再扮演重要角色。ThreadGroup還旨在用於診斷目的,但這個角色在Java 5引入的監視和管理功能,包括java.lang.management API,中已被取代。

除了現在基本無關緊要外,ThreadGroup API和其實現存在一些重要問題:

銷燬執行緒組的能力存在缺陷。

API要求實現具有對組中的所有活動執行緒的引用。這會增加執行緒建立、執行緒啟動和執行緒終止的同步和爭用開銷。

API定義了enumerate()方法,這些方法本質上是競態條件的。

API定義了suspend()、resume()和stop()方法,這些方法本質上容易產生死鎖且不安全。

ThreadGroup現在被規定為已過時和降級如下:

明確刪除了顯式銷燬執行緒組的能力:已終止過時的destroy()方法不再執行任何操作。

刪除了守護執行緒組的概念:已終止過時的setDaemon(boolean)和isDaemon()方法設定和檢索的守護狀態被忽略。

現在,實現不再保持對子組的強引用。ThreadGroup現在在組中沒有活動執行緒且沒有其他東西保持執行緒組存活時可以被垃圾回收。

已終止的suspend()、resume()和stop()方法總是丟擲異常。

替代方案

繼續依賴非同步API。非同步API難與同步API整合,建立了兩種表示相同I/O操作的不同表示,不提供用於上下文的操作序列的統一概念,無法用於故障排除、監視、除錯和效能分析。

向Java新增語法無堆疊協程(即async/await)。與使用者模式執行緒相比,這些更易實現,並且將提供一種表示操作序列上下文的統一構造。然而,這個構造是新的,與執行緒分開,與執行緒在許多方面相似但在一些微妙的方式中不同。它將線上程設計的API和工具層面引入新的類似執行緒的構造。這需要更長時間來被生態系統接受,並且不像使用者模式執行緒與平臺一體的設計那樣優雅和和諧。

大多采用協程的語言之所以採用這種方法,是因為無法實現使用者模式執行緒(如Kotlin)、遺留的語義保證(如天生單執行緒的JavaScript)或特定於語言的技術約束(如C++)。這些限制不適用於Java。

引入一個新的用於表示使用者模式執行緒的公共類,與java.lang.Thread無關。這將是一個機會,可以擺脫Thread類在25年來積累的不必要負擔。探討和原型化了這種方法的幾個變體,但在每種情況下都遇到了如何執行現有程式碼問題。

主要問題是Thread.currentThread()廣泛用於現有程式碼,直接或間接。現有程式碼中,這個方法必須返回一個表示當前執行執行緒的物件。如果我們引入一個新的類來表示使用者模式執行緒,那currentThread()將不得不返回某種看起來像Thread但代理到使用者模式執行緒物件的包裝物件。

有兩個物件表示當前執行執行緒將會令人困惑,最終決定保留舊的Thread API不是一個重大障礙。除了一些方法(例如currentThread())外,開發人員很少直接使用Thread API;他們主要與高階API(例如ExecutorService)互動。隨時間推移,將透過棄用和刪除Thread類和ThreadGroup等類中的過時方法來擺脫不需要負擔。

測試

現有的測試將確保我們在執行它們的多種配置和執行模式下的更改不會導致意外的回退。

我們將擴充套件jtreg測試工具,以允許在虛擬執行緒的上下文中執行現有測試。這將避免需要有許多測試的兩個版本。

新測試將測試所有新的和修訂的API,以及支援虛擬執行緒的所有更改區域。

新的壓力測試將針對可靠性和效能關鍵區域。

新的微基準測試將針對效能關鍵區域。

我們將使用多個現有伺服器,包括Helidon和Jetty,進行大規模測試。

風險和假設

此提案的主要風險是由於現有API和其實現的更改而產生的相容性問題:

對java.io.BufferedInputStream、BufferedOutputStream、BufferedReader、BufferedWriter、PrintStream和PrintWriter類中的內部(和未記錄的)鎖定協議的修訂可能會影響那些假設I/O方法在呼叫時會在其上同步的程式碼。這些更改不會影響透過擴充套件這些類並假定由超類同步的程式碼,也不會影響擴充套件java.io.Reader或java.io.Writer並使用這些API公開的鎖物件的程式碼。

java.lang.ThreadGroup不再允許銷燬執行緒組,不再支援守護執行緒組的概念,並且其suspend()、resume()和stop()方法始終引發異常。

有一些源不相容的API更改和一個二進位制不相容的更改,可能會影響那些擴充套件java.lang.Thread的程式碼:

如果現有原始檔中的程式碼擴充套件了Thread並且子類中的方法與任何新的Thread方法衝突,則該檔案將無法在不進行更改的情況下編譯。

Thread.Builder被新增為巢狀介面。如果現有原始檔中的程式碼擴充套件了Thread,匯入了名為Builder的類,並且子類中引用“Builder”作為簡單名稱的程式碼,則該檔案將無法在不進行更改的情況下編譯。

Thread.threadId()被新增為一個返回執行緒識別符號的final方法。如果現有原始檔中的程式碼擴充套件了Thread,並且子類宣告瞭一個名為threadId的無引數方法,則它將無法編譯。如果存在已編譯的擴充套件Thread的程式碼,並且子類定義了一個返回型別為long且沒有引數的threadId方法,則在載入子類時將丟擲IncompatibleClassChangeError。

在混合現有程式碼與利用虛擬執行緒或新API的較新程式碼時,可能會觀察到平臺執行緒和虛擬執行緒之間的一些行為差異:

Thread.setPriority(int)方法不會對虛擬執行緒產生影響,虛擬執行緒始終具有Thread.NORM_PRIORITY優先順序。

Thread.setDaemon(boolean)方法對虛擬執行緒沒有影響,虛擬執行緒始終是守護執行緒。

Thread.stop()、suspend()和resume()方法在虛擬執行緒上呼叫時會引發UnsupportedOperationException異常。

Thread API支援建立不支援執行緒本地變數的執行緒。在不支援執行緒本地變數的執行緒上呼叫ThreadLocal.set(T)和Thread.setContextClassLoader(ClassLoader)時會引發UnsupportedOperationException異常。

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

java.net.Socket、ServerSocket和DatagramSocket定義的阻塞I/O方法現在在虛擬執行緒的上下文中被中斷時可中斷。當執行緒在套接字操作上被中斷時,現有程式碼可能會中斷,這將喚醒執行緒並關閉套接字。

虛擬執行緒不是ThreadGroup的活動成員。在虛擬執行緒上呼叫Thread.getThreadGroup()將返回一個名為"VirtualThreads"的虛擬執行緒組,該組為空。

虛擬執行緒在設定了SecurityManager的情況下沒有許可權。

在JVM TI中,GetAllThreads和GetAllStackTraces函式不返回虛擬執行緒。已啟用ThreadStart和ThreadEnd事件的現有代理程式可能會遇到效能問題,因為它們無法將這些事件限制為平臺執行緒。

java.lang.management.ThreadMXBean API支援監視和管理平臺執行緒,但不支援虛擬執行緒。

-XX:+PreserveFramePointer標誌對虛擬執行緒效能產生嚴重的負面影響。

依賴關係

JEP 416(使用Method Handles重新實現核心反射)在JDK 18中移除VM本機反射實現。這允許虛擬執行緒在透過反射呼叫方法時正常掛起。

JEP 353(使用新實現替換傳統Socket API)在JDK 13中,以及JEP 373(使用新實現替換傳統DatagramSocket API)在JDK 15中,替換了java.net.Socket、ServerSocket和DatagramSocket的實現,以適應虛擬執行緒的使用。

JEP 418(Internet地址解析SPI)在JDK 18中定義了一種主機名和地址查詢的服務提供程式介面。這將允許第三方庫實現不會在主機查詢期間釘住執行緒的替代java.net.InetAddress解析器。

本文由部落格一文多發平臺 OpenWrite 釋出!

相關文章