0️⃣ 시작하기

2023월 9월 19일에 JDK 21이 정식으로 출시

이는 2021년 9월 14일에 발표한 JDK 17 이후, 발표된 차기 Long-Term Support 버전

  • JDK 21은 총 4개의 컴포넌트로 분류된 15개의 신규 기능을 발표함

    specification / language

    • String Templates (Preview)
    • Record Patterns
    • Pattern Matching for switch
    • Unnamed Patterns and Variables (Preview)
    • Unnamed Classes and Instance Main Methods (Preview)

    core-libs

    • Squences Collections
    • Foreign Function & Memory API (Third Preview)
    • Virtual Threads
    • Vector API (Sixth Incubator)
    • Structured Concurrency (Preview)

    hotsop / gc

    • Generational ZGC (gc)
    • Deprecate the Windows 32-bit x86 Port for Removal (other)
    • Prepare to Disallow the Dynamic Loading of Agents (svc)

    security-libs

    • Key Encapsulation Mechanism API (javax.crypto)

사실, Virtual Thread는 JDK 21에서 처음 나온것은 아니고, JDK 19 (JEP425)에서 프리뷰 형태로 제공되었으나, JDK 20 (JEP436)를 거치면서 드디어 JDK 21에서 정식으로 발표되었다.


1️⃣ Platform Thread (전통적인 JAVA 스레드)

1. 기존 쓰레드 모델

image

  • JAVA의 쓰레드는 OS 쓰레드를 Wrapping 한 것 (= Platform Thread)
  • 그렇기에 사용 가능한 JAVA 쓰레드는 하드웨어 수준에 의존적이며 훨씬 적게 제한되어 있음.
  • OS 쓰레드는 생성 비용이 비싸기 때문에 애플리케이션에서는 플랫폼 쓰레드를 효율적으로 사용하기 위해 쓰레드 풀(Thread Pool)을 사용함.

2. 한계점

2-1. 처리량(throughput)의 한계

  • 기본적인 자바 애플리케이션에서의 요청(Request) 처리 방식은 Thread-Per-Request 방식으로 동작 (하나의 요청 당, 하나의 쓰레드)
  • 처리량을 높이려면 쓰레드의 증가가 필요함
  • 하지만, OS 쓰레드의 제약으로 인해 쓰레드는 무한정 늘릴 수 없음
  • 즉, 애플리케이션의 처리량은 쓰레드 풀에서 감당할 수 있는 범위를 넘어설 수 없다.

2-2. Blocking I/O

  • Thread-Per-Request 모델에서는 요청을 처리하는 쓰레드에서 I/O 작업을 처리할 때 Blocking이 발생함
  • Blocking 상태에서 쓰레드는 작업을 마칠 때까지 다른 요청을 처리하지 못하고 대기해야한다 (Non-Runnable)
  • 많은 요청을 처리해야 하는 상황이라면 Blocking으로 발생하는 낭비를 줄여야 할 필요가 있다.

3. 대안으로서의 Reactive Programming

  • Blocking 방식의 처리량 제한을 해결하기 위해 Non-Blocking 방식의 Reactive Programming이 등장했다.
  • 쓰레드가 I/O 작업이 끝나는 것을 기다리지 않고, 반납하여 다른 요청을 처리할 수 있게 한다.
  • I/O 작업 제외하고 연산을 수행하는 동안 쓰레드를 보유하기에, 적은 수의 쓰레드로도 동시 요청을 처리할 수 있다.
  • 대표적으로 Webflux가 Non-Blocking 방식으로 동작한다.

리액티브 프로그래밍이 기존 자바 플랫폼의 문제점을 해결할 수 있을 것으로 보이나, 단점 또한 존재한다.

  • 코드를 작성하고 이해하는 비용이 높다.
  • Reactive하게 동작하는 라이브러리의 지원을 필요로 한다.
    • 자바 진영에서 가장 많이 사용하는 orm인, JPA를 사용할 수 없다.
    • R2DBC 드라이버를 통해 DB 액세스가 가능하지만, 기능이 제한적이고 상대적으로 레퍼런스가 적다.
  • 자바 디자인은 스레드 기반이고 Exception Stack trace, Debugger 등이 스레드를 기반으로 하기 때문에, 디버깅이 상당히 어렵다.
    • 리액티브 작업은 하나의 쓰레드가 작업하는 것이 아니고, 여러 스레드를 거쳐 처리되기 때문

2️⃣ Virtual Thread

1. Virtual Thread 모델

image

  • 가상 쓰레드(Virtual Thread)
    • JDK에서 제공하는 경량화된 user-mode 스레드이다.
    • OS 스레드에 연결되지 않으므로 OS에서는 보이지 않으며 존재를 알지 못한다.
    • 애플리케이션 입장에서는 플랫폼 쓰레드가 아닌 가상 쓰레드만 사용한다.
  • 캐리어 쓰레드 (Carrier Thread)
    • 기존의 플랫폼 쓰레드와 동일한 형태이지만, 애플리케이션이 아닌 가상 쓰레드와 연결된다는 점에서 차이가 있다.
    • OS 쓰레드와는 기존과 똑같이 매핑된다.
  • 기본 스케줄러로 ForkJoinPool을 사용하여 캐리어 스레드를 관리하고, 가상 쓰레드의 작업 분배를 하는 역할을 가진다.

user-mode 쓰레드 운영체제의 커널 모드에서 관리되지 않고 응용 프로그램 자체에서 관리되는 스레드를 말한다. 프로그램 내부의 라이브러리나 시스템에 의해 스케줄링 된다.

2. 동작 방식

기존 쓰레드의 처리량 및 Blocking 문제를 해결하기 위한 가장 큰 변화가, 가상 쓰레드의 동작 방식에 있다.

  1. Virtual Thread1가 작업을 할당받아 Carrier Thread1와 연결된다. (현재는 Blocking된 상태)

image

  1. I/O나 Blocking 연산이 들어오면, 내부 스케줄링을 통해 Virtual Thread1와 Carrier Thread1는 Unmount 된다.

image

  1. 놀게되는 Carrier Thread1는 새로운 Virtual Thread2와 다시 Mount 된다. (또한, Virtual Thread1은 Carrier Thread2에 Mount 될 수 있음)

image

3. 자원의 차이

Mount/Unmount가 반복되는 구조에서, 가상 쓰레드는 수백만개까지 늘어날 수 있기 때문에 기존 플랫폼 쓰레드와 동일한 비용이 발생하면 감당하기 어렵다.

그렇기에, 기존 플랫폼 쓰레드와 가상 쓰레드는 사용하는 자원에 차이가 있도록 설계되었다.

  플랫폼 스레드 가상 스레드
메타 데이터 사이즈 약 2kb(OS별로 차이있음) 200~300 B
메모리 미리 할당된 Stack 사용 필요시 마다 Heap 사용
컨텍스트 스위칭 비용 1~10us (커널영역에서 발생하는 작업) ns (or 1us 미만)

4. 기본 사용법

public class DemoApplication {

	public static void main(String[] args) {
		useThreadStartBuilder();
		useThreadOfVirtualBuilder();
		executorService();
	}

	// 1. Thread start 빌더
	private static void useThreadStartBuilder() {
		Thread.startVirtualThread(() -> {
			System.out.println("1. Thread Builder 사용사용");
		});
	}

	// 2. Thread Of Virtual 빌더
	private static void useThreadOfVirtualBuilder() {
		Thread.ofVirtual().start(() ->
			System.out.println("2-1. Thread Of Virtual 사용사용")
		);

		// 이름 사용
		Thread.Builder builder = Thread.ofVirtual().name("Hello-Thread");
		builder.start(() ->
			System.out.println("2-2. Thread of Virtual 이름 사용 => " + Thread.currentThread())
		);
	}

	// 3. ExecutorService 사용
	private static void executorService() {
        Runnable runnable = () -> System.out.println("3. ExecutorService 사용사용");

		try (final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 3; i++) {
                executorService.submit(runnable);
            }
        }
    }

}

image

SpringBoot (MVC) 적용

  1. SpringBoot 3.x 에서 동작하는 방법
// Tomcat이 Virtual Thread를 사용하여 요청을 처리하도록 한다.
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
    return protocolHandler -> {
        protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
    };
}

// async 버전
@Bean
public AsyncTaskExecutor applicationTaskExecutor() {
    return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}
  1. SpringBoot 3.2 이상에서 동작하는 방법
# application.yml

spring:
    threads:
        virtual:
            enabled: true

5. 성능 테스트

사양

  • Debian12 (docker)
  • 4 Core, 8G Memory
  • JDK 21.0.2
  • SpringBoot 3.3
  • Heap Size (Xmx, Xms) 2g

환경

  • 테스트 툴: Jmeter
    • 쓰레드 수 (유저 수): 1,000
    • 쓰레드 생성 시간 (Ramp-up): 10초
    • 지속 시간 (Duration): 30초

1번 테스트 코드 - 일반 테스트

@GetMapping("/sample")
public String basicRequest() {
    System.out.println(Thread.currentThread());
    return "OK";
}

기존 쓰레드 사용 (Platform Thread)

spring.threads.virtual.enabled = false

image

가상 쓰레드 사용 (Virtual Thread)

spring.threads.virtual.enabled = true

image

  • 가상 쓰레드 사용시, 처리량이 거의 동일하거나 적은 것으로 보이는데, 이는 가상 쓰레드 스케줄링을 위한 오버헤드의 영향이 있을 수도 있다고 한다.

2번 테스트 코드 - Blocking 테스트

API GET 요청시 1초 block 후 응답

@GetMapping("/thread")
public String blockedRequest() throws InterruptedException {
    Thread.sleep(1000);
	return "Success";
}

기존 쓰레드 사용 (Platform Thread)

spring.threads.virtual.enabled = false

image

가상 쓰레드 사용 (Virtual Thread)

spring.threads.virtual.enabled = true

image

  • Blocking에서는 확실히 가상 쓰레드가 기존 플랫폼 쓰레드보다 좋은 성능을 보인점을 확인했다.

3번 테스트 코드 - DB I/O 테스트

Query 요청: 1초 대기 조회

@GetMapping("/query")
public String queryRequest() {
    return jdbcTemplate.queryForList("select sleep(1);").toString();
}

기존 쓰레드 사용 (Platform Thread)

spring.threads.virtual.enabled = false

spring.datasource.hikari.maximum-pool-size: 150

image

가상 쓰레드 사용 (Virtual Thread)

spring.threads.virtual.enabled = true

spring.datasource.hikari.maximum-pool-size: 150

image

가상 쓰레드의 처리량은 왜 늘지 않는가?

  • MySQL JDBC Driver에는 현재, Synchronized 이슈가 있어서 현재 조치 중에 있다 (= Pinning 이슈)
    • https://bugs.mysql.com/bug.php?id=110512
  • MariaDB의 경우 Connector 버전 3.3.0 에서는 가상 쓰레드 친화적으로 구성했다고 한다. (pinning 로그 발견되지 않음)
    • https://mariadb.com/kb/en/mariadb-connector-j-3-3-0-release-notes/

image

  • -Djdk.tracePinnedThreads=short 옵션을 사용하면 Pinning 이슈 여부를 확인할 수 있다.
Thread[#64,ForkJoinPool-1-worker-6,5,CarrierThreads]
    com.mysql.cj.jdbc.ConnectionImpl.isValid(ConnectionImpl.java:2496) <== monitors:1
Thread[#65,ForkJoinPool-1-worker-7,5,CarrierThreads]
    com.mysql.cj.jdbc.ConnectionImpl.isValid(ConnectionImpl.java:2496) <== monitors:1
Thread[#65,ForkJoinPool-1-worker-7,5,CarrierThreads]
    com.mysql.cj.jdbc.StatementImpl.executeQuery(StatementImpl.java:1178) <== monitors:1

6. Virtual Thread 사용시 주의 사항

1. Pooling 금지

  • 가상 쓰레드는 생성비용이 작기 때문에 쓰레드 풀을 만드는 것 자체가 낭비일 수 있다.
  • 필요할 때마다 생성하고 GC에 의해 소멸되게 방치하는 것이 좋은 선택이 될 수 있다.

2. ThreadLocal 캐시 주의

  • ThreadLocal은 내부에 값비싼 객체를 캐시하고 공유하도록 유도되었다.
  • 가상 쓰레드 또한 ThreadLocal을 지원한다.
  • 가상 쓰레드는 작업당 하나를 활용하는 것이 권장되며 내부 객체는 공유하지 않는다.
  • 따라서, 가상 쓰레드를 ThreadLocal로 활용했을 때 값비싼 객체를 캐싱하는 것은 도움되지 않으며 오히려, 예상보다 많은 메모리를 잡아 먹을 수 있다.

3. Pinning 이슈

  • synchronized 키워드를 사용한 코드 블럭 안에서 blocking IO작업을 수행하는 경우, 가상 쓰레드를 unmount 할 수 없어서 캐리어 쓰레드까지 Blocking 되는 현상을 Pinning 이라고 한다.
    • Syncronized 대신 ReentrantLock을 사용하자

    image

  • pinning이 되면, 가상 쓰레드의 이점을 가질 수 없다.
  • pinning 이 발생하는지 탐지하려면 JFR을 사용하거나 -Djdk.tracePinnedThreads 옵션을 사용하면 pinning을 탐지할 수 있다.
    • -Djdk.tracePinnedThreads 옵션은 fullshort 가 있음

Java Flight Recorder (JFR) 참고: https://javakr.medium.com/java-flight-recorder-jfr-사용-방법-ac05317f5a94

4. 추가) Overwhelming

image

  • DB Query에는 Virtual Thread를 사용할 때 SQLTransientConnectionException 이 발생할 수 있다.
  • 이는 Tomcat 이후인 JDBC 커넥션으로 로직이 넘어갔는데, 이 많은 로직이 DB Connection 을 얻으려다가 timeout (30s)이 발생하는 것으로 추정된다고 한다.
  • DB Connection 과 같은 한정된 자원에 접근을 제한하려면 semaphores를 도입하는걸 고려해볼 수 있다.
Semaphore sem = new Semaphore(150);
...
Result foo() {
    sem.acquire();
    try {
        return callLimitedService();
    } finally {
        sem.release();
    }
}

참고 자료

우아한 기술블로그: https://techblog.woowahan.com/15398

카카오 테크: https://tech.kakao.com/posts/608

망나니 개발자 블로그: https://mangkyu.tistory.com/309

Soo Story: https://findstar.pe.kr/2023/04/17/java-virtual-threads-1