>  기사  >  Java  >  Java 지식 요약: JDK19 가상 스레드

Java 지식 요약: JDK19 가상 스레드

WBOY
WBOY앞으로
2022-10-09 14:46:162515검색

이 기사에서는 java에 대한 관련 지식을 제공하며, 주로 jdk19의 가상 스레드에 대한 관련 내용을 소개합니다. 가상 스레드는 go 언어의 고루틴 및 Erlang 언어의 프로세스와 유사한 구현을 가지고 있습니다. 사용자 모드 스레드에 대해 살펴보겠습니다. 모든 사람에게 도움이 되기를 바랍니다.

Java 지식 요약: JDK19 가상 스레드

추천 학습: "java Video Tutorial"

Introduction

가상 스레드는 Go 언어의 고루틴 및 Erlang 언어의 프로세스와 유사한 구현을 갖습니다. 모드(사용자 모드) 스레드의 한 형태입니다.

과거에 Java는 컴퓨터 하드웨어의 활용도를 높이기 위해 플랫폼 스레드를 공유하기 위해 스레드 풀을 자주 사용했지만 이러한 비동기식 스타일에서는 요청의 각 단계가 다른 스레드에서 실행될 수 있으며 각 스레드는 실행 단계에 속하는 것으로 시작됩니다. 인터리브 방식으로 다른 요청을 처리하는 것은 Java 플랫폼의 설계와 일치하지 않으므로 다음과 같은 결과가 발생합니다.

  • 스택 추적이 사용 가능한 컨텍스트를 제공하지 않습니다

  • 디버거가 요청 처리 논리를 한 단계씩 진행할 수 없습니다

  • Analyzer 작업 비용은 호출자와 연결될 수 없습니다.

그리고 가상 스레드는 플랫폼 설계와 호환되는 동시에 확장성에 영향을 주지 않고 하드웨어를 최적으로 사용합니다. 가상 스레드는 운영 체제가 아닌 JDK에서 제공하는 스레드의 경량 구현입니다.

  • 가상 스레드는 특정 운영 체제 스레드에 바인딩되지 않은 스레드입니다.

  • 플랫폼 스레드는 운영 체제 스레드를 둘러싼 간단한 래퍼로서 전통적인 방식으로 구현된 스레드입니다.

요약

Java 플랫폼에 가상 스레드를 소개합니다. 가상 스레드는 처리량이 높은 동시 애플리케이션을 작성, 유지 관리 및 관찰하는 노력을 크게 줄여주는 경량 스레드입니다.

목표

  • 간단한 요청당 하나의 스레드 방식으로 작성된 서버 애플리케이션을 거의 최적의 하드웨어 활용률로 확장할 수 있습니다.

  • java.lang.ThreadAPI를 사용하는 기존 코드에서 최소한의 변경으로 가상 스레드를 채택할 수 있습니다.

  • 기존 JDK 도구를 사용하여 가상 스레드 문제를 쉽게 해결하고 디버그 및 분석할 수 있습니다.

비목표

  • 레거시 스레드 구현을 제거하거나 가상 스레드를 사용하도록 기존 애플리케이션을 마이그레이션하는 것이 목표가 아닙니다.

  • Java의 기본 동시성 모델을 변경합니다.

  • 우리의 목표는 Java 언어 또는 Java 라이브러리에 새로운 데이터 병렬 구조를 제공하는 것이 아닙니다. StreamAPI는 대규모 데이터 세트를 병렬로 처리하는 데 여전히 선호되는 방법입니다.

동기 부여

거의 30년 동안 Java 개발자들은 스레드를 동시 서버 애플리케이션의 구성 요소로 사용해 왔습니다. 각 메소드의 각 명령문은 스레드에서 실행되며 Java는 다중 스레드이므로 여러 스레드 실행이 동시에 발생합니다.

스레드는 Java의 동시성 단위입니다. 즉, 다른 단위와 동시에 실행되고 대체로 독립적으로 실행되는 순차 코드 조각입니다.

각 스레드는 로컬 변수를 저장하고 메서드 호출을 조정하는 스택과 오류 발생 시 컨텍스트를 제공합니다. 예외는 동일한 스레드의 메서드에 의해 발생되고 포착되므로 개발자는 스레드의 스택 추적을 사용하여 무엇을 찾을 수 있는지 알아낼 수 있습니다. 무슨 일이 일어났나요?

스레드는 도구의 핵심 개념이기도 합니다. 디버거는 스레드 메서드의 명령문을 살펴보고 프로파일러는 여러 스레드의 동작을 시각화하여 성능을 이해하는 데 도움을 줍니다.

두 가지 동시성 스타일

요청당 스레드 스타일

  • 서버 응용 프로그램은 일반적으로 동시 사용자 요청을 서로 독립적으로 처리하므로 응용 프로그램은 전체 작업 기간 동안 요청에 스레드를 할당하여 요청을 처리합니다. 요청이 의미가 있습니다. 이 요청 시 스레드 스타일은 플랫폼의 동시성 단위를 사용하여 애플리케이션의 동시성 단위를 나타내기 때문에 이해하기 쉽고, 프로그래밍하기 쉽고, 디버그하기 쉽고, 구성하기 쉽습니다.

  • 서버 애플리케이션의 확장성은 대기 시간, 동시성 및 처리량과 관련된 리틀의 법칙에 따라 결정됩니다. 지정된 요청 처리 기간(대기 시간) 동안 애플리케이션이 동시에 처리되는 요청 수(동시성)는 비례적으로 증가해야 합니다. 도착률(처리량)

  • 예를 들어 평균 대기 시간이 50ms인 애플리케이션이 10개의 요청을 동시에 처리하여 초당 200개의 요청 처리량을 달성한다고 가정합니다. 애플리케이션이 초당 2000개의 요청 처리량을 달성하려면 동시에 100개의 요청을 처리해야 합니다. 각 요청이 요청 기간 동안 스레드에서 처리되는 경우 애플리케이션이 이를 따라잡기 위해서는 처리량이 증가함에 따라 스레드 수도 늘어나야 합니다.

  • 안타깝게도 JDK는 스레드를 운영 체제(OS) 스레드에 대한 래퍼로 구현하기 때문에 사용 가능한 스레드 수가 제한됩니다. OS 스레드는 비용이 많이 들기 때문에 스레드를 너무 많이 가질 수 없으므로 요청당 스레드가 하나인 스타일에 구현이 적합하지 않습니다.

  • 각 요청이 해당 기간 동안 하나의 스레드, 즉 하나의 OS 스레드를 사용하는 경우 일반적으로 스레드 수는 다른 리소스(예: CPU 또는 네트워크 연결)가 소진되기 오래 전에 제한 요소가 됩니다. JDK의 현재 스레딩 구현은 애플리케이션 처리량을 하드웨어가 지원할 수 있는 수준보다 훨씬 낮은 수준으로 제한합니다. 풀은 새 스레드를 시작하는 데 드는 높은 비용을 방지하는 데 도움이 되지만 총 스레드 수를 늘리지는 않기 때문에 스레드 풀에서도 발생합니다.

비동기 스타일

하드웨어를 최대한 활용하려는 일부 개발자는 스레드 공유 스타일을 선호하여 요청별 스레드 스타일을 포기했습니다.

요청 처리 코드는 처음부터 끝까지 하나의 스레드에서 요청을 처리하는 대신 스레드가 다른 요청을 처리할 수 있도록 I/O 작업이 완료되기를 기다리는 동안 해당 스레드를 풀로 반환합니다. 이러한 세분화된 스레드 공유(코드는 I/O를 기다리는 동안이 아니라 계산을 수행하는 동안에만 스레드를 예약함)를 통해 많은 수의 스레드를 소비하지 않고도 많은 수의 동시 작업을 수행할 수 있습니다.

운영 체제 스레드의 부족으로 인한 처리량 제한을 제거하지만 비용이 많이 듭니다. I/O를 기다리지 않는 일련의 독립적인 I/O 방법을 사용하는 소위 비동기 프로그래밍 스타일이 필요합니다. O 작업이 완료되지만 대신 나중에 콜백에 완료 신호를 보냅니다. 전용 스레드가 없으면 개발자는 요청 처리 논리를 일반적으로 람다 식 형식으로 작성된 작은 단계로 나눈 다음 이를 API를 사용하여 순차 파이프라인으로 결합해야 합니다(예를 들어 CompletableFuture 또는 소위 " React" "성애" 프레임워크). 따라서 그들은 루프 및 try/catch 블록과 같은 언어의 기본 순차 합성 연산자를 포기합니다.

비동기식에서는 요청의 각 단계가 서로 다른 스레드에서 실행될 수 있으며, 각 스레드는 인터리브 방식으로 서로 다른 요청에 속하는 단계를 실행합니다. 이는 프로그램 동작을 이해하는 데 심오한 영향을 미칩니다.

  • 스택 추적은 사용 가능한 컨텍스트를 제공하지 않습니다.

  • 디버거는 요청 처리 논리를 실행할 수 없습니다.

  • 프로파일러는 작업 비용을 호출자와 연관시킬 수 없습니다.

Java의 스트리밍 API를 사용하여 짧은 파이프라인에서 데이터를 처리할 때 람다 식 결합은 관리 가능하지만 애플리케이션의 모든 요청 처리 코드를 이런 방식으로 작성해야 하면 문제가 됩니다. 이 프로그래밍 스타일은 애플리케이션의 동시성 단위(비동기 파이프)가 더 이상 플랫폼의 동시성 단위가 아니기 때문에 Java 플랫폼과 일치하지 않습니다.

비교

Java 지식 요약: JDK19 가상 스레드

가상 스레드를 사용하여 요청별 스레드 스타일 보존

플랫폼과 조화를 유지하면서 애플리케이션을 확장하려면 더 많은 작업을 수행해야 합니다. 효율적으로 스레드는 요청당 하나의 스레드 스타일을 유지하여 더 풍부해질 수 있도록 구현됩니다.

다른 언어와 런타임은 스레드 스택을 다른 방식으로 사용하기 때문에 운영 체제는 OS 스레드를 더 효율적으로 구현할 수 없습니다. 그러나 Java 런타임이 Java 스레드를 구현하는 방식으로 인해 Java 스레드와 운영 체제 스레드 간의 일대일 대응이 심각해질 수 있습니다. 운영 체제가 많은 양의 가상 주소 공간을 제한된 양의 물리적 RAM에 매핑하여 메모리가 충분하다는 환상을 제공하는 것처럼 Java 런타임도 많은 수의 가상 주소 공간을 RAM에 매핑하여 스레드가 충분하다는 환상을 제공할 수 있습니다. 적은 수의 운영 체제 스레드.

  • 가상 스레드는 특정 운영 체제 스레드에 바인딩되지 않은 스레드입니다.

  • 플랫폼 스레드는 운영 체제 스레드를 둘러싼 간단한 래퍼로서 전통적인 방식으로 구현된 스레드입니다.

요청당 스레드 스타일 애플리케이션 코드는 전체 요청에 대해 가상 스레드에서 실행될 수 있지만 가상 스레드는 CPU에서 계산을 수행할 때만 운영 체제 스레드를 사용합니다. 결과적으로 투명하게 구현된다는 점을 제외하면 비동기식 스타일과 동일한 확장성이 있습니다.

가상 스레드에서 실행되는 코드가 Java.* API에서 차단 I/O 작업을 호출하면 런타임은 비차단 운영 체제를 수행합니다. 호출하고 나중에 다시 시작할 수 있을 때까지 가상 스레드를 자동으로 일시 중단합니다.

Java 개발자에게 가상 스레드는 생성 비용이 저렴하고 개수가 거의 무한한 스레드입니다. 하드웨어 활용도가 최적에 가까워 높은 수준의 동시성을 허용하여 처리량을 늘리는 동시에 애플리케이션은 Java 플랫폼 및 해당 도구의 멀티스레드 설계와 조화를 유지합니다.

가상 스레드의 의미

가상 스레드는 저렴하고 풍부하므로 절대 공유해서는 안 됩니다(예: 스레드 풀 사용). 각 애플리케이션 작업마다 새로운 가상 스레드를 생성해야 합니다.

결과적으로 대부분의 가상 스레드는 수명이 짧고 호출 스택이 얕아서 단일 HTTP 클라이언트 호출이나 단일 JDBC 쿼리만 수행합니다. 대조적으로, 플랫폼 스레드는 무겁고 비용이 많이 들기 때문에 자주 공유되어야 합니다. 수명이 길고, 호출 스택이 깊으며, 여러 작업에서 공유되는 경향이 있습니다.

간단히 말하면, 가상 스레드는 하드웨어를 최적으로 활용하면서 Java 플랫폼의 설계와 일치하는 안정적인 요청별 스레드 스타일을 유지합니다. 가상 스레드를 사용하는 데 새로운 개념을 배울 필요는 없지만 오늘날 스레드의 높은 비용에 대응하여 학습하지 않는 습관이 필요할 수 있습니다. 가상 스레드는 애플리케이션 개발자에게 도움이 될 뿐만 아니라 프레임워크 설계자가 확장성을 저하시키지 않고 플랫폼 설계와 호환되는 사용하기 쉬운 API를 제공하는 데도 도움이 됩니다.

Description

오늘날 java.lang의 모든 인스턴스는 다음과 같습니다. JDK의 스레드는 플랫폼 스레드입니다. 플랫폼 스레드는 기본 운영 체제 스레드에서 Java 코드를 실행하고 코드 수명 전반에 걸쳐 운영 체제 스레드를 캡처합니다. 플랫폼 스레드 수는 운영 체제 스레드 수로 제한됩니다.

가상 스레드는 java.lang의 인스턴스입니다. 기본 운영 체제 스레드에서 Java 코드를 실행하지만 코드 수명 동안 해당 운영 체제 스레드를 캡처하지 않는 스레드입니다. 이는 많은 가상 스레드가 동일한 OS 스레드에서 Java 코드를 실행하여 효과적으로 공유할 수 있음을 의미합니다. 플랫폼 스레드는 귀중한 운영 체제 스레드를 독점하지만 가상 스레드는 그렇지 않습니다. 가상 스레드 수는 운영 체제 스레드 수보다 훨씬 클 수 있습니다.

가상 스레드는 운영 체제가 아닌 JDK에서 제공하는 스레드의 경량 구현입니다. 이는 다른 다중 스레드 언어(예: Go의 고루틴 및 Erlang의 프로세스)에서 성공한 사용자 모드 스레드의 한 형태입니다. 사용자 모드 스레드는 OS 스레드가 성숙하고 대중화되기 전인 초기 Java 버전에서도 소위 "그린 스레드"를 특징으로 했습니다. 그러나 Java의 녹색 스레드는 모두 OS 스레드(M:1 스케줄링)를 공유하며 결국 플랫폼 스레드를 능가하고 OS 스레드용 래퍼(1:1 스케줄링)로 구현됩니다. 가상 스레드는 M:N 스케줄링을 사용합니다. 즉, 많은 수(M)의 가상 스레드가 더 적은(N) 운영 체제 스레드에서 실행되도록 예약됩니다.

가상 스레드 VS 플랫폼 스레드

간단한 예

개발자는 가상 스레드 또는 플랫폼 스레드를 사용할 수 있습니다. 다음은 다수의 가상 스레드를 생성하는 샘플 프로그램입니다. 프로그램은 먼저 제출된 각 작업에 대해 새로운 가상 스레드를 생성하는 ExecutorService를 가져옵니다. 그런 다음 10,000개의 작업을 제출하고 모두 완료될 때까지 기다립니다.

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개의 가상 스레드를 쉽게 지원할 수 있는 간단한 코드(1초 동안 대기)입니다. 그 이면에서 JDK는 소수의 운영 체제 스레드(아마도 한 개 정도)에서 코드를 실행합니다.

이 프로그램이 ExecutorService를 사용하여 Executors.newCachedThreadPool()과 같은 각 작업에 대한 새 플랫폼 스레드를 생성한다면 상황은 매우 달라질 것입니다. ExecutorService는 10,000개의 플랫폼 스레드를 생성하려고 시도하여 10,000개의 OS 스레드를 생성하며 컴퓨터와 운영 체제에 따라 프로그램이 충돌할 수 있습니다.

반대로, 프로그램이 풀에서 플랫폼 스레드를 가져오는 ExecutorService(예: Executors.newFixedThreadPool(200))를 사용하는 경우 상황은 그다지 나아지지 않습니다. ExecutorService는 10,000개의 작업 모두에서 공유되는 200개의 플랫폼 스레드를 생성하므로 많은 작업이 동시에 실행되지 않고 순차적으로 실행되며 프로그램을 완료하는 데 오랜 시간이 걸립니다. 이 프로그램의 경우 200개의 플랫폼 스레드 풀은 초당 200개의 작업 처리량만 달성한 반면, 가상 스레드는 (충분한 워밍업 후) 초당 10,000개의 작업 처리량을 달성했습니다. 또한 예제 프로그램의 10000이 1000000으로 변경되면 프로그램은 1,000,000개의 작업을 제출하고 동시에 실행되는 1,000,000개의 가상 스레드를 생성하며 (충분한 워밍업 후) 초당 약 1,000,000개의 작업 처리량을 달성합니다.

이 프로그램의 작업이 단지 절전 모드가 아닌 1초 계산(예: 거대한 배열 정렬)을 수행하는 경우 가상 스레드이든 플랫폼 스레드이든 프로세서 코어 수 이상으로 스레드 수를 늘리는 것은 도움이 되지 않습니다. .

虚拟线程并不是更快的线程ーー它们运行代码的速度并不比平台线程快。它们的存在是为了提供规模(更高的吞吐量) ,而不是速度(更低的延迟) 。它们的数量可能比平台线程多得多,因此根据 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

java.net 및 java.nio.channels 패키지의 네트워크 API 구현은 이제 가상 스레드와 함께 작동합니다. 예를 들어 네트워크 연결 설정 또는 소켓에서 읽기와 같이 가상 스레드를 차단하는 작업은 기본 플랫폼 스레드를 해제하여 다른 일을 하세요.

중단 및 취소를 허용하기 위해 java.net.Socket, ServerSocket 및 DatagramSocket에서 정의한 차단 I/O 메서드는 이제 가상 스레드에서 호출될 때 인터럽트 가능으로 지정됩니다. 인터럽트 소켓에서 차단된 가상 스레드는 스레드를 해제하고 소켓을 닫습니다.

이러한 유형의 소켓에 대한 차단 I/O 작업은 InterruptibleChannel에서 얻을 때 항상 인터럽트 가능하므로 이러한 변경으로 인해 생성 시 이러한 API의 동작이 채널에서 얻을 때 생성자의 동작과 일치하게 됩니다.

java.io

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으로 문의하시기 바랍니다. 삭제