JEP 444: Virtual Threads

Virtual Threads

Summary

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

History

가상 스레드는 JEP 425 에서 미리보기 기능으로 제안되어 JDK 19 에 포함되었습니다.
피드백을 위한 시간을 확보하고 더 많은 경험을 얻기 위해 JEP 436 에서 다시 미리보기 기능으로 제안되어 JDK 20 에 포함되었습니다.
이 JEP는 개발자 피드백을 반영하여 JDK 20에서 다음과 같은 변경 사항을 적용하여 JDK 21에서 가상 스레드를 완성할 것을 제안합니다.

  • 가상 스레드는 이제 항상 스레드 로컬 변수를 지원합니다.
    미리보기 릴리스에서처럼 스레드 로컬 변수를 가질 수 없는 가상 스레드를 생성하는 것은 더 이상 불가능합니다.
    스레드 로컬 변수에 대한 지원이 보장됨에 따라, 더 많은 기존 라이브러리를 가상 스레드에서 변경 없이 사용할 수 있으며,
    작업 지향 코드를 가상 스레드로 마이그레이션하는 데 도움이 됩니다.

  • API를 통해 직접 생성된 가상 스레드 Thread.Builder(를 통해 생성된 스레드와 반대 Executors.newVirtualThreadPerTaskExecutor())는
    이제 기본적으로 수명 내내 모니터링되며 가상 스레드 관찰 섹션에 설명된 새 스레드 덤프를 통해 관찰할 수 있습니다.

Goals

  • 간단한 요청당 스레드 스타일로 작성된 서버 애플리케이션을 최적에 가까운 하드웨어 활용도로 확장할 수 있습니다.

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

  • 기존 JDK 도구를 사용하여 가상 스레드의 쉬운 문제 해결, 디버깅 및 프로파일링을 지원합니다.

Non-Goals

  • 기존 스레드 구현을 제거하거나 기존 애플리케이션을 가상 스레드를 사용하도록 자동으로 마이그레이션하는 것이 목표가 아닙니다.
  • Java의 기본 동시성 모델을 변경하는 것이 목표가 아닙니다.
  • Java 언어나 Java 라이브러리에서 새로운 데이터 병렬 처리 구조를 제공하는 것이 목표가 아닙니다.
    Stream API는 대용량 데이터 세트를 병렬로 처리하는 데 여전히 선호되는 방식입니다.

Motivation

Java 개발자들은 거의 30년 동안 동시 서버 애플리케이션의 구성 요소로 스레드를 사용해 왔습니다.
모든 메서드의 모든 명령문은 스레드 내에서 실행되며, Java는 멀티스레드이므로 여러 스레드가 동시에 실행됩니다.
스레드는 Java의 동시성 단위입니다. 다른 단위와 동시에(그리고 대체로 독립적으로) 실행되는 순차적인 코드 조각입니다.
각 스레드는 로컬 변수를 저장하고 메서드 호출을 조정하는 스택을 제공하며, 문제 발생 시 컨텍스트를 제공합니다.
동일한 스레드의 메서드에서 예외가 발생하고 포착되므로 개발자는 스레드의 스택 추적을 사용하여 발생한 상황을 파악할 수 있습니다.
스레드는 도구의 핵심 개념이기도 합니다. 디버거는 스레드 메서드의 명령문을 단계별로 실행하고,
프로파일러는 여러 스레드의 동작을 시각화하여 성능을 파악하는 데 도움을 줍니다.

The thread-per-request style

서버 애플리케이션은 일반적으로 서로 독립적인 동시 사용자 요청을 처리하므로,
애플리케이션이 요청 전체 기간 동안 해당 요청에 전용 스레드를 할당하여 처리하는 것이 합리적입니다.
이러한 요청당 스레드 방식은 플랫폼의 동시성 단위를 사용하여 애플리케이션의 동시성 단위를 나타내므로 이해하기 쉽고,
프로그래밍하기 쉬우며, 디버깅 및 프로파일링이 쉽습니다.

서버 애플리케이션의 확장성은 대기 시간, 동시성 및 처리량과 관련된 Little의 법칙 에 의해 지배됩니다.
주어진 요청 처리 기간(즉, 대기 시간) 동안 애플리케이션이 동시에 처리하는 요청 수(즉, 동시성)는 도착 속도(즉, 처리량)에 비례하여 증가해야 합니다.
예를 들어, 평균 대기 시간이 50ms인 애플리케이션이 10개의 요청을 동시에 처리하여 초당 200개의 요청 처리량을 달성한다고 가정해 보겠습니다.
해당 애플리케이션이 초당 2000개의 요청 처리량으로 확장되려면 100개의 요청을 동시에 처리해야 합니다.
각 요청이 요청 기간 동안 스레드에서 처리되는 경우 애플리케이션이 따라가려면 처리량이 증가함에 따라 스레드 수도 증가해야 합니다.

안타깝게도 JDK는 스레드를 운영 체제(OS) 스레드를 감싸는 래퍼로 구현하기 때문에 사용 가능한 스레드 수가 제한적입니다.
OS 스레드는 비용이 많이 들기 때문에 너무 많을 수 없으므로 요청당 스레드 구현 방식에는 적합하지 않습니다.
각 요청이 해당 기간 동안 스레드, 즉 OS 스레드를 사용하게 되면 CPU나 네트워크 연결과 같은 다른 리소스가 고갈되기 훨씬 전에 스레드 수가 제한 요소가 되는 경우가 많습니다.
JDK의 현재 스레드 구현은 애플리케이션의 처리량을 하드웨어가 지원할 수 있는 수준보다 훨씬 낮은 수준으로 제한합니다.
이는 스레드를 풀링하는 경우에도 마찬가지입니다. 풀링은 새 스레드를 시작하는 데 드는 높은 비용을 피하는 데 도움이 되지만 총 스레드 수는 증가하지 않기 때문입니다.

Improving scalability with the asynchronous style

하드웨어를 최대한 활용하고자 하는 일부 개발자들은 요청당 스레드 방식을 포기하고 스레드 공유 방식을 채택했습니다.
요청 처리 코드는 처음부터 끝까지 한 스레드에서 요청을 처리하는 대신,
다른 I/O 작업이 완료될 때까지 대기할 때 해당 스레드를 풀로 반환하여 다른 요청을 처리할 수 있도록 합니다.
이러한 세밀한 스레드 공유 방식은 코드가 계산을 수행하는 동안만 스레드를 유지하고 I/O를 기다리는 동안은 유지하지 않으므로,
많은 스레드를 소모하지 않고도 많은 수의 동시 작업을 처리할 수 있도록 합니다.
OS 스레드 부족으로 인한 처리량 제한은 해소되지만, 높은 비용이 발생합니다.
비동기 프로그래밍 방식이라고 하는, I/O 작업이 완료될 때까지 기다리지 않고 나중에 완료를 콜백에 알리는 별도의 I/O 메서드 집합을 사용하는 방식이 필요합니다.
전용 스레드가 없으면 개발자는 요청 처리 로직을 작은 단계로 나누어야 합니다.
일반적으로 람다 표현식으로 작성하고, 이를 API( 예: CompletableFuture 또는 소위 “반응형” 프레임워크)를 사용하여 순차적인 파이프라인으로 구성해야 합니다.
따라서 루프나 블록과 같은 언어의 기본적인 순차적 구성 연산자를 사용하지 않게 됩니다 try/catch.

비동기 방식에서는 요청의 각 단계가 서로 다른 스레드에서 실행될 수 있으며, 각 스레드는 서로 다른 요청에 속하는 단계들을 인터리브 방식으로 실행합니다.
이는 프로그램 동작을 이해하는 데 중요한 의미를 지닙니다. 스택 추적은 사용 가능한 컨텍스트를 제공하지 않고,
디버거는 요청 처리 로직을 단계별로 실행할 수 없으며, 프로파일러는 작업 비용을 호출자와 연관시킬 수 없습니다.
Java 스트림 API를 사용하여 짧은 파이프라인에서 데이터를 처리할 때는 람다 표현식을 작성하는 것이 어렵지 않지만,
애플리케이션의 모든 요청 처리 코드를 이러한 방식으로 작성해야 하는 경우에는 문제가 됩니다.
이러한 프로그래밍 방식은 애플리케이션의 동시성 단위인 비동기 파이프라인이 더 이상 플랫폼의 동시성 단위가 아니기 때문에 Java 플랫폼과 상충됩니다.

Preserving the thread-per-request style with virtual threads

애플리케이션이 플랫폼과의 조화를 유지하면서 확장 가능하도록 하려면 요청당 스레드 방식을 유지해야 합니다.
스레드를 더 효율적으로 구현하여 더 풍부하게 만들면 이를 달성할 수 있습니다.
운영 체제는 다양한 언어와 런타임이 스레드 스택을 사용하는 방식이 다르기 때문에 OS 스레드를 더 효율적으로 구현할 수 없습니다.
그러나 Java 런타임은 Java 스레드를 OS 스레드와의 일대일 대응 관계를 끊는 방식으로 구현할 수 있습니다.
운영 체제가 큰 가상 주소 공간을 제한된 양의 물리적 RAM에 매핑하여 메모리가 풍부한 것처럼 보이도록 하는 것처럼,
Java 런타임은 많은 수의 가상 스레드를 적은 수의 OS 스레드에 매핑하여 스레드가 풍부한 것처럼 보이도록 할 수 있습니다.

가상 스레드java.lang.Thread 는 특정 OS 스레드에 종속되지 않은 인스턴스입니다.
반면 플랫폼 스레드는java.lang.Thread OS 스레드를 감싸는 얇은 래퍼 형태로 기존 방식으로 구현된 인스턴스입니다.

요청당 스레드 방식의 애플리케이션 코드는 요청 전체 기간 동안 가상 스레드에서 실행될 수 있지만,
가상 스레드는 CPU에서 계산을 수행하는 동안만 OS 스레드를 사용합니다. 결과적으로 비동기 방식과 동일한 확장성을 제공하지만, 투명하게 구현됩니다.
가상 스레드에서 실행되는 코드가 API에서 블로킹 I/O 작업을 호출하면 java.*런타임은 비블로킹 OS 호출을 수행하고 나중에 다시 시작할 수 있을 때까지
가상 스레드를 자동으로 일시 중단합니다. Java 개발자에게 가상 스레드는 단순히 생성 비용이 저렴하고 거의 무한히 풍부한 스레드입니다.
하드웨어 활용도가 최적에 가까워 높은 수준의 동시성과 그 결과 높은 처리량을 제공하는 동시에 애플리케이션은 Java 플랫폼 및 해당 도구의 멀티스레드 설계와 조화를 이룹니다.

Implications of virtual threads

가상 스레드는 저렴하고 풍부하므로 절대 풀링해서는 안 됩니다. 모든 애플리케이션 작업마다 새로운 가상 스레드를 생성해야 합니다.
따라서 대부분의 가상 스레드는 수명이 짧고 호출 스택이 얕아 단일 HTTP 클라이언트 호출이나 단일 JDBC 쿼리 정도만 수행합니다.
반면 플랫폼 스레드는 무겁고 비용이 많이 들기 때문에 풀링해야 하는 경우가 많습니다.
플랫폼 스레드는 일반적으로 수명이 길고 호출 스택이 깊으며 여러 작업에서 공유됩니다.

요약하자면, 가상 스레드는 Java 플랫폼의 설계와 조화를 이루는 안정적인 요청당 스레드 스타일을 유지하면서도 사용 가능한 하드웨어를 최적으로 활용합니다.
가상 스레드를 사용한다고 해서 새로운 개념을 배울 필요는 없지만, 오늘날의 높은 스레드 비용에 대처하기 위해 개발된 습관을 버려야 할 수도 있습니다.
가상 스레드는 애플리케이션 개발자에게 도움이 될 뿐만 아니라, 프레임워크 설계자가 확장성을 저해하지 않으면서
플랫폼 설계와 호환되는 사용하기 쉬운 API를 제공하는 데에도 도움이 됩니다.

Description

오늘날 java.lang.ThreadJDK 의 모든 인스턴스는 플랫폼 스레드 입니다.
플랫폼 스레드는 기본 OS 스레드에서 Java 코드를 실행하고 코드의 전체 수명 동안 OS 스레드를 캡처합니다.
플랫폼 스레드의 수는 OS 스레드 수로 제한됩니다.

가상 스레드 는 기본 OS 스레드에서 Java 코드를 실행하지만, 코드의 전체 수명 동안 OS 스레드를 캡처하지 않는 인스턴스입니다
java.lang.Thread. 즉, 여러 가상 스레드가 동일한 OS 스레드에서 Java 코드를 실행하여 사실상 공유할 수 있습니다. 플랫폼 스레드는 귀중한 OS 스레드를 독점하는 반면,
가상 스레드는 그렇지 않습니다. 가상 스레드의 수는 OS 스레드 수보다 훨씬 많을 수 있습니다.

가상 스레드는 OS가 아닌 JDK에서 제공하는 가벼운 스레드 구현입니다.
가상 스레드는 사용자 모드 스레드 의 한 형태로, 다른 멀티스레드 언어(예: Go의 고루틴, Erlang의 프로세스)에서 성공적으로 사용되었습니다.
사용자 모드 스레드는 OS 스레드가 아직 성숙하고 널리 보급되지 않았던 초기 Java 버전에서 소위 “그린 스레드” 로 등장하기도 했습니다.
그러나 Java의 그린 스레드는 모두 하나의 OS 스레드를 공유했으며(M:1 스케줄링),
결국 OS 스레드의 래퍼로 구현된 플랫폼 스레드(1:1 스케줄링)에 밀려 성능이 떨어졌습니다.
가상 스레드는 M:N 스케줄링을 사용하는데, 이는 많은 수(M)의 가상 스레드가 적은 수(N)의 OS 스레드에서 실행되도록 스케줄링되는 방식입니다.

Using virtual threads vs. platform threads

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

1
2
3
4
5
6
7
8
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
} // executor.close() is called implicitly, and waits

이 예제에서 작업은 간단한 코드, 즉 1초간의 대기 시간으로 이루어지며, 최신 하드웨어는 이러한 코드를 동시에 실행하는 10,000개의 가상 스레드를 쉽게 지원할 수 있습니다.
하지만 JDK는 내부적으로 소수의 OS 스레드, 아마도 하나 정도의 스레드에서 코드를 실행합니다.

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

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

이 프로그램의 작업들이 단순히 휴면 상태가 아니라 1초 동안 계산(예: 거대한 배열 정렬)을 수행한다면,
프로세서 코어 수를 초과하는 스레드 수를 늘리는 것은 가상 스레드든 플랫폼 스레드든 아무런 도움이 되지 않습니다.
가상 스레드는 더 빠른 스레드가 아닙니다. 플랫폼 스레드보다 코드를 더 빠르게 실행하지도 않습니다.
가상 스레드는 속도(낮은 지연 시간)가 아닌 확장성(높은 처리량)을 제공하기 위해 존재합니다.
플랫폼 스레드보다 훨씬 더 많은 수의 가상 스레드가 존재할 수 있으므로, 리틀의 법칙에 따라 더 높은 처리량에 필요한 높은 동시성을 가능하게 합니다.

다시 말해, 가상 스레드는 다음과 같은 경우 애플리케이션 처리량을 크게 향상시킬 수 있습니다.

  • 동시 작업 수가 많고(수천개 이상)
  • 작업 부하는 CPU에 국한되지 않습니다. 프로세서 코어보다 스레드가 훨씬 많으면 처리량이 향상될 수 없기 때문입니다.

가상 스레드는 일반적인 서버 애플리케이션의 처리량을 개선하는 데 도움이 됩니다.
이러한 애플리케이션은 많은 수의 동시 작업으로 구성되어 있고 작업의 상당 부분이 대기하는 데 소요되기 때문입니다.

가상 스레드는 플랫폼 스레드가 실행할 수 있는 모든 코드를 실행할 수 있습니다.
특히 가상 스레드는 플랫폼 스레드와 마찬가지로 스레드 로컬 변수 와 스레드 인터럽트를 지원합니다.
즉, 요청을 처리하는 기존 Java 코드는 가상 스레드에서 쉽게 실행될 수 있습니다.
많은 서버 프레임워크는 이 작업을 자동으로 수행하여 모든 수신 요청에 대해 새 가상 스레드를 시작하고 애플리케이션의 비즈니스 로직을 해당 스레드에서 실행합니다.

다음은 두 개의 다른 서비스의 결과를 집계하는 서버 애플리케이션의 예입니다.
가상의 서버 프레임워크(그림에는 표시되지 않음)는 각 요청에 대해 새로운 가상 스레드를 생성하고 handle해당 가상 스레드에서 애플리케이션 코드를 실행합니다.
그러면 애플리케이션 코드는 ExecutorService첫 번째 예와 동일한 방식으로 두 개의 새로운 가상 스레드를 생성하여 리소스를 동시에 가져옵니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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를 제공하며,
이를 통해 스레드 간의 관계가 플랫폼과 해당 도구에 알려집니다.

Do not pool virtual threads

ExecutorService개발자는 일반적으로 애플리케이션 코드를 기존 스레드 풀 기반에서 작업당 가상 스레드 방식으로 마이그레이션합니다 ExecutorService.
스레드 풀은 다른 리소스 풀과 마찬가지로 비용이 많이 드는 리소스를 공유하도록 설계되었지만,
가상 스레드는 비용이 많이 들지 않으므로 풀링할 필요가 없습니다.

개발자는 제한된 리소스에 대한 동시 접근을 제한하기 위해 스레드 풀을 사용하는 경우가 있습니다.
예를 들어, 서비스가 20개 이상의 동시 요청을 처리할 수 없는 경우, 크기가 20인 스레드 풀에 제출된 작업을 통해 서비스에 대한 모든 요청을 처리하면 문제가 해결됩니다.
플랫폼 스레드의 높은 비용으로 인해 스레드 풀이 보편화되면서 이러한 관용구가 널리 사용되기 시작했습니다.
하지만 동시성을 제한하기 위해 가상 스레드를 풀링하려는 유혹에 빠지지 마십시오. 대신 세마포어와 같이 해당 목적으로 특별히 설계된 구조를 사용하십시오.

개발자는 스레드 풀과 함께 스레드 로컬 변수를 사용하여 동일한 스레드를 공유하는 여러 작업 간에 비용이 많이 드는 리소스를 공유하는 경우가 있습니다.
예를 들어, 데이터베이스 연결을 생성하는 데 비용이 많이 드는 경우, 해당 연결을 한 번 열어 스레드 로컬 변수에 저장한 후
나중에 동일한 스레드의 다른 작업에서 사용할 수 있습니다. 스레드 풀을 사용하는 코드에서 작업당 가상 스레드를 사용하는 코드로 마이그레이션하는 경우,
모든 가상 스레드에 대해 비용이 많이 드는 리소스를 생성하면 성능이 크게 저하될 수 있으므로 이러한 관용구 사용에 주의해야 합니다.
이러한 코드를 대체 캐싱 전략을 사용하도록 변경하여 비용이 많이 드는 리소스를 매우 많은 가상 스레드 간에 효율적으로 공유하도록 하십시오.

Observing virtual threads

명확한 코드를 작성하는 것만으로는 충분하지 않습니다.
실행 중인 프로그램의 상태를 명확하게 표현하는 것은 문제 해결, 유지 관리 및 최적화에도 필수적이며,
JDK는 오랫동안 스레드 디버깅, 프로파일링 및 모니터링 메커니즘을 제공해 왔습니다.
이러한 도구는 가상 스레드에도 동일한 기능을 제공해야 합니다. 단, 가상 스레드의 양이 많기 때문에 어느 정도 조정이 필요할 수 있습니다.
결국 가상 스레드는 .NET의 인스턴스이기 때문입니다 java.lang.Thread.

자바 디버거는 가상 스레드를 단계별로 실행하고, 호출 스택을 표시하고, 스택 프레임의 변수를 검사할 수 있습니다.
JDK의 로우오버헤드 프로파일링 및 모니터링 메커니즘인 JDK Flight Recorder(JFR)는 애플리케이션 코드의 이벤트(예: 객체 할당 및 I/O 작업)를
해당 가상 스레드와 연결할 수 있습니다. 이러한 도구는 비동기 방식으로 작성된 애플리케이션에서는 이러한 작업을 수행할 수 없습니다.
비동기 방식에서는 작업이 스레드와 관련이 없으므로 디버거는 작업 상태를 표시하거나 조작할 수 없으며,
프로파일러는 작업이 I/O 대기에 소요되는 시간을 파악할 수 없습니다.

스레드 덤프는 요청당 스레드 방식으로 작성된 애플리케이션 문제 해결에 널리 사용되는 또 다른 도구입니다.
하지만 JDK의 기존 스레드 덤프는 jstack이나 를 사용하여 얻은 jcmd스레드 목록을 단순하게 표시합니다.
이는 수십 또는 수백 개의 플랫폼 스레드에는 적합하지만, 수천 또는 수백만 개의 가상 스레드에는 적합하지 않습니다.
따라서 기존 스레드 덤프를 확장하여 가상 스레드를 포함하지 않습니다.
대신, jcmd 에서 가상 스레드와 플랫폼 스레드를 의미 있는 방식으로 그룹화하여 보여주는 새로운 유형의 스레드 덤프를 도입할 것입니다.
프로그램이 구조적 동시성을 사용하면 스레드 간의 더욱 풍부한 관계를 보여줄 수 있습니다 .

많은 스레드를 시각화하고 분석하는 데 도구가 도움이 될 수 있으므로 jcmd일반 텍스트 외에도 JSON 형식으로 새 스레드 덤프를 내보낼 수 있습니다.

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

새로운 스레드 덤프 형식에는 기존 스레드 덤프에 나타나는 객체 주소, 잠금, JNI 통계, 힙 통계 및 기타 정보가 포함되지 않습니다.
또한, 많은 스레드를 나열해야 할 수도 있으므로, 새 스레드 덤프를 생성해도 애플리케이션이 중단되지 않습니다.

시스템 속성을 jdk.trackAllThreads로 설정하면 false(즉 -Djdk.trackAllThreads=false, 명령줄 옵션을 사용하여) Thread.BuilderAPI를 통해
직접 생성된 가상 스레드가 런타임에 추적되지 않아 새 스레드 덤프에 나타나지 않을 수 있습니다.
이 경우 새 스레드 덤프에는 네트워크 I/O 작업에서 차단된 가상 스레드와 위에 표시된 작업당 새 스레드로 생성된 가상 스레드가 나열됩니다 ExecutorService.

가상 스레드는 JDK에 구현되어 있으며 특정 OS 스레드에 종속되지 않으므로 OS에서는 보이지 않고,
OS는 가상 스레드의 존재를 인식하지 못합니다. OS 수준 모니터링을 통해 JDK 프로세스가 가상 스레드보다 적은 OS 스레드를 사용하는 것을 확인할 수 있습니다.

Scheduling virtual threads

유용한 작업을 수행하려면 스레드를 스케줄링해야 합니다. 즉, 프로세서 코어에서 실행되도록 할당해야 합니다.
OS 스레드로 구현된 플랫폼 스레드의 경우, JDK는 OS의 스케줄러에 의존합니다. 반면, 가상 스레드의 경우 JDK는 자체 스케줄러를 사용합니다.
JDK 스케줄러는 가상 스레드를 프로세서에 직접 할당하는 대신, 플랫폼 스레드에 할당합니다(이는 앞서 언급한 가상 스레드의 M:N 스케줄링과 같습니다).
그러면 플랫폼 스레드는 평소처럼 OS에 의해 스케줄링됩니다.

JDK의 가상 스레드 스케줄러는 ForkJoinPoolFIFO 모드로 작동하는 작업 훔치기(work-stealing)입니다.
스케줄러의 병렬 처리 는 가상 스레드 스케줄링에 사용 가능한 플랫폼 스레드 수를 의미합니다. 기본적으로 병렬 처리는 사용 가능한 프로세서 수와 같지만,
시스템 속성을 사용하여 조정할 수 있습니다 jdk.virtualThreadScheduler.parallelism.
이는 병렬 스트림 구현 등에 사용되며 LIFO 모드로 작동하는 공통 풀ForkJoinPool 과는 다릅니다.

스케줄러가 가상 스레드를 할당하는 플랫폼 스레드를 가상 스레드의 캐리어 라고 합니다.
가상 스레드는 수명 주기 동안 여러 캐리어에 스케줄링될 수 있습니다. 즉, 스케줄러는 가상 스레드와 특정 플랫폼 스레드 간의 연관성을 유지하지 않습니다.
Java 코드 관점에서 볼 때, 실행 중인 가상 스레드는 현재 캐리어와 논리적으로 독립적입니다.

  • 가상 스레드에서는 통신사의 신원을 알 수 없습니다. 반환되는 값은 Thread.currentThread()항상 가상 스레드 자체입니다.
  • 캐리어와 가상 스레드의 스택 추적은 별개입니다. 가상 스레드에서 발생한 예외에는 캐리어의 스택 프레임이 포함되지 않습니다. 스레드 덤프에는 가상 스레드 스택에 있는 캐리어의 스택 프레임이 표시되지 않으며, 그 반대의 경우도 마찬가지입니다.
  • 캐리어의 스레드 로컬 변수는 가상 스레드에서 사용할 수 없으며 그 반대의 경우도 마찬가지입니다.

또한, Java 코드 관점에서는 가상 스레드와 그 실행자가 일시적으로 OS 스레드를 공유한다는 사실이 눈에 띄지 않습니다.
반면 네이티브 코드 관점에서는 가상 스레드와 실행자가 모두 동일한 네이티브 스레드에서 실행됩니다.
따라서 동일한 가상 스레드에서 여러 번 호출되는 네이티브 코드는 호출될 때마다 다른 OS 스레드 식별자를 사용할 수 있습니다.

스케줄러는 현재 가상 스레드에 대한 시간 공유를 구현하지 않습니다. 시간 공유는 할당된 CPU 시간을 소비한 스레드를 강제로 선점하는 방식입니다.
플랫폼 스레드 수가 비교적 적고 CPU 사용률이 100%일 때 시간 공유는 일부 작업의 지연 시간을 줄이는 데 효과적일 수 있지만,
가상 스레드가 백만 개일 때는 시간 공유가 그만큼 효과적일지 확실하지 않습니다.

Executing virtual threads

가상 스레드를 활용하기 위해 프로그램을 다시 작성할 필요는 없습니다.
가상 스레드는 애플리케이션 코드가 스케줄러에게 명시적으로 제어권을 넘겨줄 것을 요구하거나 기대하지 않습니다.
다시 말해, 가상 스레드는 협력적이 지 않습니다. 사용자 코드는 플랫폼 스레드가 프로세서 코어에 할당되는 방식이나 시기를 가정해서는 안 되는 것처럼,
가상 스레드가 플랫폼 스레드에 할당되는 방식이나 시기를 가정해서는 안 됩니다.

가상 스레드에서 코드를 실행하기 위해 JDK의 가상 스레드 스케줄러는 가상 스레드를 플랫폼 스레드에 마운트하여 플랫폼 스레드에서 실행되도록 할당합니다.
이렇게 하면 플랫폼 스레드가 가상 스레드의 실행 매개체가 됩니다. 나중에 코드를 실행한 후 가상 스레드는 실행 매개체에서 마운트 해제 될 수 있습니다.
그러면 플랫폼 스레드가 해제되어 스케줄러가 다른 가상 스레드를 마운트하여 다시 실행 매개체가 됩니다.

일반적으로 가상 스레드는 JDK에서 I/O 또는 기타 블로킹 작업(예: .)으로 인해 블로킹될 때 마운트 해제됩니다 BlockingQueue.take().
블로킹 작업이 완료될 준비가 되면(예: 소켓에서 바이트 수신) 가상 스레드를 스케줄러에 다시 제출하고, 스케줄러는 가상 스레드를 캐리어에 마운트하여 실행을 재개합니다.

가상 스레드의 마운트 및 언마운트 작업은 빈번하고 투명하게 수행되며, OS 스레드를 차단하지 않습니다.
예를 들어, 앞서 살펴본 서버 애플리케이션에는 차단 작업에 대한 호출을 포함하는 다음 코드 줄이 포함되어 있습니다.

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

이러한 작업으로 인해 가상 스레드가 여러 번 마운트 및 마운트 해제됩니다.
일반적으로는 호출할 때마다 한 번씩 수행되고 get(), I/O를 수행하는 과정에서도 여러 번 수행될 수 있습니다 send(…).

JDK에서 대부분의 블로킹 작업은 가상 스레드를 마운트 해제하여 해당 스레드의 캐리어와 기본 OS 스레드가 새 작업을 수행할 수 있도록 합니다.
그러나 JDK의 일부 블로킹 작업은 가상 스레드를 마운트 해제하지 않아 해당 캐리어와 기본 OS 스레드를 모두 차단합니다.
이는 OS 수준(예: 많은 파일 시스템 작업) 또는 JDK 수준(예: Object.wait())의 제한 때문입니다.
이러한 블로킹 작업의 구현은 스케줄러의 병렬 처리를 일시적으로 확장하여 OS 스레드 캡처를 보완합니다.
결과적으로 스케줄러의 플랫폼 스레드 수가 ForkJoinPool일시적으로 사용 가능한 프로세서 수를 초과할 수 있습니다.
스케줄러에서 사용할 수 있는 최대 플랫폼 스레드 수는 시스템 속성을 사용하여 조정할 수 있습니다 jdk.virtualThreadScheduler.maxPoolSize.

가상 스레드가 캐리어에 고정되어 있기 때문에 차단 작업 중에 마운트 해제할 수 없는 두 가지 시나리오가 있습니다 .

  • 블록이나 메서드 내부에서 코드를 실행할 때 synchronized또는
  • native메서드나 외부 함수를 실행할 때 .

고정은 애플리케이션을 부정확하게 만들지는 않지만, 확장성을 저해할 수 있습니다.
가상 스레드가 I/O와 같은 차단 작업을 수행하거나 BlockingQueue.take()고정된 상태에서 실행되면,
해당 스레드의 캐리어와 기본 OS 스레드는 작업 시간 동안 차단됩니다. 장시간 빈번한 고정은 캐리어를 포착하여 애플리케이션의 확장성을 저해할 수 있습니다.

스케줄러는 병렬 처리를 확장하여 고정(pinning)을 보상하지 않습니다.
대신, 자주 synchronized실행되고 잠재적으로 긴 I/O 작업을 보호하는 블록이나 메서드를 수정하여 빈번하고
오래 지속되는 고정을 방지하십시오 java.util.concurrent.locks.ReentrantLock. 드물게 사용되거나(예: 시작 시에만 수행됨)
메모리 내 작업을 보호하는 블록이나 메서드는 교체할 필요가 없습니다 synchronized. 항상 그렇듯이 잠금 정책은 간단하고 명확하게 유지하도록 노력하십시오.

새로운 진단 기능은 코드를 가상 스레드로 마이그레이션하고 특정 사용을 synchronized잠금으로 대체해야 하는지 여부를 평가하는 데 도움이 됩니다 java.util.concurrent.

  • JDK Flight Recorder(JFR) 이벤트는 고정된 스레드가 차단될 때 발생합니다( JDK Flight Recorder 참조 ).
  • 시스템 속성은 jdk.tracePinnedThreads스레드가 고정된 상태에서 차단될 때 스택 추적을 트리거합니다.
  • 를 실행하면 -Djdk.tracePinnedThreads=full스레드가 고정된 상태에서 차단될 때 전체 스택 추적이 출력되고,
  • 네이티브 프레임과 모니터를 보유한 프레임이 강조 표시됩니다. 를 실행하면 -Djdk.tracePinnedThreads=short문제가 있는 프레임만 출력됩니다.

향후 릴리스에서는 위의 첫 번째 제한 사항, 즉 synchronized. 내부 고정 기능을 제거할 수 있을 것입니다.
두 번째 제한 사항은 네이티브 코드와의 원활한 상호 작용을 위해 필요합니다.

Memory use and interaction with garbage collection

가상 스레드 스택은 Java의 가비지 수집 힙에 스택 청크 객체로 저장됩니다.
스택은 애플리케이션 실행 시 메모리 효율성과 JVM에 설정된 플랫폼 스레드 스택 크기까지 깊이 있는 스택을 수용하기 위해 증가하거나 감소합니다.
이러한 효율성 덕분에 서버 애플리케이션에서 다수의 가상 스레드를 사용할 수 있으며, 결과적으로 요청당 스레드 방식의 지속적인 활용이 가능해집니다.

위의 두 번째 예 에서 , 가상 프레임워크가 각 요청을 처리할 때 새로운 가상 스레드를 생성하고 해당 메서드를 호출한다는 점을 기억하세요.
깊은 호출 스택의 끝(인증, 트랜잭션 등)에서 handle호출하더라도 , 짧은 작업만 수행하는 여러 가상 스레드가 생성됩니다.
따라서 깊은 호출 스택을 가진 각 가상 스레드에는 메모리를 거의 소모하지 않는 얕은 호출 스택을 가진 여러 가상 스레드가 생성됩니다.handlehandle

가상 스레드가 필요로 하는 힙 공간과 가비지 컬렉터 활동량은 일반적으로 비동기 코드와 비교하기 어렵습니다.
백만 개의 가상 스레드는 최소 백만 개의 객체를 필요로 하지만, 플랫폼 스레드 풀을 공유하는 백만 개의 작업도 마찬가지입니다.
또한, 요청을 처리하는 애플리케이션 코드는 일반적으로 I/O 작업 전반에 걸쳐 데이터를 유지합니다.
요청당 스레드 코드는 해당 데이터를 로컬 변수에 보관하고, 이 로컬 변수는 힙의 가상 스레드 스택에 저장됩니다.
반면, 비동기 코드는 파이프라인의 한 단계에서 다음 단계로 전달되는 힙 객체에 동일한 데이터를 보관해야 합니다.
한편, 가상 스레드에 필요한 스택 프레임 레이아웃은 컴팩트 객체보다 낭비적입니다.
반면, 가상 스레드는 저수준 GC 상호 작용에 따라 여러 상황에서 스택을 변경하고 재사용할 수 있는 반면,
비동기 파이프라인은 항상 새 객체를 할당해야 하므로 가상 스레드가 더 적은 할당을 필요로 할 수 있습니다.
전반적으로 요청당 스레드와 비동기 코드의 힙 소비량과 가비지 컬렉터 활동은 거의 유사합니다.
시간이 지남에 따라 가상 스레드 스택의 내부 표현이 훨씬 더 간결해질 것으로 기대합니다.

플랫폼 스레드 스택과 달리 가상 스레드 스택은 GC 루트가 아닙니다.
따라서 가상 스레드 스택에 포함된 참조는 G1과 같은 가비지 컬렉터가 동시 힙 스캐닝을 수행하는 stop-the-world 일시 정지 상태에서 탐색되지 않습니다.

가상 스레드의 현재 한계는 G1 GC가 거대한 스택 청크 객체를 지원하지 않는다는 것입니다.
가상 스레드의 스택이 영역 크기의 절반(512KB 정도)에 도달하면 a가 StackOverflowErrorthrow될 수 있습니다.

Detailed changes

나머지 하위 섹션에서는 Java 플랫폼과 그 구현 전반에 걸쳐 제안하는 변경 사항을 자세히 설명합니다.

  • java.lang.Thread
  • Thread-local variables
  • java.util.concurrent
  • Networking
  • java.io
  • Java Native Interface (JNI)
  • Debugging (JVM TI, JDWP, and JDI)
  • JDK Flight Recorder (JFR)
  • Java Management Extensions (JMX)

java.lang.Thread

API를 다음과 같이 업데이트합니다 java.lang.Thread.

  • Thread.Builder, Thread.ofVirtual(), 및 는 Thread.ofPlatform()가상 및 플랫폼 스레드를 생성하는 새로운 API입니다. 예를 들어,
    1
    Thread thread = Thread.ofVirtual().name("duke").unstarted(runnable);
    “duke” 라는 이름의 아직 시작되지 않은 새로운 가상 스레드를 만듭니다.
  • Thread.startVirtualThread(Runnable)가상 스레드를 만들고 시작하는 편리한 방법입니다.
  • A는 Thread.Builder스레드나 를 생성할 수 있으며 ThreadFactory, 이를 통해 동일한 속성을 가진 여러 스레드를 생성할 수 있습니다.
  • Thread.isVirtual()스레드가 가상 스레드인지 테스트합니다.
  • Thread.getAllStackTraces()이제 모든 스레드가 아닌 모든 플랫폼 스레드의 맵을 반환합니다.

이 JEP에서는 API java.lang.Thread가 변경되지 않습니다. Thread클래스에 정의된 생성자는 이전과 마찬가지로 플랫폼 스레드를 생성합니다. 새로운 공개 생성자는 없습니다.

가상 스레드와 플랫폼 스레드 간의 주요 API 차이점은 다음과 같습니다.

  • 공개 Thread생성자는 가상 스레드를 생성할 수 없습니다.
  • 가상 스레드는 항상 데몬 스레드입니다. 이 Thread.setDaemon(boolean)메서드는 가상 스레드를 데몬이 아닌 스레드로 변경할 수 없습니다.
  • 가상 스레드는 고정된 우선순위를 갖습니다 Thread.NORM_PRIORITY. 이 Thread.setPriority(int)메서드는 가상 스레드에는 영향을 미치지 않습니다.
    이 제한은 향후 릴리스에서 다시 검토될 수 있습니다.
  • 가상 스레드는 스레드 그룹의 활성 멤버가 아닙니다. 가상 스레드에서 호출하면 Thread.getThreadGroup()이름이 인 플레이스홀더 스레드 그룹을 반환합니다
    “VirtualThreads”. Thread.BuilderAPI는 가상 스레드의 스레드 그룹을 설정하는 메서드를 정의하지 않습니다.
  • 집합 으로 실행할 경우 가상 스레드에는 권한이 없습니다 SecurityManager.

Thread-local variables

가상 스레드는 플랫폼 스레드처럼 스레드 로컬 변수( ThreadLocal)와 상속 가능한 스레드 로컬 변수( InheritableThreadLocal)를 지원하므로
스레드 로컬을 사용하는 기존 코드를 실행할 수 있습니다. 그러나 가상 스레드는 매우 많을 수 있으므로
스레드 로컬을 사용할 때는 신중하게 고려해야 합니다. 특히, 스레드 풀에서 동일한 스레드를 공유하는 여러 작업 간에 비용이 많이 드는
리소스를 풀링하는 데 스레드 로컬을 사용하지 마십시오. 가상 스레드는 수명 동안 단일 작업만 실행하도록 설계되었으므로 풀링해서는 안 됩니다.
수백만 개의 스레드로 실행할 때 메모리 사용량을 줄이기 위해 가상 스레드를 준비하면서 JDK java.base모듈에서 스레드 로컬의 많은 사용을 제거했습니다.

시스템 속성을 jdk.traceVirtualThreadLocals사용하면 가상 스레드가 스레드 로컬 변수의 값을 설정할 때 스택 추적을 트리거할 수 있습니다.
이 진단 출력은 가상 스레드를 사용하도록 코드를 마이그레이션할 때 스레드 로컬 변수를 제거하는 데 도움이 될 수 있습니다.
true스택 추적을 트리거하려면 시스템 속성을 로 설정하십시오. 기본값은 입니다 false.

일부 사용 사례에서는 범위가 지정된 값( JEP 429 )이 스레드 로컬보다 더 나은 대안이 될 수 있습니다.

java.util.concurrent

잠금을 지원하는 기본 API인 는 java.util.concurrent.LockSupport이제 가상 스레드를 지원합니다.
가상 스레드를 파킹하면 기본 플랫폼 스레드가 다른 작업을 수행할 수 있도록 해제되고, 가상 스레드를 파킹 해제하면 해당 스레드가 계속 진행되도록 예약됩니다.
이 변경을 통해 가상 스레드에서 호출될 때 LockSupport해당 스레드를 사용하는 모든 API( Locks, Semaphores, 블로킹 큐 등)가 정상적으로 파킹될 수 있습니다.

Executors.newThreadPerTaskExecutor(ThreadFactory)또한 ExecutorService, 각 작업에 대해 새 스레드를 생성하는 Executors.newVirtualThreadPerTaskExecutor()를 생성합니다
이러한 메서드를 사용하면 스레드 풀과 ExecutorService를 사용하는 기존 코드와의 마이그레이션 및 상호 운용성이 가능해집니다.

Networking

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

java.net.Socket중단 및 취소를 허용하기 위해, , ServerSocket, 에 정의된 차단 I/O 메서드는 DatagramSocket이제 가상 스레드에서 호출될 때 중단 가능 하도록 지정되었습니다.
소켓에서 차단된 가상 스레드를 중단하면 스레드가 언파크되고 소켓이 닫힙니다. InterruptibleChannel 에서 가져온 이러한 유형의 소켓에서 차단 I/O 작업은 항상 중단 가능했으므로,
이번 변경을 통해 생성자를 사용하여 생성된 이러한 API의 동작과 채널에서 가져온 API의 동작이 일치하게 됩니다.

java.io

이 java.io패키지는 바이트 및 문자 스트림을 위한 API를 제공합니다. 이러한 API의 구현은 매우 동기화되어 있으며,
가상 스레드에서 사용될 때 고정을 방지하기 위해 변경이 필요합니다.

배경 지식으로, 바이트 지향 입출력 스트림은 스레드로부터 안전하도록 지정되어 있지 않으며, close()읽기 또는 쓰기 메서드에서 스레드가 차단된 상태에서 호출될 때
예상되는 동작을 지정하지 않습니다. 대부분의 경우, 여러 동시 스레드에서 특정 입출력 스트림을 사용하는 것은 합리적이지 않습니다.
문자 지향 판독기/기록기 또한 스레드로부터 안전하도록 지정되어 있지 않지만, 하위 클래스에 잠금 객체를 제공합니다.
고정 외에도 이러한 클래스의 동기화는 문제가 있고 일관성이 없습니다. 예를 들어, 잠금 에서 사용되는 스트림 디코더와 인코더는 InputStreamReader OutputStreamWriter객체가
아닌 스트림 객체에서 동기화됩니다.

고정을 방지하기 위해 이제 구현은 다음과 같이 작동합니다.

  • BufferedInputStream, BufferedOutputStream, BufferedReader, BufferedWriter, PrintStream, 그리고 PrintWriter이제 직접 사용할 때 모니터 대신 명시적 잠금을 사용합니다.
    이러한 클래스는 하위 클래스화될 때 이전과 마찬가지로 동기화됩니다.
  • InputStreamReader OutputStreamWriter 에서 사용되는 스트림 디코더와 인코더는 현재 포함하는 InputStreamReader것과 OutputStreamWriter동일한 잠금을 사용합니다

더 나아가 이러한 불필요한 잠금을 모두 제거하는 것은 이 JEP의 범위를 벗어납니다.

BufferedOutputStream또한 , BufferedWriter, , 및 스트림 인코더 에서 사용하는 버퍼의 초기 크기가 OutputStreamWriter
이제 더 작아져서 힙에 많은 스트림이나 작성자가 있는 경우 메모리 사용량이 줄었습니다.
이는 소켓 연결에 버퍼링된 스트림이 있는 백만 개의 가상 스레드가 있는 경우에 발생할 수 있는 현상입니다.

Java Native Interface (JNI)

IsVirtualThreadJNI는 객체가 가상 스레드인지 테스트하기 위해 새로운 함수 하나를 정의합니다 .
그 외에는 JNI 사양이 변경되지 않았습니다.

Debugging

디버깅 아키텍처는 JVM TI(JVM Tool Interface), JDWP(Java Debug Wire Protocol), JDI(Java Debug Interface)의 세 가지 인터페이스로 구성됩니다.
이제 세 가지 인터페이스 모두 가상 스레드를 지원합니다.

JVM TI 에 대한 업데이트는 다음과 같습니다.

  • jthread(즉, 객체에 대한 JNI 참조 ) 를 사용하여 호출되는 대부분의 함수는 Thread가상 스레드에 대한 참조를 사용하여 호출할 수 있습니다.
    AgentStartFunction, , PopFrame, ForceEarlyReturn*, StopThread, 와 같은 소수의 함수는 GetThreadCpuTime가상 스레드에서 지원되지 않거나 선택적으로 지원됩니다.
    이 SetLocal*함수들은 중단점 또는 단일 단계 이벤트에서 일시 중단된 가상 스레드의 최상위 프레임에서 로컬 변수를 설정하는 것으로 제한됩니다.
  • 이제 모든 스레드가 아닌 모든 플랫폼 스레드를 반환하도록 함수가 지정 되었습니다 GetAllThreads.GetAllStackTraces
  • 초기 VM 시작이나 힙 반복 중에 게시된 이벤트를 제외한 모든 이벤트는 가상 스레드 컨텍스트에서 이벤트 콜백을 호출할 수 있습니다.
  • 일시 중단/재개 구현을 통해 디버거가 가상 스레드를 일시 중단하고 재개할 수 있으며, 가상 스레드가 마운트되면 플랫폼 스레드도 일시 중단될 수 있습니다.
  • 새로운 기능을 can_support_virtual_threads사용하면 에이전트가 가상 스레드의 스레드 시작 및 종료 이벤트를 더욱 세부적으로 제어할 수 있습니다.
  • 새로운 기능은 가상 스레드의 대량 중단 및 재개를 지원합니다. 이를 위해서는 해당 can_support_virtual_threads기능이 필요합니다.

기존 JVM TI 에이전트는 대부분 이전과 동일하게 작동하지만, 가상 스레드에서 지원되지 않는 함수를 호출할 경우 오류가 발생할 수 있습니다.
이는 가상 스레드를 인식하지 못하는 에이전트가 가상 스레드를 사용하는 애플리케이션과 함께 사용될 때 발생합니다.
플랫폼 스레드만 포함하는 배열을 반환하도록 변경된 사항은 일부 에이전트에 문제가 될 수 있습니다.
ThreadStartThreadEnd 및 이벤트를 GetAllThreads활성화하는 기존 에이전트는 이러한 이벤트를 플랫폼 스레드로 제한하는 기능이 없기 때문에 성능 문제가 발생할 수 있습니다.

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가상 스레드가 고정된 상태에서 대기 중임을 나타냅니다. 즉, 플랫폼 스레드를 해제하지 않은 상태입니다( 위 참조 ).
    이 이벤트는 기본적으로 활성화되며 임계값은 20ms입니다.
  • jdk.VirtualThreadSubmitFailed가상 스레드 시작 또는 언파킹이 실패했음을 나타내며, 이는 리소스 문제로 인한 것일 수 있습니다.
    이 이벤트는 기본적으로 활성화되어 있습니다.

Java Management Extensions (JMX)

java.lang.management.ThreadMXBean플랫폼 스레드의 모니터링 및 관리만 지원합니다. 이 findDeadlockedThreads()메서드는 교착 상태에 있는 플랫폼 스레드의 사이클을 찾습니다.
교착 상태에 있는 가상 스레드의 사이클은 찾지 않습니다.

새로운 메서드는 위에서com.sun.management.HotSpotDiagnosticsMXBean 설명한 새로운 스타일의 스레드 덤프를 생성합니다.
이 메서드는 로컬 또는 원격 JMX 도구에서 플랫폼을 통해 간접적으로 호출할 수도 있습니다 .MBeanServer

Alternatives

  • 비동기 API를 계속 사용해야 합니다. 비동기 API는 동기 API와 통합하기 어렵고, 동일한 I/O 작업을 두 가지 방식으로 표현하는 분리된 환경을 조성하며,
    플랫폼에서 문제 해결, 모니터링, 디버깅 및 프로파일링을 위한 컨텍스트로 사용할 수 있는 작업 시퀀스에 대한 통합된 개념을 제공하지 않습니다.

  • Java 언어에 구문적 스택리스 코루틴 (예: async/await )을 추가합니다 . 이는 사용자 모드 스레드보다 구현이 쉽고, 일련의 작업 맥락을 나타내는 통합 구조를 제공합니다.
    하지만 이 구조는 스레드와는 별개의 새로운 개념으로, 여러 면에서 스레드와 유사하지만 미묘한 차이점도 있습니다.
    스레드용 API와 코루틴용 API로 세상을 나누게 될 것이며, 새로운 스레드 유사 구조를 플랫폼의 모든 계층과 툴에 도입해야 할 것입니다.
    생태계가 이를 받아들이는 데는 더 오랜 시간이 걸릴 것이고, 사용자 모드 스레드만큼 플랫폼과 조화를 이루지 못할 것입니다.
    구문적 코루틴을 채택한 대부분의 언어는 사용자 모드 스레드(예: Kotlin), 레거시 의미 보장(예: 본질적으로 단일 스레드인 JavaScript),
    또는 언어별 기술적 제약(예: C++)을 구현할 수 없기 때문에 그렇게 했습니다. 이러한 제약은 Java에는 적용되지 않습니다.

  • 사용자 모드 스레드를 표현하는 새로운 공개 클래스를 도입합니다. 이 클래스는 .NET과는 무관합니다 java.lang.Thread.
    이는 25년 동안 이 클래스에 축적된 불필요한 짐을 버릴 수 있는 기회가 될 것입니다 Thread.
    이 접근 방식의 여러 변형을 탐구하고 프로토타입을 개발했지만, 모든 경우 기존 코드를 어떻게 실행할지에 대한 문제를 해결해야 했습니다.
    가장 큰 문제는 Thread.currentThread()기존 코드에서 직간접적으로 광범위하게 사용된다는 것입니다(예: 잠금 소유권 확인 또는 스레드 로컬 변수).
    이 메서드는 현재 실행 중인 스레드를 나타내는 객체를 반환해야 합니다. 사용자 모드 스레드를 나타내는 새로운 클래스를 도입한다면,
    사용자 모드 스레드 객체에 위임하는 래퍼 currentThread()객체처럼 보이는 일종의 래퍼 객체를 반환해야 할 것입니다 Thread.
    현재 실행 스레드를 두 개의 객체로 표현하는 것은 혼란스러울 수 있으므로, 결국 기존 ThreadAPI를 유지하는 것이 큰 문제가 되지 않는다는 결론을 내렸습니다.
    와 같은 몇 가지 메서드를 제외하고는 currentThread()개발자가 ThreadAPI를 직접 사용하는 경우는 거의 없으며, 대부분 와 같은 상위 수준 API를 사용하여 상호 작용합니다.
    시간이 지남에 따라 와 같은 클래스 및 관련 클래스 ExecutorService에서 불필요한 부분을 제거하고,
    더 이상 사용되지 않는 메서드를 지원 중단하고 제거할 것입니다.ThreadThreadGroup

Testing

  • 기존 테스트를 통해 여기서 제안하는 변경 사항이 다양한 구성 및 실행 모드에서 예기치 않은 회귀를 일으키지 않는지 확인할 수 있습니다.
  • 테스트 하네스를 확장하여 jtreg기존 테스트를 가상 스레드 환경에서 실행할 수 있도록 하겠습니다. 이렇게 하면 여러 테스트를 두 버전으로 실행할 필요가 없어집니다.
  • 새로운 테스트에서는 모든 신규 및 개정된 API와 가상 스레드를 지원하도록 변경된 모든 영역을 테스트합니다.
  • 새로운 스트레스 테스트는 신뢰성과 성능에 중요한 영역을 대상으로 합니다.
  • 새로운 마이크로벤치마크는 성능이 중요한 영역을 타겟으로 합니다.
  • 대규모 테스트를 위해 Helidon 과 Jetty 를 포함한 여러 기존 서버를 사용할 것입니다.

Risks and Assumptions

이 제안의 주요 위험은 기존 API와 구현의 변경으로 인한 호환성 문제입니다.

  • java.io.BufferedInputStream, BufferedOutputStream, BufferedReader, BufferedWriter, PrintStream, 및 클래스 에서 사용되는 내부(문서화되지 않은)
    잠금 프로토콜의 개정은 PrintWriterI/O 메서드가 호출되는 스트림에서 동기화된다고 가정하는 코드에 영향을 미칠 수 있습니다.
    이러한 변경 사항은 이러한 클래스를 확장하고 슈퍼클래스의 잠금을 가정하는 코드에는 영향을 미치지 않으며,
    해당 API에서 제공하는 잠금 객체를 확장하거나 java.io.Reader사용 하는 코드에도 영향을 미치지 않습니다 java.io.Writer

몇 가지 소스 및 바이너리 비호환 변경 사항은 다음을 확장하는 코드에 영향을 미칠 수 있습니다 java.lang.Thread.

  • Thread여러 개의 새로운 메서드를 정의합니다. 기존 소스 파일의 코드가 확장되고 Thread하위 클래스의 메서드가 새로운 Thread메서드와 충돌하는 경우,
    해당 파일은 변경 없이 컴파일되지 않습니다.
  • Thread.Builder새로운 중첩 인터페이스입니다. 기존 소스 파일의 코드가 를 확장하고 Thread, 이라는 클래스를 가져오고 Builder,
    하위 클래스의 코드가 를 Builder간단한 이름으로 참조하는 경우, 해당 파일은 변경 없이 컴파일되지 않습니다.
  • Thread.isVirtual()새로운 final 메서드입니다. 기존에 컴파일된 코드가 확장 Thread되고 하위 클래스가 동일한 이름과 반환 유형을 가진 메서드를 선언하는 경우,
    IncompatibleClassChangeError하위 클래스가 로드되면 런타임에 오류가 발생합니다.

기존 코드와 가상 스레드 또는 새로운 API를 활용하는 최신 코드를 혼합할 때 플랫폼 스레드와 가상 스레드 간에 몇 가지 동작 차이가 나타날 수 있습니다.

  • 이 Thread.setPriority(int)방법은 가상 스레드에는 영향을 미치지 않으며, 가상 스레드의 우선순위는 항상 .입니다 Thread.NORM_PRIORITY.
  • 이 Thread.setDaemon(boolean)방법은 가상 스레드에는 영향을 미치지 않으며, 가상 스레드는 항상 데몬 스레드입니다.
  • Thread.getAllStackTraces()이제 모든 스레드의 맵이 아닌 모든 플랫폼 스레드의 맵을 반환합니다.
  • java.net.Socket, ServerSocket, 로 정의된 블로킹 I/O 메서드는 DatagramSocket이제 가상 스레드 컨텍스트에서 호출될 때 인터럽트 가능합니다.
    소켓 작업에서 블로킹된 스레드가 인터럽트되면 기존 코드가 중단될 수 있으며, 이로 인해 스레드가 깨어나고 소켓이 닫힙니다.
  • 가상 스레드는 의 활성 멤버가 아닙니다 ThreadGroup. Thread.getThreadGroup()가상 스레드에서 호출하면 “VirtualThreads”비어 있는 더미 그룹이 반환됩니다.
  • 보안 관리자를 설정한 상태로 실행하면 가상 스레드에 권한이 없습니다.
    Java 17 이상에서 보안 관리자를 사용하여 실행하는 방법에 대한 자세한 내용은 JEP 411(보안 관리자 제거를 위한 지원 중단)을 참조하십시오.
  • JVM TI에서 GetAllThreads및 GetAllStackTraces함수는 가상 스레드를 반환하지 않습니다.
    ThreadStart및 ThreadEnd이벤트를 활성화하는 기존 에이전트는 이벤트를 플랫폼 스레드로 제한하는 기능이 없기 때문에 성능 문제가 발생할 수 있습니다.
  • API java.lang.management.ThreadMXBean는 플랫폼 스레드의 모니터링과 관리를 지원하지만 가상 스레드는 지원하지 않습니다.
  • 이 -XX:+PreserveFramePointer플래그는 가상 스레드 성능에 엄청난 부정적 영향을 미칩니다.

Dependencies

  • JDK 18의 JEP 416(메서드 핸들을 사용한 코어 리플렉션 재구현) 에서는 VM 네이티브 리플렉션 구현을 제거했습니다.
    이를 통해 메서드가 리플렉션 방식으로 호출될 때 가상 스레드가 정상적으로 대기할 수 있습니다.
  • JDK 13의 JEP 353(레거시 소켓 API 재구현) 과 JDK 15의 JEP 373(레거시 DatagramSocket API 재구현) 은
    가상 스레드와 함께 사용하도록 설계된 새로운 구현으로 java.net.Socket, ServerSocket, 및 구현을 대체했습니다.DatagramSocket
  • JDK 18의 JEP 418(인터넷 주소 확인 SPI) 은 호스트 이름 및 주소 조회를 위한 서비스 제공자 인터페이스를 정의했습니다.
    이를 통해 타사 라이브러리는 java.net.InetAddress호스트 조회 중 스레드를 고정하지 않는 대체 확인자를 구현할 수 있습니다.

참조