首頁  >  文章  >  Java  >  Java知識點總結之JDK19虛擬線程

Java知識點總結之JDK19虛擬線程

WBOY
WBOY轉載
2022-10-09 14:46:162551瀏覽

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

Java知識點總結之JDK19虛擬線程

推薦學習:《java影片教學

介紹

#虛擬執行緒具有和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 平台不一致,因為應用程式的並發單元(非同步管道)不再是平台的並發單元。

對比

Java知識點總結之JDK19虛擬線程

#使用虛擬執行緒保留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 <pid> Thread.dump_to_file -format=json <file>

新的线程转储格式列出了在网络 I/O 操作中被阻塞的虚拟线程,以及由上面所示的 new-thread-per-task ExecutorService 创建的虚拟线程。它不包括对象地址、锁、 JNI 统计信息、堆统计信息以及传统线程转储中出现的其他信息。此外,由于可能需要列出大量线程,因此生成新的线程转储并不会暂停应用程序。

下面是这样一个线程转储的示例,它取自类似于上面第二个示例的应用程序,在 JSON 查看器中呈现 :

Java知識點總結之JDK19虛擬線程

由于虚拟线程是在 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視頻教程

以上是Java知識點總結之JDK19虛擬線程的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:juejin.im。如有侵權,請聯絡admin@php.cn刪除