본문 바로가기
Develop/Java

[Java] Thread Pool 이해하기

by 연로그 2022. 9. 12.
반응형

📚 함께 읽으면 좋은 글

 

📑 목차

  1. Green Thread vs Native Thread
  2. Java는 어떤 스레드를 사용하는가
  3. Thread Pool

 

 


💥 Green Thread vs Native Thread

 

🔸 Green Thread

  • = Virtual Thread
  • User Level Thread
  • OS가 아닌 library 또는 Virtual Machine에 의해 다뤄지는 스레드
  • 기본 OS 기능에 의존하지 않고 다중 스레드 환경을 모방
  • 실제로 여러 코어에서 사용하는 것은 불가능
  • 차단 시스템 호출 시 같은 프로세스 내의 모든 스레드가 차단됨

 

🔸 Native Thread

  • = Non-Green Thread
  • Kernel Level Thread
  • OS에 의해 다뤄지는 스레드

 

 


❓ Java는 어떤 스레드를 사용하는가

 

 JDK 1.1에서는 Green Thread를 제공한다. JVM이 스레드를 직접 생성하고 스케줄링했고 OS는 JVM을 하나의 스레드로만 인식할 수 있었다. (JDK 1.1 for Solaris Developer's Guide) Green Thread는 멀티 프로세서 시스템을 이용할 수 없는 등 여러 비효율적인 면이 있다. 따라서 Java 1.2에서부터는 Green Thread을 사용하지 않도록 변경되었다. 현재의 Java는Native Thread를 사용하고 있다.

 

🔻 Native Thread는 항상 효율적일까?
 Native Thread는 실행하는데 매우 효율적이지만 시작 및 중지 비용에 있어서는 Green Thread가 더 효율적이다. 수명이 짧은 작업들의 경우 Green Thread가 더 효과적으로 사용될 수 있는데 안타깝게도 Java에서는 이를 지원하지 않는다. 라이브러리로 Green Thread를 구현하는 방법도 있지만 이를 직접 구현하는 것은 어려운 작업이 될 것이다. 이 때 대안책으로 사용되는 것 중 하나가 Fiber이다.

 

 


✨ Thread Pool

 

  • Thread: 어떤 프로세스 내에서 실행되는 흐름의 단위
  • Pool: 사용이 준비된 상태로 초기화된 개체 집합

 

 Java에서는 스레드들이 OS 자원을 사용하는 Native Thread와 매핑된다. (1.1에서는 Green Thread) 만약 이 스레드들이 끝도 없이 생성되면 OS 자원이 빠르게 소진될 것이다. Thread Pool은 이전에 생성되었던 스레드를 재사용할 수 있게 해준다. 스레드 개수에 제한을 둬서 OS 자원이 빠르게 소진되는 현상을 막을 수도 있고 요청이 도착할 때 스레드를 새로 생성할 필요 없이 이미 있는 스레드를 재사용하면 되니 응답 속도 측면에서도 효율적이다. 이제 Thread Pool의 동작 방식을 간단히 살펴보자.

 

  1. 병렬 작업하는 형식으로 concurrent code를 작성
  2. 실행을 위해 스레드 풀에 1의 코드를 submit
  3. 스레드 풀에서는 작업들을 실행하기 위해 (재사용되는) 여러 스레드를 제어

 

 

 

 Java에서 Thread Pool을 이용하기 위해 Executors, Executor, ExecutorService를 지원한다. ExecutorService를 이용해 ThreadPoolExecutor를 생성하고, Task를 실행하고, 스레드 풀을 종료할 수도 있다.

class ThreadTest {

    @Test
    void test() {
        ExecutorService executorService = Executors.newFixedThreadPool(2);	// ThreadPoolExecutor 생성
        executorService.submit(new RunnableThread()); 				// Runnable을 구현한 객체 submit
        executorService.shutdown(); 						// ThreadPoolExecutor 종료
    }

    private static class RunnableThread implements Runnable {

        @Override
        public void run() {
            System.out.println(Thread.currentThread());
        }
    }
}

결과 화면

 

 

🌠 ThreadPoolExecutor 생성하기

  • newFixedThreadPool(int)
    : 인자로 주어진 숫자만큼의 스레드를 가진 풀 생성

  • newCachedThreadPool()
    : 이미 생성된 스레드가 있으면 해당 스레드를 사용하고, 없다면 새로운 스레드를 생성하는 풀 생성

  • newSingleThreadExecutor()
    : 스레드 하나만 가질 수 있는 스레드풀 생성
    = newFixedThreadPool(1)

 

🔻 newFixedThreadPool(int) 예제

더보기

 인자로 주어진 숫자만큼의 스레드를 생성한다는 것은 어떻게 확인할 수 있을까? 예제 코드를 통해 확인해보자. 스레드를 2개 가질 수 있는 스레드 풀을 생성했다. RunnableThread라는 클래스를 만들어  3개의 Task들을 만들고 스레드풀을 이용해 각 Task들을 실행시켰다. 각 Task들은 서로를 구분하기 위해 number라는 값을 가졌다. 다음과 같은 코드를 보고 어떤 결과가 나올지 예상해보자.

 

public class ThreadTest {

    @Test
    void test() {
        // 스레드를 2개 갖는 풀 생성
        ExecutorService executorService = Executors.newFixedThreadPool(2);

        // Runnable을 구현한 객체(=Task)를 3개 실행
        for (int i = 0; i < 3; i++) {
            executorService.execute(new RunnableThread(i));
        }

        // 스레드풀 종료
        executorService.shutdown();
    }

    private static class RunnableThread implements Runnable {

        private final int number;

        public RunnableThread(final int number) {
            this.number = number;
        }

        @Override
        public void run() {
            // run 메서드가 호출되면 아래 문구가 3번 출력된다.
            for (int i = 1; i <= 3; i++) {
                try {
                    System.out.printf("%d번 태스크는 %s를 사용한다. (%d번째 출력)%n", number, Thread.currentThread().getName(), i);
                    Thread.sleep(5);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }
    }
}
출력 결과

 

먼저 thread pool을 생성하면 아래와 같이 2개의 스레드를 가진 풀이 생성될 것이다.

 

3개의 Runnable Thread를 생성하고 ExecutorService.execute()를 통해 하나씩 실행시켰다. 0번, 1번 Task에서는 스레드가 존재해서 실행할 수 있지만 2번 Task는 할당받을 스레드가 없어 잠시 대기해야 한다.

 

 

0번 Task가 작업이 끝나면 Thread-1은 사용 가능한 상태가 된다. 이때 대기하고 있던 2번 Task가 Thread-1를 할당 받아 실행한다.

 

🔻 newCachedThreadPool()은 언제 쓰일까?

더보기

60초 동안 사용되지 않는 스레드는 종료하여 캐시에서 제거되므로 오랫동안 유휴 상태로 남아 있는 풀로 인한 리소스가 낭비가 적다. 단시간에 많은 비동기 작업을 실행하는 프로그램의 경우 성능이 향상된다고 한다. 하지만 스레드가 갑자기 폭발적으로 생성된다면 마찬가지로 OS 자원의 소모가 빨라진다. 그래서 어드민 페이지 같은 트래픽이 많지 않은 곳에서 자주 쓰인다고 한다.

 

 

🌠 Thread Pool에 작업 요청하기

  • void execute()
    1. 예외 발생 시 해당 스레드 종료 및 스레드 풀에서 제거
    2. 새로운 스레드 생성해 다른 작업 처리
  • Future<?> submit()
    • 예외 발생해도 스레드 종료하지 않고 다음 작업에 사용
    • 처리 결과를 Future<?>로 반환
      (처리 성공 시 Future.get() 결과가 null이다)

 

 

🌠 Thread Pool 종료하기

ExecutorService를 종료시키지 않으면 계속 다음 Task 수행을 위해 스레드가 계속 대기한다. 직접 종료시켜줘야 자원을 해제할 수 있다. 심지어는 계속 대기중인 ExecutorService 때문에 JVM이 계속 실행되어서 앱이 끝에 도달해도 완전히 중지되지 않는 현상이 발생할 수 있다. ExecutorService의 다양한 종료 메서드를 살펴보자.

 

  • shutdown()
    • 새로운 Task 요청 거절
    • 실행 중인 모든 작업을 완료된 후에 종료
  • shutdownNow()
    • 즉시 중지
    • 실행 중인 모든 스레드가 동시에 중지된다는 보장은 없음

 

🔻 shutdown() vs shutdownNow()

더보기

shutdown()은 실행 중인 작업이 엄청 오래 걸리거나 무한으로 실행된다면 계속 중지되지 않을수도 있다. shutdownNow()는 즉시 중지해서 현재 작업 내역이 날아갈 위험이 있다.  이 두 가지를 절충해서 한 가지 대안으로 일정 시간 동안 기다리고 그 후에도 종료되지 않은 작업이 있다면 강제 종료 시켜버리는 방법이 있다. 이 때 awaitTermination()을 활용하면 된다. awaitTermination()는 실행 중인 모든 작업이 완료될 때까지 특정 시간 동안 대기하다가 완료되지 않은 작업들의 존재 여부에 따라 true/false를 반환한다. 이 점을 이용해 다음과 같은 코드를 짜볼 수 있다.

 

executorService.shutdown();
try {
    if (!executorService.awaitTermination(800, TimeUnit.MILLISECONDS)) {
        executorService.shutdownNow();
    } 
} catch (InterruptedException e) {
    executorService.shutdownNow();
}

 


참고

반응형