JAVA/JAVA기본

Java Thread Deep Dive

realizers 2024. 3. 3. 16:02
728x90
반응형

 

서론


자바를 사용하다 보면 Thread에 대해 많이 들어보고, 대략적인 흐름은 알지만 무심하게 지나치게 되는 상황이 있곤 합니다. 그리고 우리는 ThreadPoolExecutor, ForkJoinPool, VirtualThread, Webflux 등 Thread를 다양하게 사용하는 상황에 대비해 기본적인 Thread에 대해 알 필요가 있습니다. 그럼 지금부터 Thread에 대해 알아보겠습니다.

참고로 VirtualThread에 의해 만들어지는 Thread는 OS와 매칭되는 흐름이 다르기 때문에 해당 글은 VirtualThread에 의해 만들어진 Thread가 아님을 알립니다.

 

 

Thread란?


자바 Thread는 JVM에서 사용자 모드 Thread를 생성할 때 System call을 통해 커널에서 생성된 커널 Thread와 1:1 매칭이 이루어지게 됩니다. 그리고 만들어진 Thread는 커널에서 관리하게 됩니다.

즉, 우리가 애플리케이션에서 Thread를 만들었다면 커널 영역에도 Thread가 하나 만들어지고 이에 매칭이 된다라고 생각해 주시면 될 거 같습니다. 

자바에서는 Platform Thread로 정의되어 있으며, OS 플랫폼에 따라 JVM이 사용자 모드 Thread를 Kernel Thread와 매칭하게 됩니다.

 

Thread의 구조


프로세스는 기본적으로 code 영역, data 영역, stack 영역, heap 영역으로 나눌 수 있습니다. 프로세스마다 4가지의 영역을 가지고 있기 때문에 메모리도 많이 차지할 뿐만 아니라 프로세스끼리 공유 자원을 사용할 때 오버헤드가 큽니다. 

하지만 하나의 프로세스에 여러 스레드를 사용함으로써 메모리 자원을 효율적으로 사용할 수 있고, 오버헤드 비용이 상대적으로 적어집니다. 

 

프로세스 내에 code 영역, data 영역, heap 영역은 공유하고, 각 스레드마다 stack 영역을 확보함으로써 한정된 자원을 보다 효율적으로 사용할 수 있습니다.

보다 자세한 내용을 알고 싶으면 해당 링크에서 확인하실 수 있습니다.

 

Thread의 실행


start 메서드는 Thread를 실행시키는 메서드이며, System call을 통해서 Kernel에 Kernel Thread 생성을 요청하게 됩니다. 이렇게 만들어진 Thread는 OS Scheduler에 의해 실행 순서가 제어되며, JVM은 실행 순서를 제어할 수 없습니다. 그리고 Thread는 독립적으로 실행되며, Thread가 종료된 이후에는 재사용할 수 없습니다.

Thread 생성의 흐름

 

💡 Thread 생성

  • Main Thread가 새로운 Thread를 생성합니다. (Thread를 만들었지만 Heap영역에 메모리만 할당)
  • Main Thread가 start 메서드를 호출하여 Thread를 실행시킵니다.(Kernel Thread와 1:1 매칭)
    1. start 메서드를 호출하면 내부적으로 native 메서드인 start0 메서드를 호출하여 Kernel에게 Kernel Thread를 생성해 달라고 System call을 요청 보내게 됩니다.
    2. Kernel은 System call 요청을 받아 Kernel Thread를 생성하게 되고, Kernel Thread는 자바 Thread와 1:1 매칭이 이루어지게 됩니다.
  • Kernel Thread는 Os Scheduler로부터 CPU를 할당받기 전까지는 실행대기(Runnable) 상태로 머물게 됩니다.
  • Kernel Thread가 Os Scheduler에 의해 CPU를 할당받아 실행 상태가 되면 JVM에서 매칭된 Thread의 run 메서드를 호출하여 작업을 이어나가게 됩니다.

 

🧨 주의점

  • 자바에서 Thread를 생성하고 실행할 때 아래와 같은 코드를 작성하여 run 메서드를 호출한다면 이는 새로운 스레드를 생성하는 게 아니라 직접 호출한 Thread의 Stack에서 단지 run 메서드가 실행될 뿐입니다. 따라서 새로운 Thread를 생성하고 반드시 start 메서드를 호출해야 합니다. 
Thread thread = new Thread();
// 이는 잘못된 방법입니다.
thread.run(); 

// 옳바른 사용 방법
thread.start();

 

💡 Thread 종료

  • Thread는 run 메서드의 로직이 모두 수행되면 자동적으로 종료하게 됩니다.
  • Thread는 예외가 발생하여 종료된 경우, 다른 Thread에게 영향을 미치지 않습니다.

 

Thread의 lifecycle


Os에서는 프로세스의 생명주기가 생성상태, 준비상태, 실행상태, 대기상태, 종료상태로 구분되지만 자바 Thread의 생명주기는 조금 다릅니다. 아래 그림을 통해 하나씩 알아보겠습니다. 그리고 이해를 돕기 위해 Runnable와 Running 상태를 구분하였지만 Thread는 Runnable 상태만을 가집니다.

Thread의 생명 주기

NEW

  • Thread 객체는 생성되었지만 아직 Kernel Thread와 매칭되지 않은 상태이며, Heap 영역에 객체로만 생성되어 있는 상태입니다.

RUNNABLE

  • Kernel에게 System call을 요청하여 Kernel Thread가 생성되고, java Thread와 1:1 매칭이 된 상태입니다.
  • 만들어진 Thread는 바로 실행되는 게 아니라 언제든지 실행할 준비가 되어 있는 상태입니다. 이때 CPU의 자원을 할당받으면 실행되게 됩니다.
  • CPU의 자원을 받아 실행 상태가 된다면 내부적으로 Thread의 run 메서드를 호출하게 됩니다. 또한 context-switch가 발생하게 됩니다.
  • 운영체제에서 준비 상태로 보면 이해하기 편할 거 같습니다.

WAITING

  • Thread가 실행 상태에 있다가 다른 Thread의 특정 작업이 끝나길 기다리는 상태입니다.
  • Waiting 상태에 있는 Thread는 다른 Thread에 의해 notify를 받을 때까지 혹은 join 메서드로 인해 작업을 완료하거나 인터럽트가 발생할 때까지 대기하게 됩니다.

TIMED_WAITING

  • 지정된 시간 동안 Thread는 일시 정지 상태가 됩니다.
  • Thread의 일시 정지 시간이 길어지고, CPU의 자원을 할당받지 못하는 상황이 발생하면 기아 상태가 발생하게 되는데 시간을 지정함으로써 이를 피할 수 있습니다. 그리고 Os 내부적으로 에이징 기법을 사용하여 기아 상태를 방지합니다.
  • 해당 상태에서 Runnable 상태로 갈 수 있는데 이때는 지정된 시간이 만료되거나, 인터럽트가 발생한 거나, notify 같은 메서드로 인해 통지를 받을 때 상태를 바꿀 수 있습니다.

BLOCKED

  • critical section(임계 영역)에서 작업 중인 Thread가 있는 상황에서 해당 영역에 접근을 시도하는 Thread가 있으면 해당 Thread는 blocking 됩니다.
  • critical section에서 작업 중인 Thread가 context-switch가 발생해도 다른 Thread는 critical section에 접근할 수 없습니다. 무조건 lock을 획득하고 나서 접근을 시도할 수 있습니다.

TERMINATED

  • Thread가 실행이 완료되거나 예외로 인해 예기치 않게 종료된 상태입니다.
  • 종료된 Thread는 재사용할 수 없습니다.

 

Thread의 주요 API


Thread를 사용하다 보면 제일 많이 쓰는? sleep 메서드와 join 메서드에 대해 간략하게 알아보겠습니다.

 

 

💡 Sleep 메서드

  • sleep 메서드는 지정된 시간 동안 Thread를 일시 정지 상태로 만들고, 지정된 시간이 만료되면 Runable 상태가 됩니다
  • sleep 메서드는 native 메서드를 통해서 System call을 통해 Kernel 모드로 수행 후 사용자 모드로 전환됩니다.
  • critical section에서 sleep된 Thread는 획득한 Lock을 잃지 않고 계속 유지하게 됩니다.
  • sleep된 Thread에게 인터럽트가 발생한 경우 해당 Thread는 깨어나게 되고, 실행 대기 상태에서 실행 상태로 전환되어 InterruptedException을 처리하게 됩니다.

 

지정된 시간이 만료한 경우

 

sleep중 인터럽트가 발생한 경우

 

 

🤔 Sleep(0)과 Sleep(N)의 차이

  • sleep 메서드는 native 메서드이기 때문에 sleep 메서드를 호출하면 System call을 통해 User 모드에서 Kernel 모드로 전환됩니다.
  • 다른 Thread에게 명확하게 실행을 양보하기 위해서는 sleep(0)이 아닌 sleep(n)을 사용해야 합니다.

 

Sleep(0)의 동작 방식

  • Thread가 Kernel 모드로 전환 후 Os Scheduler는 현재 Thread와 동일한 우선순위를 가지는 다른 Thread가 있는지 확인하고, 다른 Thread가 있는 상황에서 그 Thread가 Runnable 상태라면 그 Thread에게 CPU를 할당함으로써 context-switch가 발생하게 됩니다.
  • 만약 우선순위가 동일한 Runnable 상태의 다른 Thread가 없다면 Os Scheduler는 현재 Thread에게 CPU를 할당함으로써 context-swith가 발생하지 않게 됩니다.

Sleep(N)의 동작 방식

  • Thread가 Kernel 모드로 전환 후 Os Scheduer는 현재 Thread의 상태와 상관없이 일시 정지 상태로 두고, 다른 Thread에게 CPU를 할당함으로써 context-swith가 발생하게 됩니다.

 

 

💡 Join 메서드

  • join 메서드는 한 Thread가 다른 Thread의 작업이 종료될 때까지 실행을 중지하고, 대기 상태에 머무르다가 해당 Thread가 종료하면 실행 대기 상태로 전환되었다가 실행 상태가 됩니다.
  • Thread의 순서를 제어하거나 다른 Thread의 작업을 기다리거나 순차적인 흐름을 구성할 때 사용할 수 있습니다.
  • Object 클래스의 native wait() 메서드로 연결되며 System call을 통해 Kernel 모드로 수행됩니다. 또한 내부적으로 wait와 notify 메서드를 가지고 제어하게 됩니다.

 

정상적인 wait와 notify의 흐름

 

public static void main(String[] args) throws InterruptedException {

        Thread threadB = new Thread(() -> {
            try {
                System.out.println("threadB가 실행되고 있습니다.");
                Thread.sleep(5000);
                System.out.println("threadB의 작업이 완료되었습니다.");
            } catch (InterruptedException e) {}
        });

        Thread threadA = new Thread(() -> {
            try {
                System.out.println("threadA가 실행되고 있습니다.");
                threadB.join();
                System.out.println("threadA는 threadB의 작업이 완료되고 후속 로직을 수행하고 있습니다.");
            } catch (InterruptedException e) {}
        });

        threadA.start();
        threadB.start();
}
// 결과
threadA가 실행되고 있습니다.
threadB가 실행되고 있습니다.
threadB의 작업이 완료되었습니다.
threadA는 threadB의 작업이 완료되고 후속 로직을 수행하고 있습니다.

 

인터럽트가 발생한 경우

 

public class ThreadExample {

    public static void main(String[] args) throws InterruptedException {

        Thread threadB = new Thread(() -> {
            try {
                System.out.println("threadB가 실행되고 있습니다.");
                Thread.sleep(5000);
                System.out.println("threadB의 작업이 완료되었습니다.");
            } catch (InterruptedException e) {
                System.out.println("threadB도 인터럽트에 의해 실행이 중지됩니다.");
            }
        });

        Thread threadA = new Thread(() -> {
            try {
                System.out.println("threadA가 실행되고 있습니다.");
                threadB.join();
                System.out.println("threadA는 threadB의 작업이 완료되고 후속 로직을 수행하고 있습니다.");
            } catch (InterruptedException e) {
                System.out.println("threadA는 인터럽트가 발생했습니다.");
            }
        });

        Thread threadC = new Thread(() -> {
            System.out.println("threadC는 threadA에게 인터럽트를 발생시킵니다.");
            threadA.interrupt();
        });

        threadA.start();
        threadB.start();
        threadC.start();
    }
}
// 결과
threadB가 실행되고 있습니다.
threadC는 threadA에게 인터럽트를 발생시킵니다.
threadA가 실행되고 있습니다.
threadA는 인터럽트가 발생했습니다.
threadB의 작업이 완료되었습니다.

 

Thread의 예외처리


우리는 흔히 Spring Boot에서 ThreadPoolTaskExecutor를 빈으로 정의하고, @Async 어노테이션을 사용한 경험이 있거나 Completablefuture를 사용한 경험이 있을 것입니다. 이때 예외를 처리해야 하는데 우선 기본적으로 어떻게 할 수 있는지 알아보겠습니다.

 

기본적으로 Thread의 run 메서드는 예외를 던질 수 없기 때문에 예외가 발생한 경우 run 메서드 안에서만 예외를 처리해야 합니다. 그렇기 때문에 자바에서 Thread가 비정상적으로 종료되거나, 특정한 예외를 Thread 외부에서 캐치하기 위해서 자바에서는 UncaughtExceptionHandler 인터페이스를 제공하고 있습니다. (다만 상황에 따라 Thread 생성 시 Runnable 인터페이스가 아닌 Callable 인터페이스를 사용한다면 예외처리 가능합니다.)

 

💡 UncaughtExceptionHandler

  • RuntimeException 예외로 인해 Thread가 비정상적으로 종료되었을 때 호출되는 인터페이스 핸들러입니다.
  • 어떤 원인으로 Thread가 종료되었는지 파악할 수 있습니다.
  • Thead 각각으로 예외 핸들러를 설정할 수 있을 뿐 아니라, 기본적인 핸들러를 통해서도 설정할 수 있습니다.
public class ThreadExample {

    // 커스텀하게 핸들러 작성
    private static final Thread.UncaughtExceptionHandler HANDLER = (thread, exception) -> {
        System.out.println("threadName: " + thread.getName() + ", message: " + exception.getMessage());
    };

    public static void main(String[] args) throws InterruptedException {

        // 기본적인 예외 핸들러 작성
        Thread.setDefaultUncaughtExceptionHandler(((t, e) -> {
            System.out.println(t.getName() + "에서 에외 발생: " + e.getMessage());
        }));

        Thread threadA = new Thread(() -> {
            System.out.println("threadA가 실행되고 있습니다.");
            throw new RuntimeException("실행 도중 예기치 않은 이유로 예외가 발생하였습니다.");
        });
        // 핸들러 설정
        threadA.setUncaughtExceptionHandler(HANDLER);


        Thread threadB = new Thread(() -> {
            System.out.println("threadB가 실행되고 있습니다.");
            throw new NullPointerException("NPE 발생");
        });

        threadA.start();
        threadB.start();
    }
}
// 결과
threadA가 실행되고 있습니다.
threadA가 실행되고 있습니다.
Thread-1에서 에외 발생: NPE 발생
threadName: Thread-0, message: 실행 도중 예기치 않은 이유로 예외가 발생하였습니다.

 

 

Thread의 물품 보관소인 ThreadLocal


애플리케이션을 만들다 보면 우리는 Thread 각각에게 데이터를 저장하고 싶을 때가 있을 수 있습니다. 예를 들어 spring security를 사용할 때 각 사용자의 정보는 Thread마다 따로 보관해야 할 때가 있죠. 이때 ThreadLocal을 사용하여 문제를 해결할 수 있습니다.

 

💡 ThreadLocal이란?

  • 자바에서 Thread는 오직 자신만 접근하여 읽고 쓸 수 있는 로컬 저장소를 제공하는데 이를 ThreadLocal이라 합니다.
  • ThreadLocal은 Thread간 격리되어 있습니다.
  • Thread는 ThreadLocal에 저장된 값을 특정 위치나 시점에 상관없이 어디서나 전역변수처럼 접근하여 사용할 수 있습니다.

 

💡 ThreadLocal의 동작 원리

 

데이터 저장시

public class ThreadExample {

    private static final ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<>();

    public static void main(String[] args) throws InterruptedException {

        Thread threadA = new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "의 값: " + THREAD_LOCAL.get());
            THREAD_LOCAL.set("threadA에 값 추가 hello world?!");
            System.out.println(Thread.currentThread().getName() + "의 값: " + THREAD_LOCAL.get());

            // 사용 후 반드시 remove 호출
            THREAD_LOCAL.remove();
        });

        Thread threadB = new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "의 값: " + THREAD_LOCAL.get());
            THREAD_LOCAL.set("threadB에 값 추가 HELLO WORLD?!");
            System.out.println(Thread.currentThread().getName() + "의 값: " + THREAD_LOCAL.get());

            // 사용 후 반드시 remove 호출
            THREAD_LOCAL.remove();
        });

        threadA.setName("threadA");
        threadA.start();

        threadB.setName("threadB");
        threadB.start();
    }
}
// 결과
threadA의 값: null
threadB의 값: null
threadA의 값: threadA에 값 추가 hello world?!
threadB의 값: threadB에 값 추가 HELLO WORLD?!

 

데이터 조회 시

 

 

 

🧨 ThreadLocal 사용 시 주의점

  • ThreadLocal에 저장된 값은 Thread마다 독립적으로 저장되기 때문에 데이터를 삭제하지 않아도 메모리를 점유하는 것 외에 문제가 발생하지 않습니다.(이 또한 문제이기 하죠 삭제하지 않으면 메모리 누수 ^_^)
  • 그리고 스프링은 ThreadPool 기반이므로 ThreadLocal 사용 시 반드시 remove 메서드를 호출해야 합니다.
  • remove 메서드를 호출하지 않는다면 ThreadPool은 Thread를 재사용하기 때문에 현재 Thread는 이전 Thread에서 삭제하지 않은 데이터를 참조할 수 있기 때문에 문제가 발생하게 됩니다.

 

💡 ThreadLocal에 데이터 저장 시 흐름

  • ThreadLocal에 데이터를 저장하는 순서는 대락적으로 아래 그림처럼 흘러갑니다. 
  • 클라이언트 A의 요청이 왔을 때 WAS는 ThreadPool에서 가용 Thread 하나를 조회하게 됩니다. 그리고 Thread-A가 할당되고, 클라이언트A의 정보는 Thread-A의 ThreadLocal에 저장됩니다.

 

 

🤔 ThreadLocal의 remove 메서드를 호출하지 않는다면?

  • 클라이언트 B의 요청이 들어온 경우 이번에도 똑같이 WAS는 ThreadPool에서 가용 Thread 하나를 조회하게 됩니다. 여기서 Thread-A가 할당될 수도 있고, 다른 Thread가 할당될 수도 있지만 편의상 Thread-A가 할당된다고 가정하겠습니다. 이때 Thread-A는 기존 클라이언트 A의 정보를 저장하고 있었지만 remove 메서드를 호출하지 않아 더미 데이터를 보관하고 있었습니다. 이때 get 메서드를 통해 조회하면 예기치 않게 클라이언트A의 정보를 반환하게 됩니다.

 

User Thread와 Daemon Thread


ThreadPoolExecutor를 사용하다 보면 개발자가 Thread를 Daemon Thread로 만들지 User Thread로 만들지 한 번쯤 고민하게 됩니다. 이때 각각이 무엇인지 살펴보겠습니다.

 

우선 자바에서 User Thread에 의해 만들어진 Thread는 User Thread이고, Daemon Thread에 의해 만들어진 Thread는 Daemon Thread입니다. 즉 자식 Thread는 부모 Thread를 상속받게 됩니다. 
그리고 자바 애플리케이션이 시작되면 JVM은 User Thread인 Main Thread와 Daemon Thread를 동시에 생성하고 시작하게 됩니다.

 

💡 Main Thread

  • Main Thread는 자바 애플리케이션에서 가장 중요한 Thread입니다. JVM은 애플리케이션이 시작되면 Main Thread를 생성하게 됩니다.
  • Main Thread에서 여러 하위 스레드를 추가할 수도 있고, 만들어진 하위 스레드에 또 하위 스레드를 만들 수 있습니다.
  • Main Thread는 User Thread이므로 Main Thread에 의해 만들어진 스레드 또한 User Thread입니다. 하지만 setDaemon 메서드를 통해 Daemon Thread로 만들 수 있습니다.

💡 User Thread

  • User Thread는 Main Thread에서 직접 생성한 스레드입니다.
  • User Thread는 각각 독립적인 생명주기를 가지고 실행되며, Main Thread를 포함한 모든 사용자 스레드가 종료되면 애플리케이션이 종료하게 됩니다.
  • User Thread는 foreground에서 실행되는 높은 우선순위를 가지며, JVM은 사용자 스레드가 스스로 종료될 때까지 애플리케이션을 종료하지 않고 기다립니다.
  • 자바는 ThreadPoolExecutor를 사용하여 User Thread를 만들게 됩니다.

 

💡 Daemon Thread

  • Daemon Thread는 JVM에서 생성한 스레드이거나 직접 Daemon Thread로 생성한 경우입니다.
  • 모든 User Thread가 완료되면 Daemon Thread의 실행 여부와 상관없이 JVM은 Daemon Thread를 강제 종료하고 애플리케이션이 종료되게 됩니다.
  • Daemon Thread의 생명주기는 User Thread에 따라 다르며, 낮은 우선순위를 가지고 background로 실행됩니다.
  • Daemon Thread는 User Thread를 보조 및 지원하는 성격을 가진 스레드로 보통 사용자 스레드를 방해하지 않으며 백그라운드에서 자동적으로 작동되는 기능을 가진 스레드입니다.
  • 자바가 제공하는 ForkJoinPool은 Daemon Thread를 생성하게 됩니다.
public class ThreadExample {

    public static void main(String[] args) throws InterruptedException {

        Thread userThread = new Thread(() -> {
            System.out.println("1. 스레드의 데몬 여부: "+ Thread.currentThread().isDaemon());
            new Thread(() -> System.out.println("1-1. 사용자 스레드에 의해 만들어진 스레드의 데몬 여부: "+ Thread.currentThread().isDaemon()))
                    .start();
        });
        Thread daemonThread = new Thread(() -> {
            System.out.println("2. 스레드의 데몬 여부: "+ Thread.currentThread().isDaemon());
            new Thread(() -> System.out.println("2-2. 데몬 스레드에 의해 만들어진 스레드의 데몬 여부: "+ Thread.currentThread().isDaemon()))
                    .start();
        });
        daemonThread.setDaemon(true);

        userThread.start();
        daemonThread.start();
    }
}
// 결과
1. 스레드의 데몬 여부: false
2. 스레드의 데몬 여부: true
2-2. 데몬 스레드에 의해 만들어진 스레드의 데몬 여부: true
1-1. 사용자 스레드에 의해 만들어진 스레드의 데몬 여부: false

 

 

 

꺼억...!

 

 

 

 

 

 

 

728x90
반응형