본문 바로가기

Thread 생성과 start

Java 25 환경에서 Thread 상속 방식과 스택·힙 배치를 확인하고 run 직접 호출 오류를 교정합니다.

Java 프로그램은 main 스레드에서 시작하지만 필요하면 새 Thread 객체를 만들고 별도 호출 스택을 열 수 있습니다.

핵심은 객체 생성, 실행 등록, run() 수행이 서로 다른 사건이라는 점입니다.

개발 환경을 먼저 고정하고 이름이 보이는 짧은 프로그램으로 구분을 확인합니다.


run 직접 호출에 대한 오해

다음 코드는 Thread를 두 개 만들지만 start()가 아니라 run()을 직접 부릅니다.

두 작업 줄의 actual 값은 모두 main입니다.

메서드 본문은 실행됐어도 새 실행 흐름은 한 개도 생기지 않았습니다.

이 하위 타입의 run()을 직접 호출해도 Thread 객체의 생명 주기는 시작되지 않습니다. 호출이 정상 반환하면 아직 NEW이며, 그 뒤 첫 start()는 가능합니다. 반대로 한 번 시작한 객체의 run() 본문을 다시 호출하는 것과 그 객체를 다시 start()하는 것은 다른 사건입니다. 실제 새 실행을 원한다면 새 객체와 첫 start()가 필요합니다.

lab/RunMethodCreatesNoThreadBug.java
public final class RunMethodCreatesNoThreadBug {
    public static void main(String[] args) {
        Thread first = new NamedWork("alpha");
        Thread second = new NamedWork("beta");
        first.run();
        second.run();
        System.out.println("wrong-result=both calls used " + Thread.currentThread().getName());
    }

    private static final class NamedWork extends Thread {
        NamedWork(String name) {
            super(name);
        }

        @Override
        public void run() {
            System.out.println(
                    "requested=" + getName() + ", actual=" + Thread.currentThread().getName());
        }
    }
}
객체 생성·run 호출·start의 실행 주체를 구분한다

Thread 객체 생성만으로 작업은 실행되지 않습니다. 직접 run 호출은 main에서 실행되고 start가 요청한 작업은 별도 스레드에서 실행됩니다.

호출 주체 · new / run / start

객체 생성·run 호출·start의 실행 주체를 구분한다

new는 객체를 만들고, run 직접 호출은 main에서 실행하며, start는 별도 실행 흐름을 요청합니다.

객체 생성과 직접 호출과 새 실행의 구별 세 영역은 new·run·start의 각 계약을 보여줍니다. run을 직접 부른 원 출력 actual=main과 뒤 start예제의 새 흐름을 구별하며 영역 사이 실행 사슬을 만들지 않습니다. 객체 생성 new NamedWork main이 객체 생성 아직 작업 실행 없음 새 스레드 시작 안 함 직접 run run 직접 호출 first.run / second.run 호출자는 main run 본문도 main alpha·beta 요청 이름 actual=main start 요청 start로 실행 예약 새 흐름에서 run main은 계속 진행 완료 대기는 별도 join 영역은 세 호출 계약의 비교 실선: 각각 호출·실행의 관계 세 줄의 같은 실행 순서 아님
객체 생성과 직접 호출과 새 실행의 구별 세 영역은 new·run·start의 각 계약을 보여줍니다. run을 직접 부른 원 출력 actual=main과 뒤 start예제의 새 흐름을 구별하며 영역 사이 실행 사슬을 만들지 않습니다. 객체 생성 new NamedWork main이 객체 생성 아직 작업 실행 없음 새 스레드 시작 안 함 직접 run run 직접 호출 first.run / second.run 호출자는 main run 본문도 main alpha·beta 요청 이름 actual=main start 요청 start로 실행 예약 새 흐름에서 run main은 계속 진행 완료 대기는 별도 join 영역은 세 호출 계약의 비교 실선: 각각 호출·실행의 관계 세 줄의 같은 실행 순서 아님

new NamedWork(name)

작업을 수행하는 흐름
객체를 만드는 main
확인할 차이
새 객체를 만들며 아직 작업을 시작하지 않음

first.run() / second.run()

작업을 수행하는 흐름
호출자인 main
확인할 차이
requested=alpha, actual=main
requested=beta, actual=main

start()

작업을 수행하는 흐름
새로 시작한 스레드가 run() 수행
확인할 차이
호출한 main은 계속 진행하며 완료 대기는 별도 join()

위 코드는 run()을 직접 부른 사례입니다. start() 행은 뒤의 구간 합 예제와 Thread API 계약을 비교한 것으로, 위 코드를 수정해 얻은 출력은 아닙니다.

run()은 일반 인스턴스 메서드 호출입니다.

현재 main 스택에 프레임을 하나 더 쌓고 반환합니다.

start()는 JVM에 새 스레드 시작을 요청하고, 새 스택이 준비된 뒤 그 스레드가 run()을 호출하게 합니다.

따라서 동시 실행이 목적이면 호출 지점을 눈으로 확인해야 합니다.


재현 가능한 실행 환경

이 트랙의 예시는 Java 25에서 javac와 java로 확인합니다.

터미널에서 java --version, javac --version이 같은 JDK 계열인지 먼저 봅니다.

IDE 언어 수준과 빌드 도구 체인도 25로 맞춥니다.

한글 언어팩이나 화면 배치는 코드 의미와 무관하므로 IDE 메뉴를 외우기보다 명령행 기준을 남기는 편이 재현에 유리합니다.

소스 파일 이름은 public class와 같아야 합니다.

기본 패키지 예제는 학습 폴더에서 바로 컴파일할 수 있지만 실제 프로젝트는 패키지 경로를 일치시킵니다.

이전 class 파일이 결과를 가리는 상황을 막으려면 깨끗한 산출물 디렉터리에 컴파일하고 실행 클래스 경로를 명시합니다.

src/JavaEnvironmentProbe.java
public final class JavaEnvironmentProbe {
    public static void main(String[] args) {
        Runtime runtime = Runtime.getRuntime();
        System.out.println("feature=" + Runtime.version().feature());
        System.out.println("vm=" + System.getProperty("java.vm.name"));
        System.out.println("processors=" + runtime.availableProcessors());
        System.out.println("main-thread=" + Thread.currentThread().getName());
    }
}

버전 출력은 컴파일 환경이 아니라 현재 실행 JVM을 보여 줍니다.

소스 대상 버전과 실행 버전도 분리합니다. 예를 들어 기존 JDK 25의 javac --release 21로 컴파일하면 Java 21의 언어·표준 API 대상으로 검사하지만, JDK 25의 java로 실행한 feature는 여전히 25입니다. 이 절의 코드는 이 조합으로도 확인할 수 있으며, 이것이 실제 Java 21 JVM에서 실행했다는 뜻은 아닙니다.

CI와 로컬 결과가 다르면 먼저 실행 경로의 java가 어느 설치를 가리키는지 확인합니다.


Thread를 상속해 작업 단위 구성

가장 직접적인 생성법은 Thread를 상속하고 run()을 override하는 것입니다.

다음 예제는 두 구간 합을 서로 다른 스택에서 계산하고 join 뒤 결합합니다.

src/RangeSumThread.java
public final class RangeSumThread extends Thread {
    private final int startInclusive;
    private final int endInclusive;
    private long result;

    public RangeSumThread(String name, int startInclusive, int endInclusive) {
        super(name);
        this.startInclusive = startInclusive;
        this.endInclusive = endInclusive;
    }

    @Override
    public void run() {
        long sum = 0;
        for (int n = startInclusive; n <= endInclusive; n++) {
            sum += n;
        }
        result = sum;
        System.out.println(getName() + " stack finished " + startInclusive + ".." + endInclusive);
    }

    public long result() {
        return result;
    }

    public static void main(String[] args) throws InterruptedException {
        RangeSumThread left = new RangeSumThread("left-sum", 1, 500);
        RangeSumThread right = new RangeSumThread("right-sum", 501, 1000);
        left.start();
        right.start();
        left.join();
        right.join();
        System.out.println("total=" + (left.result() + right.result()));
    }
}

두 작업자는 각자 result를 쓰고 main은 join 뒤에 읽는다

left/right가 독립 result를 기록하고 모두 join 뒤 500500을 합산합니다. 완료 전후 읽기 경계를 단계로 보여 주되 상대 실행 순서·성능을 단정하지 않습니다.

두 작업자는 각자 result를 쓰고 main은 join 뒤에 읽는다

left와 right는 범위와 결과 필드가 다른 두 RangeSumThread 객체입니다. 각 run의 sum과 n은 호출별 지역 변수이며 main은 두 join 뒤 합계 500500을 출력합니다.

THREAD 상속 · 필드 / 지역 변수

두 작업자는 각자 result를 쓰고 main은 join 뒤에 읽는다

두 RangeSumThread 객체는 각자 result를 씁니다. 각 run의 sum·n은 해당 호출의 지역 변수입니다.

자기 필드에 기록한 두 범위의 합산 각 RangeSumThread는 자기 result필드에 기록하고 main은 두join정상반환뒤125250과375250을 합합니다. 각 run의 지역sum·n은 별도 실행 프레임에 속합니다. left-sum 1..500 / 자기 result 125250 right-sum 501..1000 / 자기 result 375250 main · 모두 join 뒤 각 결과를 읽어 합산 total=500500 실선: 작업자별 결과가 최종 합산에 사용됨 같은 총합은 계산 계약이며 속도·스케줄링 측정이 아님
자기 필드에 기록한 두 범위의 합산 각 RangeSumThread는 자기 result필드에 기록하고 main은 두join정상반환뒤125250과375250을 합합니다. 각 run의 지역sum·n은 별도 실행 프레임에 속합니다. left-sum 1..500 / 자기 result 125250 right-sum 501..1000 / 자기 result 375250 main · 모두 join 뒤 각 슬롯 결과를 읽음 total=500500 실선: 기록 결과 → 마지막 합산 작업자 사이의 실행 사슬 아님 호출·완료 순서는 고정 안 함

left / left-sum

각 객체의 필드
startInclusive=1
endInclusive=500
result=125250
지역 계산·결과 읽기
run()의 지역 변수 sum·n
자기 result에 기록

right / right-sum

각 객체의 필드
startInclusive=501
endInclusive=1000
result=375250
지역 계산·결과 읽기
run()의 지역 변수 sum·n
자기 result에 기록

main

각 객체의 필드
두 객체의 result를 읽음
지역 계산·결과 읽기
두 join()이 정상 반환한 뒤
total=500500

필드는 각 힙 객체에, sum과 n은 JVM의 논리적 호출 프레임에 속합니다. 부분합은 소스에서 계산한 값이며 실제 출력은 두 범위의 완료 로그와 최종 합계입니다.

이 예의 조건

  • 작업자 사이 완료 순서를 정하지 않는다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2각 RangeSumThread는 자기 result 필드에 기록하고 main은 두 join의 정상 반환 뒤 125250과 375250을 합합니다. 각 run의 지역 sum·n은 별도 실행 프레임에 속합니다.
41..500
5자기 result
8501..1000
9자기 result
원 도표의 설명과 주석
구분내용
설명각 RangeSumThread는 자기 result 필드에 기록하고 main은 두 join의 정상 반환 뒤 125250과 375250을 합합니다. 각 run의 지역 sum·n은 별도 실행 프레임에 속합니다.
전체 읽기 정보두 작업자는 각자 result를 쓰고 main은 join 뒤에 읽는다 두 작업자는 각자 result를 쓰고 main은 join 뒤에 읽는다 left와 right는 범위와 결과 필드가 다른 두 RangeSumThread 객체입니다. 각 run의 sum과 n은 호출별 지역 변수이며 main은 두 join 뒤 합계 500500을 출력합니다. THREAD 상속 · 필드 / 지역 변수 두 작업자는 각자 result를 쓰고 main은 join 뒤에 읽는다 두 RangeSumThread 객체는 각자 result를 씁니다. 각 run의 sum·n은 해당 호출의 지역 변수입니다. left / left-sum 각 객체의 필드 startInclusive=1 endInclusive=500 result=125250 지역 계산·결과 읽기 run() 의 지역 변수 sum · n 자기 result 에 기록 right / right-sum 각 객체의 필드 startInclusive=501 endInclusive=1000 result=375250 지역 계산·결과 읽기 run() 의 지역 변수 sum · n 자기 result 에 기록 main 각 객체의 필드 두 객체의 result 를 읽음 지역 계산·결과 읽기 두 join() 이 정상 반환한 뒤 total=500500 필드는 각 힙 객체에, sum 과 n 은 JVM 의 논리적 호출 프레임에 속합니다. 부분합은 소스에서 계산한 값이며 실제 출력은 두 범위의 완료 로그와 최종 합계입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
자기 범위

두 RangeSumThread에 범위를 제공합니다.

  • left: 1..500
  • right: 501..1000
  • run의 지역 sum/n은 서로 다른 프레임이다.
자기 필드 기록

각 작업자가 result를 저장합니다.

  • left.result: 125250
  • right.result: 375250
  • 인스턴스 필드 소유가 쓰기를 분리한다.
완료 후 읽기

두 join 뒤 main이 합합니다.

  • total: 500500
  • 읽기 조건: 두 join 정상 반환
  • 완료 경계를 지난 결과만 소비한다.

두 작업자는 각자 result를 쓰고 main은 join 뒤에 읽는다

left/right가 독립 result를 기록하고 모두 join 뒤 500500을 합산합니다. 완료 전후 읽기 경계를 단계로 보여 주되 상대 실행 순서·성능을 단정하지 않습니다.

두 작업자는 각자 result를 쓰고 main은 join 뒤에 읽는다

left와 right는 범위와 결과 필드가 다른 두 RangeSumThread 객체입니다. 각 run의 sum과 n은 호출별 지역 변수이며 main은 두 join 뒤 합계 500500을 출력합니다.

THREAD 상속 · 필드 / 지역 변수

두 작업자는 각자 result를 쓰고 main은 join 뒤에 읽는다

두 RangeSumThread 객체는 각자 result를 씁니다. 각 run의 sum·n은 해당 호출의 지역 변수입니다.

자기 필드에 기록한 두 범위의 합산 각 RangeSumThread는 자기 result필드에 기록하고 main은 두join정상반환뒤125250과375250을 합합니다. 각 run의 지역sum·n은 별도 실행 프레임에 속합니다. left-sum 1..500 / 자기 result 125250 right-sum 501..1000 / 자기 result 375250 main · 모두 join 뒤 각 결과를 읽어 합산 total=500500 실선: 작업자별 결과가 최종 합산에 사용됨 같은 총합은 계산 계약이며 속도·스케줄링 측정이 아님
자기 필드에 기록한 두 범위의 합산 각 RangeSumThread는 자기 result필드에 기록하고 main은 두join정상반환뒤125250과375250을 합합니다. 각 run의 지역sum·n은 별도 실행 프레임에 속합니다. left-sum 1..500 / 자기 result 125250 right-sum 501..1000 / 자기 result 375250 main · 모두 join 뒤 각 슬롯 결과를 읽음 total=500500 실선: 기록 결과 → 마지막 합산 작업자 사이의 실행 사슬 아님 호출·완료 순서는 고정 안 함

left / left-sum

각 객체의 필드
startInclusive=1
endInclusive=500
result=125250
지역 계산·결과 읽기
run()의 지역 변수 sum·n
자기 result에 기록

right / right-sum

각 객체의 필드
startInclusive=501
endInclusive=1000
result=375250
지역 계산·결과 읽기
run()의 지역 변수 sum·n
자기 result에 기록

main

각 객체의 필드
두 객체의 result를 읽음
지역 계산·결과 읽기
두 join()이 정상 반환한 뒤
total=500500

필드는 각 힙 객체에, sum과 n은 JVM의 논리적 호출 프레임에 속합니다. 부분합은 소스에서 계산한 값이며 실제 출력은 두 범위의 완료 로그와 최종 합계입니다.

이 예의 조건

  • 작업자 사이 완료 순서를 정하지 않는다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2각 RangeSumThread는 자기 result 필드에 기록하고 main은 두 join의 정상 반환 뒤 125250과 375250을 합합니다. 각 run의 지역 sum·n은 별도 실행 프레임에 속합니다.
41..500
5자기 result
8501..1000
9자기 result
원 도표의 설명과 주석
구분내용
설명각 RangeSumThread는 자기 result 필드에 기록하고 main은 두 join의 정상 반환 뒤 125250과 375250을 합합니다. 각 run의 지역 sum·n은 별도 실행 프레임에 속합니다.
전체 읽기 정보두 작업자는 각자 result를 쓰고 main은 join 뒤에 읽는다 두 작업자는 각자 result를 쓰고 main은 join 뒤에 읽는다 left와 right는 범위와 결과 필드가 다른 두 RangeSumThread 객체입니다. 각 run의 sum과 n은 호출별 지역 변수이며 main은 두 join 뒤 합계 500500을 출력합니다. THREAD 상속 · 필드 / 지역 변수 두 작업자는 각자 result를 쓰고 main은 join 뒤에 읽는다 두 RangeSumThread 객체는 각자 result를 씁니다. 각 run의 sum·n은 해당 호출의 지역 변수입니다. left / left-sum 각 객체의 필드 startInclusive=1 endInclusive=500 result=125250 지역 계산·결과 읽기 run() 의 지역 변수 sum · n 자기 result 에 기록 right / right-sum 각 객체의 필드 startInclusive=501 endInclusive=1000 result=375250 지역 계산·결과 읽기 run() 의 지역 변수 sum · n 자기 result 에 기록 main 각 객체의 필드 두 객체의 result 를 읽음 지역 계산·결과 읽기 두 join() 이 정상 반환한 뒤 total=500500 필드는 각 힙 객체에, sum 과 n 은 JVM 의 논리적 호출 프레임에 속합니다. 부분합은 소스에서 계산한 값이며 실제 출력은 두 범위의 완료 로그와 최종 합계입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
자기 범위

두 RangeSumThread에 범위를 제공합니다.

  • left: 1..500
  • right: 501..1000
  • run의 지역 sum/n은 서로 다른 프레임이다.
자기 필드 기록

각 작업자가 result를 저장합니다.

  • left.result: 125250
  • right.result: 375250
  • 인스턴스 필드 소유가 쓰기를 분리한다.
완료 후 읽기

두 join 뒤 main이 합합니다.

  • total: 500500
  • 읽기 조건: 두 join 정상 반환
  • 완료 경계를 지난 결과만 소비한다.

두 객체의 startInclusive, endInclusive, result는 힙에 있습니다.

JVM의 논리적 실행 모델에서 각 run()의 지역 변수 sum과 n은 해당 호출 프레임의 지역 변수입니다.

main은 두 객체 참조를 공유하지만 join 전에 결과를 읽지 않습니다.

result가 volatile이 아니어도 여기서는 작업자 종료 뒤 해당 join()의 정상 반환이 쓰기와 읽기를 연결합니다. 또한 start() 전에 설정한 입력은 새 작업자의 실행보다 앞서게 됩니다. 이 두 경계를 지킨 예제와, 작업 중간의 가변 필드를 동기화 없이 계속 읽는 예제는 다릅니다. JLS의 happens-before 규칙을 적용하는 위치를 각각 표시해 보세요.

이 배치 차이가 공유 상태와 스레드-지역 상태를 가르는 출발점입니다.

left 객체를 main도 참조한다고 해서 left의 result를 쓰는 작업자가 둘이 된 것은 아닙니다. 원 코드에서는 left.run만 left.result를 쓰고 main은 종료 뒤 읽습니다. 반대로 두 Thread 객체가 같은 배열을 생성자 인자로 받으면, 객체가 둘이라는 이유만으로 배열도 둘이 되지는 않습니다. 지역 변수의 참조 값과 참조가 가리키는 대상을 각각 따라가세요.

상속 방식은 짧고 직관적이지만 클래스가 다른 부모를 상속할 수 없고 작업과 실행 정책이 결합됩니다.

뒤 문서에서 Runnable을 분리하는 이유를 비교합니다.

기존 Thread 하위 타입을 확장해야 하는 특별한 경우가 아니라면 보통 작업 객체 분리를 선호합니다.


Thread.start의 일회성

종료된 작업자를 재사용하려고 같은 객체에 start()를 다시 호출하면 IllegalThreadStateException이 발생합니다.

이 실패는 실행 여부가 아니라 Thread 인스턴스의 생명 주기 규칙입니다.

start() 직후 상태를 조회해 반드시 RUNNABLE이라고 검사하지는 않습니다. 짧은 작업은 조회 전에 이미 종료될 수 있습니다. 종료 뒤 상태를 확인하는 이 예제는 먼저 join()을 정상 완료했으므로 TERMINATED를 확인할 수 있습니다.

lab/StartTwiceFailure.java
public final class StartTwiceFailure {
    public static void main(String[] args) throws InterruptedException {
        Thread oneShot = new Thread(() -> System.out.println("one run"), "one-shot");
        oneShot.start();
        oneShot.join();
        System.out.println("state-before-restart=" + oneShot.getState());
        oneShot.start();
    }
}

같은 Thread의 두 번째 start는 실패한다

NEW→start→join/TERMINATED→동일 객체 재시작 실패의 실제 수명입니다. 객체 정체성을 고정한 일회성 상태 전환이 적합합니다.

같은 Thread의 두 번째 start는 실패한다

oneShot은 한 번 실행된 뒤 join으로 종료를 확인합니다. TERMINATED 출력 뒤 같은 객체를 다시 시작하면 IllegalThreadStateException이 발생합니다.

THREAD · 한 번만 시작

같은 Thread의 두 번째 start는 실패한다

첫 실행이 끝난 Thread는 다시 시작할 수 없습니다. TERMINATED에서 NEW로 돌아가지 않습니다.

시작과 종료와 재시작 실패원 객체의 첫start·join·상태조회 뒤 두 번째start는 IllegalThreadStateException입니다. 실제 순서와 한 번만 시작하는 객체 계약을 연결하며 두 번째run출력이 없습니다.
객체 생성NEW / 실행 전
연결 관계객체 생성 · NEW / 실행 전 → 첫 start · one run 한 번
첫 startone run 한 번
연결 관계첫 start · one run 한 번 → join 정상 반환 · TERMINATED 확인
join 정상 반환TERMINATED 확인
연결 관계join 정상 반환 · TERMINATED 확인 → 같은 객체 재시작 · IllegalThreadState · Exception / 다시 실행 안 함
같은 객체 재시작IllegalThreadStateException / 다시 실행 안 함

실선: 원 main의 호출 순서종료 후 NEW로 돌아가지 않음

  1. 1 · 객체 생성

    상태·역할
    oneShot은 아직 시작 전
    실제 출력 또는 결과
    출력 없음
  2. 2 · 첫 start()

    상태·역할
    작업 실행을 요청
    실제 출력 또는 결과
    one run 한 번
  3. 3 · join() 뒤

    상태·역할
    종료를 확인하고 상태 조회
    실제 출력 또는 결과
    state-before-restart=TERMINATED
  4. 4 · 두 번째 start()

    상태·역할
    같은 객체의 재시작 시도
    실제 출력 또는 결과
    IllegalThreadStateException
    두 번째 작업 출력 없음

표의 순서는 원문 호출 순서입니다. 첫 start()가 반환한 직후의 상태를 관찰한 표는 아닙니다. 반복 실행에는 새 Thread 객체나 별도의 실행 정책이 필요합니다.

이 예의 조건

  • 같은 Thread 객체를 재시작하는 원 예제이다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2원 객체의 첫 start·join·상태 조회 뒤 두 번째 start는 IllegalThreadStateException입니다. 실제 순서와 한 번만 시작하는 객체 계약을 연결하며 두 번째 run 출력이 없습니다.
4NEW
5실행 전
12Exception
13다시 실행 안 함
원 도표의 설명과 주석
구분내용
설명원 객체의 첫 start·join·상태 조회 뒤 두 번째 start는 IllegalThreadStateException입니다. 실제 순서와 한 번만 시작하는 객체 계약을 연결하며 두 번째 run 출력이 없습니다.
전체 읽기 정보같은 Thread의 두 번째 start는 실패한다 같은 Thread의 두 번째 start는 실패한다 oneShot은 한 번 실행된 뒤 join으로 종료를 확인합니다. TERMINATED 출력 뒤 같은 객체를 다시 시작하면 IllegalThreadStateException이 발생합니다. THREAD · 한 번만 시작 같은 Thread의 두 번째 start는 실패한다 첫 실행이 끝난 Thread는 다시 시작할 수 없습니다. TERMINATED에서 NEW로 돌아가지 않습니다. 1 · 객체 생성 상태·역할 oneShot 은 아직 시작 전 실제 출력 또는 결과 출력 없음 2 · 첫 start() 상태·역할 작업 실행을 요청 실제 출력 또는 결과 one run 한 번 3 · join() 뒤 상태·역할 종료를 확인하고 상태 조회 실제 출력 또는 결과 state-before-restart=TERMINATED 4 · 두 번째 start() 상태·역할 같은 객체의 재시작 시도 실제 출력 또는 결과 IllegalThreadStateException 두 번째 작업 출력 없음 표의 순서는 원문 호출 순서입니다. 첫 start() 가 반환한 직후의 상태를 관찰한 표는 아닙니다. 반복 실행에는 새 Thread 객체나 별도의 실행 정책이 필요합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
생성

NEW 객체를 만듭니다.

  • 상태: NEW
  • run 횟수: 0
  • start 이전 객체와 실행 중 작업을 구분한다.
첫 실행/회수

start 후 one run을 실행하고 join합니다.

  • 출력: one run
  • 상태: TERMINATED
  • 종료된 객체가 NEW로 돌아가지 않는다.
재시작 실패

같은 객체의 start를 다시 호출합니다.

  • 예외: IllegalThreadStateException
  • 둘째 run: 없음
  • Thread 생명 주기는 한 번의 start만 허용한다.

같은 Thread의 두 번째 start는 실패한다

NEW→start→join/TERMINATED→동일 객체 재시작 실패의 실제 수명입니다. 객체 정체성을 고정한 일회성 상태 전환이 적합합니다.

같은 Thread의 두 번째 start는 실패한다

oneShot은 한 번 실행된 뒤 join으로 종료를 확인합니다. TERMINATED 출력 뒤 같은 객체를 다시 시작하면 IllegalThreadStateException이 발생합니다.

THREAD · 한 번만 시작

같은 Thread의 두 번째 start는 실패한다

첫 실행이 끝난 Thread는 다시 시작할 수 없습니다. TERMINATED에서 NEW로 돌아가지 않습니다.

시작과 종료와 재시작 실패원 객체의 첫start·join·상태조회 뒤 두 번째start는 IllegalThreadStateException입니다. 실제 순서와 한 번만 시작하는 객체 계약을 연결하며 두 번째run출력이 없습니다.
객체 생성NEW / 실행 전
연결 관계객체 생성 · NEW / 실행 전 → 첫 start · one run 한 번
첫 startone run 한 번
연결 관계첫 start · one run 한 번 → join 정상 반환 · TERMINATED 확인
join 정상 반환TERMINATED 확인
연결 관계join 정상 반환 · TERMINATED 확인 → 같은 객체 재시작 · IllegalThreadState · Exception / 다시 실행 안 함
같은 객체 재시작IllegalThreadStateException / 다시 실행 안 함

실선: 원 main의 호출 순서종료 후 NEW로 돌아가지 않음

  1. 1 · 객체 생성

    상태·역할
    oneShot은 아직 시작 전
    실제 출력 또는 결과
    출력 없음
  2. 2 · 첫 start()

    상태·역할
    작업 실행을 요청
    실제 출력 또는 결과
    one run 한 번
  3. 3 · join() 뒤

    상태·역할
    종료를 확인하고 상태 조회
    실제 출력 또는 결과
    state-before-restart=TERMINATED
  4. 4 · 두 번째 start()

    상태·역할
    같은 객체의 재시작 시도
    실제 출력 또는 결과
    IllegalThreadStateException
    두 번째 작업 출력 없음

표의 순서는 원문 호출 순서입니다. 첫 start()가 반환한 직후의 상태를 관찰한 표는 아닙니다. 반복 실행에는 새 Thread 객체나 별도의 실행 정책이 필요합니다.

이 예의 조건

  • 같은 Thread 객체를 재시작하는 원 예제이다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2원 객체의 첫 start·join·상태 조회 뒤 두 번째 start는 IllegalThreadStateException입니다. 실제 순서와 한 번만 시작하는 객체 계약을 연결하며 두 번째 run 출력이 없습니다.
4NEW
5실행 전
12Exception
13다시 실행 안 함
원 도표의 설명과 주석
구분내용
설명원 객체의 첫 start·join·상태 조회 뒤 두 번째 start는 IllegalThreadStateException입니다. 실제 순서와 한 번만 시작하는 객체 계약을 연결하며 두 번째 run 출력이 없습니다.
전체 읽기 정보같은 Thread의 두 번째 start는 실패한다 같은 Thread의 두 번째 start는 실패한다 oneShot은 한 번 실행된 뒤 join으로 종료를 확인합니다. TERMINATED 출력 뒤 같은 객체를 다시 시작하면 IllegalThreadStateException이 발생합니다. THREAD · 한 번만 시작 같은 Thread의 두 번째 start는 실패한다 첫 실행이 끝난 Thread는 다시 시작할 수 없습니다. TERMINATED에서 NEW로 돌아가지 않습니다. 1 · 객체 생성 상태·역할 oneShot 은 아직 시작 전 실제 출력 또는 결과 출력 없음 2 · 첫 start() 상태·역할 작업 실행을 요청 실제 출력 또는 결과 one run 한 번 3 · join() 뒤 상태·역할 종료를 확인하고 상태 조회 실제 출력 또는 결과 state-before-restart=TERMINATED 4 · 두 번째 start() 상태·역할 같은 객체의 재시작 시도 실제 출력 또는 결과 IllegalThreadStateException 두 번째 작업 출력 없음 표의 순서는 원문 호출 순서입니다. 첫 start() 가 반환한 직후의 상태를 관찰한 표는 아닙니다. 반복 실행에는 새 Thread 객체나 별도의 실행 정책이 필요합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
생성

NEW 객체를 만듭니다.

  • 상태: NEW
  • run 횟수: 0
  • start 이전 객체와 실행 중 작업을 구분한다.
첫 실행/회수

start 후 one run을 실행하고 join합니다.

  • 출력: one run
  • 상태: TERMINATED
  • 종료된 객체가 NEW로 돌아가지 않는다.
재시작 실패

같은 객체의 start를 다시 호출합니다.

  • 예외: IllegalThreadStateException
  • 둘째 run: 없음
  • Thread 생명 주기는 한 번의 start만 허용한다.

작업을 다시 수행하려면 새 Thread 객체를 만들거나 실행기에 같은 Runnable을 다시 제출합니다.

TERMINATED에서 NEW로 돌아가는 전이는 없습니다.

예외를 catch해 억지로 재사용하려 해도 작업은 실행되지 않습니다.


객체와 호출 스택을 추적하는 방법

Thread 인스턴스와 실제 current 스레드를 혼동하면 로그도 잘못 해석합니다.

this.getName()은 객체 이름이고 Thread.currentThread().getName()은 지금 코드를 실행하는 흐름의 이름입니다.

정상적인 start() 경로에서는 둘이 같지만 앞의 run 직접 호출에서는 다릅니다.

각 스레드는 독립 Java 스택을 가져 같은 메서드를 동시에 호출해도 지역 변수는 겹치지 않습니다.

반면 인스턴스 필드, static 필드, 배열과 컬렉션 원소는 힙에 있어 참조를 전달하면 공유됩니다.

지역 변수 안의 참조 값은 스택에 있어도 그 참조가 가리키는 객체는 힙이라는 점을 함께 기억합니다.

스택 크기는 무한하지 않습니다.

스레드를 너무 많이 만들면 네이티브 메모리와 스케줄러 비용이 커지고, 깊은 재귀 호출은 각 스레드에서 StackOverflowError를 낼 수 있습니다.

플랫폼 스레드 개수를 입력 데이터 개수와 일대일로 연결하지 않습니다.


명시적 이름으로 작업 실행을 래핑

이름은 동시 로그와 스레드 덤프에서 문제를 찾는 가장 싼 관찰 수단입니다.

업무 범주와 번호를 포함하고 사용자 개인정보는 넣지 않습니다.

처리되지 않은 예외의 처리 정책도 스레드 생성 지점에서 함께 정할 수 있습니다.

app/NamedImportWorkers.java
import java.util.List;

public final class NamedImportWorkers {
    public static void main(String[] args) throws InterruptedException {
        List<String> files = List.of("java.md", "thread.md", "memory.md");
        Thread[] workers = new Thread[files.size()];
        for (int i = 0; i < workers.length; i++) {
            String file = files.get(i);
            workers[i] = new Thread(() -> importFile(file), "import-" + (i + 1));
            workers[i].setUncaughtExceptionHandler(
                    (thread, error) ->
                            System.err.println(
                                    thread.getName() + ":" + error.getClass().getSimpleName()));
            workers[i].start();
        }
        for (Thread worker : workers) {
            worker.join();
        }
        System.out.println("imported=" + workers.length);
    }

    private static void importFile(String file) {
        System.out.println(Thread.currentThread().getName() + " -> " + file);
    }
}

출력 순서는 달라도 각 파일 이름이 한 번씩 등장하고 마지막 imported는 항상 뒤에 옵니다. 이 예제는 파일 이름을 출력하며 실제 파일을 읽거나 저장하지 않습니다.

여기서 imported=3은 생성한 작업자 수를 출력한 값입니다. 작업자가 실패해도 join()은 종료를 기다릴 뿐 작업자의 예외를 main에 다시 던지지 않습니다. 실제 가져오기의 성공 건수를 뜻하게 하려면 성공 결과와 실패 원인을 별도로 회수해야 합니다.

제품 코드에서는 Thread 배열 관리보다 실행기의 생명 주기 API를 쓰는 편이 낫지만, 이 예제는 객체 생성과 start 구분을 드러내기 위해 낮은 수준 API를 사용합니다.


연습 문제

생성자에서 작업자 이름과 메시지를 받고 run()에서 실제 current 스레드 이름과 메시지를 출력하는 하위 타입을 작성하세요.

main은 두 인스턴스를 start하고 join한 다음 종료 문장을 출력해야 합니다.

검산 입력은 이름 message-a·메시지 memory와 이름 message-b·메시지 stack 두 짝입니다. 두 작업 줄에는 실제 현재 스레드 이름이 각 짝과 맞아야 하며, 둘의 앞뒤는 고정하지 않습니다. 종료 문장 message workers terminated가 두 줄 뒤인지 별도로 확인하세요. 이 입력과 출력 조건을 적은 뒤 하위 타입을 작성하면 해설 코드의 구조를 그대로 외우지 않아도 검사할 수 있습니다.

정답과 실행 확인

생성자에서 super(name)을 먼저 호출해 Thread 객체 이름을 정합니다.

run 안에서는 current 스레드를 조회해 실제 실행 흐름도 확인합니다.

exercise/MessageThreadSolution.java
public final class MessageThreadSolution {
    private static final class MessageThread extends Thread {
        private final String message;

        MessageThread(String name, String message) {
            super(name);
            this.message = message;
        }

        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName() + " says " + message);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread first = new MessageThread("message-a", "memory");
        Thread second = new MessageThread("message-b", "stack");
        first.start();
        second.start();
        first.join();
        second.join();
        System.out.println("message workers terminated");
    }
}

앞의 두 줄 순서는 보장하지 않지만 둘 다 main이 아닌 이름을 출력합니다.

마지막 문장은 join 뒤라서 두 작업이 끝난 다음에만 보입니다.

Thread 생성 학습의 판별 기준은 명확합니다.

new는 NEW 상태 객체를 만들고, start()가 새 스택을 가진 실행 흐름을 등록하며, JVM이 그 흐름에서 run()을 호출합니다.

같은 객체를 다시 시작할 수 없고 공유 힙 변경에는 별도의 동시성 규칙이 필요합니다.

연습: run() 호출 횟수와 start() 횟수 추적하기

다음 하위 타입을 main에서 한 번 직접 호출하고, 같은 객체를 한 번 시작합니다. 다른 스레드의 개입·인터럽트는 없다고 가정합니다. before, after-direct, 두 actual, 마지막 runs와 restart를 예측하세요. 이 실행이 스레드를 두 번 시작한 것인지도 설명하세요.

exercise/RunThenStartTraceSolution.java
public final class RunThenStartTraceSolution {
    private static final class CountingThread extends Thread {
        private int runs;

        CountingThread() { super("counted-worker"); }

        @Override
        public void run() {
            runs++;
            System.out.println("actual=" + Thread.currentThread().getName());
        }
    }

    public static void main(String[] args) throws InterruptedException {
        CountingThread worker = new CountingThread();
        System.out.println("before=" + worker.getState());
        worker.run();
        System.out.println("after-direct=" + worker.getState());
        worker.start();
        worker.join();
        System.out.println("runs=" + worker.runs + ", state=" + worker.getState());
        try {
            worker.start();
        } catch (IllegalThreadStateException expected) {
            System.out.println("restart=" + expected.getClass().getSimpleName());
        }
    }
}
메서드 실행과 스레드 생명 주기 풀이

출력 순서는 before=NEW, actual=main, after-direct=NEW, actual=counted-worker, runs=2, state=TERMINATED, restart=IllegalThreadStateException입니다. 첫 run은 일반 메서드 호출이고 첫 start만 새 스레드의 실행을 등록합니다. runs=2는 메서드 본문이 두 번 실행됐다는 뜻이며 새 스레드 시작에 성공한 횟수는 한 번입니다.

처음 증가는 main에서 start() 전에, 두 번째 증가는 작업자에서 일어나며 main의 읽기는 join() 뒤입니다. 이 예제에는 runs의 동시 증가가 없고 시작·종료 순서도 연결됩니다. 직접 호출과 새 작업자의 실행을 동시에 하도록 바꾸면 같은 가변 필드의 경쟁을 따로 검토해야 합니다.