목차
- OS 스레드와 Java Platform Thread
- Java Virtual Thread
- Virtual Thread 사용해 보기
- Virtual Thread 효율적으로 사용하기
Java 21에 새롭게 추가된 Virtual Thread에 대해 알아본다. 먼저, 가상 스레드를 일목요연하게 정리해 주신 카카오 안정수(James.star)님께 감사드린다. 아래 링크를 통해 영상을 볼 수 있다.
추가로 Oracle에서 제공한 Virtual Thread 문서를 참고하여 가상 스레드를 정리해 보았다. 예시코드는 모두 아래 깃헙에서 볼 수 있다.
https://github.com/gihyeon6394/hello-java-virtualthread
주의사항으로는 인텔리제이 버전 2023.03부터 Java 21 표준 피처를 지원한다. 코딩해보고 싶으면 참고하자.
https://kghworks.tistory.com/173
OS 스레드와 Java Platform Thread
스레드는 OS 프로세스의 가장 작은 실행단위로, Java 프로그램에서의 스레드는 java.lang.Thread의 인스턴스로서 구현된다. 기존 Java의 스레드는 Platform Thread였으며, Java 21부터 새로운 유형의 스레드 Virtual Thread가 추가되었다.
Platform Thread?
Platform Thread (플랫폼 스레드)는 OS 스레드를 래핑 하여 구현한 JVM 스레드다. 따라서 Platform Thread 수는 OS thread 수에 제한된다. 아래처럼 스레드를 생성하고 실행할 수 있다.
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
System.out.println("This is thread1");
});
Thread thread2 = new Thread(() -> {
System.out.println("This is thread2");
});
thread1.start(); // thread 1 실행
thread2.start(); // thread 2 실행
thread1.join(); // thread 1이 종료될 때까지 기다림
thread2.join(); // thread 2가 종료될 때까지 기다림
}
Java Virtual Thread
... (생략) Virtual threads are lightweight threads that dramatically reduce the effort of writing, maintaining, and observing high-throughput concurrent applications.
* https://openjdk.org/jeps/444: Virtual Threads Summary
Java 21부터 경량 스레드를 목적으로 Virtual Thread가 추가되었다. 마찬가지로 java.lang.Thread의 인스턴스로서 구현된다. 그러나 Virtual Thread는 OS thread 수에 종속되지 않는다. OS thread에서 실행되지만, Virtual Thread에서 실행되는 코드가 I/O blocking을 일으키면, Java runtime은 해당 Virtual thread가 다시 실행이 가능해질 때까지 중지시킨다. 따라서 OS thread는 다른 Virtual Thread를 실행시킬 수 있다. (그러니까 실제 OS thread 수보다 더 많은 thread를 실행시킨다)
oracle에서는 마치 OS Virtual Memory와 비슷하다고 표현한다. OS는 물리 메모리보다 더 큰 크기의 가상 메모리를 생성해 두고, 실제로는 프로그램의 일부만 메모리에 올려 실행하며 전체적인 멀티프로그래밍 성능은 높이는 기법과 비슷하기 때문이다.
Virtual Thread 특징과 적합한 사용처
Virtual Thread는 Platform Thtread 보다 실행 속도가 빠른 스레드가 아니다. OS 스레드 수보다 동시에 더 많은 스레드를 제공해 높은 처리량 (throughput)을 제공하는 스레드일 뿐 더 빠른 실행 속도 (lower latency)를 제공하지 않는다. 따라서 실행시간의 대부분이 blocking (e.g. I/O blocking)되는 스레드에 적합하다. CPU intensive 한 작업에는 비적합하다. 전체적인 처리량을 높여줄 뿐 단일 스레드에 대한 처리 속도는 동일하기 때문이다.
Virtual Thread 스케줄링
Java platform thread는 OS가 스케줄링하지만, 가상 스레드는 Java runtime이 스케줄링한다. Java runtime은 가상 스레드를 platform thread에 할당하거나 mount 한 뒤 OS에게 스케줄링을 위임하여 일반적으로 Platform thread를 스케줄링할 수 있게 한다. 즉, 가상 스레드를 Platform thread에 연결(할당)하기까지의 스케쥴링은 Java runtime의 몫이다.
가상 스레드를 실행하고 있는 Platform thread를 carrier (캐리어)라고 한다. carrier로부터 이미 실행하고 있는 가상 스레드를 unmount 할 수 있다. 이를 테면, 가상 스레드에서 I/O blocking이 일어나면, Java runtime은 carrier로부터 가상스레드를 unmount 하고, carrier가 다른 가상 스레드를 실행할 수 있게 한다. (전체 throughput 증가)
Virtual Thread pinned
가상스레드가 캐리어에 pinned 되면, blocking에 진입해도 unmount 할 수 없다. pinned이 발생하는 상황은 아래 두 가지다.
- 가상 스레드가 synchronized 블록이나 메서드를 실행 중일 때
- 가상 스레드가 native 메서드나 foreign function을 실행 중일 때
Virtual Thread 사용해 보기
아래처럼 Thread.ofVirtual()을 호출해서 가상 스레드를 생성하고, 시작시킬 수 있다.
Thread thread = Thread.ofVirtual() // Thread.Builder instance 생성
.start(() -> System.out.println("Hello"));
thread.join(); // Waits for this thread to terminate.
스레드 명명
String threadName = "thread No.1";
Thread.Builder builder = Thread.ofVirtual().name(threadName); // Thread.Builder instance 생성
Runnable task = () -> System.out.println("Running thread");
Thread t = builder.start(task); // Runnable을 Thread에 등록하고 Thread를 실행
System.out.println("Running thread name : " + t.getName()); // Running thread name : thread No.1
t.join();
스레드명 채번
Thread.Builder builder = Thread.ofVirtual().name("worker-", 0); // worker-0, worker-1, worker-2, ...
Runnable task = () -> {
System.out.println("Thread ID: " + Thread.currentThread().threadId());
};
// name "worker-0"
Thread t1 = builder.start(task);
t1.join();
System.out.println(t1.getName() + " terminated");
// name "worker-1"
Thread t2 = builder.start(task);
t2.join();
System.out.println(t2.getName() + " terminated");
Executor로 가상 스레드 관리하기
try (ExecutorService myExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<?> future = myExecutor.submit(() -> System.out.println("Running thread"));
future.get();
System.out.println("Task completed");
// some task
Future<?> future2 = myExecutor.submit(() -> System.out.println("Running thread2 something else"));
future2.get();
System.out.println("Task completed2");
} catch (Exception e) {
throw new RuntimeException(e);
}
Executors는 스레드의 관리와 생성을 별도로 해주는 API이다. 예제에서는 ExecutorService.submit(Runnable)가 호출될 때마다 가상스레드가 생성, 시작된다.
Virtual Thread 효율적으로 사용하기
팁 1. Thread-Per-Request 스타일로 짧게, 동기(Synchronous), blocking I/O API 사용하기
가상 스레드가 기존의 Platfrom thread와 구별되는 가장 큰 특징은 OS 스레드 수에 종속되지 않는다는 것이다. 따라서 하나의 Java 프로세스에서 수천~만 개의 가상스레드를 생성할 수 있다. 이런 특징은 thread-per-request sytle 애플리케이션에서에서는 가상 스레드를 사용하면 수만 개의 스레드(요청)를 열 수 있다는 뜻이기도 하다. 당연히 thread-per-reqeust 애플리케이션에서 이점이 크다.
Spring Web MVC를 예로 들 수 있다. 대표적인 Thread-per-reqeust 프레임워크로 client의 요청마다 WAS servlet의 스레드를 하나씩 할당해 처리하도록 되어있다. 먼저 아래와 같이 Bean을 설정해 준다.
// Async Task를 Virtual Thtread로 처리
@Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
public AsyncTaskExecutor asyncTaskExecutor() {
return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}
// Web 요청을 처리할 때 Tomcat (WAS)이 Virtual Thread로 처리하도록
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
스레드 설정을 application.yaml로부터 주입받을 수 있게 처리할 수도 있다. (Baeldung 참고)
@EnableAsync
@Configuration
@ConditionalOnProperty(
value = "spring.thread-executor",
havingValue = "virtual"
)
public class ThreadConfig {
@Bean
public AsyncTaskExecutor applicationTaskExecutor() {
return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
}
application.yaml
spring:
thread-executor: virtual
//...
이렇게 하면 아래와 같은 Controller 요청은 모두 WAS 스레드를 carrier로 사용해 가상스레드에서 처리할 수 있게 된다.
@GetMapping("/blockingReqeustTest")
public String getBlockingRequestTest() throws InterruptedException {
Thread.sleep(1000); // 사용자 요청을 처리하는데 1초가 걸린다는 가정
return "OK";
}
실제 성능은 아래 카카오 안정수 님의 성능 테스트처럼 (유튜브 캡쳐본) 어마어마한 개선을 가져온다.
팁 2. Concurrent Task를 가상 스레드로 표현하고, 가상 스레드를 Pooling 하지 않기
Platform thread는 매우 비싼 자원이다. OS 스레드에 매핑되므로 스레드를 관리하기 위한 방법으로 Pooling(풀에 리소스를 관리하는 것)했다. 그러나 가상 스레드는 값싼 자원이다. 얼마든지 만들 수 있다.
// Bad
ExecutorService sharedThreadPool = Executors.newFixedThreadPool(10);
Future<String> f1 = sharedThreadPool.submit(() -> "Hello thread No. 1");
Future<String> f2 = sharedThreadPool.submit(() -> "Hello thread No. 2");
try {
System.out.println(f1.get());
System.out.println(f2.get());
} catch (Exception e) {
throw new RuntimeException(e);
}
스레드풀 (10개)로부터 스레드를 획득하여 task를 실행한다. 가상스레드를 사용한다면 위처럼 할 필요가 없다.
// Good
try(ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> f3 = executor.submit(() -> "Hello Virtual thread No. 3"); // Virtual thread 생성
Future<String> f4 = executor.submit(() -> "Hello Virtual thread No. 4"); // Virtual thread 생성
System.out.println(f3.get());
System.out.println(f4.get());
} catch (Exception e) {
throw new RuntimeException(e);
}
스레드를 pooling 하지 않고, ExecutorService로부터 가상스레드를 생성하면서 task를 진행한다.
팁 3. 동시성을 제한할 때는 세마포어 사용 (java.util.concurrent.Semaphore)
blocking I/O 연산에 무제한으로 가상스레드를 사용하는 것이 제한될 수 있다. 예를 들어 DB에 접근하는 로직을 가상스레드로 제한 없이 사용한다면, 동시성이 매우 높은 요청을 디비가 모두 처리해야 하게 된다.
ExecutorService es = Executors.newFixedThreadPool(10); // 10개의 Platform thread만 허용
for (int i = 0; i < 100; i++) {
es.submit(() -> {
try {
executeDatabase();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
}
가상 스레드는 풀링 할 수 없으므로 위와 같이 스레드 풀 수를 제한하는 것으로는 동시성 수를 제한할 수 없다. 따라서 아래와 같이 java.util.concurrent.Semaphore를 사용하여 동시성을 제한하도록 하자
Semaphore semaphore = new Semaphore(10); // 10개의 Virtual thread만 허용
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100; i++) {
executor.submit(() -> {
try {
semaphore.acquire(); // semaphore를 획득
executeDatabase();
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
semaphore.release(); // semaphore를 반환
}
});
}
}
여담으로, Java의 Database Conneciton Pool의 경우 이미 자체적으로 세마포어를 구현해 두었다. 따라서 커넥션 풀을 넘는 스레드를 생성하려 할 때 (가상스레드 포함)는 자체적으로 차단하기 때문에 데이터베이스 Connection을 위해서라면 위처럼 추가적인 제한을 줄 필요는 없다.
진짜 그럴까?
org.apache.commons.dbcp2 커넥션풀을 사용해 가상스레드로 제한 없이 커넥션을 맺는 코드를 실행해 봤다. 그러나 콘솔에는 8개의 커넥션씩 맺어지는 것을 볼 수 있었다.
public class DBCPDataSource {
private static BasicDataSource ds = new BasicDataSource();
static {
ds.setDriverClassName("com.mysql.cj.jdbc.Driver");
ds.setUrl("jdbc:mysql://localhost:3306/example_jdbc?serverTimezone=Asia/Seoul");
ds.setUsername("root");
ds.setPassword("root");
ds.setMinIdle(5);
ds.setMaxIdle(10);
ds.setMaxOpenPreparedStatements(100);
}
public static Connection getConnection() throws SQLException {
return ds.getConnection();
}
private DBCPDataSource(){ }
}
...
private static void limitConcurrencyDatabase() {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
long now = System.nanoTime();
for (int i = 0; i < 100; i++) {
executor.submit(() -> {
try {
executeDatabaseReal(now);
} catch (ClassNotFoundException | SQLException e) {
throw new RuntimeException(e);
}
});
}
}
}
private static void executeDatabaseReal(long now) throws ClassNotFoundException, SQLException {
Connection connection = DBCPDataSource.getConnection(); // 최대 10개의 Connection을 허용
System.out.println("Connected : " + (System.nanoTime() - now) / 1000000 + "ms");
PreparedStatement statement = connection.prepareStatement("select sleep(5)");
statement.executeQuery();
statement.close();
connection.close();
}
팁 4. thread-local variables 조심해서 사용하기 (thread-local에 캐싱 지양)
가상스레드도 Platform thread와 마찬가지로 thread-local variables를 지원한다. 스레드 context 관련 정보를 담기 적절한 곳이긴 한데, 가상스레드의 경우 주의할 필요가 있다. 보통 비싼 객체 (생성 비용이 비싸고, 자주 사용되는)를 thread-local에 캐싱해 두고 사용한다. 스레드 안에서 여러 번 인스턴스화되는 것을 방지하고자 하는 것이 주목적이다.
Platform Thread의 경우 pooling 되기 때문에 인스턴스화되는 객체는 최대 pool 사이즈로 제한된다. 그러나 가상스레드는 pooling 되지 않는다. 따라서 생성비용이 비싼 객체는 제한 없이 마음껏 인스턴스화되어 Heap을 차지(가상스레드 개수만큼)할 수 있다. 주의할 필요가 있다.
팁 5. 가상 스레드 실행 단위에서 synchronized 주의하기 (pinning)
가상스레드가 안에서 synchronized 블록을 실행하던 중 blocking 연산이 발생해도 캐리어로부터 unmount 불가능한 것을 pinning이라고 했다. 이러한 blocking 연산이 가상 스레드 안에서 반복되면 당연히 서버 전체 처리량 (throughput)을 저하시킨다. 따라서 가상 스레드 안에서 synchronized블록과 같은 것에 주의해야 한다.
JDK Flight Recorder (JFR)은 pinning이 발생하면 jdk.VirtualThreadPinned 스레드를 발생시킨다.
-Djdk.tracePinnedThreads=full 옵션을 활용하면 완전한 스택 트레이스를 출력한다. 이러한 수단으로 잦은 pinning이 탐지된 곳에는 synchronized 대신 java.util.concurrent.locks.ReentrantLock으로 대체하는 것을 고려해 볼 만하다.
참고
https://product.kyobobook.co.kr/detail/S000015846766
https://techblog.woowahan.com/15398/?utm_source=oneoneone
https://www.baeldung.com/spring-6-virtual-threads
'Programming > Languages (Java, etc)' 카테고리의 다른 글
[Kotlin, Jackson] @JsonCreator 로 enum 값 유연하게 역직렬화 (0) | 2024.08.09 |
---|---|
[Kotlin] runBlocking vs coroutineScope (0) | 2024.08.02 |
[JPA] JPA와 @Transactional (2) | 2023.12.03 |
[Spring] AOP 2편 - Spring의 AOP과 @Transactional (1) | 2023.12.03 |
[Java] AOP 1편 - 핵심 기술 Proxy, Dynamic proxy, Factory bean (1) | 2023.12.02 |
댓글