자바 Thread_15
가상 스레드
- 시작 메서드를 실행하면 애플리케이션에 새로운 OS스레드를 만들어달라고 OS에 요청함
- 이후 JVM에 요청해 정해진 크기의 스택 공간을 할당받아 스레드와 로컬변수를 저장함
=> OS는 CPU 스레드 스케줄링과 실행에 전적인 책임이 있음, JVM내부의 스레드는 얕은 계층, 혹은 운영체제 스레드를 감싸고 있는 것에 불과 (자바 스레드 == 플랫폼 스레드)
- 자바 플랫폼 스레드는 기본적으로 무거움(비용이 큼) 그리고 OS스레드와 1:1 매칭된다(OS스레드가 JVM의 정적 공간에 묶여있음) — —
가상스레드는 JDK 21이상부터 사용가능
- 가상 스레드는 다른 스레드와 달리 우리가 동시에 실행하고 메서드를 시작할 코드를 가지고 있어야함
- 하지만 플랫폼 스레드와 달리 가상 스레드는 JVM에 완전히 속해서 JVM의 관리를 받음(고정 크기 스택으로 할당X)
- 운영체제는 가상 스레드를 생성하거나 관리하는 데 전혀 관여하지 않고 존재 자체를 모름
사실 가상 스레드는 힙 메모리에 할당된 다른 자바 객체처럼 더 이상 필요가 없어지면 JVM의 가비지 컬렉션에 의해 반환됩니다
- 이러한 사실의 결과로 플랫폼 스레드는 만들고 관리하는 비용이 많이 드는 반면, 가상 스레드는 저비용으로 빠르게 많은 양을 생성
가상 스레드가 그냥 자바 객체라면 어떻게 실제로 CPU에 실행시킬까??”
우리가 엔진에 가상 스레드 하나를 생성하자마자 JVM은 비교적 작은 내부 플랫폼 스레드 풀을 만듭니다
그리고 이제 언제든 JVM이 특정 가상 스레드, 스레드 A를 실행시키려 하면 풀 내 플랫폼 스레드 중 하나에 마운트합니다
가상 스레드가 플랫폼 스레드에 마운트 되면 그 플랫폼 스레드는 캐리어 스레드라고 불립니다
이제 가상 스레드 실행이 끝나면 JVM은 캐리어에서 스레드 마운트를 해제하고 플랫폼 스레드를 다른 가상 스레드가 사용할 수 있게 합니다
이 가상 스레드 객체는 가비지가 됩니다, 가비지 컬렉션에서 청소를 하겠죠 하지만 특정 상황에서, 예를 들어 스레드 A가 안 끝났고 더 이상 진행할 수 없는 순간이 오면 JVM은 마운트를 해제하고 현재 상태를 힙에 저장합니다
이 상태에는 명령 포인터와 캐리어 스레드 스택의 스냅샷이 포함
이 지점에서 JVM은 플랫폼 스레드를 다른 가상 스레드에 마운트 할 수 있게 됩니다
1
2
3
4
5
6
7
8
9
10
11
12
13
- 예를 들어 스레드 B는 이후 스레드 A가 계속할 수 있을 때 JVM은 다음과 같은 일 중 하나를 합니다
- 다른 플랫폼 스레드를 사용할 수 있으면 다른 가상 스레드를 이동하지 않고, 가상 스레드 A를 바로 마운트
- 반면, 현재 가능한 플랫폼 스레드가 없다면 스레드 A는 대기
- 스레드 B와 같이 캐리어 스레드로 실행되는 가상스레드 중 하나가 더 이상 진행되지 않으면 JVM은 캐리어 스레드에서 힙으로 상태를 복사해 스레드 B를 마운트 해제
- 캐리어 스레드 명령 포인터를 사용해 스레드 A를 해당 캐리어 스레드에 마운트
- 가상 스레드 A의 명령 포인터죠, 그리고 힙 메모리에서 스레드 A 스택 데이터의 스냅샷을 떠서 캐리어 스레드 스택 메모리로 다시 복사
- 여기서 중요한 것은 우리 개발자들이 캐리어 스레드와 가상 스레드의 스케줄링을 거의 제어할 수 없음 (JVM이 내부적으로 우리를 위해 관리) <br>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private static final int NUMBER_OF_VIRTUAL_THREADS = 1000;
public static void main(String[] args) throws InterruptedException {
Runnable runnable = () -> System.out.println("Thread" + Thread.currentThread());
// NUMBER_OF_VIRTUAL_THREADS 수 만큼 스레드 리스트에 스레드를 담아 스레드를 실행함
List<Thread> virtualThreads = new ArrayList<>();
for (int i = 0; i < NUMBER_OF_VIRTUAL_THREADS; i++) {
Thread virutalThread = Thread.ofVirtual().unstarted(runnable);
virtualThreads.add(virutalThread);
}
for (Thread virtualThread : virtualThreads) {
virtualThread.start();
}
for (Thread virtualThread : virtualThreads) {
virtualThread.join();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 개수를 늘려도 자기 컴퓨터의 스레드 개수를 넘지못함
private static final int NUMBER_OF_VIRTUAL_THREADS = 2;
public static void main(String[] args) throws InterruptedException {
List<Thread> virtualThreads = new ArrayList<>();
for (int i = 0; i < NUMBER_OF_VIRTUAL_THREADS; i++) {
Thread virutalThread = Thread.ofVirtual().unstarted(new BlockingTask());
virtualThreads.add(virutalThread);
}
for (Thread virtualThread : virtualThreads) {
virtualThread.start();
}
for (Thread virtualThread : virtualThreads) {
virtualThread.join();
}
}
private static class BlockingTask implements Runnable {
@Override
public void run() {
// 스레드를 재우기전 출력(마운트 해제전)
System.out.println("Inside thread "+ Thread.currentThread() + " before block call");
try {
// 스레드를 1초간 재움
Thread.sleep(1000);
}catch (Exception e) {
throw new RuntimeException(e);
}
// 다시 가상스레드에 마운트
System.out.println("Inside thread "+ Thread.currentThread() + " after block call");
}
}
sleep이 끝났을 떄 다른 플랫폼 스레드(캐리어스레드)에 마운트가 될 수 있음(스켈줄링 순서는 프로그램이 실행될때마다 달라질수있음)
- 개발자가 가상스레드를 관리리하고 스케줄링에 관여할 수 없음(OS가 알아서 관리함)
하나의 스레드가 sleep으로 들어가면 마운트해제하고 다른 가상스레드를 바로 실행하는것이 핵심!
- 가상스레드는 힙 메모리를 할당받는 객체와 유사!!
가상 스레드를 통해 블로킹연산 성능을 어떻게 올리나??
- 작업 내용이 가상스레드가 CPU연산만 하는거라면 가상스레드는 어떤 성능 이득도 주지않음
- 여러 작업을 작은 풀에서 스케줄링 하는것임
가상 스레드로 실행하려는 코드에 스레드가 오랜 시간 기다려야하는 연산이 포함되어 있다면, 이상적으로 가상스레드는 성능측면에서 유리함
- 블로킹 연산이 있을때 스레드에서 실행되는지 확인함
만약 가상스레드라면 캐리어 스레드를 차단하지않고 가상스레드를 마운트 해제함 -> JVM은 내부적으로 네트워크 연산의 논블로킹 버전을 사용함(복잡성은 비공개)
다른 사용자로부터 다른 요청이 오면 새로운 가상스레드를 만들어 처리함 (가상스레드 생성비용은 새로운 스택프레임이 필요없기에 상대적으로 가벼움)
- 시간이 지나 응답요청이 오면 첫번쨰 가상스레드를 다시 마운트하고 멈췄던 지점부터 코드를 실행함 -> 이후 완료되면 GC에 의해 정리됨
- 논블로킹으로 처리가 되면 운영체제가 스케줄링에 참여하지않았기에 컨텍스트 스위치가 일어나지않음
=> 그저 다른 가상 스레드에 속한 다른 조각의 코드를 찾아 실행을 계속해나갈뿐임 - 차단된 가상 스레드 마운트해제하고 새 가상스레드를 생성하고 마운트하는게 컨텍스트 스위치보다 비교적 좋음
- 가상 스레드의 이점은 블로킹 IO 연산을 훨씬 넘어서고 , 긴 블로킹 연산들이 가상 스레드를 지원하기 위해 다시 개발되었고 캐리어 스레드가 발생하면 이를 해제함
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private static final int NUMBER_OF_TASK = 10000;
public static void main(String[] args) throws InterruptedException {
System.out.println("NUMBER_OF_TASK = " + NUMBER_OF_TASK);
long start = System.currentTimeMillis();
performTask();
System.out.println("Tasks took to Complete" + (System.currentTimeMillis() - start));
}
private static void performTask() {
try (ExecutorService executorService = Executors.newCachedThreadPool()) {
for (int i = 0; i < NUMBER_OF_TASK; i++) {
executorService.submit(() -> blockingIoOperation());
}
}
}
private static void blockingIoOperation() {
System.out.println("blocking task from thread: " + Thread.currentThread());
try {
Thread.sleep(1000);
}catch (Exception e) {
throw new RuntimeException(e);
}
}
- 플랫폼 스레드 풀에서 동시에 10000개 작업을 실행
- 작업 단위 스레드 모델을 사용할떄 캐시 스레드풀을 사용하면 애플리케이션이 충돌함 -> OS가 플랫폼 스레드를 더 많이 할당하는 것을 거부
1
2
3
4
5
6
7
private static void performTask() {
try (ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < NUMBER_OF_TASK; i++) {
executorService.submit(() -> blockingIoOperation());
}
}
}
- 작업단위 가상 스레드 -> 발생하는 모든 작업에 대해 새로운 가상 스레드를 생성 (i5-1135G7 기준 2초걸림)
- 내부적으로 사용자의 CPU 스레드수만큼만 만들어서 작업을 처리함
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class Main {
private static final int NUMBER_OF_TASK = 10000;
public static void main(String[] args) throws InterruptedException {
System.out.println("NUMBER_OF_TASK = " + NUMBER_OF_TASK);
long start = System.currentTimeMillis();
performTask();
System.out.println("Tasks took to Complete" + (System.currentTimeMillis() - start));
}
private static void performTask() {
try (ExecutorService executorService = Executors.newFixedThreadPool(1000)) {
for (int i = 0; i < NUMBER_OF_TASK; i++) {
// 1초를 맞추기위해 10ms를 * 1000함
executorService.submit(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 100; j++) {
blockingIoOperation();
}
}
});
}
}
}
private static void blockingIoOperation() {
System.out.println("blocking task from thread: " + Thread.currentThread());
try {
// 슬립을 10ms으로 줄임
Thread.sleep(10);
}catch (Exception e) {
throw new RuntimeException(e);
}
}
- 해당 코드를 1000개 고정 스레드로 실행하면 10초가 아니라 그 이상의 시간이 걸림 => 이 현상이 블로킹으로 인해 컨텍스트 스위치가 일어나는 높은 부하이며, 플랫폼스레드가 너무 많아 스레싱현상이 나타남
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private static void performTask() {
try (ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor
// 스레드풀 크기를 작업마다 가상스레드를 생성하게 만들어줌
()) {
for (int i = 0; i < NUMBER_OF_TASK; i++) {
executorService.submit(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 100; j++) {
blockingIoOperation();
}
}
});
}
}
}
해당 코드를 실행하게되면 가상스레드를 생성하고 마운트, 해제하는 과정이 컨텍스트스위치보다 빠르다는 것을 알 수 있게됨(훨씬 가벼움)
- 가상스레드는 IO 바운드 애플리케이션에 가장 적합
코어 단위 스레드 모델을 논블로킹IO로 구현하면 비슷한 성능을 낼 수 있음
가상스레드 모범 사례
가상스레드를 사용할 시 가장 중요한 점은 CPU연산만 포함한 작업에서는 성능이점이 없음 -> 이런 상황에서 스면 간접접근밖에 안됨, 이런한 점때문에 가상스레드라는 개념이 추가되고 기존 플랫폼스레드와 함께 있음
- 가상 스레드가 대기 시간 측면에서 전혀 이점이 없음
가상 스레드를 사용해 얻는 유일한 이점은 처리량 증가임
- 짧고 빈번한 블로킹 호출과 함께 작업 단위 스레드 모델을 사용하면 컨텍스트 스위치 비용이 발생함
그러나 가상 스레드를 사용하면 JVM에서 가상 스레드를 마운트하고 해제하는 비용만 발생함
BEST Pracitce
- 가상 스레드를 고정 크기로 만들 수 없음
좋은 방법은 Executors.newVirtualThreadPerTaskExecutor() 통해 작업 실행자마다 새로운 가상 스레드를 실행하는것임
- 가상 스레드가 항상 데몬스레드 !
- 즉 가상 스레드는 애플리케이션이 종료되는 것을 절대 막지못함
가상 스레드의 우선순위를 설정하는것은 아무차이없고 설정한 값도 무시됨
- 디버깅할때 가상 스레드가 캐리어 스레드위에서 실행되고 있는 사실은 숨겨짐
- 디버깅 도구나 개발환경을 활용해 기존 스레드와 동일하게 해야함 (대신 디버깅이 진짜 어려움, 가상 스레드가 몇개가 생길지모름)
출처 - https://kmooc.udemy.com/course/java-multi-threading/ (Java 멀티스레딩, 병행성 및 성능 최적화 - 전문가 되기 )