Thread 생성과 start
Thread를 상속해 스레드를 만들어 보며 start()와 run()을 호출했을 때 각각 무슨 일이 일어나는지, 같은 Thread 객체를 다시 시작할 수 없는 이유, 스레드에 이름과 예외 처리기를 붙이는 방법을 배웁니다.
프로세스와 스레드에서는 할 일을 담은 Runnable을 Thread 생성자에 넘기고 start()를 호출해 새 스레드를 시작했습니다. 스레드를 만드는 방법은 하나 더 있습니다. Thread 클래스를 상속한 하위 클래스를 만들고, 스레드가 할 일을 그 클래스의 run() 메서드에 직접 적는 방법입니다. 이 글에서는 이 방법으로 스레드를 만들어 보면서 Thread 객체를 만드는 일, start()를 호출하는 일, run()이 실행되는 일이 각각 언제 어느 스레드에서 일어나는지 구별합니다.
이 구별이 중요한 까닭은 스레드를 처음 다룰 때 가장 흔한 실수가 여기서 나오기 때문입니다. start() 대신 run()을 호출해도 컴파일 오류는 나지 않고 출력도 그럴듯하지만, 새 스레드는 하나도 생기지 않습니다. 끝난 스레드를 다시 쓰려고 같은 객체에 start()를 한 번 더 호출하면 예외가 발생합니다. 글의 끝에서는 스레드에 이름을 붙이고 예외 처리기를 다는 방법도 살펴봅니다. 실행 결과는 JDK 21에서 얻었고, 여러 스레드가 함께 출력하는 예제는 실행할 때마다 순서가 달라질 수 있으므로 어느 부분이 달라지고 어느 부분이 늘 같은지 함께 짚습니다.
run()을 호출하면 새 스레드가 생길까
Thread를 상속해 스레드를 만들 때는 스레드가 할 일을 run() 메서드에 적습니다. 아래 NamedWork가 그런 클래스입니다. 생성자의 super(name)은 부모 클래스인 Thread의 생성자에 스레드 이름을 넘기고, @Override를 붙인 run()은 Thread에 원래 있던 run()을 재정의합니다. 할 일이 run()에 있으니 run()을 호출하면 새 스레드에서 실행될 것 같습니다. main은 이름이 alpha와 beta인 객체를 하나씩 만들고 각각의 run()을 호출해 봅니다.
run()은 이름 두 개를 출력합니다. requested= 뒤의 getName()은 이 Thread 객체에 붙인 이름이고, actual= 뒤의 Thread.currentThread().getName()은 이 코드를 지금 실제로 실행하고 있는 스레드의 이름입니다. 마지막 줄은 main이 두 호출을 마친 뒤 직접 출력합니다.
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());
}
}
}requested=alpha, actual=main
requested=beta, actual=main
wrong-result=both calls used main이 출력은 몇 번을 실행해도 같습니다. actual=이 두 번 모두 main이라는 것은 두 run()의 본문을 모두 main 스레드가 실행했다는 뜻입니다. first.run()은 다른 인스턴스 메서드 호출과 똑같이 main의 호출 스택에 프레임을 하나 쌓고, 본문을 실행한 뒤 돌아옵니다. 그다음 second.run()이 같은 방식으로 실행되므로 두 줄의 순서도 바뀌지 않습니다. 메서드 본문은 실행됐지만 새 스레드는 하나도 생기지 않은 것입니다.
이 실수를 알아차리기 어려운 이유는 requested=에 alpha와 beta가 제대로 찍히기 때문입니다. getName()은 그 객체가 스레드로 실행 중인지와 상관없이 객체에 저장된 이름을 돌려줄 뿐입니다. 지금 이 코드를 실행하는 스레드가 누구인지 알고 싶다면 언제나 Thread.currentThread()에 물어야 합니다. 같은 이유로 main에서 객체를 만들 때 Thread 하위 클래스의 생성자 안에서 Thread.currentThread().getName()을 출력하면 main이 나옵니다. 생성자를 실행하는 것은 새 스레드가 아니라 객체를 만드는 쪽이기 때문입니다.
정리하면 객체 생성, run() 호출, start() 호출은 서로 다른 일입니다. new NamedWork("alpha")는 NEW 상태의 Thread 객체를 만들 뿐 아직 아무 작업도 실행하지 않고, run()을 직접 호출하면 호출한 스레드가 그 자리에서 본문을 실행하고 돌아옵니다. 새 스레드는 start()를 호출해야 시작되며, JVM이 시작한 그 스레드가 run()을 호출합니다. start()를 호출한 스레드는 run()이 끝나기를 기다리지 않고 바로 다음 줄로 넘어가므로, 끝나기를 기다리려면 따로 join()을 호출해야 합니다. 아래 그림은 이 세 가지를 나란히 놓고 각각 어느 스레드가 무엇을 하는지 비교합니다.
Thread 객체 생성만으로 작업은 실행되지 않습니다. 직접 run 호출은 main에서 실행되고 start가 요청한 작업은 별도 스레드에서 실행됩니다.
호출 주체 · new / run / start
객체 생성·run 호출·start의 실행 주체를 구분한다
new는 객체를 만들고, run 직접 호출은 main에서 실행하며, start는 별도 실행 흐름을 요청합니다.
new NamedWork(name)
- 작업을 수행하는 흐름
- 객체를 만드는
main - 확인할 차이
- 새 객체를 만들며 아직 작업을 시작하지 않음
first.run() / second.run()
- 작업을 수행하는 흐름
- 호출자인
main - 확인할 차이
requested=alpha, actual=mainrequested=beta, actual=main
start()
- 작업을 수행하는 흐름
- 새로 시작한 스레드가
run()수행 - 확인할 차이
- 호출한
main은 계속 진행하며 완료 대기는 별도join()
위 코드는 run()을 직접 부른 사례입니다. start() 행은 뒤의 구간 합 예제와 Thread API 계약을 비교한 것으로, 위 코드를 수정해 얻은 출력은 아닙니다.
고치려면 두 run() 호출을 start()로 바꾸면 됩니다. 이때 main이 두 스레드가 끝나기를 기다리도록 join()을 추가하고, 더는 맞지 않는 wrong-result 줄은 지웁니다. NamedWork 클래스는 그대로 둡니다.
public static void main(String[] args) throws InterruptedException {
Thread first = new NamedWork("alpha");
Thread second = new NamedWork("beta");
first.start();
second.start();
first.join();
second.join();
}requested=beta, actual=beta
requested=alpha, actual=alpha이제 줄마다 requested와 actual이 같아졌습니다. start()로 시작한 새 스레드가 자기 객체의 run()을 실행하므로 Thread.currentThread()가 바로 그 객체를 돌려주기 때문입니다. 대신 두 줄의 순서는 정해지지 않습니다. 30번 실행했을 때 alpha 줄이 먼저 나온 것이 17번, beta 줄이 먼저 나온 것이 13번이었고, 위 출력은 beta가 먼저 나온 경우입니다. 두 스레드가 시작된 뒤에는 어느 쪽이 먼저 출력할지 운영체제의 스케줄러가 정하기 때문입니다.
거꾸로 말하면 run()을 직접 호출한 처음 버전의 출력 순서가 늘 같았던 것은 main 한 스레드가 두 본문을 차례로 실행했기 때문입니다. 순서를 정하는 코드가 없는데도 여러 번 실행한 출력의 순서가 한 번도 바뀌지 않는다면, 새 스레드가 정말 생겼는지 의심해 볼 만합니다.
더 깊이: Thread.run()의 기본 구현
Thread를 상속하지 않고 new Thread(task)처럼 Runnable을 넘겨도 스레드가 그 작업을 실행하는 것은 Thread.run()의 기본 구현 덕분입니다. 이 메서드는 생성자로 받은 Runnable이 있으면 그 작업의 run()을 호출하고, 없으면 아무것도 하지 않고 돌아옵니다. 새 스레드는 언제나 자기 Thread 객체의 run()을 호출하므로, 하위 클래스가 run()을 재정의했다면 재정의한 본문이 실행되고 그렇지 않다면 넘겨받은 Runnable이 실행됩니다. 따라서 하위 클래스가 run()을 재정의하면서 super.run()을 호출하지 않으면, 생성자에 Runnable을 넘겼더라도 그 작업은 실행되지 않습니다.
run()을 직접 호출했을 때의 동작도 이 구현에서 나옵니다. Runnable을 넘겨 만든 플랫폼 스레드 객체의 run()을 직접 부르면 앞의 예제처럼 호출한 스레드에서 그 작업이 실행됩니다. 반면 가상 스레드 객체는 run()을 직접 불러도 작업을 실행하지 않습니다. JDK 21에서 Thread.ofVirtual().unstarted(task)로 만든 객체의 run()을 호출해 보면 작업이 출력해야 할 줄이 나오지 않고 상태도 NEW 그대로입니다. 어느 쪽이든 새 스레드는 생기지 않으며, 자세한 설명은 Thread.run() 문서에 있습니다.
두 스레드로 구간 합을 나눠 계산하기
이번에는 start()로 스레드를 제대로 시작해 일을 나눠 보겠습니다. 다음 프로그램은 1부터 1000까지의 합을 두 스레드가 절반씩 맡아 계산합니다. RangeSumThread는 계산할 구간의 시작과 끝을 생성자로 받아 필드에 저장하고, run()에서 그 구간의 합을 지역 변수 sum에 모은 뒤 필드 result에 저장합니다. main은 두 객체를 만들어 start()로 시작하고, join()으로 둘 다 끝나기를 기다린 다음 두 결과를 더해 출력합니다.
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() + " 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()));
}
}right-sum finished 501..1000
left-sum finished 1..500
total=500500첫 두 줄의 순서는 실행할 때마다 달라질 수 있습니다. 30번 실행했을 때 left-sum이 먼저 나온 것과 right-sum이 먼저 나온 것이 15번씩이었습니다. main은 left.start()를 호출한 뒤 기다리지 않고 곧바로 right.start()를 호출하므로, 두 스레드는 거의 동시에 출발하고 어느 쪽이 먼저 끝날지는 스케줄러에 달려 있습니다. 반면 마지막 줄 total=500500은 언제나 맨 끝에 나오고 값도 늘 같습니다. main은 두 join()이 모두 돌아온 뒤에야 결과를 읽고 이 줄을 출력하기 때문입니다.
결과를 join() 뒤에 읽는 데에는 출력 순서보다 더 중요한 이유가 있습니다. 프로세스와 스레드에서 본 것처럼, 한 스레드가 한 일은 다른 스레드가 그 스레드에 대한 join()에서 정상적으로 돌아온 뒤에 모두 보입니다. 이렇게 앞 동작의 결과가 뒤 동작에서 반드시 보이도록 정한 순서 관계를 happens-before 관계라고 하며, JLS 17.4.5가 정합니다. 그래서 result를 volatile로 선언하거나 동기화하지 않아도(둘 다 19장에서 배웁니다) main은 두 결과를 확실히 읽습니다. 같은 규칙에 따라 start()를 호출하기 전에 생성자에서 저장한 구간의 시작과 끝도 새 스레드에서 그대로 보입니다.
아래 그림은 두 작업자가 각자의 result에 값을 기록하고 main이 두 join() 뒤에 그 값을 합치는 과정을 단계별로 보여 줍니다. 그림의 부분합 125250과 375250은 프로그램이 출력하는 값이 아니라 소스에서 계산한 값입니다.
두 작업자는 각자 result를 쓰고 main은 join 뒤에 읽는다
left/right가 독립 result를 기록하고 모두 join 뒤 500500을 합산합니다. 완료 전후 읽기 경계를 단계로 보여 주되 상대 실행 순서·성능을 단정하지 않습니다.
left와 right는 범위와 결과 필드가 다른 두 RangeSumThread 객체입니다. 각 run의 sum과 n은 호출별 지역 변수이며 main은 두 join 뒤 합계 500500을 출력합니다.
THREAD 상속 · 필드 / 지역 변수
두 작업자는 각자 result를 쓰고 main은 join 뒤에 읽는다
두 RangeSumThread 객체는 각자 result를 씁니다. 각 run의 sum·n은 해당 호출의 지역 변수입니다.
left / left-sum
- 각 객체의 필드
startInclusive=1endInclusive=500result=125250- 지역 계산·결과 읽기
run()의 지역 변수sum·n자기result에 기록
right / right-sum
- 각 객체의 필드
startInclusive=501endInclusive=1000result=375250- 지역 계산·결과 읽기
run()의 지역 변수sum·n자기result에 기록
main
- 각 객체의 필드
- 두 객체의
result를 읽음 - 지역 계산·결과 읽기
- 두
join()이 정상 반환한 뒤total=500500
필드는 각 힙 객체에, sum과 n은 JVM의 논리적 호출 프레임에 속합니다. 부분합은 소스에서 계산한 값이며 실제 출력은 두 범위의 완료 로그와 최종 합계입니다.
이 예의 조건
- 작업자 사이 완료 순서를 정하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 각 RangeSumThread는 자기 result 필드에 기록하고 main은 두 join의 정상 반환 뒤 125250과 375250을 합합니다. 각 run의 지역 sum·n은 별도 실행 프레임에 속합니다. |
| 4 | 1..500 |
| 5 | 자기 result |
| 8 | 501..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에 범위를 제공합니다. |
|
|
| 자기 필드 기록 각 작업자가 result를 저장합니다. |
|
|
| 완료 후 읽기 두 join 뒤 main이 합합니다. |
|
|
두 작업자는 각자 result를 쓰고 main은 join 뒤에 읽는다
left/right가 독립 result를 기록하고 모두 join 뒤 500500을 합산합니다. 완료 전후 읽기 경계를 단계로 보여 주되 상대 실행 순서·성능을 단정하지 않습니다.
left와 right는 범위와 결과 필드가 다른 두 RangeSumThread 객체입니다. 각 run의 sum과 n은 호출별 지역 변수이며 main은 두 join 뒤 합계 500500을 출력합니다.
THREAD 상속 · 필드 / 지역 변수
두 작업자는 각자 result를 쓰고 main은 join 뒤에 읽는다
두 RangeSumThread 객체는 각자 result를 씁니다. 각 run의 sum·n은 해당 호출의 지역 변수입니다.
left / left-sum
- 각 객체의 필드
startInclusive=1endInclusive=500result=125250- 지역 계산·결과 읽기
run()의 지역 변수sum·n자기result에 기록
right / right-sum
- 각 객체의 필드
startInclusive=501endInclusive=1000result=375250- 지역 계산·결과 읽기
run()의 지역 변수sum·n자기result에 기록
main
- 각 객체의 필드
- 두 객체의
result를 읽음 - 지역 계산·결과 읽기
- 두
join()이 정상 반환한 뒤total=500500
필드는 각 힙 객체에, sum과 n은 JVM의 논리적 호출 프레임에 속합니다. 부분합은 소스에서 계산한 값이며 실제 출력은 두 범위의 완료 로그와 최종 합계입니다.
이 예의 조건
- 작업자 사이 완료 순서를 정하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 각 RangeSumThread는 자기 result 필드에 기록하고 main은 두 join의 정상 반환 뒤 125250과 375250을 합합니다. 각 run의 지역 sum·n은 별도 실행 프레임에 속합니다. |
| 4 | 1..500 |
| 5 | 자기 result |
| 8 | 501..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에 범위를 제공합니다. |
|
|
| 자기 필드 기록 각 작업자가 result를 저장합니다. |
|
|
| 완료 후 읽기 두 join 뒤 main이 합합니다. |
|
|
스레드마다 따로 가진 것과 함께 쓰는 것
두 스레드는 같은 run() 메서드를 동시에 실행하는데도 지역 변수 sum과 n이 서로 섞이지 않습니다. JVM 메모리 구조에서 배운 것처럼 메서드를 호출할 때마다 호출 스택에 프레임이 하나 생기고, 지역 변수는 그 프레임에 들어갑니다. 그런데 스레드는 저마다 자기 호출 스택을 가지므로 left-sum의 run() 프레임과 right-sum의 run() 프레임은 서로 다른 스택에 따로 만들어집니다. 같은 메서드를 실행해도 지역 변수는 호출마다 따로 생기는 것입니다.
반면 객체는 힙에 있고, 힙은 프로세스 안의 모든 스레드가 함께 씁니다. 필드 startInclusive, endInclusive, result는 힙에 있는 두 RangeSumThread 객체 안에 있습니다. main도 두 객체의 참조를 지역 변수 left와 right에 들고 있으므로, left가 가리키는 객체는 main과 left-sum 두 스레드가 함께 접근하는 객체입니다. 하지만 함께 접근한다고 해서 값을 쓰는 스레드가 둘이 되지는 않습니다. result에 쓰는 것은 그 객체의 run()을 실행하는 스레드 하나뿐이고, main은 그 스레드가 끝난 뒤에 읽기만 하므로 문제가 생기지 않습니다.
지역 변수에 참조가 들어 있을 때는 한 가지를 더 따져야 합니다. 스택에 있는 것은 참조 값뿐이고, 그 참조가 가리키는 객체는 여전히 힙에 있습니다. 예를 들어 long[] 배열 하나를 만들어 두 RangeSumThread의 생성자에 함께 넘기면, 스레드 객체는 둘이지만 배열은 하나입니다. 두 스레드가 그 배열의 같은 칸을 동시에 바꾸면 결과가 틀어질 수 있으므로 스레드마다 다른 칸을 맡기거나 19장에서 배울 동기화를 써야 합니다. 무엇이 공유되는지 판단할 때는 변수가 어디에 선언됐는지보다, 그 변수가 가리키는 객체를 몇 개의 스레드가 손에 쥐고 있는지를 따라가야 합니다.
스택은 스레드마다 따로 잡히는 만큼 크기에도 한도가 있습니다. 재귀 호출이 너무 깊어지면 그 스레드에서 StackOverflowError가 발생하고, 스레드를 많이 만들수록 스택에 필요한 메모리와 스케줄링 비용이 함께 늘어납니다. 컨텍스트 스위칭과 스레드 수에서 본 것처럼 입력 하나마다 플랫폼 스레드를 하나씩 만드는 설계를 피해야 하는 이유 가운데 하나입니다.
같은 Thread 객체는 두 번 시작할 수 없다
스레드가 할 일을 마쳤는데 같은 일을 한 번 더 시키고 싶다면, 끝난 Thread 객체에 start()를 다시 호출하고 싶어집니다. 다음 프로그램이 그렇게 합니다. oneShot을 시작하고 join()으로 끝나기를 기다린 뒤 상태를 출력하고, 같은 객체에 start()를 한 번 더 호출합니다.
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();
}
}one run
state-before-restart=TERMINATED
Exception in thread "main" java.lang.IllegalThreadStateException
at java.base/java.lang.Thread.start(Thread.java:1525)
at StartTwiceFailure.main(StartTwiceFailure.java:7)첫 줄은 새 스레드 one-shot이 출력했고, 둘째 줄은 join()이 돌아온 뒤 main이 출력했습니다. join()이 정상적으로 돌아왔다는 것은 oneShot이 이미 끝났다는 뜻이므로 상태는 TERMINATED입니다. 그다음 두 번째 start()에서 IllegalThreadStateException이 발생합니다. 이 예외는 IllegalArgumentException을 상속한 비검사 예외라서 컴파일러가 처리를 요구하지 않았고, 아무도 잡지 않았으므로 main 스레드가 여기서 끝나 프로세스는 실패를 뜻하는 종료 코드 1로 끝납니다. 스택 트레이스의 Thread.java:1525 같은 줄 번호는 JDK 21 기준이며 버전마다 다릅니다.
하나의 Thread 객체는 한 번의 실행 흐름을 나타냅니다. 그 흐름은 한 번 시작해서 한 번 끝나면 수명이 다하므로, 상태로 말하면 NEW에서 RUNNABLE로 가는 길은 한 번뿐이고 TERMINATED에서 NEW로 돌아가는 길은 없습니다. 그래서 이미 시작한 객체라면 아직 실행 중이든 이미 끝났든 start()를 다시 호출할 때 같은 예외가 발생합니다. 예외를 catch로 잡는다고 작업이 다시 실행되지도 않습니다. 아래 그림은 이 프로그램에서 oneShot 객체가 생성부터 재시작 실패까지 거치는 과정입니다.
같은 Thread의 두 번째 start는 실패한다
NEW→start→join/TERMINATED→동일 객체 재시작 실패의 실제 수명입니다. 객체 정체성을 고정한 일회성 상태 전환이 적합합니다.
oneShot은 한 번 실행된 뒤 join으로 종료를 확인합니다. TERMINATED 출력 뒤 같은 객체를 다시 시작하면 IllegalThreadStateException이 발생합니다.
THREAD · 한 번만 시작
같은 Thread의 두 번째 start는 실패한다
첫 실행이 끝난 Thread는 다시 시작할 수 없습니다. TERMINATED에서 NEW로 돌아가지 않습니다.
실선: 원 main의 호출 순서종료 후 NEW로 돌아가지 않음
1 · 객체 생성
- 상태·역할
oneShot은 아직 시작 전- 실제 출력 또는 결과
- 출력 없음
2 · 첫
start()- 상태·역할
- 작업 실행을 요청
- 실제 출력 또는 결과
one run한 번
3 ·
join()뒤- 상태·역할
- 종료를 확인하고 상태 조회
- 실제 출력 또는 결과
state-before-restart=TERMINATED
4 · 두 번째
start()- 상태·역할
- 같은 객체의 재시작 시도
- 실제 출력 또는 결과
IllegalThreadStateException두 번째 작업 출력 없음
표의 순서는 원문 호출 순서입니다. 첫 start()가 반환한 직후의 상태를 관찰한 표는 아닙니다. 반복 실행에는 새 Thread 객체나 별도의 실행 정책이 필요합니다.
이 예의 조건
- 같은 Thread 객체를 재시작하는 원 예제이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 객체의 첫 start·join·상태 조회 뒤 두 번째 start는 IllegalThreadStateException입니다. 실제 순서와 한 번만 시작하는 객체 계약을 연결하며 두 번째 run 출력이 없습니다. |
| 4 | NEW |
| 5 | 실행 전 |
| 12 | Exception |
| 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 객체를 만듭니다. |
|
|
| 첫 실행/회수 start 후 one run을 실행하고 join합니다. |
|
|
| 재시작 실패 같은 객체의 start를 다시 호출합니다. |
|
|
같은 Thread의 두 번째 start는 실패한다
NEW→start→join/TERMINATED→동일 객체 재시작 실패의 실제 수명입니다. 객체 정체성을 고정한 일회성 상태 전환이 적합합니다.
oneShot은 한 번 실행된 뒤 join으로 종료를 확인합니다. TERMINATED 출력 뒤 같은 객체를 다시 시작하면 IllegalThreadStateException이 발생합니다.
THREAD · 한 번만 시작
같은 Thread의 두 번째 start는 실패한다
첫 실행이 끝난 Thread는 다시 시작할 수 없습니다. TERMINATED에서 NEW로 돌아가지 않습니다.
실선: 원 main의 호출 순서종료 후 NEW로 돌아가지 않음
1 · 객체 생성
- 상태·역할
oneShot은 아직 시작 전- 실제 출력 또는 결과
- 출력 없음
2 · 첫
start()- 상태·역할
- 작업 실행을 요청
- 실제 출력 또는 결과
one run한 번
3 ·
join()뒤- 상태·역할
- 종료를 확인하고 상태 조회
- 실제 출력 또는 결과
state-before-restart=TERMINATED
4 · 두 번째
start()- 상태·역할
- 같은 객체의 재시작 시도
- 실제 출력 또는 결과
IllegalThreadStateException두 번째 작업 출력 없음
표의 순서는 원문 호출 순서입니다. 첫 start()가 반환한 직후의 상태를 관찰한 표는 아닙니다. 반복 실행에는 새 Thread 객체나 별도의 실행 정책이 필요합니다.
이 예의 조건
- 같은 Thread 객체를 재시작하는 원 예제이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 객체의 첫 start·join·상태 조회 뒤 두 번째 start는 IllegalThreadStateException입니다. 실제 순서와 한 번만 시작하는 객체 계약을 연결하며 두 번째 run 출력이 없습니다. |
| 4 | NEW |
| 5 | 실행 전 |
| 12 | Exception |
| 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 객체를 만듭니다. |
|
|
| 첫 실행/회수 start 후 one run을 실행하고 join합니다. |
|
|
| 재시작 실패 같은 객체의 start를 다시 호출합니다. |
|
|
같은 일을 다시 실행하려면 새 Thread 객체를 만들어 시작합니다. 할 일을 Runnable로 떼어 두면 같은 작업 객체를 새 Thread에 몇 번이든 다시 넘길 수 있는데, 이 방법은 다음 글에서 다룹니다. 작업을 자주 반복해서 맡겨야 한다면 스레드를 직접 만들기보다 22장에서 배울 실행기(executor)에게 작업을 제출하는 편이 낫습니다.
이 예제가 상태를 join() 뒤에 조회한 데에도 이유가 있습니다. start() 직후에 getState()를 호출하면 대개 RUNNABLE이 나오지만 그렇게 정해져 있지는 않습니다. 작업이 아주 짧으면 조회하기 전에 이미 끝나 TERMINATED가 나올 수도 있습니다. 빈 작업을 시작하자마자 상태를 조회하는 프로그램을 이 환경에서 40번 실행했을 때는 모두 RUNNABLE이었지만, 이것은 이 환경에서 자주 나온 값일 뿐입니다. 반면 join()이 정상적으로 돌아온 뒤에는 그 스레드가 끝났다는 것이 확실하므로 언제나 TERMINATED가 나옵니다.
스레드에 이름과 예외 처리기 붙이기
여러 스레드가 함께 일하는 프로그램에서 문제가 생기면 로그와 스레드 덤프를 보게 되는데, 거기서 스레드를 구별하는 가장 쉬운 단서가 이름입니다. 이름을 지정하지 않으면 Thread-0, Thread-1 같은 이름이 붙어 어떤 일을 하는 스레드인지 알 수 없습니다. 다음 프로그램은 파일 세 개를 가져오는 일을 스레드 세 개에 맡기면서 import-1부터 import-3까지 이름을 붙입니다. 실제로 파일을 읽지는 않고, 어느 스레드가 어떤 파일을 맡았는지만 출력합니다.
각 스레드에는 setUncaughtExceptionHandler로 잡히지 않은 예외의 처리기(uncaught exception handler)도 등록합니다. 처리기는 스레드의 run()이 잡지 않은 예외로 끝나려 할 때 호출되며, 예외가 난 스레드와 예외 객체를 받습니다. 여기서는 스레드 이름과 예외 클래스 이름을 표준 오류에 출력합니다.
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);
}
}import-2 -> thread.md
import-3 -> memory.md
import-1 -> java.md
imported=3세 작업 줄의 순서는 실행할 때마다 달라집니다. 30번 실행했을 때 세 줄을 늘어놓을 수 있는 여섯 가지 순서가 모두 나왔습니다. 그래도 각 파일 이름은 정확히 한 번씩 나오고, 마지막 imported=3은 언제나 맨 끝에 옵니다. main이 세 스레드를 모두 join()한 다음에 이 줄을 출력하기 때문입니다. 이 실행에서는 예외가 나지 않았으므로 처리기는 한 번도 호출되지 않았습니다.
처리기가 호출되는 모습도 확인해 보겠습니다. importFile 메서드의 끝에 다음 줄을 추가하면 thread.md를 맡은 스레드가 예외로 끝납니다.
if (file.equals("thread.md")) throw new IllegalStateException("broken");import-1 -> java.md
import-2 -> thread.md
import-2:IllegalStateException
import-3 -> memory.md
imported=3import-2 스레드가 예외로 끝나기 직전에 처리기가 호출되어 셋째 줄을 표준 오류에 출력했습니다. 처리기를 등록하지 않았다면 JVM의 기본 처리기가 Exception in thread "import-2"로 시작하는 스택 트레이스를 출력했을 것입니다. 작업 줄과 처리기 줄의 순서는 이번에도 실행마다 다르지만, 마지막 줄은 여전히 imported=3입니다. 프로세스와 스레드에서 본 것처럼 join()은 스레드가 끝나기를 기다릴 뿐 그 스레드의 예외를 main에 다시 던지지 않기 때문입니다.
즉 imported=3은 성공한 가져오기의 수가 아니라 만든 스레드의 수입니다. 실제 성공 건수를 알려면 작업마다 성공 여부와 실패 원인을 따로 기록해 두고 join() 뒤에 모아야 합니다. 같은 작업에서 난 예외가 실행 방식에 따라 어디로 전달되는지는 다음 글에서 더 자세히 비교합니다.
이름에는 업무 종류와 번호처럼 로그를 읽는 사람이 스레드의 역할을 알아볼 수 있는 정보를 넣습니다. 반대로 사용자 이름이나 이메일 주소 같은 개인정보는 넣지 않습니다. 스레드 이름은 로그와 스레드 덤프, 모니터링 도구 곳곳에 그대로 남기 때문입니다. 이 예제는 객체 생성과 start()를 드러내려고 Thread 배열을 직접 관리했지만, 실제 서비스 코드에서는 스레드를 직접 만들고 관리하기보다 22장에서 배울 실행기에게 맡기는 편이 낫습니다.
더 깊이: Thread.Builder로 같은 설정의 스레드 만들기
Java 21부터는 Thread.ofPlatform()이 돌려주는 빌더(Thread.Builder)로 같은 설정의 스레드를 여러 개 만들 수 있습니다. name("import-", 1)은 이름의 앞부분과 시작 번호를 정하는데, 이 빌더로 스레드를 만들 때마다 번호가 1씩 올라가 import-1, import-2, import-3이 차례로 붙습니다. 처리기도 빌더에 한 번만 정해 두면 됩니다. 앞 예제의 첫 번째 반복문을 다음처럼 바꿔도 출력은 같습니다.
Thread.Builder builder = Thread.ofPlatform()
.name("import-", 1)
.uncaughtExceptionHandler((thread, error) ->
System.err.println(thread.getName() + ":" + error.getClass().getSimpleName()));
for (int i = 0; i < workers.length; i++) {
String file = files.get(i);
workers[i] = builder.start(() -> importFile(file));
}builder.start(task)는 스레드를 만들어 곧바로 시작하고, 만들기만 하려면 builder.unstarted(task)를 씁니다. 빌더에는 다음 글에서 배울 데몬 여부도 daemon(true)처럼 정할 수 있고, Thread.ofPlatform() 대신 Thread.ofVirtual()로 시작하면 가상 스레드를 만듭니다.
Thread 상속은 언제 쓸까
Thread를 상속하는 방법은 짧고 직관적이지만 한계가 두 가지 있습니다. 먼저 Java 클래스는 부모를 하나만 가질 수 있으므로 Thread를 상속한 클래스는 다른 클래스를 상속할 수 없습니다. 더 큰 문제는 할 일과 그 일을 실행하는 수단이 한 클래스에 묶인다는 점입니다. RangeSumThread의 구간 합 계산은 꼭 새 스레드에서 해야 하는 일이 아닌데도, 이 계산을 쓰려면 매번 스레드 객체를 만들어야 합니다. 같은 계산을 실행기에게 맡기고 싶어져도 클래스부터 고쳐야 합니다.
그래서 보통은 할 일을 Runnable로 따로 만들고 Thread에는 실행만 맡깁니다. Thread 자체의 동작을 바꿔야 하는 드문 경우가 아니라면 상속은 잘 쓰지 않습니다. 다음 글에서 이렇게 나누면 무엇이 달라지는지 살펴봅니다.
정리
Thread를 상속하고run()을 재정의하면 그 본문이 새 스레드가 할 일이 됩니다. 생성자에서super(name)으로 스레드 이름을 정할 수 있습니다.new는NEW상태의 객체를 만들 뿐입니다.start()를 호출해야 새 스레드가 시작되고, 그 스레드가run()을 호출합니다.run()을 직접 호출하면 호출한 스레드에서 보통 메서드처럼 실행되고 새 스레드는 생기지 않습니다. 지금 코드를 실행하는 스레드는getName()이 아니라Thread.currentThread()로 확인합니다.- 스레드마다 호출 스택이 따로 있어서 지역 변수는 섞이지 않습니다. 힙의 객체는 그 참조를 가진 모든 스레드가 함께 씁니다.
- 하나의
Thread객체는 한 번만 시작할 수 있습니다. 다시start()하면IllegalThreadStateException이 발생하므로, 같은 일을 다시 하려면 새Thread객체를 만듭니다. - 스레드에는 역할을 알 수 있는 이름을 붙이고, 필요하면 잡히지 않은 예외를 받을 처리기를 등록합니다.
join()은 작업이 성공했는지 알려 주지 않습니다.
연습 문제
1. 이름과 메시지를 출력하는 스레드
Thread를 상속한 MessageThread를 작성하세요. 생성자에서 스레드 이름과 메시지를 받고, run()에서는 지금 실행 중인 스레드의 이름과 메시지를 한 줄로 출력합니다. main은 이름이 message-a이고 메시지가 memory인 객체와, 이름이 message-b이고 메시지가 stack인 객체를 만들어 둘 다 시작합니다. 그리고 두 스레드를 모두 join()한 다음 message workers terminated를 출력해야 합니다.
풀기 전에 검사할 조건부터 정해 두세요. 두 작업 줄에는 각 객체에 준 이름과 같은 스레드 이름이 찍혀야 하고, main이 찍히면 안 됩니다. 두 줄의 순서는 정해지지 않으므로 검사하지 않고, 종료 문장이 두 줄보다 뒤에 오는지만 확인합니다.
정답과 해설
생성자의 첫 줄에서 super(name)을 호출해 스레드 이름을 정하고, 메시지는 필드에 저장합니다. run()에서는 getName()이 아니라 Thread.currentThread().getName()으로 실제로 실행 중인 스레드를 확인합니다.
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");
}
}message-b says stack
message-a says memory
message workers terminated30번 실행했을 때 두 작업 줄의 순서는 15번씩 바뀌었습니다. 어느 순서든 두 줄 모두 main이 아닌 이름을 출력하는데, start()로 시작한 새 스레드가 run()을 실행했기 때문입니다. 마지막 줄은 두 join()이 돌아온 뒤에 출력되므로 언제나 맨 끝입니다. 만약 start()를 run()으로 바꾸면 두 줄이 모두 main says로 시작하고 순서도 고정되는데, 이것이 본문에서 본 실수입니다.
2. join 하나를 빠뜨리면
RangeSumThread의 main에서 left.join(); 한 줄만 지우면 무엇이 출력될까요? right.join()은 그대로 남아 있습니다. 나올 수 있는 total 값을 생각해 보고, 여러 번 실행해 매번 total=500500이 나오더라도 이 코드가 맞다고 할 수 없는 이유를 설명하세요.
정답과 해설
main은 right-sum이 끝나기만 기다린 뒤 left.result()를 읽습니다. 그때 left-sum이 이미 125250을 저장했고 그 값이 main에 보인다면 total=500500이 나옵니다. 하지만 left-sum이 아직 result에 값을 저장하지 않았다면 필드의 기본값 0을 읽어 total=375250이 나옵니다.
이 환경에서 그렇게 바꿔 40번 실행했을 때는 40번 모두 total=500500이 나왔습니다. 두 스레드가 하는 일의 양이 비슷해서, main이 right-sum을 기다리는 사이에 left-sum도 대개 끝나기 때문입니다. 그러나 left-sum이 늦게 스케줄되면 틀린 합이 나올 수 있고, left에 대한 join()이 없으면 left-sum이 이미 값을 저장했더라도 그 값이 main에서 보인다는 보장도 없습니다. 테스트에서 늘 맞는 답이 나와도 코드가 맞다는 뜻은 아니므로, 결과를 읽기 전에 그 결과를 쓴 스레드마다 join()이 있는지 확인해야 합니다.
3. run()과 start()를 한 번씩 호출하면
아래 CountingThread는 run()이 실행될 때마다 runs를 1 늘리고 실행 중인 스레드의 이름을 출력합니다. main은 같은 객체에 run()을 한 번 직접 호출한 뒤 start()로 한 번 시작하고, 끝난 뒤에 다시 start()를 시도합니다. 다른 스레드가 끼어들거나 중단을 요청하는 일은 없다고 가정하고, 실행하기 전에 여섯 줄의 출력을 예측하세요. 이 프로그램이 스레드를 몇 번 시작한 것인지도 설명하세요.
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()은 main에서 실행되므로 actual=main이 나오고, 일반 메서드 호출일 뿐이라 객체의 상태는 여전히 NEW입니다. 아직 시작한 적이 없으니 그다음 start()는 성공하고, 새 스레드 counted-worker가 run()을 한 번 더 실행합니다. runs=2는 run()의 본문이 두 번 실행됐다는 뜻일 뿐이고 스레드가 시작된 것은 한 번입니다. 그 스레드가 끝난 뒤의 두 번째 start()는 IllegalThreadStateException으로 거절됩니다.
runs는 두 스레드가 바꾸는 필드인데도 값이 정확한 이유도 짚어 봅시다. 첫 증가는 main이 start()를 호출하기 전에 일어나고, 두 번째 증가는 새 스레드에서 일어나며, main은 join()이 돌아온 뒤에 값을 읽습니다. start()와 join()이 앞뒤 순서를 정해 주므로 두 스레드가 runs를 동시에 바꾸는 순간이 없습니다. 만약 start() 직후 join() 전에 main이 run()을 또 호출하도록 바꾸면 두 스레드가 같은 필드를 동시에 바꿀 수 있고, 그때는 19장에서 배울 동기화가 필요합니다.
다음 글
Runnable과 데몬 스레드에서는 할 일을 Runnable로 떼어 Thread와 분리하고, run()과 start()로 실행할 때 예외가 전달되는 곳이 어떻게 다른지, JVM이 데몬 스레드를 기다리지 않고 종료하는 규칙은 무엇인지 살펴봅니다.