Java 스레드 생명주기 및 상태 관리

자바에서 스레드는 프로그램 내에서 독립적으로 실행되는 작업 단위를 의미합니다. 동시성 프로그래밍의 핵심 요소인 스레드를 효과적으로 활용하기 위해서는 스레드의 생성 방법, 주요 제어 메서드, 그리고 스레드 상태에 대한 이해가 필수적입니다.

스레드 생성 기법

자바에서 스레드를 생성하는 방법은 크게 세 가지로 나눌 수 있습니다.

1. Thread 클래스 상속

Thread 클래스를 상속받아 새로운 스레드 클래스를 정의하고, run() 메서드를 오버라이드하여 스레드가 수행할 작업을 명시합니다. 이 방법은 간단하지만 자바의 단일 상속 제한 때문에 다른 클래스를 상속받을 수 없다는 제약이 있습니다.

public class CustomThread extends Thread {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " - 스레드 실행 중 (Thread 상속)");
    }
}

public class ThreadExample {
    public static void main(String[] args) {
        CustomThread myThread = new CustomThread();
        myThread.start();
    }
}

2. Runnable 인터페이스 구현

Runnable 인터페이스를 구현하여 run() 메서드를 정의하는 방식입니다. 이 구현체를 Thread 객체의 생성자에 전달하여 스레드를 생성합니다. Runnable은 인터페이스이므로 다른 클래스를 상속받을 수 있으며, 동일한 Runnable 객체를 여러 Thread 객체에 할당하여 자원을 공유하는 데 용이합니다.

public class WorkerTask implements Runnable {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " - 작업 실행 중 (Runnable 구현)");
    }
}

public class RunnableExample {
    public static void main(String[] args) {
        WorkerTask task = new WorkerTask();
        Thread threadA = new Thread(task, "작업자-A");
        threadA.start();

        Thread threadB = new Thread(task, "작업자-B"); // 동일한 Runnable 객체 공유
        threadB.start();
    }
}

3. Callable 인터페이스와 FutureTask 활용

Callable 인터페이스는 Runnable과 유사하지만, call() 메서드가 값을 반환하고 예외를 던질 수 있다는 차이점이 있습니다. Callable 객체는 FutureTask로 감싸져 Thread 객체의 생성자에 전달됩니다. FutureTask는 비동기 계산의 결과를 검색하거나 취소할 수 있는 기능을 제공합니다.

import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
import java.util.concurrent.ExecutionException;

public class DataProcessor implements Callable<String> {
    private final int startNum;

    public DataProcessor(int startNum) {
        this.startNum = startNum;
    }

    @Override
    public String call() throws Exception {
        int sum = 0;
        for (int i = 0; i < 5; i++) {
            sum += (startNum + i);
            System.out.println(Thread.currentThread().getName() + " - 현재 계산 값: " + sum);
            Thread.sleep(100); // 작업 시뮬레이션
        }
        return "최종 합계: " + sum + " (" + Thread.currentThread().getName() + ")";
    }
}

public class CallableExample {
    public static void main(String[] args) {
        DataProcessor processor = new DataProcessor(10);
        FutureTask<String> futureTask = new FutureTask<>(processor);
        Thread callableThread = new Thread(futureTask, "데이터 처리 스레드");
        
        callableThread.start();

        try {
            String result = futureTask.get(); // 스레드 작업 완료 대기 및 결과 획득
            System.out.println("비동기 작업 결과: " + result);
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
    }
}

스레드 생성 방법 비교

  • Thread 상속: 단일 상속의 제약이 있지만, 스레드 관련 메서드에 직접 접근하기 용이합니다.
  • Runnable 구현: 다중 상속 제약을 피하고, 여러 스레드가 하나의 Runnable 객체를 공유하여 자원 관리에 효율적입니다. 반환 값이 없는 작업을 수행할 때 적합합니다.
  • Callable 구현: 반환 값이 있는 작업을 수행하거나 예외 처리가 필요할 때 유용하며, FutureTask를 통해 비동기 결과 관리가 가능합니다. 스레드 풀에서 주로 사용됩니다.

주요 스레드 제어 메서드

스레드의 생명주기를 관리하고 동작을 제어하기 위한 여러 메서드가 존재합니다.

  • start(): 스레드를 실행 가능한 상태(Runnable)로 전환하고 JVM 스케줄러에게 해당 스레드를 실행하도록 요청합니다. 이 메서드는 스레드의 새로운 실행 흐름을 시작합니다.
  • run(): 스레드가 실제로 수행할 작업을 정의하는 메서드입니다. start() 메서드에 의해 내부적으로 호출되며, 직접 run()을 호출하면 새로운 스레드가 생성되지 않고 현재 스레드에서 일반 메서드처럼 실행됩니다.
  • sleep(long millis): 현재 실행 중인 스레드를 지정된 밀리초(ms) 동안 일시 정지(Timed Waiting)시킵니다. 이 메서드는 static이며, 락(lock)을 해제하지 않습니다.
  • yield(): 현재 실행 중인 스레드가 CPU 사용 권한을 포기하고 Runnable 상태로 돌아가 다른 스레드에게 실행 기회를 양보합니다. 이 메서드도 static이며, 특정 스레드에게 기회를 주는 것이 아니라 스케줄러에게 힌트를 제공하는 수준입니다.
  • join(): 호출한 스레드가 특정 스레드의 작업이 완료될 때까지 기다리게 합니다. 예를 들어, threadA.join()을 호출하면 현재 스레드는 threadA가 종료될 때까지 Waiting 상태로 대기합니다.
  • interrupt(): 스레드에 인터럽트 요청을 보냅니다. 스레드가 sleep(), wait(), join() 등과 같이 블로킹된 상태였다면 InterruptedException을 발생시키고, 그렇지 않다면 스레드의 인터럽트 상태 플래그를 true로 설정합니다. 이를 통해 스레드를 안전하게 종료하는 로직을 구현할 수 있습니다.
  • setDaemon(boolean on): 스레드를 데몬 스레드(Daemon Thread) 또는 사용자 스레드(User Thread)로 설정합니다. 데몬 스레드는 주 스레드의 작업을 돕는 보조적인 역할을 하며, 모든 사용자 스레드가 종료되면 JVM이 자동으로 종료됩니다. 이 메서드는 스레드가 start()되기 전에 호출해야 합니다.
  • isDaemon(): 해당 스레드가 데몬 스레드인지 여부를 반환합니다.
  • setPriority(int newPriority): 스레드의 우선순위를 설정합니다. 우선순위는 1(MIN_PRIORITY)부터 10(MAX_PRIORITY)까지 있으며, 기본값은 5(NORM_PRIORITY)입니다. 우선순위는 스케줄러가 스레드에 CPU 시간을 할당하는 방식에 영향을 줄 수 있지만, OS 및 JVM 구현에 따라 다를 수 있습니다.

join() 메서드 예시

세 개의 스레드를 생성하고, join()을 사용하여 특정 실행 순서를 보장하는 예시입니다.

public class ThreadOrderExample {
    private static void printCompletion(String taskName) {
        System.out.println(taskName + " 완료.");
    }

    public static void main(String[] args) {
        Thread lastTask = new Thread(() -> printCompletion("마지막 작업"), "LastTask");
        
        Thread middleTask = new Thread(() -> {
            try {
                lastTask.join(); // 마지막 작업이 끝날 때까지 대기
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                System.out.println(Thread.currentThread().getName() + " 중단됨.");
            }
            printCompletion("중간 작업");
        }, "MiddleTask");

        Thread firstTask = new Thread(() -> {
            try {
                middleTask.join(); // 중간 작업이 끝날 때까지 대기
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                System.out.println(Thread.currentThread().getName() + " 중단됨.");
            }
            printCompletion("첫 번째 작업");
        }, "FirstTask");

        firstTask.start();
        middleTask.start();
        lastTask.start();
    }
}

이 코드를 실행하면 "마지막 작업 완료." → "중간 작업 완료." → "첫 번째 작업 완료." 순서로 출력될 것입니다.

스레드 상태

스레드는 생명주기 동안 다양한 상태를 거칩니다. 자바 Thread.State enum은 다음과 같은 6가지 주요 상태를 정의합니다.

  • NEW (생성): new 연산자로 스레드 객체를 생성했지만, 아직 start() 메서드가 호출되지 않은 상태입니다.
  • RUNNABLE (실행 가능): start() 메서드가 호출되어 JVM 스케줄러가 스레드를 실행할 수 있는 상태입니다. 이 상태는 다시 'Ready' (CPU 할당 대기)와 'Running' (CPU 할당 받아 실행 중)으로 나눌 수 있습니다.
  • BLOCKED (블록): 모니터 락(monitor lock)을 얻기 위해 대기하고 있는 상태입니다. 예를 들어, synchronized 블록이나 메서드에 진입하려 하지만 다른 스레드가 이미 락을 가지고 있을 때 발생합니다.
  • WAITING (대기): 다른 스레드의 특정 행동(예: Object.notify(), Object.notifyAll())을 기다리며 무기한 대기하고 있는 상태입니다. Object.wait(), Thread.join(), LockSupport.park() 메서드 호출 시 발생합니다.
  • TIMED_WAITING (시간 제한 대기): 특정 시간 동안 대기하거나, 다른 스레드의 특정 행동을 기다리는 상태입니다. Thread.sleep(), Object.wait(long timeout), Thread.join(long timeout), LockSupport.parkNanos(), LockSupport.parkUntil() 메서드 호출 시 발생합니다.
  • TERMINATED (종료): 스레드의 run() 메서드가 완료되거나 예외로 인해 종료된 상태입니다. 스레드의 생명주기가 끝납니다.

스레드 상태 전이

스레드는 위에서 언급된 메서드 호출이나 외부 이벤트에 의해 한 상태에서 다른 상태로 전환될 수 있습니다.

  • NEW -> RUNNABLE: start() 메서드 호출 시
  • RUNNABLE -> BLOCKED: synchronized 블록 진입 시 락을 얻지 못했을 때
  • RUNNABLE -> WAITING/TIMED_WAITING: Object.wait(), Thread.join(), Thread.sleep() 등 호출 시
  • WAITING/TIMED_WAITING -> BLOCKED/RUNNABLE: 대기 조건이 충족되거나 시간이 만료된 후 락을 얻기 위해 대기하거나 실행 준비 상태로 돌아갈 때
  • 모든 상태 -> TERMINATED: run() 메서드 실행 완료 또는 예외 발생 시

주요 스레드 제어 메서드 비교

sleep()wait(), yield(), join()

  • sleep(long millis):
    • 현재 스레드를 지정된 시간 동안 TIMED_WAITING 상태로 만듭니다.
    • 정적(static) 메서드이며, 스레드가 보유한 모니터 락(monitor lock)을 해제하지 않습니다.
    • 주로 스레드의 실행을 일시적으로 지연시키고 싶을 때 사용됩니다.
  • wait() / wait(long timeout):
    • 현재 스레드를 WAITING 또는 TIMED_WAITING 상태로 만듭니다.
    • 인스턴스(instance) 메서드이며, 호출하는 스레드가 보유한 모니터 락을 해제합니다.
    • notify() 또는 notifyAll() 호출을 통해 깨어날 때까지 기다립니다.
    • synchronized 블록/메서드 내에서만 호출할 수 있습니다. 주로 스레드 간의 협업(생산자-소비자 패턴 등)에 사용됩니다.
  • yield():
    • 현재 실행 중인 스레드를 RUNNABLE 상태로 되돌리고, 다른 스레드에게 CPU 실행 기회를 양보하도록 스케줄러에게 힌트를 제공합니다.
    • 정적(static) 메서드이며, 락을 해제하지 않습니다.
    • 스케줄러에 의해 무시될 수 있으므로, 실행 보장에 대한 강제성이 없습니다.
  • join() / join(long timeout):
    • 현재 스레드가 join()을 호출한 스레드가 종료될 때까지 WAITING 또는 TIMED_WAITING 상태로 대기하게 합니다.
    • 인스턴스(instance) 메서드입니다.
    • 스레드 간의 특정 실행 순서를 보장해야 할 때 사용됩니다.

Thread 클래스 상속과 Runnable 인터페이스 구현의 차이

  • 상속의 제약: Thread 클래스를 상속받는 경우 자바의 단일 상속 특성상 다른 클래스를 상속받을 수 없습니다. Runnable 인터페이스를 구현하면 이러한 제약에서 자유롭습니다.
  • 자원 공유: Runnable 인터페이스를 구현하는 방식은 여러 스레드가 동일한 Runnable 객체 인스턴스를 공유할 수 있으므로, 동일한 데이터나 자원에 접근하여 처리하는 다중 스레드 환경에 더 적합합니다. Thread 클래스를 상속받는 경우 각 스레드가 별개의 객체를 가지므로 자원 공유가 더 복잡해질 수 있습니다.
  • 유연성 및 설계: Runnable은 '작업' 자체를 정의하고, Thread는 '작업을 수행하는 주체'를 정의한다는 관점에서 역할 분리가 명확합니다. 이는 객체 지향 설계 원칙에 더 부합합니다.

Runnable 인터페이스와 Callable 인터페이스 구현의 차이

  • 반환 값: Runnablerun() 메서드는 반환 값이 void인 반면, Callablecall() 메서드는 제네릭 타입의 반환 값을 가집니다.
  • 예외 처리: Callablecall() 메서드는 throws Exception을 선언하여 Checked Exception을 던질 수 있지만, Runnablerun() 메서드는 Checked Exception을 던질 수 없습니다.
  • 결과 관리: CallableFuture 또는 FutureTask와 함께 사용되어 스레드 실행 결과를 비동기적으로 얻고, 작업 상태를 확인하거나 취소하는 등의 더 정교한 제어가 가능합니다.

태그: java Thread concurrency Multithreading Runnable

8월 3일 15:50에 게시됨