[JAVA] 가상(Virtual) 스레드 사용 예시
들어가기 앞서
가상 스레드가 무엇인지 모르겠다면 아래 이전 글을 읽고 오면 도움이 될것이다.
https://lold2424.tistory.com/240
[JAVA] 가상(Virtual)스레드란?
스레드의 종류스레드는 사용자 수준 스레드인 ULT(User-Level Thread)와 커널 수준 스레드인 KLT(Kernel-Level Threads)로 나뉜다.두 방식은 스레드를 누가 관리하는지, 스케줄링과 전환을 어디서 처리하는지
lold2424.tistory.com
이전 글에 가상 스레드의 등장 배경과 구조에 대해 설명했기 때문에 이번 글에서는 활용 예시와 OS 스레드와의 비교를 중점으로 다루겠다.
가상 스레드와 플랫폼 스레드의 차이점
플랫폼 스레드의 경우 CPU 연산이 많은 작업에 유리하지만, 대량의 I/O, 블로킹 작업에는 가상 스레드가 유리하다.
그 이유는 아래와 같다.
1. 스레드 수 제한
기존 ThreadPool의 경우 아래와 같이 스레드 수를 제한해야 한다.
ExecutorService executor = Executors.newFixedThreadPool(100);
이러면 실제로 100개의 OS 플랫폼 스레드가 생성되기 때문에 메모리 소모가 심하다.
이는 곧, 수천 개 이상의 스레드를 생성할 수 없다는 소리다. (물리적으로 불가능)
하지만, Virtual Thread의 경우 아래와 같이 스레드 수를 제한할 필요가 없다.
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
JVM이 경량화 관리를 해주기 때문에, 작업 1개당 가상 스레드 1개를 생성할 수 있다.
이러면 동시 스레드를 몇만개 이상 돌리더라도 메모리에 부담이 없기 때문에 문제 없이 실행이 가능하다.
2. 블로킹 I/O 처리 방식
기존 ThreadPool의 경우 블로킹 I/O(Thread.sleep()
, readLine()
등) 를 사용하면 해당 플랫폼 스레드가 그대로 대기 상태가 된다.
이러면 다른 작업은 처리가 불가능하기 때문에 스레드 수 제한이 치명적으로 다가온다.
하지만, 가상 스레드의 경우 JVM이 I/O 발생을 감지하면 중단하고 다른 가상 스레드를 캐리어 스레드에 태운다.
이러면 OS 스레드는 놀지않고 일을 계속하기 때문에 비동기 처리와 유사한 효과를 볼 수 있다.
사용 예시
플랫폼 스레드의 경우
ExecutorService pool = Executors.newFixedThreadPool(100);
for (int i = 0; i < 100000; i++) {
pool.submit(() -> Thread.sleep(1000)); // 대부분 큐에 쌓임
}
- 총 시간이 1000초 이상이 걸리게 된다.
가상 스레드의 경우
ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 100000; i++) {
pool.submit(() -> Thread.sleep(1000));
}
병렬적으로 처리하기 때문에 총 시간이 약 1초로 매우 짧게 걸리게 된다.
가상 스레드 생성 방식
Thread.startVirtualThread()
가장 기본적인 가상 스레드 생성 방식이다.
public class VirtualThreadSimple {
public static void main(String[] args) {
Runnable task = () -> {
System.out.println("가상 스레드 실행: " + Thread.currentThread());
try {
Thread.sleep(500); // Sleep도 가능 (JVM이 블로킹 처리)
} catch (InterruptedException e) {
e.printStackTrace();
}
};
Thread thread = Thread.startVirtualThread(task); // 바로 실행됨
try {
thread.join(); // 메인 스레드가 기다림
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("메인 종료");
}
}
- 즉시 실행되고 종료되는 단발성 작업에 유리하다.
- 명시적으로 스레드 객체를 컨트롤할 수 있다.
- 스레드 이름을 지정하거나 우선순위 조정이 가능하다.
Executors.newVirtualThreadPerTaskExecutor()
JDK21에서 가상 스레드가 등장하면서 사용이 가능해졌다.
import java.util.concurrent.*;
public class VirtualThreadExecutor {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 5; i++) {
final int num = i;
executor.submit(() -> {
System.out.println("작업 " + num + ": " + Thread.currentThread());
Thread.sleep(200);
return null;
});
}
executor.shutdown();
}
}
- 작업마다 새로운 가상 스레드를 생성한다.
- try - with - resources 문법이 가능하다.
- 이러면 shutdown() 호출을 따로 해줄 필요가 없다.
ExecutorService 일반 인터페이스
기존 ExecutorService
를 기반으로 가상 스레드를 만드는 방식이다.
참고로 ExecutorService
는 Java의 java.util.concurrent 패키지에 속한 Executor
프레임워크의 핵심 추상화 인터페이스 중 하나다.
이는 나중에 가상 스레드 병렬화 처리할때 다루도록 하겠다.
아래와 같이 플랫폼 스레드를 기존에 사용했다고 가정한다.
ExecutorService executor = Executors.newFixedThreadPool(5);
for (int i = 1; i <= 10; i++) {
int num = i;
executor.submit(() -> {
System.out.println("작업 " + num + " 실행: " + Thread.currentThread());
});
}
executor.shutdown();
가상 스레드의 경우 아래처럼 코드를 작성할 수 있다.
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 10; i++) {
int num = i;
executor.submit(() -> {
System.out.println("작업 " + num + " 실행: " + Thread.currentThread());
});
}
executor.shutdown();
플랫폼 스레드와 비교하면 단 한줄만 변경된것을 확인할 수 있다.
Executors.newFixedThreadPool(5) ➝ Executors.newVirtualThreadPerTaskExecutor()
실제 비교 실험을 통해 효율성 확인
동일한 작업을 기존 스레드와 가상 스레드로 실행해보고, 실행 시간 차이를 비교해보겠다.
리소스 사용 차이도 Jconsol이나 외부 프로그램을 통해 확인이 가능하나, 설명이 길어지는 관계로 실행 시간 차이만 비교해보겠다.
기존 스레드
import java.util.concurrent.*;
public class ThreadPoolTest {
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(100);
int taskCount = 10000;
long start = System.currentTimeMillis();
CountDownLatch latch = new CountDownLatch(taskCount);
for (int i = 0; i < taskCount; i++) {
executor.submit(() -> {
try {
Thread.sleep(1000); // I/O 흉내
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
});
}
latch.await(); // 모든 작업 완료 대기
long end = System.currentTimeMillis();
System.out.println("ThreadPool 실행 시간: " + (end - start) + "ms");
executor.shutdown();
}
}
위 코드를 실행하면 대략적으로 100초 이상이 소요된다.
물론 더 좋은 성능의 컴퓨터나 서버를 사용한다면 더 많은 스레드를 동시에 돌릴 수 있겠으나, 물리적으로 한계가 정해져 있는것은 동일하다고 생각하면 될것같다.
가상 스레드
import java.util.concurrent.*;
public class VirtualThreadTest {
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
int taskCount = 10000;
long start = System.currentTimeMillis();
CountDownLatch latch = new CountDownLatch(taskCount);
for (int i = 0; i < taskCount; i++) {
executor.submit(() -> {
try {
Thread.sleep(1000); // I/O 흉내
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
});
}
latch.await(); // 모든 작업 완료 대기
long end = System.currentTimeMillis();
System.out.println("Virtual Thread 실행 시간: " + (end - start) + "ms");
executor.shutdown();
}
}
위 코드를 실행하면 모든 작업이 동시에 실행되기 때문에 매우 빠른 속도로 작업을 완료한다.
사용 설명을 위해 10000개의 스레드를 예시로 들었을 뿐이지 스레드를 몇십만, 몇백만 단위로 넘어가면 더한 성능 차이가 발생한다는것을 알 수 있다.
실제로 기존 스레드를 주어진 메모리보다 더 많이 사용하게 하려고 하면 메모리 부족(OutOfMemoryError)이 발생하거나 성능이 급하락 하게 된다.