본문 바로가기

프로세스와 스레드

실행 중인 프로그램이 프로세스와 스레드라는 단위로 실행되는 방식을 알아보고, 스레드를 만들어 start로 시작하고 join으로 기다리면서 실행 순서가 매번 달라질 수 있는 이유를 배웁니다.

지금까지 작성한 프로그램은 모두 main 메서드의 첫 줄부터 마지막 줄까지 한 줄씩 차례로 실행됐습니다. 이렇게 실행되는 흐름이 하나뿐이면 한 번에 한 가지 일만 할 수 있습니다. 파일을 읽거나 네트워크 응답을 기다리는 동안에는 다른 일이 모두 멈추고, 컴퓨터에 코어가 여러 개 있어도 긴 계산에는 그중 하나만 쓰입니다. 여러 일을 동시에 진행하려면 프로그램 안에 실행 흐름이 여러 개 있어야 하는데, 이 실행 흐름 하나하나를 스레드(thread)라고 합니다.

이 장부터 여러 스레드를 다루는 동시성 프로그래밍을 배웁니다. 첫 글에서는 운영체제가 프로그램을 프로세스와 스레드라는 단위로 실행하는 방식을 살펴보고, 스레드를 직접 만들어 start()로 시작하고 join()으로 끝나기를 기다려 봅니다. 미리 알아 둘 점이 하나 있습니다. 여러 스레드가 실행되는 순서는 정해져 있지 않아서, 이 글의 예제 가운데 일부는 실행할 때마다 출력 순서가 달라집니다. 출력에서 어느 부분이 달라질 수 있고 어느 부분은 항상 같은지 구별하는 것이 이 장 전체에서 가장 중요한 습관입니다.

실행 중인 프로그램, 프로세스

터미널에서 java Hello를 실행하면 운영체제는 JVM 프로그램을 메모리에 올리고 실행을 시작합니다. 이렇게 실행 중인 프로그램 하나를 프로세스(process)라고 합니다. 같은 프로그램을 두 번 실행하면 프로세스도 두 개가 생기고, 운영체제는 각각에 프로세스 번호(PID, process ID)를 붙여 구별합니다. 운영체제가 프로세스의 주소 공간과 상태를 어떻게 관리하는지는 프로세스의 개념에서 자세히 다룹니다.

운영체제는 프로세스마다 따로 쓰는 메모리 공간(가상 주소 공간)과 열린 파일, 네트워크 연결 같은 자원을 나눠 줍니다. 그래서 한 프로세스는 다른 프로세스의 메모리를 직접 읽거나 고칠 수 없습니다. 한 프로그램이 잘못된 값을 써도 다른 프로그램의 메모리는 망가지지 않으므로, 이 격리는 일종의 안전장치입니다.

대신 두 프로세스가 데이터를 주고받으려면 파이프, 소켓, 파일 같은 프로세스 간 통신(IPC, inter-process communication) 수단으로 바이트를 복사해 보내야 합니다. 한 프로세스에 있는 Java 객체의 참조를 다른 프로세스에 넘겨 그대로 쓸 수는 없습니다. 게다가 프로세스를 새로 만드는 일과 프로세스끼리 통신하는 일은 모두 비용이 큰 편입니다.

프로세스 안의 실행 흐름, 스레드

프로세스 안에서 실제로 명령을 하나씩 실행하는 흐름이 스레드입니다. 모든 프로세스에는 스레드가 적어도 하나 있고, 필요하면 더 만들 수 있습니다. Java 프로그램에서는 JVM이 main이라는 이름의 스레드를 만들어 그 위에서 main 메서드를 호출합니다. 지금까지 작성한 코드는 모두 이 main 스레드에서 실행된 것입니다.

같은 프로세스의 스레드들은 어떤 것은 각자 갖고 어떤 것은 함께 씁니다. 스레드마다 따로 갖는 것은 Java 스택과 프로그램 카운터(program counter)입니다. JVM 메모리 구조에서 본 것처럼 스택에는 메서드를 호출할 때마다 매개변수와 지역 변수를 담은 프레임이 쌓이는데, 이 스택이 스레드마다 하나씩 있습니다. 프로그램 카운터는 그 스레드가 지금 어느 명령을 실행하고 있는지 가리키는 위치입니다. 반면 힙에 만든 객체와 static 필드는 같은 JVM 안의 모든 스레드가 함께 씁니다. 운영체제 입장에서 스레드가 무엇을 함께 쓰고 무엇을 따로 갖는지는 스레드의 개념에서 더 볼 수 있습니다.

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

서로 다른 프로세스의 주소 공간, 같은 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와 서로 다른 스레드 식별자를 출력하지만, 메모리 배치나 공유 힙을 직접 측정하지는 않습니다.

그림에서 스레드 A의 지역 변수 a와 스레드 B의 지역 변수 b는 서로 다른 스택 프레임에 있습니다. 하지만 두 변수가 같은 힙 객체를 가리키면 그 객체는 두 스레드가 함께 쓰는 객체가 됩니다. 변수가 지역 변수라고 해서 그 변수가 가리키는 객체까지 한 스레드만 쓰는 것은 아니라는 뜻입니다. 반대로 다른 프로세스 B에 있는 객체에는 어떤 참조로도 닿을 수 없습니다.

스레드가 힙을 함께 쓰기 때문에 스레드끼리는 협력하기 쉽습니다. 데이터를 복사해 보낼 필요 없이 객체 참조 하나만 넘기면 되고, 스레드를 만드는 비용도 프로세스를 만드는 비용보다 훨씬 작습니다. 그런데 바로 그 공유 때문에 새로운 문제가 생깁니다. 두 스레드가 같은 객체를 동시에 바꾸면 실행 순서에 따라 결과가 달라지는 경쟁 상태(race condition)가 생길 수 있습니다. 이 문제와 해결 방법은 19장 가시성과 동기화에서 다루고, 이 글의 예제들은 스레드마다 서로 다른 객체에 쓰도록 만들어 이 문제를 피합니다.

프로세스 번호와 스레드 이름 출력해 보기

다음 프로그램은 같은 작업을 세 번 실행하면서, 그때마다 지금 이 코드를 실행하고 있는 프로세스와 스레드를 출력합니다. 한 번은 main 스레드에서 직접 호출하고, 나머지 두 번은 새로 만든 스레드 두 개에서 실행합니다.

먼저 처음 보는 API를 정리하겠습니다. ProcessHandle.current().pid()는 운영체제가 이 프로세스에 붙인 번호를 돌려줍니다. Thread.currentThread()는 이 코드를 지금 실행하고 있는 스레드의 Thread 객체를 돌려주고, getName()과 threadId()는 그 스레드의 이름과 JVM이 붙인 고유 번호를 돌려줍니다. 스레드에게 시킬 일은 Runnable 인터페이스로 표현합니다. Runnable에는 run() 메서드 하나만 있고, 코드의 () -> { … }는 그 run()의 본문을 짧게 적은 람다식입니다. 람다식은 익명 클래스로 동작 전달하기에서 잠깐 보았고 30장에서 자세히 배우므로, 여기서는 '실행할 코드를 담은 객체'로 읽으면 충분합니다.

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

new Thread(report, "board-left")는 할 일 report와 이름 board-left를 가진 Thread 객체를 만듭니다. 하지만 객체를 만들었다고 스레드가 실행되지는 않습니다. start()를 호출해야 JVM이 새 스레드를 시작하고, 그 스레드가 report.run()을 실행합니다. 마지막의 join()은 해당 스레드가 끝날 때까지 main 스레드를 기다리게 합니다.

실행 결과
pid=6747, thread=main, id=1
pid=6747, thread=board-left, id=18
pid=6747, thread=board-right, id=19

세 줄의 pid가 모두 같으므로 세 작업은 한 프로세스 안에서 실행됐습니다. 반면 스레드 이름과 번호는 줄마다 다르므로 서로 다른 세 스레드가 실행했습니다. 첫 줄의 thread=main을 눈여겨보세요. report.run()처럼 run()을 직접 호출하면 새 스레드가 생기지 않고, 호출한 main 스레드에서 보통 메서드처럼 실행됩니다. 새 실행 흐름을 만드는 것은 start()뿐이며, 이 차이는 Thread 생성과 start에서 자세히 살펴봅니다.

이 출력에는 실행할 때마다 달라질 수 있는 부분이 있습니다. 우선 pid 숫자는 운영체제가 실행할 때마다 새로 정하므로 매번 다릅니다. 스레드 번호는 이 실행에서 main이 1, 새 스레드가 18과 19였지만 JDK 버전이나 설정에 따라 달라질 수 있습니다. 새 스레드가 2보다 훨씬 큰 번호를 받은 것은 JVM이 시작하면서 내부 작업용 스레드를 여러 개 먼저 만들기 때문입니다.

board-left 줄과 board-right 줄의 순서도 바뀔 수 있습니다. 이 글을 쓰며 60번 실행했을 때 54번은 board-left가, 6번은 board-right가 먼저 출력됐습니다. 두 스레드가 시작된 뒤에는 어느 쪽이 먼저 출력할지 운영체제의 스케줄러가 정하기 때문입니다. 반면 첫 줄은 언제나 맨 앞에 옵니다. report.run()이 출력을 마치고 돌아온 뒤에야 start()가 호출되므로, 그 전에는 출력할 다른 스레드가 아예 없기 때문입니다.

threadId()는 Java 19에 추가된 메서드입니다. 오래된 코드에서는 같은 용도로 getId()를 쓰는데, 이 메서드는 final이 아니어서 하위 클래스가 재정의해 엉뚱한 값을 돌려줄 수 있다는 이유로 Java 19부터 사용 중단(deprecated) 대상이 되었습니다.

여러 스레드가 함께 실행되는 방식

코어가 하나뿐인 컴퓨터를 생각해 봅시다. 코어 하나는 한순간에 한 스레드의 명령만 실행할 수 있습니다. 그래서 운영체제의 스케줄러(scheduler)는 실행할 준비가 된 스레드들에게 짧은 시간 조각(time slice)을 번갈아 나눠 주고, 시간이 다 되면 다른 스레드로 바꿉니다. 이 교대가 사람이 느끼지 못할 만큼 빠르게 일어나므로 여러 프로그램이 동시에 실행되는 것처럼 보입니다. 이렇게 시간을 잘게 나눠 여러 작업을 번갈아 진행하는 방식을 멀티태스킹(multitasking) 또는 시분할(time sharing)이라고 합니다.

코어가 여러 개라면 서로 다른 스레드가 서로 다른 코어에서 정말로 같은 순간에 실행될 수 있습니다. 여기서 두 용어를 구별해야 합니다. 동시성(concurrency)은 여러 작업이 겹치는 기간 동안 함께 진행되는 것으로, 한 코어에서 번갈아 실행돼도 동시성은 있습니다. 병렬성(parallelism)은 여러 작업이 같은 순간에 실제로 함께 실행되는 것으로, 코어가 여러 개 있어야 가능합니다. 스레드를 여러 개 만들면 동시성은 생기지만, 그 스레드들이 병렬로 실행될지는 코어 수와 다른 프로그램의 부하, 스레드끼리 서로 기다리는지에 달려 있습니다.

멀티프로세싱(multiprocessing)이라는 말도 자주 나옵니다. 하드웨어를 이야기할 때는 CPU나 코어가 여러 개인 시스템을 뜻하고, 프로그램 설계를 이야기할 때는 일을 여러 프로세스로 나눠 처리하는 방식을 뜻합니다. 여러 프로세스로 나누면 한 프로세스가 죽어도 다른 프로세스는 영향을 받지 않지만, 앞에서 본 것처럼 프로세스끼리는 IPC로 통신해야 하므로 비용이 큽니다. 이 장에서 다루는 것은 한 프로세스 안에서 여러 스레드로 일을 나누는 멀티스레딩(multithreading)입니다.

new Thread(…)로 만든 Java 스레드는 운영체제 스레드 하나와 짝을 이루어 실행되므로, 어느 스레드를 언제 실행할지는 운영체제의 스케줄러가 정합니다. 뒤에서 소개할 가상 스레드가 도입된 뒤로는 둘을 구별하려고 이런 스레드를 플랫폼 스레드(platform thread)라고 부릅니다. 스케줄러는 코어 수, 다른 프로그램의 부하, 각 스레드가 무엇을 기다리는지를 보고 그때그때 결정합니다. 그래서 프로그램 코드만 보고는 스레드들이 어떤 순서로 실행될지 알 수 없고, 다음 예제가 바로 그 점을 보여 줍니다.

먼저 시작한 스레드가 먼저 끝나지는 않는다

다음 프로그램은 스레드 두 개를 만들어 slow를 먼저, fast를 나중에 시작합니다. 두 스레드는 같은 work 메서드를 실행하는데, slow는 80밀리초, fast는 5밀리초 동안 잠든 뒤 자기 이름을 출력합니다. Thread.sleep(millis)는 현재 스레드를 지정한 시간 동안 멈추게 하는 메서드입니다. 자는 도중에 다른 스레드가 중단을 요청하면 InterruptedException이 발생하므로 catch로 받는데, catch 안의 interrupt() 호출이 무슨 뜻인지는 interrupt 플래그와 InterruptedException 처리에서 다룹니다.

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("main: joined both");
    }

    private static void work(String name, long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        System.out.println("done=" + name);
    }
}
실행 결과
done=fast
done=slow
main: joined both

slow를 먼저 시작했는데도 fast가 먼저 끝났습니다. start()는 새 스레드를 실행할 수 있는 상태로 만들 뿐, 그 스레드가 언제 실행되고 언제 끝날지까지 정하지는 않기 때문입니다. 두 스레드는 거의 같은 때에 출발했고, 잠자는 시간이 짧은 fast가 먼저 깨어나 출력했습니다.

main이 slow.join()을 먼저 호출한 것도 완료 순서에는 영향을 주지 않습니다. join()은 그것을 호출한 스레드, 여기서는 main만 멈춰 세웁니다. main이 slow를 기다리는 동안에도 이미 시작된 fast는 자기 일을 계속하므로, 'slow를 먼저 기다린다'와 'slow가 먼저 끝난다'는 서로 다른 말입니다. 두 join()의 순서를 바꿔도 출력은 같습니다. 바뀌는 것은 main이 어느 스레드를 먼저 기다리느냐뿐이고, 두 작업자는 그와 상관없이 각자 실행됩니다.

이 출력에서 항상 같은 것과 달라질 수 있는 것을 나눠 보겠습니다. 마지막 줄 main: joined both는 언제나 맨 끝에 나옵니다. main은 두 join()이 모두 돌아온 뒤에야 이 줄을 출력하고, 각 작업자는 끝나기 전에 자기 줄을 이미 출력했기 때문입니다. 반면 두 done 줄의 순서는 Java가 약속하지 않습니다. 이 글을 쓰며 40번 실행했을 때는 매번 fast가 먼저였지만, 그것은 잠자는 시간의 차이가 75밀리초나 될 만큼 크기 때문입니다. sleep은 그 시간 동안 멈춰 달라는 요청일 뿐이고 깨어난 뒤 언제 코어를 받을지는 스케줄러가 정하므로, 컴퓨터가 몹시 바쁘면 slow가 먼저 출력될 수도 있습니다.

그래서 실제 코드에서는 sleep 시간의 차이로 실행 순서를 맞추면 안 됩니다. 이 예제의 sleep은 차이를 눈에 띄게 하려고 넣은 교육용 장치입니다. 한 작업이 다른 작업의 결과를 기다려야 한다면 지금 배우는 join()처럼 순서를 명시적으로 만드는 수단을 써야 합니다. 뒤에서는 큐를 20장 생산자·소비자와 조건 대기에서, Future를 Callable과 Future로 작업 결과 받기에서 배웁니다. 이런 프로그램을 테스트할 때도 두 done 줄의 순서가 아니라 각 이름이 정확히 한 번씩 출력됐는지, 마지막 줄이 맨 끝에 있는지를 확인해야 합니다.

CPU를 쓰는 작업과 기다리는 작업

스레드를 몇 개 만드는 것이 좋은지는 작업이 시간을 어디에 쓰는지에 따라 달라집니다. 큰 수의 합이나 이미지 변환처럼 계산만 하는 작업을 CPU 중심 작업(CPU-bound task)이라고 합니다. 이런 작업은 대부분의 시간 동안 코어를 쓰므로, 코어 수보다 스레드가 많으면 남는 스레드는 코어를 기다릴 뿐입니다. 스레드를 늘린다고 계산이 빨라지지 않고 스레드를 바꾸는 비용만 늘어나는데, 이 비용은 다음 글에서 다룹니다.

파일을 읽거나 다른 서버의 응답을 기다리는 시간이 대부분인 작업은 I/O 중심 작업(I/O-bound task)이라고 합니다. 기다리는 스레드는 코어를 쓰지 않으므로, 그동안 다른 스레드가 코어를 쓸 수 있습니다. 그래서 I/O 중심 작업은 코어 수보다 많은 작업을 동시에 진행해 대기 시간을 서로 겹치면 전체 처리량이 늘어날 수 있습니다. 다만 데이터베이스 연결 수나 외부 API의 호출 한도, 메모리 같은 다른 자원이 금방 한계가 됩니다.

결국 모든 프로그램에 맞는 스레드 수 공식은 없습니다. 계산 시간과 대기 시간의 비율, 코어 수, 목표 응답 시간, 메모리, 외부 시스템의 한도를 재 보고 정해야 합니다. 코어가 여러 개라도 스레드들이 락 하나를 두고 서로 기다리면 실제로 병렬로 실행되는 구간은 줄어듭니다. 그러니 스레드가 여러 개 보인다는 사실만으로 프로그램이 병렬로 빨라졌다고 판단하면 안 됩니다.

Java 21에서 정식 기능이 된 가상 스레드(virtual thread)는 이 가운데 대기가 많은 작업의 비용을 줄여 줍니다. 가상 스레드는 JVM이 관리하는 가벼운 스레드라서 아주 많이 만들 수 있고, 기다리는 동안에는 대개 운영체제 스레드를 붙잡고 있지 않습니다. 하지만 코어를 늘려 주지는 않으므로 CPU 중심 작업은 빨라지지 않고, 여러 스레드가 같은 객체를 바꿀 때의 안전 문제도 그대로 남습니다. 가상 스레드는 가상 스레드와 ScopedValue에서 배웁니다.

일을 나눠 계산하고 결과 합치기

이번에는 CPU 중심 작업 하나를 두 스레드로 나눠 보겠습니다. 1부터 1,000,000까지의 합을 앞쪽 절반과 뒤쪽 절반으로 나눠 스레드 range-a와 range-b가 하나씩 계산하고, main은 두 스레드가 끝나기를 기다렸다가 두 부분합을 더합니다.

계산 결과를 어디에 담을지가 먼저 문제입니다. 람다식 안에서는 바깥 지역 변수에 값을 대입할 수 없습니다. 람다식이 쓸 수 있는 바깥 지역 변수는 값이 바뀌지 않는 변수뿐이기 때문입니다. 그래서 결과를 담을 작은 객체 Result를 두 개 만들고, 각 스레드가 자기 몫의 객체 필드에 결과를 쓰게 했습니다. 이 규칙은 Runnable을 만드는 세 가지 방법과 람다 캡처에서 자세히 다룹니다.

src/ParallelCpuWork.java
public final class ParallelCpuWork {
    public static void main(String[] args) throws InterruptedException {
        Result left = new Result();
        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;
    }
}
실행 결과
sum=500000500000

이 결과는 몇 번을 실행해도 같습니다. 두 스레드 중 어느 쪽이 먼저 끝나든 결과가 같도록 두 가지 조건을 갖췄기 때문입니다. 첫째, 두 스레드는 서로 다른 객체에 씁니다. range-a는 left에만, range-b는 right에만 쓰므로 한 변수에 두 스레드가 함께 쓰는 일이 없습니다.

둘째, main은 두 join()이 모두 돌아온 뒤에 결과를 읽습니다. Java 언어 명세(JLS)는 한 스레드 안의 모든 동작이, 다른 스레드가 그 스레드에 대한 join()에서 정상적으로 돌아오기 전에 일어난다고 정합니다. 이처럼 앞 동작의 결과가 뒤 동작에서 반드시 보이도록 정한 순서 관계를 happens-before 관계라고 합니다. 덕분에 join()에서 돌아온 main은 작업자가 쓴 값을 확실히 읽을 수 있습니다. 같은 명세에 따라 start()를 호출하기 전에 main이 해 둔 일도 새 스레드에서 모두 보이며, 정확한 규칙은 JLS 17.4.5와 동시성 패키지의 메모리 일관성 설명에 있습니다.

두 부분합은 손으로 계산해 볼 수 있습니다. 1부터 n까지의 합은 n(n+1)/2이므로 전체 합은 1,000,000×1,000,001/2 = 500,000,500,000입니다. 앞쪽 절반 left는 500,000×500,001/2 = 125,000,250,000이고, 뒤쪽 절반 right는 전체에서 이 값을 뺀 375,000,250,000입니다. 프로그램은 마지막 합만 출력하며, 아래 그림의 부분합은 이렇게 계산한 값입니다.

두 작업자는 다른 결과에 쓰고 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()이 정말 필요한지 확인해 보겠습니다. 위 코드에서 a.join();과 b.join(); 두 줄을 지우고 실행하면 다음과 같이 출력됩니다.

실행 결과(join 두 줄을 지운 경우)
sum=0

main이 두 스레드를 시작하자마자 결과를 읽었기 때문에, 작업자들이 계산을 마치기 전의 기본값 0을 읽은 것입니다. 이 환경에서는 20번 실행해 20번 모두 sum=0이 나왔지만, 이것도 정해진 결과는 아닙니다. 실행 시점에 따라 한쪽 부분합만 더해진 값이나 올바른 합이 나올 수도 있고, join()이 없으면 작업자가 이미 쓴 값이라도 main에서 보인다는 보장이 없습니다. 가끔은 맞는 답이 나온다는 점이 이런 버그를 더 찾기 어렵게 만듭니다.

이 예제는 두 스레드로 나눴을 때 더 빨라지는지는 재지 않았습니다. 코어가 둘 이상이면 두 계산이 동시에 진행될 수 있지만, 계산이 이 정도로 작으면 스레드를 만들고 시작하는 비용이 아낀 시간보다 클 수도 있습니다. 스레드를 만드는 비용은 다음 글에서 직접 재 봅니다.

스레드의 상태

스레드는 만들어진 뒤 끝날 때까지 여러 상태를 거칩니다. Java는 이 상태를 Thread.State 열거형의 여섯 값으로 나타내고, getState()로 특정 스레드의 현재 상태를 볼 수 있습니다. 표의 오른쪽 열은 이 글의 예제에서 각 상태가 나타나는 상황입니다.

상태뜻이 글의 예제에서
NEW만들어졌지만 아직 start()하지 않음new Thread(…) 직후의 스레드
RUNNABLE실행 중이거나 코어를 받기를 기다리는 중합을 계산하는 range-a
BLOCKED모니터 락을 얻으려고 기다리는 중이 글에는 없음(19장의 synchronized에서 다룸)
WAITING다른 스레드의 동작을 기한 없이 기다리는 중join()으로 기다리는 main
TIMED_WAITING정해진 시간까지 기다리는 중sleep(80) 중인 slow
TERMINATEDrun()이 끝나 실행을 마침join()이 돌아온 뒤의 작업자
여섯 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의 분류이며 운영체제 스레드 상태와 같은 목록이 아닙니다. 행 순서는 필수 전이 경로가 아닙니다. 이 장의 예제가 여섯 상태를 모두 관찰했다는 뜻도 아닙니다.

그림의 화살표는 대표적인 이동 경로를 보여 줄 뿐, 모든 스레드가 여섯 상태를 차례로 거친다는 뜻은 아닙니다. 계산만 하고 끝나는 스레드는 NEW, RUNNABLE, TERMINATED만 거칠 수 있고, 기다리던 스레드는 기다림이 끝나면 다시 RUNNABLE로 돌아갑니다.

상태 값은 진단을 위한 정보라는 점을 기억해야 합니다. getState()가 돌려준 값은 조회한 순간의 상태여서, 그 값을 받아 들었을 때는 이미 바뀌었을 수도 있습니다. getState 문서도 이 메서드가 동기화를 제어하는 용도가 아니라 시스템 상태를 살펴보는 용도라고 밝힙니다. 그러니 특정 상태가 될 때까지 getState()를 반복해서 확인하는 방식으로 작업 순서를 맞추면 안 됩니다.

또한 Thread.State는 JVM의 분류이고 운영체제의 스레드 상태와 일대일로 대응하지 않습니다. Thread.State 문서에 따르면 RUNNABLE인 스레드도 실제로는 운영체제의 자원, 예를 들어 코어 차례를 기다리고 있을 수 있습니다. BLOCKED는 모니터 락을 기다리는 경우만 뜻하므로 모든 기다림을 가리키지도 않습니다.

운영 중인 프로그램의 스레드 상태를 한꺼번에 찍어 보는 스레드 덤프를 읽을 때도 같은 주의가 필요합니다. RUNNABLE인 스레드가 많다고 일이 잘 진행되는 것은 아닙니다. 코어보다 훨씬 많은 계산 스레드가 RUNNABLE이면 각 스레드는 차례를 기다리며 조금씩만 진행하고, 그만큼 응답이 늦어집니다. 반대로 WAITING인 스레드가 많은 것은 일감을 기다리는 정상적인 대기일 수도 있고 서로를 영원히 기다리는 교착 상태(deadlock)의 신호일 수도 있으므로, 무엇을 기다리는지 함께 확인해야 합니다. 스레드 덤프는 스레드 덤프와 프로파일러로 장애 원인 찾기에서 다룹니다.

NEW에서 RUNNABLE로 가는 길은 한 번뿐입니다. 하나의 Thread 객체는 한 번만 시작할 수 있어서, 이미 시작했거나 끝난 객체에 다시 start()를 호출하면 IllegalThreadStateException이 발생합니다. 같은 일을 다시 실행하려면 새 Thread 객체를 만들거나, 22장 실행자와 스레드 풀에서 배울 실행기(executor)에게 작업을 맡깁니다.

우선순위(setPriority)도 오해하기 쉬운 기능입니다. 우선순위는 스케줄러에게 주는 힌트일 뿐이고 운영체제와 JVM에 따라 효과가 다르므로, 실행 순서를 우선순위에 맡기면 안 됩니다. 상태가 바뀌는 과정은 스레드 정보와 생명 주기에서 하나씩 재현해 봅니다.

여러 작업자를 시작하고 모두 기다리기

여러 스레드에 일을 나눠 줄 때는 보통 두 단계로 씁니다. 먼저 모든 스레드를 만들어 시작하고, 그다음 모든 스레드를 차례로 join()합니다. 다음 프로그램은 게시글 세 개에 대한 작업 publish, index, notify를 스레드 세 개에 하나씩 맡기고, 모두 끝나면 완료한 스레드 수를 출력합니다. 작업 하나는 레코드 Task로 표현했습니다.

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) {}
}
실행 결과(한 예)
board-publish:publish#101
board-notify:notify#103
board-index:index#102
all-complete=3

세 작업 줄의 순서는 실행할 때마다 달라질 수 있습니다. 이 글을 쓰며 여섯 번 실행했을 때 네 가지 순서가 나왔고, 위 출력은 그중 하나입니다. 스레드를 publish, index, notify 순서로 시작했어도 각 스레드가 언제 코어를 받아 출력할지는 스케줄러가 정하기 때문입니다. 항상 같은 것은 마지막 줄 all-complete=3이 맨 끝에 온다는 점뿐입니다. 두 번째 반복문이 세 스레드를 모두 join()한 뒤에야 이 줄을 출력하기 때문입니다.

출력이 섞여도 어느 스레드가 쓴 줄인지 알 수 있도록 각 줄 앞에 스레드 이름을 붙인 점도 눈여겨보세요. 여러 스레드가 함께 쓰는 로그에는 이렇게 스레드 이름을 남겨 두어야 나중에 출처를 가릴 수 있습니다. 스레드를 만들 때 이름을 지정하지 않으면 Thread-0, Thread-1 같은 이름이 붙어 어떤 일을 하는 스레드인지 알기 어렵습니다.

반복문을 둘로 나눈 데에도 이유가 있습니다. 첫 번째 반복문 안에서 start() 바로 다음에 join()을 호출하면, 스레드 하나를 시작하고 그 스레드가 끝날 때까지 기다린 다음에야 다음 스레드를 시작하게 됩니다. 실제로 그렇게 바꿔 실행하면 출력은 매번 publish, index, notify 순서로 나옵니다. 순서가 고정된 대신 세 작업이 동시에 진행되는 구간이 사라지므로, 스레드를 쓴 의미가 없어집니다.

스레드가 끝난 것과 작업이 성공한 것

all-complete=3이 출력됐다고 세 작업이 모두 성공했다고 말할 수는 없습니다. 이름은 publish, index, notify이지만, 이 프로그램에는 발행이 끝나야 색인하고 색인이 끝나야 알린다는 순서가 구현돼 있지 않습니다. 세 작업은 서로 기다리지 않는 독립 작업일 뿐입니다. 더 중요한 점은 join()이 스레드가 끝나기를 기다릴 뿐, 그 스레드가 성공했는지는 알려 주지 않는다는 것입니다. 다음 프로그램은 작업자 스레드에서 예외가 발생하는 경우입니다.

lab/JoinDoesNotRethrow.java
public final class JoinDoesNotRethrow {
    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            throw new IllegalStateException("boom");
        }, "failing-worker");
        worker.start();
        worker.join();
        System.out.println("joined, state=" + worker.getState());
    }
}
실행 결과(표준 오류와 표준 출력)
Exception in thread "failing-worker" java.lang.IllegalStateException: boom
	at JoinDoesNotRethrow.lambda$main$0(JoinDoesNotRethrow.java:4)
	at java.base/java.lang.Thread.run(Thread.java:1583)
joined, state=TERMINATED

작업자 스레드에서 아무도 잡지 않은 예외는 그 스레드만 끝냅니다. RuntimeException과 비검사 예외에서 본 것처럼 JVM의 기본 처리기가 예외 정보를 표준 오류에 출력하고, 스레드는 TERMINATED 상태가 됩니다. 그런데 main의 join()은 이 예외를 다시 던지지 않고 정상적으로 돌아오므로 마지막 줄도 출력됩니다. main이 정상적으로 끝났기 때문에 프로세스의 종료 코드도 성공을 뜻하는 0입니다. 스택 트레이스의 Thread.java:1583 같은 줄 번호는 이 출력을 만든 JDK 21 기준이며, JDK 버전에 따라 달라집니다.

따라서 작업자가 성공했는지 알려면 결과나 실패 정보를 따로 전달받아야 합니다. 예를 들어 작업자가 결과 객체에 성공 여부와 예외를 함께 기록하고, main이 join() 뒤에 그 기록을 확인하는 방식입니다. 이런 일을 편하게 해 주는 도구는 Callable과 Future로 작업 결과 받기에서 배웁니다.

정리

  • 프로세스는 실행 중인 프로그램이고, 운영체제가 프로세스마다 메모리 공간과 자원을 따로 줍니다. 프로세스끼리는 IPC로만 데이터를 주고받습니다.
  • 스레드는 프로세스 안의 실행 흐름입니다. 스레드마다 스택과 프로그램 카운터를 따로 갖고, 같은 프로세스의 힙 객체와 static 필드는 함께 씁니다.
  • start()만 새 스레드를 시작합니다. run()을 직접 부르면 호출한 스레드에서 보통 메서드처럼 실행됩니다.
  • 시작한 순서가 완료 순서를 정하지 않습니다. 스레드 사이의 순서가 필요하면 sleep이 아니라 join() 같은 명시적인 수단을 씁니다.
  • join()에서 돌아온 뒤에는 그 스레드가 쓴 값을 확실히 읽을 수 있습니다. 하지만 join()은 스레드가 끝났다는 것만 알려 줄 뿐, 작업의 성공 여부나 예외는 전달하지 않습니다.
  • 스레드 상태는 진단용 정보이고, 하나의 Thread 객체는 한 번만 시작할 수 있습니다.

연습 문제

1. 세 구간으로 나눠 합 구하기

1부터 900까지의 합을 겹치지 않는 세 구간으로 나눠 스레드 세 개가 하나씩 계산하고, main이 모든 스레드를 join()한 뒤 세 부분합을 더해 출력하는 프로그램을 작성하세요. 구간이 빠지거나 겹치지 않았는지 확인할 수 있도록 각 스레드는 자기 구간과 부분합도 출력합니다.

풀기 전에 검산 방법부터 정해 두면 좋습니다. 각 구간의 원소 개수는 끝 − 시작 + 1이고, 세 개수를 더하면 900이어야 합니다. 구간을 시작 순서로 늘어놓았을 때 다음 구간의 시작이 앞 구간의 끝 + 1이면 빠지거나 겹친 수가 없습니다. 최종 합은 900×901/2와 같아야 하고, 출력 순서는 검사 대상이 아닙니다.

정답과 해설

구간은 1부터 300까지, 301부터 600까지, 601부터 900까지로 나눕니다. 각 작업자는 배열 sums에서 자기 번호의 칸에만 쓰고, main은 세 스레드를 모두 join()한 뒤에 배열을 읽습니다. 반복 변수 i를 int index = i;로 복사해 두는 이유는, i는 반복할 때마다 값이 바뀌어 람다식 안에서 쓸 수 없기 때문입니다.

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 + " sum=" + sum);
            });
            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) {}
}
실행 결과(한 예)
Range[start=301, end=600] sum=135150
Range[start=1, end=300] sum=45150
Range[start=601, end=900] sum=225150
total=405450

세 구간 줄은 동시에 실행되는 세 스레드가 각자 출력하므로, 여러 번 실행하면 순서가 바뀝니다. 하지만 각 줄의 내용과 마지막 줄 total=405450은 언제나 같습니다. 세 구간의 원소 개수는 300개씩이어서 합이 900이고, 405450은 900×901/2와 같으므로 검산과도 맞습니다.

2. run 직접 호출과 두 번째 start

현재 스레드 이름을 run-on=이름 형식으로 출력하는 Runnable을 하나 만드세요. 먼저 main에서 그 작업의 run()을 직접 호출하고, 이어서 같은 작업을 담은 이름 worker의 Thread를 만들어 start()한 뒤 join()합니다. 마지막으로 이미 끝난 그 Thread 객체에 다시 start()를 호출하고, 발생하는 예외를 잡아 restart-rejected를 출력하세요. 출력될 세 줄과 그 순서를 미리 예측하고, 이 예제의 출력 순서는 앞의 예제들과 달리 왜 항상 같은지 설명하세요.

정답과 해설
exercise/StartOnceSolution.java
public class StartOnceSolution {
    public static void main(String[] args) throws InterruptedException {
        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");
        }
    }
}
실행 결과
run-on=main
run-on=worker
restart-rejected

첫 줄은 main 스레드가 run()을 보통 메서드처럼 호출한 결과이므로, 이때는 새 스레드가 생기지 않았습니다. 두 번째 줄만 새 스레드 worker에서 실행됐습니다. 두 번째 start()는 작업을 실행하기 전에 IllegalThreadStateException으로 거부되는데, 하나의 스레드는 한 번만 시작할 수 있다고 start 문서에 정해져 있기 때문입니다.

이 세 줄은 몇 번을 실행해도 같은 순서로 나옵니다. 첫 run() 호출은 출력을 마친 뒤에야 돌아오고, start()는 그다음에 호출됩니다. 또 main은 join()으로 worker가 끝나기를 기다린 뒤에 두 번째 start()를 시도합니다. 이렇게 앞 단계가 끝나야 다음 단계가 시작되도록 코드에 순서가 명시돼 있습니다. 앞의 예제들에서 순서가 바뀐 것은 동시에 실행 중인 여러 스레드가 각자 출력했기 때문입니다.

다음 글

컨텍스트 스위칭과 스레드 수에서는 운영체제가 스레드를 바꿀 때 무엇을 저장하고 복원하는지 살펴보고, 작은 작업마다 스레드를 새로 만들면 왜 손해인지 실제로 시간을 재 봅니다.