ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Java] Thread란?
    Java 2025. 12. 1. 14:19

    1. 프로세스(Process)와 스레드(Thread)의 물리적 차이

    이 부분은 "운영체제 레벨에서 Java가 어떻게 자원을 쓰는지" 명확히 짚고 넘어가야 합니다.

    이전 포스팅에서 작성했던 내용을 참고해주세요.

    https://msj9965.tistory.com/36

    2. 스레드의 생명주기 (Lifecycle)

    Thread.getState()로 확인할 수 있는 6가지 상태는 스레드 덤프를 분석하거나 교착 상태(Deadlock)를 해결할 때 필수적인 지식입니다.

    1) NEW

    • 스레드 객체는 생성되었지만, 아직 start()가 호출되지 않은 상태입니다.
    • 커널 레벨의 스레드(Native Thread)는 아직 생성되지 않은 순수한 자바 객체 상태입니다.

    2) RUNNABLE (실행 대기 + 실행 중)

    • 주의: 이름은 '실행 가능'이지만, 실제로는 실행 중인 상태(Running)와 실행을 위해 대기하는 상태(Ready)를 모두 포함합니다.
    • Java에서는 OS 스케줄러가 누구에게 CPU를 줄지 결정하므로, Java 레벨에서는 이를 구분하지 않고 통칭하여 RUNNABLE이라고 합니다.

    3) WAITING / TIMED_WAITING (일시 정지)

    • 스레드가 작업을 멈추고 기다리는 상태입니다.
    • WAITING: 다른 스레드가 깨워줄 때까지 (notify(), join()) 무한정 기다립니다.
    • TIMED_WAITING: sleep(1000)처럼 정해진 시간만큼만 기다립니다.

    4) BLOCKED (차단)

    • 공유 객체의 Lock(Monitor)을 획득하려고 했으나, 다른 스레드가 이미 Lock을 쥐고 있어서 진입하지 못하는 상태입니다.
    • 성능 저하의 주범인 병목 구간이 여기서 발생합니다.

    5) TERMINATED

    • run() 메서드의 실행이 완료되어 스레드가 종료된 상태입니다. 한 번 죽은 스레드는 다시 start() 할 수 없습니다.

    3. 컨텍스트 스위칭 (Context Switching)

    Context Switching은 다음과 같습니다.
    CPU는 한 번에 하나의 프로세스(또는 스레드)만 실행할 수 있습니다. 하지만 우리는 여러 작업을 동시에 하는 것처럼 느낍니다. 이는 CPU가 아주 빠른 속도로 여러 프로세스/스레드를 번갈아 가며 실행하기 때문입니다.

    이때, 실행 중이던 프로세스/스레드의 상태(Context)를 저장하고, 다음 순서의 프로세스/스레드 상태를 읽어와 실행을 재개하는 과정을 컨텍스트 스위칭이라고 합니다.

    무엇이 저장되고 로드될까요?

    여기서 말하는 '문맥(Context)'은 CPU가 해당 작업을 중단한 지점부터 다시 실행하기 위해 필요한 모든 정보를 말합니다.

    PCB (Process Control Block): 프로세스의 문맥을 저장하는 블록.
    TCB (Thread Control Block): 스레드의 문맥을 저장하는 블록.

    자바 개발자로서 중요한 점은 스레드 컨텍스트 스위칭입니다. 스레드는 프로세스 내의 메모리(Heap, Data, Code 영역)를 공유하므로, 프로세스 스위칭보다 비용이 적습니다.

    스레드 컨텍스트 스위칭 시 저장/로드되는 정보는 다음과 같습니다.
    Program Counter (PC): 다음 실행할 명령어의 주소
    Register Sets: CPU 레지스터 값들 (변수 값 등)
    Stack Pointer: 현재 스레드의 스택 위치



    컨텍스트 스위칭의 비용은 어떨까요?

    컨텍스트 스위칭은 공짜가 아닙니다. 너무 잦은 스위칭은 오히려 성능 저하를 일으킵니다. 이를 오버헤드(Overhead)라고 합니다.

    1.  CPU 시간 소모:
        문맥을 저장하고 로드하는 시간 동안 CPU는 실제 작업(유저 코드 실행)을 하지 못합니다. 
    2.  캐시 오염 (Cache Pollution) 
        CPU 캐시 메모리에는 현재 실행 중인 스레드의 데이터가 담겨 있습니다.
        스레드가 바뀌면 캐시에 있는 데이터는 무효화되고, 새로운 스레드의 데이터로 다시 채워야 합니다. 이 과정에서 성능 저하가 발생합니다.


    자바(Java)와 컨텍스트 스위칭

    자바의 스레드(`java.lang.Thread`)는 기본적으로 OS의 네이티브 스레드(Kernel Thread)와 1:1로 매핑됩니다. (JDK 21 이전 기준)

    1.  OS 스케줄러의 영향: 자바 스레드의 스케줄링과 컨텍스트 스위칭은 JVM이 아닌 OS 커널이 담당합니다.
    2.  비용 문제: 요청이 많은 웹 애플리케이션(Spring Boot 등)에서 스레드를 무한정 늘리면, 실제 처리보다 컨텍스트 스위칭에 쓰이는 비용이 더 커져 서버가 멈추는 스레싱(Thrashing) 현상이 발생할 수 있습니다.
    3.  해결책 (Virtual Threads): 이를 해결하기 위해 JDK 21부터 가상 스레드(Virtual Threads)가 도입되었습니다. 가상 스레드는 OS 스레드 하나 위에서 여러 개의 가상 스레드가 동작하며, 컨텍스트 스위칭 비용이 획기적으로 낮습니다(JVM 레벨에서 처리).


    내용을 정리하면 다음과 같습니다.

    정의: CPU 제어권을 다른 스레드에 넘겨주기 위해 현재 상태를 저장하고 새 상태를 불러오는 작업.
    핵심: 동시성(Concurrency)을 위한 필수 과정이지만, 비용(Overhead)이 발생한다.
    스레드 vs 프로세스: 스레드는 메모리를 공유하므로 프로세스 스위칭보다 가볍지만, 여전히 비용은 든다.
    개발자의 관점: 스레드를 너무 많이 생성하면 컨텍스트 스위칭 비용 때문에 오히려 성능이 떨어질 수 있으므로, 적절한 스레드 풀(Thread Pool) 설정이 중요하다.


    4. 스레드 구현 방식

    실무에서는 거의 무조건 Runnable 인터페이스를 사용합니다.

    방식 1: Thread 클래스 상속

    class MyThread extends Thread {
        @Override
        public void run() {
            System.out.println("Hello Thread!");
        }
    }
    // 사용: new MyThread().start();
    
    • 단점: Java는 다중 상속이 불가능하므로, 다른 클래스를 상속받을 수 없게 됩니다. 확장성이 떨어집니다.

    방식 2: Runnable 인터페이스 구현 (추천)

    class MyTask implements Runnable {
        @Override
        public void run() {
            System.out.println("Hello Runnable!");
        }
    }
    // 사용: new Thread(new MyTask()).start();
    
    • 유연성: 다른 클래스를 상속받으면서 스레드 기능도 수행할 수 있습니다.
    • 설계 철학: 작업의 내용(Runnable)과 작업을 수행하는 주체(Thread)를 분리합니다. 이는 나중에 배울 ExecutorService(스레드 풀)로 작업을 넘길 때 코드 수정 없이 그대로 사용할 수 있게 해줍니다.

    5. 데몬 스레드 (Daemon Thread)

    단순히 "백그라운드 스레드"라고만 알고 넘어가기엔 중요한 특징들이 있습니다. ‘JVM이 언제 종료되는가?’를 결정하는 핵심 요소이기 때문입니다.

    1) 정의와 핵심 차이 (User Thread vs Daemon Thread)

    자바의 스레드는 크게 두 가지로 나뉩니다.

    • User Thread (일반 스레드):
      • 우리가 흔히 만드는 스레드입니다. (main() 스레드 포함)
      • JVM의 종료 규칙: 실행 중인 User Thread가 하나라도 남아있다면, JVM은 종료되지 않습니다.
    • Daemon Thread:
      • User Thread를 보조하는 역할을 합니다.
      • JVM의 종료 규칙: 실행 중인 User Thread가 모두 종료되면, Daemon Thread가 실행 중이라 하더라도 JVM은 강제로 종료됩니다.

    2) 대표적인 예시: Garbage Collector (GC)

    데몬 스레드를 이해하는 가장 완벽한 예시는 GC(Garbage Collector)입니다.

    • 만약 GC가 User Thread라면?
      • 프로그램이 끝났는데도 GC가 메모리 청소를 하느라 JVM이 영원히 종료되지 않게 됩니다.
    • GC는 Daemon Thread이기 때문에, 메인 프로그램(User Thread)이 끝나면 완료 여부와 상관없이 JVM이 종료됩니다.

    3) 구현 시 주의사항 (코드 레벨)

    데몬 스레드를 설정할 때는 반드시 start() 호출 에 설정해야 합니다.

    Thread daemonThread = new Thread(() -> {
        while (true) {
            System.out.println("자동 저장 중...");
            try { Thread.sleep(3000); } catch (InterruptedException e) {}
        }
    });
    
    // ⚠️ 반드시 start() 전에 호출해야 함!
    // start() 이후에 호출하면 IllegalThreadStateException 발생
    daemonThread.setDaemon(true); 
    
    daemonThread.start();
    

    4) 주의점

    "Daemon Thread에서는 finally 블록이 실행되지 않을 수 있다."

    User Thread가 모두 종료되어 JVM이 셧다운 될 때, JVM은 Daemon Thread가 작업을 마칠 때까지 기다려주지 않습니다. 그냥 즉시 전원을 꺼버리듯 스레드를 죽여버립니다.

    Thread daemon = new Thread(() -> {
        try {
            System.out.println("작업 시작");
            Thread.sleep(10000); // 긴 작업
        } catch (InterruptedException e) {
            // ...
        } finally {
            // ⚠️ 경고: 이 코드는 실행된다는 보장이 없습니다!
            System.out.println("리소스 정리 (파일 닫기 등)"); 
        }
    });
    
    daemon.setDaemon(true);
    daemon.start();
    
    Thread.sleep(1000); // 메인 스레드 1초 후 종료
    System.out.println("메인 스레드 종료");
    // 결과: "리소스 정리" 로그는 찍히지 않고 프로그램이 끝남.
    

    💡 Insight:

    따라서 파일 입출력(I/O)이나 DB 연결 해제와 같이 데이터 무결성이 중요한 작업은 절대 데몬 스레드에 맡기면 안 됩니다.

    5) 언제 사용하는가?

    • 자동 저장 (Auto-save): 워드 프로세서 등에서 메인 작업이 끝나면 자동 저장도 더 이상 필요 없으므로.
    • 미디어 플레이어의 백그라운드 재생: (단, 앱 종료 시 같이 꺼져야 하는 경우)
    • 모니터링/Health Check: 애플리케이션 상태를 주기적으로 서버에 전송하는 작업.

     

Designed by MSJ.