본문 바로가기

프로세스와 스레드

독립 메모리의 프로세스와 공유 힙의 스레드를 비교하고 시작 순서와 완료 순서가 다를 수 있음을 실행합니다.

운영체제는 실행 중인 프로그램을 프로세스로 관리하고, 프로세스 안의 실제 실행 흐름을 스레드로 스케줄링합니다.

멀티태스킹은 짧은 시간 조각을 여러 작업에 번갈아 배정해 동시에 진행되는 것처럼 보이게 합니다.

여러 코어에서는 일부 스레드가 실제 병렬로 실행될 수 있지만 실행 순서는 여전히 스케줄러와 작업 상태에 달려 있습니다.

멀티프로세싱은 둘 이상의 프로세스 또는 처리 자원이 일을 나누는 관점이며, 각 프로세스의 격리 비용과 통신 경계를 함께 고려합니다.


시작 순서와 종료 순서의 차이

start는 새 스레드를 실행 가능 상태로 등록할 뿐 완료 순서를 예약하지 않습니다.

slow는 80ms, fast는 5ms 동안 잠들도록 요청하지만 두 작업의 실제 완료 순서는 스케줄러에 따라 달라질 수 있습니다.

lab/ThreadCompletionOrderAssumption.java
public final class ThreadCompletionOrderAssumption {
    public static void main(String[] args) throws InterruptedException {
        Thread slow = new Thread(() -> work("slow", 80), "slow-worker");
        Thread fast = new Thread(() -> work("fast", 5), "fast-worker");
        slow.start();
        fast.start();
        slow.join();
        fast.join();
        System.out.println("assumed=slow-fast, observed=see-above");
    }

    private static void work(String name, long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        System.out.println("done=" + name);
    }
}

fast가 먼저 출력될 수 있지만 어느 작업이 먼저 끝나는지는 보장하지 않습니다.

sleep 시간은 교육용으로 차이를 크게 만든 것이며 일반 코드에서 시간 지연으로 순서를 보장해서는 안 됩니다.

순서 의존성이 있다면 join, Queue, Future 같은 명시적 조정 수단을 사용합니다.

원 main의 slow.join은 main의 다음 진행만 막습니다. 먼저 시작해 둔 fast가 그동안 실행되는 것을 막지 않으므로 “slow를 먼저 기다림”과 “slow가 먼저 끝남”은 다릅니다. 두 작업자를 모두 회수한 뒤 찍는 마지막 문장이 두 완료 로그보다 뒤라는 관계는 확인할 수 있습니다. 앞의 두 로그는 순서 대신 이름별로 한 번씩 나타나는지 검사하세요.


프로세스 격리와 스레드 공유 범위

프로세스마다 가상 주소 공간과 자원이 격리됩니다.

한 프로세스의 일반 객체 참조를 다른 프로세스가 그대로 읽을 수 없고 IPC가 필요합니다.

격리는 장애 전파를 줄이지만 통신과 생성 비용이 큽니다.

같은 JVM의 스레드는 힙 객체와 static 상태를 공유할 수 있고, 각자 Java 스택과 프로그램 카운터를 갖습니다. 네이티브 메서드 스택의 제공과 구현은 JVM에 달려 있습니다.

공유 힙은 빠른 협업을 가능하게 하지만 동시에 바꾸면 경합 조건이 생깁니다.

19장에서 가시성과 synchronized로 이 위험을 다룹니다.

프로세스의 격리와 스레드의 공유 범위를 구분한다

서로 다른 프로세스의 주소 공간, 같은 JVM의 공유 힙, 스레드별 Java 실행 문맥과 지역 참조가 가리키는 객체의 범위를 비교합니다.

메모리 범위 · 공유 / 분리

프로세스의 격리와 스레드의 공유 범위를 구분한다

개별 스택의 지역 참조라도 같은 힙 객체를 가리킬 수 있습니다. 공유 여부는 대상 객체까지 확인합니다.

분리된 프레임과 같은 힙 객체 두 스레드의 지역 참조가 같은 힙 객체를 가리킬 수 있음을 점선으로 연결합니다. 다른 프로세스의 주소 공간은 별도 영역이며 객체 참조가 직접 넘어가지 않습니다. 프로세스 A · 같은 JVM 프로세스 B 스레드 A 프레임 지역 참조 a 스레드 B 프레임 지역 참조 b 같은 힙 객체 공유하는 가변 상태 별도 공간의 객체 A의 참조 직접 사용 불가 점선: 각 지역 참조가 같은 객체를 가리킴 · 다른 프로세스 객체로 연결되지 않음 두 프레임은 분리돼도 객체 접근 규칙 필요 · 실제 메모리 배치 측정 아님
분리된 프레임과 같은 힙 객체 두 스레드의 지역 참조가 같은 힙 객체를 가리킬 수 있음을 점선으로 연결합니다. 다른 프로세스의 주소 공간은 별도 영역이며 객체 참조가 직접 넘어가지 않습니다. 프로세스 A · 같은 JVM 프로세스 B · 별도 공간 스레드 A의 프레임 지역 참조 a 스레드 B의 프레임 지역 참조 b 같은 힙 객체 공유하는 가변 상태 별도 프로세스 객체 A의 참조를 그대로 B에서 사용하지 않음 점선: 같은 객체를 가리킴 프레임이 달라도 대상은 공유 프로세스 간 교환에는 IPC 실제 주소 배치 측정 아님

다른 프로세스

공유·분리 범위
각자의 가상 주소 공간
확인할 점
일반 객체 참조를 그대로 교환하지 않음 · IPC 필요

같은 JVM의 스레드

공유·분리 범위
힙 객체와 static 상태를 공유할 수 있음
확인할 점
공유하는 가변 객체에는 접근 규칙이 필요

스레드별 Java 실행 문맥

공유·분리 범위
Java 스택 프레임과 pc 분리
확인할 점
각 호출의 지역 변수·계산 상태를 유지

지역 변수의 객체 참조

공유·분리 범위
참조 값은 각 프레임에 있어도 대상 객체는 같을 수 있음
확인할 점
참조가 지역 변수라는 이유로 객체가 독점되지는 않음

논리적 공유 범위를 비교한 표입니다. 이어지는 ProcessAndThreadIdentity는 같은 pid와 서로 다른 스레드 식별자를 출력하지만, 메모리 배치나 공유 힙을 직접 측정하지는 않습니다.

src/ProcessAndThreadIdentity.java
public final class ProcessAndThreadIdentity {
    public static void main(String[] args) throws InterruptedException {
        long processId = ProcessHandle.current().pid();
        Runnable report =
                () ->
                        System.out.println(
                                "pid="
                                        + processId
                                        + ", thread="
                                        + Thread.currentThread().getName()
                                        + ", id="
                                        + Thread.currentThread().threadId());
        Thread left = new Thread(report, "board-left"), right = new Thread(report, "board-right");
        report.run();
        left.start();
        right.start();
        left.join();
        right.join();
    }
}

세 출력의 pid는 같고 threadId와 이름은 다릅니다.

실행 버전과 관찰 범위: 위 Thread.threadId()는 Java 19부터 제공됩니다. 이 장은 가상 스레드 설명까지 포함하므로 JDK 21 이상을 기준으로 따라가면 됩니다. 출력된 식별자 숫자는 실행마다 달라질 수 있으므로 특정 숫자를 외우지 말고 같은 프로세스의 서로 다른 스레드라는 관계를 확인하세요. 이 출력만으로 실제 코어의 병렬 사용이나 힙의 물리 배치를 측정한 것은 아닙니다.

main도 JVM이 만든 하나의 스레드입니다.

report.run()은 main에서 일반 메서드처럼 실행되고, start()만 새 실행 흐름을 만듭니다.


CPU-bound와 I/O-bound의 대기 모양

CPU 중심 작업은 계산 시간 대부분을 RUNNABLE 상태로 쓰므로 코어 수보다 지나치게 많은 스레드는 문맥 전환만 늘릴 수 있습니다.

I/O 중심 작업은 네트워크·디스크를 기다리며 CPU를 놓는 시간이 많아 더 많은 동시 작업이 유리할 수 있습니다.

스레드 수 공식 하나를 모든 프로그램에 적용하지 않습니다.

계산/대기 비율, 코어 수, 응답 시간, 메모리, 외부 시스템 제한을 측정합니다.

Java 21+ 가상 스레드는 대기 작업의 스레드 비용을 줄이지만 CPU 코어를 늘리지 않고 공유 상태 안전성도 자동 해결하지 않습니다.

동시성과 병렬성도 구별해야 합니다.

한 코어에서 두 작업이 번갈아 진척되면 동시성이 있지만 같은 순간에 명령을 실행하는 병렬 상태는 아닙니다.

여러 코어가 있어도 락 하나를 놓고 기다리면 실제 병렬 구간은 작아집니다.

반대로 I/O 요청은 CPU를 동시에 쓰지 않아도 여러 응답을 겹쳐 기다리면서 전체 처리량을 높일 수 있습니다.

따라서 “스레드가 여러 개 보인다”는 관찰만으로 병렬 성능을 주장하지 않습니다.

src/ParallelCpuWork.java
public final class ParallelCpuWork {
    public static void main(String[] args) throws InterruptedException {
        Result left = new Result(), right = new Result();
        Thread a = new Thread(() -> left.value = sum(1, 500_000), "range-a");
        Thread b = new Thread(() -> right.value = sum(500_001, 1_000_000), "range-b");
        a.start();
        b.start();
        a.join();
        b.join();
        System.out.println("sum=" + (left.value + right.value));
    }

    private static long sum(int start, int end) {
        long result = 0;
        for (int i = start; i <= end; i++) {
            result += i;
        }
        return result;
    }

    private static final class Result {
        long value;
    }
}

두 작업자는 다른 결과에 쓰고 main은 join 뒤 합친다

독립 두 작업자가 자기 Result를 쓰고 모두 join 뒤 main이 합산합니다. 두 작업 순서를 고정하지 않고 시작/완료 경계/집계로 나누면 가시성 조건이 분명합니다.

두 작업자는 다른 결과에 쓰고 main은 join 뒤 합친다

range-a와 range-b가 겹치지 않는 정수 범위를 각각 left.value와 right.value에 기록하고 main이 두 join 뒤 최종 합계를 읽는 구조를 비교합니다.

병렬 계산 · 결과 소유권

두 작업자는 다른 결과에 쓰고 main은 join 뒤 합친다

두 작업자는 서로 다른 Result에 씁니다. main은 두 join이 정상 반환한 뒤 합계를 읽습니다.

두 부분합에서 합산으로 향하는 결과 각 작업자는 자기 Result에만 쓰고 main은 정상 반환한 두 join 뒤 두 결과를 읽어 합합니다. 화살표는 결과 사용 관계이며 작업자 사이 완료 순서를 예약하지 않습니다. range-a 1..500000 / left.value 125000250000 range-b 500001..1000000 right.value=375000250000 main · 모두 join 뒤 각 결과를 읽어 합산 sum=500000500000 실선: 작업자별 결과가 최종 합산에 사용됨 같은 총합은 계산 계약이며 속도·스케줄링 측정이 아님
두 부분합에서 합산으로 향하는 결과 각 작업자는 자기 Result에만 쓰고 main은 정상 반환한 두 join 뒤 두 결과를 읽어 합합니다. 화살표는 결과 사용 관계이며 작업자 사이 완료 순서를 예약하지 않습니다. range-a 1..500000 / left.value 125000250000 range-b 500001..1000000 right.value=375000250000 main · 모두 join 뒤 각 슬롯 결과를 읽음 sum=500000500000 실선: 기록 결과 → 마지막 합산 작업자 사이의 실행 사슬 아님 호출·완료 순서는 고정 안 함

range-a

기록 대상·계산 범위
left.value
1..500000
완료 뒤 값
125000250000

range-b

기록 대상·계산 범위
right.value
500001..1000000
완료 뒤 값
375000250000

main

기록 대상·계산 범위
a.join(); b.join();
두 결과를 읽어 합산
완료 뒤 값
sum=500000500000

두 부분합은 원문의 범위와 덧셈에서 계산한 값이며 실제 출력은 마지막 sum=500000500000 한 줄입니다. 정상 반환한 두 join 뒤에 결과를 읽습니다. 실행 시간, 코어 사용이나 속도 향상은 측정하지 않습니다.

이 예의 조건

  • 작업자 종료 순서를 보장하지 않으며 속도 측정이 아니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2각 작업자는 자기 Result에만 쓰고 main은 정상 반환한 두 join 뒤 두 결과를 읽어 합합니다. 화살표는 결과 사용 관계이며 작업자 사이의 완료 순서를 보장하지 않습니다.
원 도표의 설명과 주석
구분내용
설명각 작업자는 자기 Result에만 쓰고 main은 정상 반환한 두 join 뒤 두 결과를 읽어 합합니다. 화살표는 결과 사용 관계이며 작업자 사이의 완료 순서를 보장하지 않습니다.
전체 읽기 정보두 작업자는 다른 결과에 쓰고 main은 join 뒤 합친다 두 작업자는 다른 결과에 쓰고 main은 join 뒤 합친다 range-a와 range-b가 겹치지 않는 정수 범위를 각각 left.value와 right.value에 기록하고 main이 두 join 뒤 최종 합계를 읽는 구조를 비교합니다. 병렬 계산 · 결과 소유권 두 작업자는 다른 결과에 쓰고 main은 join 뒤 합친다 두 작업자는 서로 다른 Result에 씁니다. main은 두 join이 정상 반환한 뒤 합계를 읽습니다. range-a 기록 대상·계산 범위 left.value 1..500000 완료 뒤 값 125000250000 range-b 기록 대상·계산 범위 right.value 500001..1000000 완료 뒤 값 375000250000 main 기록 대상·계산 범위 a.join(); b.join(); 두 결과를 읽어 합산 완료 뒤 값 sum=500000500000 두 부분합은 원문의 범위와 덧셈에서 계산한 값이며 실제 출력은 마지막 sum=500000500000 한 줄입니다. 정상 반환한 두 join 뒤에 결과를 읽습니다. 실행 시간, 코어 사용이나 속도 향상은 측정하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
분리된 범위

두 작업자가 자기 Result를 보유합니다.

  • left: 1..500000
  • right: 500001..1000000
  • 한 결과 필드마다 기록자를 분리한다.
기록/종료

각 작업자가 부분합을 기록합니다.

  • left.value: 125000250000
  • right.value: 375000250000
  • 어느 쪽이 먼저 끝나도 다른 결과를 덮지 않는다.
모두 join 후

main이 두 값을 읽어 합합니다.

  • sum: 500000500000
  • 읽기 조건: 두 join 정상 반환
  • 완료 회수 전 결과 소비를 하지 않는다.

두 작업자는 다른 결과에 쓰고 main은 join 뒤 합친다

독립 두 작업자가 자기 Result를 쓰고 모두 join 뒤 main이 합산합니다. 두 작업 순서를 고정하지 않고 시작/완료 경계/집계로 나누면 가시성 조건이 분명합니다.

두 작업자는 다른 결과에 쓰고 main은 join 뒤 합친다

range-a와 range-b가 겹치지 않는 정수 범위를 각각 left.value와 right.value에 기록하고 main이 두 join 뒤 최종 합계를 읽는 구조를 비교합니다.

병렬 계산 · 결과 소유권

두 작업자는 다른 결과에 쓰고 main은 join 뒤 합친다

두 작업자는 서로 다른 Result에 씁니다. main은 두 join이 정상 반환한 뒤 합계를 읽습니다.

두 부분합에서 합산으로 향하는 결과 각 작업자는 자기 Result에만 쓰고 main은 정상 반환한 두 join 뒤 두 결과를 읽어 합합니다. 화살표는 결과 사용 관계이며 작업자 사이 완료 순서를 예약하지 않습니다. range-a 1..500000 / left.value 125000250000 range-b 500001..1000000 right.value=375000250000 main · 모두 join 뒤 각 결과를 읽어 합산 sum=500000500000 실선: 작업자별 결과가 최종 합산에 사용됨 같은 총합은 계산 계약이며 속도·스케줄링 측정이 아님
두 부분합에서 합산으로 향하는 결과 각 작업자는 자기 Result에만 쓰고 main은 정상 반환한 두 join 뒤 두 결과를 읽어 합합니다. 화살표는 결과 사용 관계이며 작업자 사이 완료 순서를 예약하지 않습니다. range-a 1..500000 / left.value 125000250000 range-b 500001..1000000 right.value=375000250000 main · 모두 join 뒤 각 슬롯 결과를 읽음 sum=500000500000 실선: 기록 결과 → 마지막 합산 작업자 사이의 실행 사슬 아님 호출·완료 순서는 고정 안 함

range-a

기록 대상·계산 범위
left.value
1..500000
완료 뒤 값
125000250000

range-b

기록 대상·계산 범위
right.value
500001..1000000
완료 뒤 값
375000250000

main

기록 대상·계산 범위
a.join(); b.join();
두 결과를 읽어 합산
완료 뒤 값
sum=500000500000

두 부분합은 원문의 범위와 덧셈에서 계산한 값이며 실제 출력은 마지막 sum=500000500000 한 줄입니다. 정상 반환한 두 join 뒤에 결과를 읽습니다. 실행 시간, 코어 사용이나 속도 향상은 측정하지 않습니다.

이 예의 조건

  • 작업자 종료 순서를 보장하지 않으며 속도 측정이 아니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2각 작업자는 자기 Result에만 쓰고 main은 정상 반환한 두 join 뒤 두 결과를 읽어 합합니다. 화살표는 결과 사용 관계이며 작업자 사이의 완료 순서를 보장하지 않습니다.
원 도표의 설명과 주석
구분내용
설명각 작업자는 자기 Result에만 쓰고 main은 정상 반환한 두 join 뒤 두 결과를 읽어 합합니다. 화살표는 결과 사용 관계이며 작업자 사이의 완료 순서를 보장하지 않습니다.
전체 읽기 정보두 작업자는 다른 결과에 쓰고 main은 join 뒤 합친다 두 작업자는 다른 결과에 쓰고 main은 join 뒤 합친다 range-a와 range-b가 겹치지 않는 정수 범위를 각각 left.value와 right.value에 기록하고 main이 두 join 뒤 최종 합계를 읽는 구조를 비교합니다. 병렬 계산 · 결과 소유권 두 작업자는 다른 결과에 쓰고 main은 join 뒤 합친다 두 작업자는 서로 다른 Result에 씁니다. main은 두 join이 정상 반환한 뒤 합계를 읽습니다. range-a 기록 대상·계산 범위 left.value 1..500000 완료 뒤 값 125000250000 range-b 기록 대상·계산 범위 right.value 500001..1000000 완료 뒤 값 375000250000 main 기록 대상·계산 범위 a.join(); b.join(); 두 결과를 읽어 합산 완료 뒤 값 sum=500000500000 두 부분합은 원문의 범위와 덧셈에서 계산한 값이며 실제 출력은 마지막 sum=500000500000 한 줄입니다. 정상 반환한 두 join 뒤에 결과를 읽습니다. 실행 시간, 코어 사용이나 속도 향상은 측정하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
분리된 범위

두 작업자가 자기 Result를 보유합니다.

  • left: 1..500000
  • right: 500001..1000000
  • 한 결과 필드마다 기록자를 분리한다.
기록/종료

각 작업자가 부분합을 기록합니다.

  • left.value: 125000250000
  • right.value: 375000250000
  • 어느 쪽이 먼저 끝나도 다른 결과를 덮지 않는다.
모두 join 후

main이 두 값을 읽어 합합니다.

  • sum: 500000500000
  • 읽기 조건: 두 join 정상 반환
  • 완료 회수 전 결과 소비를 하지 않는다.

두 join이 정상 반환한 뒤 결과를 읽으면 작업자의 계산 완료와 결과 쓰기의 가시성이 보장됩니다.

join 없이 즉시 합산하면 기본값 0을 읽을 수 있습니다.

이 코드에서 두 Result 객체를 각 스레드가 하나씩 쓰게 해 쓰기 충돌도 피했습니다.

여기서는 두 사실이 함께 필요합니다. 첫째, range-a는 left만, range-b는 right만 쓰므로 같은 값에 두 작업자가 덧셈하지 않습니다. 둘째, main은 두 대상의 종료를 기다린 뒤 읽습니다. 1부터 n까지의 합 n(n+1)/2를 쓰면 전체 합은 1,000,000×1,000,001/2이고, 뒤 구간 합은 전체에서 앞 500,000개 합을 빼서 구할 수 있습니다. 도식의 부분합은 이 계산값이며 원 프로그램은 최종 합만 출력합니다. 동시성 API의 메모리 순서 규칙은 start와 종료를 회수한 join 사이의 가시성도 설명합니다.


스케줄러 상태를 결과 순서와 혼동하지 않는 이유

NEW 스레드는 아직 start되지 않았고, RUNNABLE은 실제 CPU 실행 중과 실행 대기를 함께 포함합니다.

BLOCKED는 모니터 락, WAITING·TIMED_WAITING은 다른 사건이나 시간 경과를 기다립니다.

TERMINATED는 run이 끝난 상태입니다.

호출과 시작의 차이: Runnable.run()을 직접 호출하면 호출한 스레드에서 일반 메서드처럼 실행합니다. 새 스레드에서 수행하게 하려면 그 작업을 담은 Thread의 start()를 호출해야 합니다. 하나의 Thread 객체는 최대 한 번만 시작할 수 있고, 종료 후에도 다시 start()하면 IllegalThreadStateException이 발생합니다. 같은 작업을 다시 수행하려면 새 Thread 객체를 만들거나 뒤에서 배우는 실행 관리 방식을 사용하세요.

상태는 진단용 순간값입니다. Thread.State는 JVM의 분류이며 운영체제의 상태 목록과 일대일로 같지 않습니다. RUNNABLE이어도 CPU 같은 운영체제 자원을 기다릴 수 있고, BLOCKED는 특히 모니터 락 진입 대기이므로 모든 대기를 뜻하지 않습니다. getState()로 특정 상태가 보일 때까지 기다리는 코드로 작업 간 순서를 정하지 마세요. Thread.State의 분류와 getState의 진단용 계약을 기준으로 원인을 확인합니다.

우선순위는 힌트이며 정확한 실행 순서 규칙이 아닙니다.

OS와 JVM 구현에 따라 영향이 다릅니다.

사용자 기능 순서를 우선순위나 sleep에 맡기지 않습니다.

시작 순서 반례를 확인할 때도 단일 실행 로그 한 번으로 일반화하지 않습니다.

앞 예제의 서로 다른 지연은 역순 완료를 관찰하기 위한 설정이며, 특정 출력 순서를 보장하지는 않습니다.

실제 기능에서 결과 순서를 요구하면 스케줄러 우연 대신 단계별 래치, 큐의 FIFO 규칙, 이전 스레드의 join처럼 원인과 결과가 연결된 수단을 둡니다.

반대로 서로 독립인 계산은 출력 순서를 단언에서 제거하고 결과 집합과 최종 합계만 검사합니다.

운영에서 RUNNABLE 비율이 높다고 항상 건강한 것도 아닙니다.

코어 수보다 훨씬 많은 계산 스레드가 실행 가능 큐에 쌓이면 각 작업은 조금씩만 진행되고 꼬리 지연 시간이 커질 수 있습니다.

WAITING이 많아도 의도한 큐 대기인지 교착 상태 전조인지 소유자와 대기 원인을 함께 확인합니다.

Java 스레드 상태는 JVM이 제공하는 관찰 정보이며 업무 성공을 나타내는 지표는 아닙니다.

여섯 JVM 스레드 상태는 진단용 분류다

NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED의 API 의미와 관찰 한계를 비교합니다. 상태들을 정해진 순서로 모두 거친다는 뜻은 아닙니다.

THREAD.STATE · 진단 분류

여섯 JVM 스레드 상태는 진단용 분류다

여섯 상태는 대기 원인을 설명하는 분류입니다. 이 순서로 반드시 전이하거나 모두 관찰되는 것은 아닙니다.

상태의 대표 진입 원인과 종료 NEW에서 start로 실행 가능해지고 run 종료에서 TERMINATED가 됩니다. 세 대기 분류는 각각 모니터·래치·시간 조건의 가능한 대표 진입이며 순차로 모든 상태를 겪는 그림이 아닙니다. NEW 아직 시작하지 않음 RUNNABLE 실행 가능 / 실행 중 TERMINATED 종료는 성공과 별개 BLOCKED 모니터 진입 대기 WAITING 래치 등 기한 없는 대기 TIMED_WAITING sleep 등 시간 제한 대기 start run 종료 모니터 래치 시간 실선은 가능한 대표 대기 진입 경로 · 모든 상태를 차례로 겪는 경로가 아님 세 대기 원인이 해제되면 다시 실행 가능 · 상태 조회는 순간의 진단
상태의 대표 진입 원인과 종료 NEW에서 start로 실행 가능해지고 run 종료에서 TERMINATED가 됩니다. 세 대기 분류는 각각 모니터·래치·시간 조건의 가능한 대표 진입이며 순차로 모든 상태를 겪는 그림이 아닙니다. NEW 아직 시작하지 않음 RUNNABLE 실행 가능 / 실행 중 BLOCKED 모니터 진입 대기 WAITING 래치 등 기한 없는 대기 TIMED_WAITING sleep 등 시간 제한 대기 TERMINATED 실행 종료 / 성공과 별개 start 모니터 래치 시간 run 종료 실선: 가능한 대표 진입 경로 세 대기는 차례로 겪지 않음 해제 뒤 다시 실행 가능 JVM 진단 분류 / CPU보장 아님

NEW

의미
아직 시작하지 않음
관찰의 한계
객체 생성만으로 작업이 실행되지는 않음

RUNNABLE

의미
JVM에서 실행 가능하거나 실행 중
관찰의 한계
실제 CPU 사용 중이라는 보장은 아님

BLOCKED

의미
모니터 락 획득 대기
관찰의 한계
모든 대기를 뜻하지 않음

WAITING

의미
다른 스레드의 동작을 기한 없이 기다림
관찰의 한계
대기 원인과 대상도 함께 확인

TIMED_WAITING

의미
지정한 시간 한도가 있는 대기
관찰의 한계
sleep이나 시간 제한 대기

TERMINATED

의미
스레드 실행 종료
관찰의 한계
업무 성공 여부는 결과·예외로 확인

Thread.State API의 분류이며 운영체제 스레드 상태와 같은 목록이 아닙니다. 행 순서는 필수 전이 경로가 아닙니다. 이 장의 예제가 여섯 상태를 모두 관찰했다는 뜻도 아닙니다.

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

public final class ConcurrentBoardTasks {
    public static void main(String[] args) throws InterruptedException {
        List<Task> tasks =
                List.of(
                        new Task("publish", 101),
                        new Task("index", 102),
                        new Task("notify", 103));
        Thread[] workers = new Thread[tasks.size()];
        for (int i = 0; i < tasks.size(); i++) {
            Task task = tasks.get(i);
            workers[i] = new Thread(() -> run(task), "board-" + task.action());
            workers[i].start();
        }
        for (Thread worker : workers) {
            worker.join();
        }
        System.out.println("all-complete=" + workers.length);
    }

    private static void run(Task task) {
        System.out.println(
                Thread.currentThread().getName()
                        + ":"
                        + task.action()
                        + "#"
                        + task.postId());
    }

    private record Task(String action, long postId) {}
}

개별 출력 순서는 고정하지 않고 마지막 all-complete만 모든 작업자 종료 뒤 나온다는 규칙으로 둡니다.

종료와 성공을 구분하세요. 이 예제의 세 작업은 로그를 각각 출력하는 독립 작업입니다. 이름이 publish·index·notify여도 발행 완료 후 색인, 색인 완료 후 알림이라는 업무 의존 순서는 구현하지 않았습니다. 또한 join()은 대상 스레드의 종료를 기다리며 대상의 예외를 호출자에게 자동으로 다시 던지지 않습니다. 따라서 일반 업무 코드에서 all-complete만으로 세 작업 모두 성공했다고 결론 내리지 않습니다. 실제 성공 여부는 결과·실패 정보를 별도로 전달받아 확인해야 합니다.

로그에는 스레드 이름을 넣어 섞인 출력의 출처를 구분합니다.


연습 문제

1부터 900까지를 겹치지 않는 세 범위로 나눠 계산하고 join 뒤 합치세요.

누락·중복을 막기 위해 각 범위 시작과 끝도 출력합니다.

해설을 열기 전에 각 구간의 원소 수를 끝−시작+1로 세어 합이 900인지 확인하세요. 정렬한 구간에서 다음 시작이 앞 끝+1이면 중복이나 빈 구간이 없습니다. 전체 합은 세 부분합의 합과 900×901/2를 서로 비교해 검산할 수 있습니다. 출력 순서가 아니라 이 범위 집합과 마지막 합을 검사 대상으로 정합니다.

정답과 해설

각 작업자는 sums의 자기 인덱스에만 쓰며 main은 모두 join한 뒤 읽습니다.

범위는 1~300, 301~600, 601~900입니다.

exercise/ThreeRangeSumSolution.java
public final class ThreeRangeSumSolution {
    public static void main(String[] args) throws InterruptedException {
        Range[] ranges = {new Range(1, 300), new Range(301, 600), new Range(601, 900)};
        long[] sums = new long[3];
        Thread[] workers = new Thread[3];
        for (int i = 0; i < 3; i++) {
            int index = i;
            workers[i] =
                    new Thread(
                            () -> {
                                Range r = ranges[index];
                                long sum = 0;
                                for (int v = r.start(); v <= r.end(); v++) {
                                    sum += v;
                                }
                                sums[index] = sum;
                                System.out.println(r);
                            });
            workers[i].start();
        }
        for (Thread worker : workers) {
            worker.join();
        }
        System.out.println("total=" + (sums[0] + sums[1] + sums[2]));
    }

    private record Range(int start, int end) {}
}

총합은 405450입니다.

범위 출력 순서는 달라도 최종 합과 범위 집합은 일정합니다.

프로세스와 스레드를 구분했다면 공유 힙과 개별 스택, start()와 완료 순서, CPU 중심 작업과 I/O 중심 작업의 스레드 수 기준을 각각 설명할 수 있어야 합니다.

추가 연습: 직접 호출과 한 번만 시작하는 작업자

아래 조건대로 하나의 Runnable이 현재 스레드 이름을 출력하게 만드세요. main 스레드 이름은 main으로 직접 정하고, 먼저 그 작업의 run()을 직접 호출합니다. 이어 같은 작업을 담은 이름 worker의 새 플랫폼 Thread를 만들어 start()하고 join()합니다. 종료한 그 객체에 다시 start()했을 때의 예외를 잡아 restart-rejected를 출력하세요. 세 출력의 이름·순서를 예측하고, 처음의 직접 호출이 새 스레드를 생성한 것인지 설명하세요.

정답 및 풀이 보기

출력은 run-on=main, run-on=worker, restart-rejected 순서입니다. 첫 작업은 main 안의 일반 호출이고, 두 번째 작업만 새 스레드에서 실행됩니다. 첫 호출이 반환한 뒤 start()하며, join()이 반환한 뒤 재시작을 시도하므로 이 예제의 세 줄 사이에는 명시적인 순서가 있습니다. 이는 앞의 독립 작업자 여러 개가 완료하는 순서를 보장했다는 뜻은 아닙니다.

exercise/StartOnceSolution.java
public class StartOnceSolution {
    public static void main(String[] args) throws InterruptedException {
        Thread.currentThread().setName("main");
        Runnable task = () -> System.out.println(
                "run-on=" + Thread.currentThread().getName());
        task.run();
        Thread worker = new Thread(task, "worker");
        worker.start();
        worker.join();
        try {
            worker.start();
        } catch (IllegalThreadStateException expected) {
            System.out.println("restart-rejected");
        }
    }
}

종료한 스레드의 이름이 다시 출력되는 것이 정답이 아닙니다. start의 한 번만 시작하는 계약 때문에 두 번째 start()는 작업을 실행하기 전에 거부됩니다.