JDK21의 새로운 기능 가상 스레드

1. 초록

가상 스레드는 처리량이 높은 동시 애플리케이션을 작성, 유지 관리 및 관찰하는 노력을 크게 줄여주는 경량 스레드입니다. 그리고 가상 스레드의 프로그램은 IO를 기다리는 동안 플랫폼 스레드를 포기하므로 ​​CPU가 아닌 오버로드된 멀티 스레드 프로그램의 처리량이 기하급수적으로 향상됩니다. 이것은 정말 멋진 기능입니다.

2. 연혁

가상 스레드는 JEP 425에서 미리 보기 기능으로 제안되었으며 JDK 19에서 릴리스되었습니다. 피드백을 받고 더 많은 경험을 축적할 시간을 허용하기 위해 JEP 436은 다시 한번 가상 스레드를 미리 보기 기능으로 제안하고 이를 JDK 20에서 릴리스합니다. 이 JEP에서는 JDK 21에서 가상 스레드를 마무리하고 개발자 피드백을 기반으로 JDK 20을 다음과 같이 변경할 것을 권장합니다.

  • 이제 가상 스레드는 항상 스레드 로컬 변수를 지원합니다. 미리보기의 경우처럼 스레드 로컬 변수를 사용하여 가상 스레드를 더 이상 생성할 수 없습니다. 스레드 로컬 변수에 대한 지원이 보장되므로 가상 스레드를 사용할 때 더 많은 기존 라이브러리가 변경되지 않고 유지되며 작업 지향 코드를 가상 스레드로 포팅하는 데 도움이 됩니다.
  • (Executors.newVirtualThreadPerTaskExecutor()를 통해 생성되지 않고) Thread.Builder API를 사용하여 직접 생성된 가상 스레드도 이제 수명 주기 전반에 걸쳐 기본적으로 모니터링되며 가상 스레드 관찰을 통해 액세스할 수 있습니다." 섹션을 통해 새 스레드 덤프를 관찰할 수 있습니다.

3. 목표

  • 요청당 간단한 스레드 방식으로 작성된 서버 애플리케이션을 거의 최적의 하드웨어 활용률로 확장할 수 있습니다.
  • 최소한의 변경으로 가상 스레드를 채택하기 위해 java.lang.Thread API를 사용하는 기존 코드를 활성화합니다.
  • 기존 JDK 도구를 사용하여 가상 스레드 문제를 쉽게 해결하고 디버깅하고 프로파일링할 수 있습니다.

4. 가상 스레드가 아닌 것

  • 기존 스레드 구현을 제거하는 대신 기존 애플리케이션은 기본적으로 가상 스레드로 마이그레이션되지 않습니다.
  • Java의 기본 동시성 모델을 변경하지 않습니다.
  • Java 언어 또는 Java 라이브러리에 새로운 데이터 병렬 구성을 제공하지 않습니다. 스트리밍 API는 대규모 데이터 세트를 병렬로 처리하는 데 여전히 선호되는 방법입니다.
    참고: 병렬성은 CPU의 멀티 코어 멀티 스레드 아키텍처를 사용하며 프로그램은 동시에 동시에 실행됩니다. 동시성은 거시적 관점에서 동시에 실행되며 병렬 또는 대체 실행일 수 있습니다. 동시성의 초점은 CPU의 각 코어의 자원을 활용하는 것입니다.IO 대기가 발생할 때 프로그램이 교대로 실행되는 경우.

5. 동기

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

6. 요청별 스레드 방식

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

서버 애플리케이션의 확장성은 대기 시간, 동시성 및 처리량을 연결하는 리틀의 법칙에 따라 결정됩니다. 지정된 요청 처리 기간(즉, 대기 시간) 동안 애플리케이션이 동시에 처리할 수 있는 요청 수 요청 수(즉, 동시성) 도착률(즉, 처리량)에 비례하여 증가해야 합니다. 예를 들어 평균 대기 시간이 50밀리초인 애플리케이션이 10개의 요청을 동시에 처리하여 초당 200개의 요청 처리량을 달성한다고 가정합니다. 애플리케이션 처리량을 초당 2000개 요청으로 확장하려면 100개 요청을 동시에 처리해야 합니다. 요청 기간 동안 각 요청이 하나의 스레드에 의해 처리되는 경우 애플리케이션이 이를 따라잡기 위해서는 처리량이 증가함에 따라 스레드 수도 증가해야 합니다.

불행하게도 JDK는 스레드를 운영 체제(OS) 스레드 주위의 래퍼로 구현하기 때문에 사용 가능한 스레드 수가 제한됩니다. 운영 체제 스레드의 비용은 매우 높기 때문에 너무 많은 스레드를 가질 수 없으므로 스레드 구현이 요청별 스레딩 스타일에 적합하지 않게 됩니다. 각 요청이 해당 기간 동안 스레드, 즉 운영 체제 스레드를 사용하는 경우 CPU 또는 네트워크 연결과 같은 다른 리소스가 소진되기 전에 스레드 수가 제한 요소가 되는 경우가 많습니다. JDK의 현재 스레딩 구현은 애플리케이션 처리량을 하드웨어가 지원하는 수준보다 훨씬 낮은 수준으로 제한합니다. 풀링은 새 스레드를 시작하는 데 드는 높은 비용을 방지하는 데 도움이 되지만 총 스레드 수를 늘리지는 않기 때문에 스레드가 풀링된 경우에도 발생합니다.

7. 확장성을 향상시키기 위해 비동기 모드를 사용하세요

하드웨어를 최대한 활용하려는 일부 개발자는 스레드 공유를 선호하여 요청별 스레드 접근 방식을 포기합니다. 요청 처리 코드는 하나의 스레드에서 요청을 끝까지 처리하는 대신 스레드가 다른 요청을 처리할 수 있도록 다른 I/O 작업이 완료되기를 기다리는 동안 해당 스레드를 스레드 풀로 반환합니다. 이러한 세분화된 스레드 공유(코드는 I/O를 기다리는 동안이 아니라 계산을 수행하는 동안에만 스레드를 예약함)를 통해 많은 수의 스레드를 소비하지 않고도 많은 수의 동시 작업을 수행할 수 있습니다. 운영 체제 스레드 부족으로 인한 처리량 제한을 제거하지만 비용이 많이 듭니다. I/O 작업이 완료될 때까지 기다리지 않는 독립적인 I/O 메서드 집합을 사용하는 소위 비동기 프로그래밍 스타일이 필요합니다. 대신 완료 신호가 나중에 콜백으로 전송됩니다. 전용 스레드가 없는 경우 개발자는 요청 처리 논리를 작은 단계(종종 람다 식으로 작성됨)로 나눈 다음 이를 API(예: CompletableFuture 또는 소위 "반응형" 프레임워크 참조)를 통해 전달해야 합니다 . 순차 파이프라인. 따라서 그들은 루프 및 try/catch 블록과 같은 언어의 기본 순차 합성 연산자를 포기합니다.

비동기식 스타일에서는 요청의 각 단계가 서로 다른 스레드에서 실행될 수 있으며, 각 스레드는 인터리브 방식으로 서로 다른 요청에 속하는 단계를 실행합니다. 이는 프로그램 동작을 이해하는 데 심오한 영향을 미칩니다. 스택 추적은 사용 가능한 컨텍스트를 제공할 수 없고, 디버거는 요청 처리 논리를 단계별로 진행할 수 없으며, 프로파일러는 작업 비용을 호출자와 연결할 수 없습니다. Java의 스트리밍 API를 사용하여 짧은 파이프라인에서 데이터를 처리할 때 람다 식을 구성하는 것이 가능하지만 애플리케이션의 모든 요청 처리 코드를 이런 방식으로 작성해야 하면 문제가 발생 합니다 . 이 프로그래밍 스타일은 애플리케이션의 동시성 단위(비동기 파이프라인)가 더 이상 플랫폼의 동시성 단위가 아니기 때문에 Java 플랫폼과 호환되지 않습니다.

8. 가상 스레드를 사용하여 요청별 스레드 코딩 스타일 유지

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

가상 스레드는 특정 운영 체제 스레드와 독립적인 java.lang.Thread의 구현입니다. 대조적으로, 플랫폼 스레드는 전통적인 방식으로 구현된 java.lang.Thread 인스턴스 객체이며 운영 체제 스레드를 둘러싼 얇은 래퍼입니다.

요청별 스레드 모드의 애플리케이션 코드는 전체 요청 기간 동안 가상 스레드에서 실행될 수 있지만 가상 스레드는 CPU에서 계산을 수행하는 동안 운영 체제 스레드만 소비합니다. 결과는 비동기식과 동일한 확장성이지만 투명한 방식입니다. 가상 스레드에서 실행되는 코드가 java.* API에서 차단 I/O 작업을 호출하면 런타임은 비차단 운영 체제를 호출하고 자동으로 일시 중단합니다. 나중에 다시 시작할 수 있을 때까지 가상 스레드입니다. Java 개발자에게 가상 스레드는 생성 비용이 저렴하고 거의 무제한인 스레드일 뿐입니다. 하드웨어 활용도는 최적에 가깝고 높은 ​​동시성을 허용하므로 높은 처리량을 제공하며 가상 스레드로 구현된 애플리케이션은 Java 플랫폼 및 관련 도구의 다중 스레드 설계와 일치합니다. 이는 개발자가 가상 ​​스레드 디버깅 비용을 학습하고 사용하고 있음을 의미합니다. 매우 낮으므로 배우고 시작하기 쉽습니다.

9. 가상 스레드의 의미

가상 스레드는 오버헤드가 낮으므로 많은 수를 지원하므로 풀링되어서는 안 됩니다. 각 애플리케이션 작업에 대해 새 가상 스레드를 생성해야 합니다. 따라서 대부분의 가상 스레드는 수명이 짧고 얕은 호출 스택을 가지며 단 하나의 HTTP 클라이언트 호출 또는 하나의 JDBC 쿼리만 수행합니다. 이에 비해 플랫폼 스레드는 비용이 많이 들고 부피가 크기 때문에 일반적으로 풀링되어야 합니다. 수명이 길고 호출 스택이 깊으며 여러 작업 간에 공유될 수 있는 경향이 있습니다.

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

10. 설명

현재 JDK의 모든 java.lang.Thread 인스턴스는 플랫폼 스레드입니다. 플랫폼 스레드는 기본 운영 체제 스레드에서 Java 코드를 실행하고 코드 수명 전반에 걸쳐 운영 체제 스레드를 캡처합니다. 플랫폼 스레드 수는 운영 체제 스레드 수에 따라 제한됩니다.

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

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

11. 가상 스레드 및 플랫폼 스레드 사용

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

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는 소수의 운영 체제 스레드에서만 코드를 실행합니다. 아마도 단 하나일 수도 있습니다.

프로그램이 ExecutorService(예: Executors.newCachedThreadPool())를 사용하여 각 작업에 대한 새 플랫폼 스레드를 생성하는 경우 상황은 매우 달라집니다. ExecutorService는 10,000개의 플랫폼 스레드를 생성하려고 시도하며 이는 10,000개의 운영 체제 스레드를 생성하며 시스템 및 운영 체제에 따라 프로그램이 충돌할 수 있습니다.

프로그램이 Executors.newFixedThreadPool(200)과 같이 스레드 풀에서 플랫폼 스레드를 얻는 ExecutorService를 사용하는 경우 상황은 그다지 나아지지 않습니다. ExecutorService는 공유할 10,000개의 작업 전체에 대해 200개의 플랫폼 스레드를 생성합니다. 실행되지 않은 작업은 java.util.concurrent.FutureTask 인스턴스로 대기열에 캐시됩니다. 대기열의 최대 크기는 Integer.MAX_VALUE입니다. s가 생성되더라도 플랫폼 스레드는 줄어들지만 200개의 플랫폼 스레드 중 하나가 작업을 요청하는 한 이 스레드의 코드에 얼마나 많은 IO 대기 작업이 있는지에 관계없이 이 스레드는 플랫폼 스레드 리소스를 해제하기 전에 작업을 완료합니다. 작업은 대기열에 추가되어 200개의 병렬 처리 방법에 따라 순차적으로 실행되며 프로그램을 완료하는 데 오랜 시간이 걸립니다.
위 시나리오에서도 가상 스레드 기술을 사용하면 가상 스레드가 IO를 기다리는 동안 플랫폼 스레드 리소스를 해제하므로 가상 스레드 작업이 완료될 때까지 가상 스레드가 플랫폼 스레드를 독점하지 않는다는 의미이므로 각 가상 스레드는 IO가 대기 중일 때 플랫폼 스레드는 포기하고 각각 대기하게 되는데, 플랫폼 스레드는 CPU 연산이 필요한 가상 스레드를 실행할 수 있어 처리량이 기하급수적으로 증가한다. 200개의 플랫폼 스레드 풀은 초당 200개의 작업만 완료할 수 있는 반면, 가상 스레드는 초당 약 10,000개의 작업을 완료할 수 있습니다(충분한 워밍업 후). 또한 예제 프로그램의 10_000이 1_000_000으로 변경되면 프로그램은 1,000,000개의 작업을 제출하고 동시에 실행할 1,000,000개의 가상 스레드를 생성하며 (충분한 워밍업 후) 처리량은 초당 약 1,000,000개의 작업에 도달합니다.

이 프로그램의 작업이 1초 안에 계산을 수행하고(예: 거대한 배열 정렬) 잠자기 상태가 아닌 경우 프로세서 코어 수를 초과하도록 스레드 수를 늘리는 것은 가상 스레드인지 여부에 관계없이 도움이 되지 않습니다. 또는 플랫폼 스레드. 가상 스레드는 더 빠른 스레드가 아니며 플랫폼 스레드보다 더 빠르게 코드를 실행하지 않습니다. 속도(낮은 대기 시간)가 아닌 규모(더 높은 처리량)를 제공하기 위해 존재합니다. 플랫폼 스레드보다 가상 스레드가 더 많을 수 있으므로 리틀의 법칙에 따라 가상 스레드는 더 높은 처리량에 필요한 더 높은 동시성을 제공할 수 있습니다.

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

  • 동시 작업 수가 많고(수천 개 이상)
  • 이 경우 프로세서 코어보다 스레드가 많아도 처리량이 향상되지 않으므로 워크로드는 CPU에 제한되지 않습니다.

가상 스레드는 일반적인 서버 애플리케이션의 처리량을 향상시키는 데 도움이 됩니다. 이러한 애플리케이션은 다양한 IO 대기를 수행하는 데 대부분의 시간을 소비하는 다수의 동시 작업으로 구성되기 때문입니다.

가상 스레드는 플랫폼 스레드가 실행할 수 있는 모든 코드를 실행할 수 있습니다. 특히 가상 스레드는 플랫폼 스레드와 마찬가지로 스레드 지역 변수와 스레드 인터럽트를 지원합니다. 이는 요청을 처리하는 기존 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);
    }
}

코드가 직접 차단되는 이와 같은 서버 애플리케이션은 사용할 수 있는 가상 스레드 수가 많기 때문에 확장성이 뛰어납니다.

Executor.newVirtualThreadPerTaskExecutor()가 가상 스레드를 생성하는 유일한 방법은 아닙니다. 나중에 설명할 새로운 java.lang.Thread.Builder API는 가상 스레드를 생성하고 시작할 수 있습니다. 또한 구조화된 동시성은 특히 스레드 간의 관계가 플랫폼과 해당 도구에 이미 알려진 이 서버 예제와 같은 코드에서 가상 스레드를 생성하고 관리하기 위한 보다 강력한 API를 제공합니다.

12. 가상 스레드를 풀링하지 마세요

개발자는 애플리케이션 코드를 기존 스레드 풀 기반 ExecutorService에서 작업별로 구분된 가상 스레드 ExecutorService로 마이그레이션하는 경우가 많습니다. 스레드 풀은 다른 리소스 풀과 마찬가지로 값비싼 리소스를 공유하도록 설계되었지만 가상 스레드는 비싸지 않으므로 풀링할 필요가 없습니다.

개발자는 때때로 스레드 풀을 사용하여 제한된 리소스에 대한 동시 액세스를 제한합니다. 예를 들어 서비스가 20개가 넘는 동시 요청을 처리할 수 없는 경우 크기 20의 스레드 풀에 제출된 작업을 통해 서비스에 대한 모든 요청을 처리하면 이를 보장할 수 있습니다. 이 관용어는 스레드 풀을 유비쿼터스하게 만드는 플랫폼 스레드의 높은 비용으로 인해 유비쿼터스화되었지만 동시성을 제한하기 위해 가상 스레드를 풀링하지는 않습니다. 대신 세마포어와 같이 이 목적을 위해 특별히 설계된 구성을 사용하십시오.

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

13. 가상 스레드 예약

유용한 작업을 수행하려면 스레드를 예약해야 합니다. 즉, 실행을 위해 프로세서 코어에 할당해야 합니다. 운영 체제 스레드로 구현된 플랫폼 스레드의 경우 JDK는 운영 체제의 스케줄러에 의존합니다. 반면, 가상 스레드의 경우 JDK에는 자체 스케줄러가 있습니다. JDK 스케줄러는 가상 스레드를 프로세서에 직접 할당하지 않고 플랫폼 스레드에 가상 스레드를 할당합니다(이것이 앞서 언급한 가상 스레드의 M:N 스케줄링입니다). 그런 다음 운영 체제는 평소와 같이 플랫폼 스레드를 예약합니다.

JDK의 가상 스레드 스케줄러는 작업 훔치기(특히 멀티 코어 프로세서 및 병렬 컴퓨팅에서 스레드 스케줄링 전략입니다. 이 전략을 사용하면 스레드가 다른 작업을 수행하는 프로세서에서 훔칠 수 있습니다(또는 "작업 훔치기").) 일부 작업 처리량과 응답성을 최대화하기 위해 여러 프로세서에 걸쳐 작업 부하의 균형을 유지합니다. 이 메커니즘은 더 나은 성능과 리소스 활용도를 달성하는 데 도움이 될 수 있습니다.) ForkJoinPool, 선입선출 (FIFO) 모드 작동 . 스케줄러 병렬성이란 가상 스레드를 예약하는 데 사용할 수 있는 플랫폼 스레드 수를 나타냅니다. 기본적으로 이는 사용 가능한 프로세서에 사용 가능한 스레드 수와 동일하지만 시스템 속성 jdk.virtualThreadScheduler.parallelism을 통해 조정할 수 있습니다. ForkJoinPool은 병렬 스트림 등을 구현하는 데 사용되며 LIFO(후입선출) 모드에서 실행되는 일반 풀과 다릅니다.

가상 스레드에 대해 스케줄러에 의해 할당된 플랫폼 스레드를 가상 스레드의 캐리어라고 합니다. 가상 스레드는 수명 동안 다른 캐리어에 예약될 수 있습니다. 즉, 스케줄러는 가상 스레드와 특정 플랫폼 스레드 간의 선호도를 유지하지 않습니다. Java 코드의 관점에서 보면 실행 중인 가상 스레드는 논리적으로 현재 캐리어와 독립적입니다.

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

게다가 가상 스레드와 해당 캐리어가 일시적으로 운영 체제 스레드를 공유한다는 사실은 Java 코드의 관점에서는 보이지 않습니다. 대신, 네이티브 코드의 관점에서 가상 스레드와 해당 캐리어는 모두 동일한 네이티브 스레드에서 실행됩니다. 따라서 동일한 가상 스레드에서 여러 번 호출되는 네이티브 코드는 각 호출에서 서로 다른 운영 체제 스레드 식별자를 관찰할 수 있습니다.

스케줄러는 현재 가상 스레드에 대한 시간 공유를 구현하지 않습니다. 시간 공유는 일정량의 CPU 시간을 소비한 스레드를 강제로 선점하는 것을 의미합니다. 플랫폼 스레드 수가 상대적으로 적고 CPU 사용률이 100%인 경우 시간 공유를 통해 특정 작업의 지연을 효과적으로 줄일 수 있지만, 수백만 개의 가상 스레드에서는 시간 공유의 효과가 명확하지 않습니다.

14. 가상 스레드 실행

가상 스레드를 활용하기 위해 프로그램을 다시 작성할 필요가 없습니다. 가상 스레드는 애플리케이션 코드가 명시적으로 스케줄러에 제어를 반환할 것을 요구하거나 기대하지 않습니다. 즉, 가상 스레드는 작동할 수 없습니다. 가비지 수집기가 가비지를 수집하는 시기를 지정할 수 없는 것처럼 사용자 코드는 가상 스레드를 제어할 수 없습니다. 어떻게 또는 언제 플랫폼 스레드가 프로세서 코어에 할당되는 방식이나 시기와 마찬가지로 스레드가 플랫폼 스레드에 할당된다고 가정할 수는 없습니다.

가상 스레드에서 코드를 실행하기 위해 JDK의 가상 스레드 스케줄러는 가상 스레드를 플랫폼 스레드에 마운트하여 실행할 플랫폼 스레드에 가상 스레드를 할당합니다. 이러한 방식으로 플랫폼 스레드는 가상 스레드의 운반자가 됩니다. 나중에 일부 코드를 실행한 후 가상 스레드를 해당 캐리어에서 언로드할 수 있습니다. 이 시점에서 플랫폼 스레드는 유휴 상태이므로 스케줄러가 다른 가상 스레드를 여기에 탑재하여 다시 캐리어로 만들 수 있습니다.

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

가상 스레드는 운영 체제 스레드를 차단하지 않고 자주 투명하게 마운트 및 마운트 해제됩니다. 예를 들어, 앞서 표시된 서버 애플리케이션에는 차단 작업에 대한 호출이 포함된 다음 코드 줄이 포함되어 있습니다.

response.send(future1.get() + future2.get());
이러한 작업을 수행하면 가상 스레드가 여러 번 마운트 및 마운트 해제되며 일반적으로 get()이 호출될 때마다 한 번씩, send(…)에서 실행됩니다. I/O 중에 여러 번 마운트될 수 있습니다.

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

캐리어에 고정되어 있기 때문에 차단 작업 중에 가상 스레드를 언로드할 수 없는 두 가지 상황이 있습니다.

  • 동기화된 블록이나 메서드 내에서 코드를 실행할 때 또는
  • 로컬 메서드나 외부 함수를 실행할 때 .

캐리어에 가상 스레드를 고정해도 애플리케이션이 올바르지 않게 되는 것은 아니지만 확장성을 방해할 수 있습니다. 가상 스레드가 캐리어에 고정되어 있는 동안 차단 작업(예: I/O 또는 BlockingQueue.take())을 수행하는 경우 해당 캐리어 및 기본 운영 체제 스레드는 작업 기간 동안 차단됩니다. 오랫동안 가상 스레드를 캐리어에 자주 고정하면 가상 스레드가 캐리어를 캡처하게 되어(가상 스레드가 플랫폼 스레드에 강하게 바인딩되어 있는 것으로 이해될 수 있음) 애플리케이션의 확장성이 손상됩니다.

스케줄러는 병렬성을 확장하여 캐리어에 고정된 가상 스레드를 보상하지 않습니다. 대신, 자주 실행되는 동기화된 블록 또는 메소드를 수정하고 가능한 긴 I/O 작업 대신 java.util.concurrent.locks.ReentrantLock을 사용하여 빈번하고 긴 잠금 캐리어를 피할 수 있습니다. 자주 사용되지 않거나(예: 시작 시에만 수행됨) 메모리 작업을 보호하는 동기화된 블록 및 메서드를 교체할 필요가 없습니다. 언제나 그렇듯, 봉쇄 전략을 간단하고 명확하게 유지하도록 노력하시기 바랍니다.

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

  • 캐리어를 잠그는 동안 스레드가 차단되면 JFR(JDK Flight Recorder) 이벤트가 생성됩니다( JDK Flight Recorder 참조 ).
  • 시스템 속성 jdk.tracePinnedThreads는 가상 스레드가 플랫폼 스레드를 잠글 때 스택 추적을 트리거합니다. -Djdk.tracePinnedThreads=full을 사용하면 스레드가 차단될 때 전체 스택 추적이 인쇄되어 로컬 프레임과 모니터를 보유하는 프레임이 강조 표시됩니다. -Djdk.tracePinnedThreads=short를 사용하면 출력이 문제의 프레임으로 제한됩니다.

15. 메모리 사용량 및 가비지 수집

가상 스레드의 스택은 스택 블록 객체의 형태로 힙 메모리에 저장됩니다. 가상 스레드의 스택은 애플리케이션이 실행됨에 따라 늘어나고 줄어들므로 JVM의 구성된 플랫폼 스레드 스택 크기만큼 스택을 수용하면서 메모리를 절약할 수 있습니다. 서버 애플리케이션이 많은 수의 가상 스레드를 가질 수 있고 요청당 스레드 접근 방식을 계속할 수 있는 것은 이러한 효율성 때문입니다.

일반적으로 가상 스레드에 필요한 힙 공간 및 가비지 수집기 활동의 양은 비동기 코드의 양보다 큽니다. 우선, 스레드 자체 소비의 관점에서 볼 때, 100만 개의 가상 스레드에는 최소 100만 개의 개체가 필요하며, 100만 개의 공유 플랫폼 스레드 풀 작업에도 100만 개의 개체가 필요합니다. 내부 할당의 세부 사항에는 약간의 차이가 있지만, 예를 들어 요청별 스레드 접근 방식으로 개발된 프로그램은 힙의 가상 스레드 스택에 저장된 로컬 변수에 데이터를 저장할 수 있지만 비동기 코드는 데이터를 저장해야 합니다. 파이프라인의 한 단계에서 다음 단계로 전달되는 힙 개체에 동일한 데이터가 보관되며 가상 스레드에 필요한 힙 프레임 레이아웃은 컴팩트 개체보다 낭비적입니다. 기본 GC 전략에 따라) 스택은 재사용되는 반면, 비동기 파이프라인에서는 항상 새 개체를 할당해야 하므로 가상 스레드에는 더 적은 할당이 필요할 수 있습니다. 전반적으로 요청 스레드 및 비동기 코드당 힙 소비 및 가비지 수집기 활동은 이 단계에서 거의 동일해야 합니다. 가상 스레드 스택의 내부 표현은 앞으로 더욱 컴팩트해질 수 있습니다.

플랫폼 스레드 스택과 달리 가상 스레드 스택은 GC 루트가 아닙니다. 따라서 가비지 수집기(예: G1)가 동시 힙 스캔을 수행할 때 Stop-the-World 일시 중지 중에 포함된 참조는 순회되지 않습니다. 이는 또한 BlockingQueue.take()와 같은 작업에서 가상 스레드가 차단되고 다른 스레드가 가상 스레드 또는 큐에 대한 참조를 얻을 수 없는 경우 가상 스레드가 인터럽트되지 않기 때문에 스레드가 가비지 수집된다는 의미이기도 합니다. 또는 차단을 해제하세요. 물론 가상 스레드가 실행 중이거나 차단되었다가 차단 해제될 수 있는 경우에는 가비지 수집되지 않습니다.

현재 가상 스레드의 한 가지 제한 사항은 G1 GC가 거대한 스택 블록 개체를 지원하지 않는다는 것입니다. 가상 스레드의 스택이 영역 크기(512KB만큼 작을 수 있음)의 절반에 도달하면 StackOverflowError가 발생할 수 있습니다.

16. 스레드 지역 변수

플랫폼 스레드와 같은 가상 스레드는 스레드 로컬 변수( ThreadLocal ) 및 상속 가능한 스레드 로컬 변수( InheritableThreadLocal )를 지원하므로 스레드 로컬 변수를 사용하는 기존 코드를 실행할 수 있습니다. 그러나 가상 스레드가 너무 많을 수 있으므로 스레드 로컬 변수를 사용할 때는 신중하게 고려해야 합니다.

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

이전의

JDK 21 출시, 새로운 기능 개요 및 문자열 템플릿에 대한 자세한 소개

추천

출처blog.csdn.net/xieshaohu/article/details/133101349