Java

[Java] 스레드(Thread) 총정리

이덩우 2024. 1. 19. 02:41

1. Thread?

- Process와 Thread

프로세스(Process)란 cpu에 의해 *메모리에 올라가 실행중인 프로그램*을 말한다.

예를 들어 PC에서 브라우저를 키고, 카카오톡을 하는 등 각각의 프로그램이 실행중인 것을 프로세스라고 말한다.

프로세스는 자신만의 메모리 공간을 포함한 *독립적인 실행공간*을 갖고있다.

 

자바의 JVM의 경우 주로 하나의 *프로세스 레벨* 안에서 실행된다.

하나의 프로세스 안에서는 동시에 여러 개의 작업을 수행할 수 있도록 *멀티 스레드를 지원*한다.

 

스레드란 무엇일까?

프로세스가 프로그램 그 자체를 의미한다면 스레드는 해당 프로그램 안에서 *실질적으로 작업을 실행하는 단위*이다.

 

프로세스가 단 하나의 스레드만으로 운영된다면 단일 스레드 환경, 여러 개의 스레드로 운영된다면 *멀티 스레드 환경*이라 부른다.

멀티 스레드는 어떤 상황을 의미할까?

 

예를 들어, 이메일을 보내기 위해 브라우저를 실행한 상황을 생각해보자. (프로세스 시작)

  1. 지인에게 고용량의 메일을 전송했다 (메일 전송 작업)
  2. 메일을 전송하고, 곧바로 수신 메일을 확인한다. (별도의 작업)

1번과 2번은 하나의 프로세스에서 발생하는 별도의 작업을 다루고있다. 

1번 작업과 2번 작업은 동시에 병렬로 처리되기 때문에, 서로 다른 스레드에서 처리되는 중이다.

 

이러한 스레드는 자바에서는 *JVM에 의해 관리된다.*

 

- Thread State

Thread의 라이프사이클

스레드의 상태는 여러 가지가 존재한다. 

  • Thread.State NEW : 스레드가 실행 준비가 완료된 상태, 스레드의 첫 시작.
  • Thread.State RUNNABLE : 스레드가 실행 가능한 상태, 스레드가 대기열에서 실행을 기다리고 있음을 의미.
  • Thread.State BLOCKED : 스레드가 차단되어 있는 상태, 스레드가 잠금(lock) 습득을 기다리는 상태.
  • Thread.State WAITING : 스레드가 대기중인 상태, 대기 상태의 스레드는 다른 스레드가 작업을 완료하기를 기다리고 있는 상태이다.
  • Thread.State TIMED_WAITING : 스레드가 정해진 시간동안 대기하는 상태, WAITING과의 차이는 정해진 시간동안 대기를 한다는 것이다.
  • Thread.State TERMINATED : 스레드가 종료되거나 죽은 상태, 종료된 스레드는 실행이 완료되었음을 의미한다.

 

위에서 실행 단계를 실제 Thread 클래스의 start() 메소드를 보면 쉽게 정리할 수 있다.

public synchronized void start() {
    if (threadStatus != 0)
        throw new IllegalThreadStateException();

    group.add(this);

    boolean started = false;
    try {
        start0();
        started = true;
    } finally {
        try {
            if (!started) {
                group.threadStartFailed(this);
            }
        } catch (Throwable ignore) {
        
        }
    }
}
  1. 스레드가 실행 가능한지 검사
    • 가장 처음 start()를 호출하게 되면 스레드가 실행 가능한지(New(0) 인지) 판단한다. 아니라면 IllegalThreadStateExceptiond 예외를 반환한다.
  2. 스레드를 스레드 그룹에 추가
    • 다음은 스레드 그룹에 실제로 스레드를 추가한다. 스레드 그룹이란, 서로 관련있는 스레드를 쉽게 다루기위한 장치이다. 자바에서는 ThreadGroup 클래스를 제공한다. 스레드 그룹에 추가된 스레드는 *실행 준비가 완료되었음*을 의미하고, 내부적으로 관련 작업들이 진행된다.
  3. JVM을 통해 스레드를 실행
    • 이후 start0()을 호출한다. 해당 메소드는 native 메소드로 선언되어있으며, JVM에 의해 run() 메소드가 호출된다. 스레드의 상태 역시 *Runnable로 변환*된다.

 

만약 스레드를 start() 메소드를 통해 호출하지 않고 run() 메소드를 직접 호출하면 *새로운 스레드가 만들어지지 않고* 메인 스레드를 통해 해당 메소드가 실행된다.

당연한 것이다! start()를 호출해야 JVM이 실제로 새로운 스레드에서 run() 메소드를 대신 호출해주는 원리이므로, 코드 레벨에서 run() 메소드를 직접 호출한다면 해당 메소드를 호출한 스레드에서 run() 메소드가 실행되는 것이다.

@Test
void threadRun() {
    Thread thread = new MyThread();

    thread.run();
    thread.run();
    thread.run();
    System.out.println("Hello: " + Thread.currentThread().getName());
}

// 출력 결과
// Thread: main
// Thread: main
// Thread: main
// Hello: main

 


2. Thread 생성

초창기 자바부터 지원하는, 기본적으로 스레드를 직접 생성하는 방법은 두 가지가 있다.

Thread 클래스를 직접 상속받는 클래스를 생성하는 방식과 Runnable 인터페이스를 활용하는 방식이다.

- Thread 클래스를 직접 extends

public class CreateByExtendsThread {
    static class MyThread extends Thread {
        int loopCount;

        public MyThread() {
            this.loopCount = 0;
        }

        @Override
        public void run() {
            for (int i = 0; i < 5; i++) {
                System.out.println("Thread getName() : " + Thread.currentThread().getName()
                        + ", loopCount : " + loopCount);
                loopCount++;
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        myThread.start();
    }
}
// 출력 결과
// Thread getName() : Thread-0, loopCount : 0
// Thread getName() : Thread-0, loopCount : 1
// Thread getName() : Thread-0, loopCount : 2
// Thread getName() : Thread-0, loopCount : 3
// Thread getName() : Thread-0, loopCount : 4

Thread 클래스를 직접 상속받아 생성하는 방식이다.

사실 후술할 Runnable 함수형 인터페이스를 활용하는 방식과 비교하면 *단점이 많다.*

  • 자바는 다중 상속이 불가능하기 때문에 다른 클래스를 상속받을 수 없음
  • 함수형 인터페이스를 사용하면 익명 함수 및 람다를 사용해 생성할 수 있지만 Thread 클래스를 상속받는 방식은 별도의 클래스를 직접 생성해야 함
  • Thread 클래스에 구현된 코드들에 의해 더 많은 자원들을 사용하기 때문에 비교적 성능이 좋지 않음 

Thread 클래스의 관련 확장 기능이 필요한 것이 아니라면 다음 소개할 Runnable 인터페이스를 활용해 생성하도록 하자.

 

- Runnable 함수형 인터페이스를 활용

package thread;

public class CreateByImplRunnable {
    static class MyThread implements Runnable {
        int loopCount;
        int millisecond;

        public MyThread(int millisecond) {
            this.loopCount = 0;
            this.millisecond = millisecond;
        }

        @Override
        public void run() {
            for (int i = 0; i < 5; i++) {
                System.out.println("Thread getName() : " + Thread.currentThread().getName()
                        + ", loopCount : " + loopCount);
                loopCount++;
                try {
                    Thread.sleep(this.millisecond);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        MyThread runnable1 = new MyThread(1000);
        Thread thread1 = new Thread(runnable1);
        thread1.start();
    }
}
// 출력 결과
// Thread getName() : Thread-0, loopCount : 0
// Thread getName() : Thread-0, loopCount : 1
// Thread getName() : Thread-0, loopCount : 2
// Thread getName() : Thread-0, loopCount : 3
// Thread getName() : Thread-0, loopCount : 4

왜 Runnable 인터페이스를 통해 Thread를 생성할 수 있을까?

 

실제로 Thread 클래스는 Runnable 인터페이스를 상속받고, start0() 메소드에서 run() 메소드를 호출하도록 설계되었다.

public class Thread implements Runnable {
	.
	.
	.
}

따라서 Runnable을 상속받는 클래스를 만들고, Thread를 생성할 때 생성자에 Runnable을 넣어주면 생성되는 것이다.

public Thread(Runnable target) {
	.
	.
	.
}

 

- 문제점

위에서 살펴본 두 방식은 다음과 같은 *한계점*이 존재한다.

  • 값의 반환이 불가능하다.
  • 스레드를 개발자가 직접 생성하고 있다. (스레드를 어떻게 만드는건지는 애플리케이션 개발자의 관심사와 멀다.)
  • 매번 스레드 생성과 종료하는 오버헤드가 발생한다.

이전에 설명했듯, Runnable과 Thread는 Java5 이전부터 스레드를 직접 다루기위해 도입된 방식이다.

따라서 위와 같은 한계점이 존재한다.

자바는 스레드를 더욱 효율적으로 다루기 위해 방법들을 꾸준히 발전시켜오고 있다! 

 

*Callable, Future, Executor, ExecutorService, Executors*에 대한 내용을 아래에서 살펴보자.

 


3. 값의 반환이 가능한 생성 방법

- Callable

기존의 Runnable 인터페이스는 결과를 반환할 수 없다는 한계점이 존재했다.

Java5 이후 제네릭이 추가되면서, 결과를 반환받을 수 있는 Callable 함수형 인터페이스가 추가되었다.

@FunctionalInterface
public interface Callable<V> {
    V call() throws Exception;
}

 

실제로 사용하는 코드를 살펴보자.

@Test
void callable_String() {
    ExecutorService executorService = Executors.newSingleThreadExecutor();

    Callable<String> callable = new Callable<String>() {
        @Override
        public String call() {
            return "Thread: " + Thread.currentThread().getName();
        }
    };

    executorService.submit(callable);
    executorService.shutdown();
}

 

- Future

Callable 인터페이스를 통해 만든 작업은 현재 가용 가능한 스레드가 없어서 실행이 뒤로 미뤄질 수 도 있고, 작업 시간이 오래걸려 요청 즉시 실행 결과를 바로 받지 못할 수 있다. 

즉, 요청한 시점과 별개로 미래의 어느 시점에 결과를 얻을 수 있는데, *미래에 완료된 Callable의 반환값을 구하기 위해 사용되는 것*이 Future이다. 즉 Future은 비동기 작업을 내부적으로 가져 미래에 실행 결과를 얻도록 도와준다. 비동기 작업의 현재 상태를 파악하고, 기다리고, 결과를 얻는 등의 메소드를 제공한다. 

public interface Future<V> {

    boolean cancel(boolean mayInterruptIfRunning);

    boolean isCancelled();

    boolean isDone();

    V get() throws InterruptedException, ExecutionException;

    V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException;
}
  • cancle()
    • 작업을 취소시키며 취소 여부를 boolean으로 반환
    • cancel() 이후에 isDone()은 항상 true를 반환
  • isDone(), isCancelled()
    • isDone()은 작업의 완료 여부, isCancelled()는 작업의 취소 여부를 반환한다.
  • get()
    • 블로킹 방식으로 결과를 가져온다.
    • 타임아웃을 설정할 수 있다.
@Test
void future() {
    ExecutorService executorService = Executors.newSingleThreadExecutor();

    Callable<String> callable = new Callable<String>() {
        @Override
        public String call() throws InterruptedException {
            Thread.sleep(3000L);
            return "Thread: " + Thread.currentThread().getName();
        }
    };


    // It takes 3 seconds by blocking(블로킹에 의해 3초 걸림)
    Future<String> future = executorService.submit(callable);

    System.out.println(future.get());

    executorService.shutdown();
}

위 코드는 3초가 걸리는 작업의 결과를 얻기 위해 Future의 get()을 호출하고 있다. get()은 언급했듯 결과를 기다리는 블로킹 동작을 하기때문에 위 실행은 적어도 3초가 걸릴 것이다.

 


4. 멀티 스레드 환경을 위한 스레드 풀(Thread Pool)

Java5에는 위에서 살펴본 Callable과 Future 외에도, 스레드 풀을 위한 기능들도 추가되었다.

스레드 풀을 위한 Executor, ExecutorService와 스레드 풀 생성을 도와주는 팩토리 클래스인 Executors에 대해 알아보자.

- Executor

동시에 여러 요청을 처리해야하는 경우, 매 번 새로운 스레드를 생성하는 것은 비효율적이다. 따라서 스레드를 미리 만들어두고 재사용하기 위한 *스레드 풀*이라는 개념이 등장하게 되는데, Executor는 스레드 풀을 구현하기 위한 기본이 되는 인터페이스이다.

 

스레드 풀은 아래와 같은 책임을 갖는다.

  1. 스레드의 사용 및 스케줄링 (라이프사이클 관리)
  2. 작업 실행

Executor는 이 중 인터페이스 분리 원칙에 따라 스케줄링은 신경쓰지 않고, *등록된 작업을 실행하는 책임*만 갖는다. 그래서 전달 받은 작업을 실행하는 메소드만 가지고 있다.

public interface Executor {
   void execute(Runnable command);
}

실제 예제를 구현해보면 아래와 같다.

@Test
void executorRun() {
    final Runnable runnable = () -> System.out.println("Thread: " + Thread.currentThread().getName());

    Executor executor = new StartExecutor();
    executor.execute(runnable);
}

static class StartExecutor implements Executor {
    @Override
    public void execute(final Runnable command) {
        new Thread(command).start();
    }
}

 

- ExecutorService

ExecutorService는 Executor를 상속받기 때문에 작업 실행의 책임 뿐 아니라, 작업(Runnable, Callable)을 등록하는 책임을 갖는다. Executor가 작업 실행의 책임만을 가졌다면, ExecutorService가 *실제로 스레드 풀의 역할*을 하게되는 것이다.

따라서 스레드 풀은 기본적으로 ExecutorService 인터페이스를 통해 구현한다. 대표적인 구현체로는 ThreadPoolExecutor가 있는데, 내부에 있는 블로킹 큐에 작업들을 등록하며 가용가능한 스레드가 생기면 작업을 넘겨주는 방식으로 동작한다.

ExecutorService는 크게 다음과 같은 기능을 제공한다.

  • 라이프사이클 관리를 위한 기능
    1. shutdown()
      • 새로운 작업을 더 이상 받지 않음
      • shutdown()이 호출되기 전 등록된 작업의 실행이 끝나고 종료됨
    2. shutdownNow()
      • shutdown과 약간 다르게 이미 등록된 작업들을 인터럽트 시킴
      • 실행되지 않은 작업들은 List<Runnable> 형태로 반환
    3. awaitTermination()
      • shutdown과 shutdownNow이 합쳐진 느낌
      • 새로운 작업을 더 이상 받지 않는다.
      • 일정 시간 모든 작업이 완료되기를 기다리고, 일정 시간이 지나면 완료되지 작업은 종료시킨다.
      • 지정한 시간 내에 모든 작업이 종료되었는지 여부를 반환

ExecutorService를 만들어 작업을 실행하면 shutdown()이 호출되기 전까지 다음 작업을 계속해서 대기하게 된다. 따라서 *작업이 완료되었으면 shutdown()을 명시적으로 호출해*줘야 한다.

@Test
void shutdown() {
    ExecutorService executorService = Executors.newFixedThreadPool(10);
    Runnable runnable = () -> System.out.println("Thread: " + Thread.currentThread().getName());
    executorService.execute(runnable);

    // shutdown 호출
    executorService.shutdown();

    // shutdown 호출 이후에는 새로운 작업들을 받을 수 없음, 에러 발생
    RejectedExecutionException result = assertThrows(RejectedExecutionException.class, 
    	() -> executorService.execute(runnable));
    assertThat(result).isInstanceOf(RejectedExecutionException.class);
}

 

  • 비동기 작업을 위한 기능
    1. submit()
      • 실행할 작업들을 추가하고, 작업의 상태와 결과를 포함하는 Future을 반환함
      • Future의 get()을 호출하면 성공적으로 작업이 완료된 후 결과를 얻을 수 있음
    2. execute()
      • 반환 타입이 void로, 실행 결과를 알 수 없다.
    3. invokeAll()
      • 모든 결과가 나올 때 까지 대기하는 블로킹 방식의 요청
      • 주어진 작업들을 모두 수행하고, 전부 끝나면 각각의 상태와 결과를 나타내는 List<Future>을 반환
      • 최대 스레드 풀의 크기만큼 작업을 동시에 실행, 스레드 수가 충분하다면 동시에 실행되는 작업들 중에서 가장 오래 걸리는 작업만큼 시간이 소요
      • 만약 스레드 수가 부족해 대기되는 작업이 있다면 추가적인 시간이 더 필요
    4. invokeAny()
      • 가장 빨리 실행된 결과가 나올 때 까지 대기하는 블로킹 방식의 요청
      • 주어진 작업들을 모두 수행하고, 가장 빨리 완료된 하나의 Future을 반환

아래는 invokeAll()의 예제 코드이다.

@Test
void invokeAny() throws InterruptedException, ExecutionException {
    ExecutorService executorService = Executors.newFixedThreadPool(10);
    Instant start = Instant.now();

    Callable<String> hello = () -> {
        Thread.sleep(1000L);
        final String result = "Hello";
        System.out.println("result = " + result);
        return result;
    };

    Callable<String> java = () -> {
        Thread.sleep(4000L);
        final String result = "Java";
        System.out.println("result = " + result);
        return result;
    };

    String result = executorService.invokeAny(Arrays.asList(hello, java));
    System.out.println("result = " + result + " time = " + Duration.between(start, Instant.now()).getSeconds());

    executorService.shutdown();
}

 

- Executors

앞서 살펴본 Executor, ExecutorService는 *스레드 풀을 위한 인터페이스*이다. 직접 스레드를 다루는 것은 번거로워 이를 도와주는 *팩토리 클래스*인 Executors가 등장했다.

Executors는 High-Level의 동시성 프로그래밍 모델로, Executor, ExecutorService 또는 SchedueledExecutorService를 구현한 스레드 풀을 손쉽게 생성해준다.

  • newFixedThreadPool()
    • 지정한 수의 고정된 스레드 개수를 갖는 스레드 풀을 생성
    • ExecutorService 인터페이스를 구현한 ThreadPoolExecutor 객체가 생성됨
  • newScheculedThreadPool()
    • 일정 시간 뒤, 혹은 주기적으로 실행되어야 하는 작업을 위한 스레드 풀을 생성
    • ScheculedExecutorService 인터페이스를 구현한 ScheculedThreadPoolExecutor 객체가 생성됨
  • newCachedThreadPool()
    • 필요할 때 필요한 만큼의 스레드를 풀이 생성함
    • 이미 생성된 스레드가 있다면 재활용 가능
  • newSingleThreadExecutor(), newSingleThreadScheduledExecutor()
    • 단일 스레드를 갖는 스레드 풀을 생성함
    • 각 newFixedThreadPool, newScheculedThreadPool에서 1개의 스레드만 생성하도록 한 것

 

 

출처
1. https://myeongdev.tistory.com/74
2. https://mangkyu.tistory.com/258
3. https://mangkyu.tistory.com/259