Post

자바 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 멀티스레딩, 병행성 및 성능 최적화 - 전문가 되기 )

This post is licensed under CC BY 4.0 by the author.