본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
18장 : 스레드 생성·제어·생명 주기

프로세스와 스레드

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

운영체제는 실행 중인 프로그램을 프로세스로 관리하고, 프로세스 안의 실제 실행 흐름을 스레드로 스케줄링합니다. 멀티태스킹은 짧은 시간 조각을 여러 작업에 번갈아 배정해 동시에 진행되는 것처럼 보이게 합니다. 여러 코어에서는 일부 스레드가 실제 병렬로 실행될 수 있지만 실행 순서는 여전히 스케줄러와 작업 상태에 달려 있습니다.

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

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

start는 새 스레드를 실행 가능 상태로 등록할 뿐 완료 순서를 예약하지 않습니다. 먼저 시작한 slow가 오래 기다리고 뒤의 빠른이 즉시 끝나면 출력 순서는 시작 호출과 반대입니다.

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);
    }
}

빠른이 먼저 출력됩니다. sleep 시간은 교육용으로 차이를 크게 만든 것이며 일반 코드에서 시간 지연으로 순서를 보장해서는 안 됩니다. 순서 의존성이 있다면 join, Queue, Future 같은 명시적 조정 수단을 사용합니다.

프로세스 구분과 스레드 공유 범위

프로세스마다 가상 주소 공간과 자원이 격리됩니다. 한 프로세스의 일반 객체 참조를 다른 프로세스가 그대로 읽을 수 없고 IPC가 필요합니다. 격리는 장애 전파를 줄이지만 통신과 생성 비용이 큽니다.

같은 프로세스의 스레드는 힙 객체와 static 필드를 공유하고 각자 Java 스택, 프로그램 카운터, 네이티브 스택을 갖습니다. 공유 힙은 빠른 협업을 가능하게 하지만 동시에 바꾸면 경합 조건이 생깁니다. 19장에서 가시성과 synchronized로 이 위험을 다룹니다.

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, "study-left"), right = new Thread(report, "study-right");
        report.run();
        left.start();
        right.start();
        left.join();
        right.join();
    }
}

세 출력의 pid는 같고 threadId와 이름은 다릅니다. 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;
    }
}

join 뒤 결과를 읽으므로 두 계산 완료와 메모리 가시성 구분이 생깁니다. join 없이 즉시 합산하면 기본값 0을 읽을 수 있습니다. 이 코드에서 두 Result 객체를 각 스레드가 하나씩 쓰게 해 쓰기 충돌도 피했습니다.

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

NEW 스레드는 아직 start되지 않았고, RUNNABLE은 실제 CPU 실행 중과 실행 대기를 함께 포함합니다. BLOCKED는 모니터 락, WAITING·TIMED_WAITING은 다른 사건이나 시간 경과를 기다립니다. TERMINATED는 run이 끝난 상태입니다.

우선순위는 힌트이며 정확한 실행 순서 규칙이 아닙니다. OS와 JVM 구현에 따라 영향이 다릅니다. 사용자 기능 순서를 우선순위나 sleep에 맡기지 않습니다.

시작 순서 반례를 확인할 때도 단일 실행 로그 한 번으로 일반화하지 않습니다. slow와 빠른의 지연을 의도적으로 다르게 둔 앞 예제는 특정 역순 결과를 재현하기 위한 실험입니다. 실제 기능에서 결과 순서를 요구하면 스케줄러 우연 대신 단계별 래치, 큐의 FIFO 규칙, 이전 스레드의 join처럼 원인과 결과가 연결된 수단을 둡니다. 반대로 서로 독립인 계산은 출력 순서를 단언에서 제거하고 결과 집합과 최종 합계만 검사합니다.

운영에서 RUNNABLE 비율이 높다고 항상 건강한 것도 아닙니다. 코어 수보다 훨씬 많은 계산 스레드가 실행 가능 큐에 쌓이면 각 작업은 조금씩만 진행되고 꼬리 지연 시간이 커질 수 있습니다. WAITING이 많아도 의도한 큐 대기인지 교착 상태 전조인지 소유자와 대기 원인을 함께 확인합니다. 상태 숫자는 스케줄러 판단의 결과이지 업무 성공 지표가 아닙니다.

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

public final class ConcurrentStudyTasks {
    public static void main(String[] args) throws InterruptedException {
        List<Task> tasks =
                List.of(new Task("read", 30), new Task("code", 50), new Task("review", 20));
        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), "study-" + task.name());
            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.minutes());
    }

    private record Task(String name, int minutes) {}
}

개별 출력 순서는 고정하지 않고 마지막 all-완료만 모든 작업자 종료 뒤 나온다는 규칙으로 둡니다. 로그에는 스레드 이름을 넣어 섞인 출력의 출처를 구분합니다.

연습 문제

1부터 900까지를 겹치지 않는 세 범위로 나눠 계산하고 join 뒤 합치세요. 누락·중복을 막기 위해 각 범위 시작과 끝도 출력합니다.

정답과 해설

각 작업자는 자기 Result만 쓰며 main은 모두 join한 뒤 읽습니다. 범위는 1300, 301600, 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 중심 작업의 스레드 수 기준을 각각 설명할 수 있어야 합니다.