join과 완료 가시성
작업자 결과를 너무 일찍 읽는 실패를 재현하고 join·this·sleep 대기의 의미를 구분해 완료를 조정합니다.
스레드를 시작하는 것과 그 결과를 사용할 수 있는 시점은 다릅니다.
join()은 이미 시작한 대상 Thread의 종료를 현재 스레드가 시간 제한 없이 기다리는 호출입니다.
단순한 시간 지연과 달리 실제 완료 사건을 기준으로 하며, join이 정상 반환한 뒤에는 대상이 기록한 값도 보인다는 메모리 일관성 효과가 있습니다.
start 직후 결과 필드 해석
아래 작업자는 래치가 열려야 42를 기록합니다.
main은 start 직후 결과를 먼저 읽으므로 실제로 wrong-read=0이 출력됩니다.
스케줄링의 우연에 기대지 않고 너무 이른 read를 확정한 사례입니다.
import java.util.concurrent.CountDownLatch;
public final class ReadBeforeJoinBug {
private static final class ResultTask implements Runnable {
private final CountDownLatch gate;
int result;
ResultTask(CountDownLatch gate) {
this.gate = gate;
}
public void run() {
try {
gate.await();
result = 42;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
public static void main(String[] args) throws InterruptedException {
CountDownLatch gate = new CountDownLatch(1);
ResultTask task = new ResultTask(gate);
Thread worker = new Thread(task, "result-worker");
worker.start();
System.out.println("wrong-read=" + task.result);
gate.countDown();
worker.join();
System.out.println("after-join=" + task.result);
}
}닫힌 게이트 앞의 0과 join 뒤의 42를 구분한다
닫힌 gate에서 0 읽기→countDown→worker의 42 쓰기→join 뒤 42 읽기의 필요한 순서입니다. 읽기/쓰기/대기 경계를 같은 영역에서 따라가기 좋습니다.
ReadBeforeJoinBug는 main의 첫 출력 뒤에 래치를 해제합니다. 작업자는 그 뒤 result에42를 쓰며 main은 작업자를 join한 다음 결과를 다시 읽습니다.
JOIN · 쓰기 전 / 종료 후
닫힌 게이트 앞의 0과 join 뒤의 42를 구분한다
닫힌 게이트에서는 result=42에 도달할 수 없습니다. 게이트를 열고 종료를 회수한 뒤 42를 읽습니다.
실선: 게이트·쓰기·읽기의 필요한 순서join은 main의 대기 / worker중지 아님
실선: 게이트·쓰기·읽기의필요한 순서join은 main의 대기 /worker중지 아님
시작 직후,
countDown()전- 작업자가 기록할 수 있는 조건
- 래치가 닫혀 있어
result = 42에 도달하지 못함 - 원문의 출력
wrong-read=0
countDown()·worker.join()뒤- 작업자가 기록할 수 있는 조건
- 게이트를 통과한 작업자의 종료까지 기다린 뒤 읽음
- 원문의 출력
after-join=42
첫 읽기가 0인 이유는 게이트를 아직 열지 않았기 때문입니다. join()은 호출자인 main이 작업자 종료를 기다리게 하며, 작업자를 멈추는 호출이 아닙니다.
이 예의 조건
- 첫 0은 닫힌 gate로 보장하며 우연한 빠른 읽기 관측이 아니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 첫 0은 게이트가 닫혀 worker가 42를 쓰지 못하는 조건으로 고정됩니다. countDown 뒤 쓰기·종료·정상 join 뒤 읽기의 필요한 앞뒤 관계를 연결합니다. |
| 4 | worker가 42를 못 씀 |
| 14 | join은 main의 대기 |
| 15 | worker 중지 아님 |
| 구분 | 내용 |
|---|---|
| 설명 | 첫 0은 게이트가 닫혀 worker가 42를 쓰지 못하는 조건으로 고정됩니다. countDown 뒤 쓰기·종료·정상 join 뒤 읽기의 필요한 앞뒤 관계를 연결합니다. |
| 전체 읽기 정보 | 닫힌 게이트 앞의 0과 join 뒤의 42를 구분한다 닫힌 게이트 앞의 0과 join 뒤의 42를 구분한다 ReadBeforeJoinBug는 main의 첫 출력 뒤에 래치를 해제합니다. 작업자는 그 뒤 result에 42를 쓰며 main은 작업자를 join한 다음 결과를 다시 읽습니다. JOIN · 쓰기 전 / 종료 후 닫힌 게이트 앞의 0과 join 뒤의 42를 구분한다 닫힌 게이트에서는 result=42에 도달할 수 없습니다. 게이트를 열고 종료를 회수한 뒤 42를 읽습니다. 시작 직후, countDown() 전 작업자가 기록할 수 있는 조건 래치가 닫혀 있어 result = 42 에 도달하지 못함 원문의 출력 wrong-read=0 countDown() · worker.join() 뒤 작업자가 기록할 수 있는 조건 게이트를 통과한 작업자의 종료까지 기다린 뒤 읽음 원문의 출력 after-join=42 첫 읽기가 0인 이유는 게이트를 아직 열지 않았기 때문입니다. join() 은 호출자인 main이 작업자 종료를 기다리게 하며, 작업자를 멈추는 호출이 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 게이트 닫힘 worker가 42를 쓰기 전에 기다립니다. |
|
|
| 게이트 해제 countDown 후 worker가 42를 저장하고 끝납니다. |
|
|
| join 후 읽기 main이 종료를 회수하고 다시 읽습니다. |
|
|
닫힌 게이트 앞의 0과 join 뒤의 42를 구분한다
닫힌 gate에서 0 읽기→countDown→worker의 42 쓰기→join 뒤 42 읽기의 필요한 순서입니다. 읽기/쓰기/대기 경계를 같은 영역에서 따라가기 좋습니다.
ReadBeforeJoinBug는 main의 첫 출력 뒤에 래치를 해제합니다. 작업자는 그 뒤 result에42를 쓰며 main은 작업자를 join한 다음 결과를 다시 읽습니다.
JOIN · 쓰기 전 / 종료 후
닫힌 게이트 앞의 0과 join 뒤의 42를 구분한다
닫힌 게이트에서는 result=42에 도달할 수 없습니다. 게이트를 열고 종료를 회수한 뒤 42를 읽습니다.
실선: 게이트·쓰기·읽기의 필요한 순서join은 main의 대기 / worker중지 아님
실선: 게이트·쓰기·읽기의필요한 순서join은 main의 대기 /worker중지 아님
시작 직후,
countDown()전- 작업자가 기록할 수 있는 조건
- 래치가 닫혀 있어
result = 42에 도달하지 못함 - 원문의 출력
wrong-read=0
countDown()·worker.join()뒤- 작업자가 기록할 수 있는 조건
- 게이트를 통과한 작업자의 종료까지 기다린 뒤 읽음
- 원문의 출력
after-join=42
첫 읽기가 0인 이유는 게이트를 아직 열지 않았기 때문입니다. join()은 호출자인 main이 작업자 종료를 기다리게 하며, 작업자를 멈추는 호출이 아닙니다.
이 예의 조건
- 첫 0은 닫힌 gate로 보장하며 우연한 빠른 읽기 관측이 아니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 첫 0은 게이트가 닫혀 worker가 42를 쓰지 못하는 조건으로 고정됩니다. countDown 뒤 쓰기·종료·정상 join 뒤 읽기의 필요한 앞뒤 관계를 연결합니다. |
| 4 | worker가 42를 못 씀 |
| 14 | join은 main의 대기 |
| 15 | worker 중지 아님 |
| 구분 | 내용 |
|---|---|
| 설명 | 첫 0은 게이트가 닫혀 worker가 42를 쓰지 못하는 조건으로 고정됩니다. countDown 뒤 쓰기·종료·정상 join 뒤 읽기의 필요한 앞뒤 관계를 연결합니다. |
| 전체 읽기 정보 | 닫힌 게이트 앞의 0과 join 뒤의 42를 구분한다 닫힌 게이트 앞의 0과 join 뒤의 42를 구분한다 ReadBeforeJoinBug는 main의 첫 출력 뒤에 래치를 해제합니다. 작업자는 그 뒤 result에 42를 쓰며 main은 작업자를 join한 다음 결과를 다시 읽습니다. JOIN · 쓰기 전 / 종료 후 닫힌 게이트 앞의 0과 join 뒤의 42를 구분한다 닫힌 게이트에서는 result=42에 도달할 수 없습니다. 게이트를 열고 종료를 회수한 뒤 42를 읽습니다. 시작 직후, countDown() 전 작업자가 기록할 수 있는 조건 래치가 닫혀 있어 result = 42 에 도달하지 못함 원문의 출력 wrong-read=0 countDown() · worker.join() 뒤 작업자가 기록할 수 있는 조건 게이트를 통과한 작업자의 종료까지 기다린 뒤 읽음 원문의 출력 after-join=42 첫 읽기가 0인 이유는 게이트를 아직 열지 않았기 때문입니다. join() 은 호출자인 main이 작업자 종료를 기다리게 하며, 작업자를 멈추는 호출이 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 게이트 닫힘 worker가 42를 쓰기 전에 기다립니다. |
|
|
| 게이트 해제 countDown 후 worker가 42를 저장하고 끝납니다. |
|
|
| join 후 읽기 main이 종료를 회수하고 다시 읽습니다. |
|
|
필드에 volatile을 붙여도 언젠가 기록될 값이 이미 기록되게 만들 수는 없습니다.
순서 문제와 가시성 문제를 분리해야 합니다.
join 호출자와 대상 상태
worker.join()을 호출하면 현재 스레드가 작업자 종료를 기다립니다.
인자가 없는 join은 시간 제한이 없고 interrupt가 오면 InterruptedException을 던집니다.
대상 스레드를 정지시키거나 CPU를 빼앗는 API가 아닙니다.
아직 시작하지 않은 대상은 기다리지 않습니다. 인자 없는 join()은 join(0)과 같고, Thread API에 따라 start하지 않은 대상에는 즉시 반환합니다. 따라서 worker.join(); worker.start();는 나중에 시작할 작업의 완료를 예약하지 않습니다. 이 문서의 완료 경계는 먼저 start한 작업자를 join하고, 그 호출이 정상 반환했다는 조건입니다.
여러 작업자를 차례로 join해도 작업자들은 이미 모두 start됐다면 서로 겹쳐 실행될 수 있습니다.
start(); join(); start(); join();처럼 각 시작 직후 join하면 사실상 직렬 실행이 됩니다.
먼저 전부 start하고 두 번째 반복문에서 join하는 패턴이 흔한 이유입니다.
public final class ParallelStartThenJoin {
public static void main(String[] args) throws InterruptedException {
long[] values = new long[3];
Thread[] workers = new Thread[3];
for (int i = 0; i < workers.length; i++) {
int slot = i;
workers[i] =
new Thread(
() -> values[slot] = sum(slot * 1000 + 1, (slot + 1) * 1000),
"sum-" + slot);
}
for (Thread worker : workers) {
worker.start();
}
for (Thread worker : workers) {
worker.join();
}
long total = 0;
for (long value : values) {
total += value;
}
System.out.println("total=" + total);
}
private static long sum(int from, int to) {
long result = 0;
for (int n = from; n <= to; n++) {
result += n;
}
return result;
}
}세 구간은 각자 values 슬롯에 쓰고 main이 합친다
세 작업자의 전용 배열 슬롯과 모두 join 뒤 total 집계입니다. 병렬 기록과 완료 경계를 구별하면 배열 자체가 가시성을 만든다는 오해를 피합니다.
ParallelStartThenJoin의 sum-0, sum-1, sum-2는1부터3000까지 서로 다른1000개 구간을 계산합니다. main은 모두 시작하고 모두 join한 뒤 total4501500을 출력합니다.
JOIN · 슬롯별 기록
세 구간은 각자 values 슬롯에 쓰고 main이 합친다
작업자 셋은 겹치지 않는 구간을 각자 values 슬롯에 씁니다. main은 모두 시작하고 모두 회수한 뒤 합칩니다.
sum-0
- 계산 범위·읽거나 쓰는 곳
1..1000values[0]에 기록- 소스 계산값 또는 최종 출력
500500
sum-1
- 계산 범위·읽거나 쓰는 곳
1001..2000values[1]에 기록- 소스 계산값 또는 최종 출력
1500500
sum-2
- 계산 범위·읽거나 쓰는 곳
2001..3000values[2]에 기록- 소스 계산값 또는 최종 출력
2500500
main
- 계산 범위·읽거나 쓰는 곳
- 모든
start()뒤 모든join()을 기다리고values를 합산 - 소스 계산값 또는 최종 출력
total=4501500
각 슬롯의 기록자는 하나입니다. 부분합은 소스에서 계산한 값이며 원문은 최종 합계 한 줄만 출력합니다. 표의 작업자 순서는 실행·완료 순서가 아닙니다.
이 예의 조건
- 작업자 간 실행 사슬이 아닌 독립 슬롯 기록이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 1..1000/1001..2000/2001..3000의 각 슬롯 500500/1500500/2500500을 모두 join의 정상 반환 뒤 4501500으로 합합니다. 각 슬롯에 기록자 한 명이며 작업자 사이의 실행 사슬이 아닙니다. |
| 구분 | 내용 |
|---|---|
| 설명 | 1..1000/1001..2000/2001..3000의 각 슬롯 500500/1500500/2500500을 모두 join의 정상 반환 뒤 4501500으로 합합니다. 각 슬롯에 기록자 한 명이며 작업자 사이의 실행 사슬이 아닙니다. |
| 전체 읽기 정보 | 세 구간은 각자 values 슬롯에 쓰고 main이 합친다 세 구간은 각자 values 슬롯에 쓰고 main이 합친다 ParallelStartThenJoin의 sum-0, sum-1, sum-2는 1부터 3000까지를 나눠 각각 1000개의 정수를 포함하는 서로 다른 구간을 계산합니다. main은 모두 시작하고 모두 join한 뒤 total 4501500을 출력합니다. JOIN · 슬롯별 기록 세 구간은 각자 values 슬롯에 쓰고 main이 합친다 작업자 셋은 겹치지 않는 구간을 각자 values 슬롯에 씁니다. main은 모두 시작하고 모두 회수한 뒤 합칩니다. sum-0 계산 범위·읽거나 쓰는 곳 1..1000 values[0] 에 기록 소스 계산값 또는 최종 출력 500500 sum-1 계산 범위·읽거나 쓰는 곳 1001..2000 values[1] 에 기록 소스 계산값 또는 최종 출력 1500500 sum-2 계산 범위·읽거나 쓰는 곳 2001..3000 values[2] 에 기록 소스 계산값 또는 최종 출력 2500500 main 계산 범위·읽거나 쓰는 곳 모든 start() 뒤 모든 join() 을 기다리고 values 를 합산 소스 계산값 또는 최종 출력 total=4501500 각 슬롯의 기록자는 하나입니다. 부분합은 소스에서 계산한 값이며 원문은 최종 합계 한 줄만 출력합니다. 표의 작업자 순서는 실행·완료 순서가 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 세 범위 분배 각 작업자에게 다른 슬롯을 줍니다. |
|
|
| 부분합 기록 자기 범위 결과를 저장합니다. |
|
|
| 모두 회수 후 main이 세 join 뒤 합을 읽습니다. |
|
|
세 구간은 각자 values 슬롯에 쓰고 main이 합친다
세 작업자의 전용 배열 슬롯과 모두 join 뒤 total 집계입니다. 병렬 기록과 완료 경계를 구별하면 배열 자체가 가시성을 만든다는 오해를 피합니다.
ParallelStartThenJoin의 sum-0, sum-1, sum-2는1부터3000까지 서로 다른1000개 구간을 계산합니다. main은 모두 시작하고 모두 join한 뒤 total4501500을 출력합니다.
JOIN · 슬롯별 기록
세 구간은 각자 values 슬롯에 쓰고 main이 합친다
작업자 셋은 겹치지 않는 구간을 각자 values 슬롯에 씁니다. main은 모두 시작하고 모두 회수한 뒤 합칩니다.
sum-0
- 계산 범위·읽거나 쓰는 곳
1..1000values[0]에 기록- 소스 계산값 또는 최종 출력
500500
sum-1
- 계산 범위·읽거나 쓰는 곳
1001..2000values[1]에 기록- 소스 계산값 또는 최종 출력
1500500
sum-2
- 계산 범위·읽거나 쓰는 곳
2001..3000values[2]에 기록- 소스 계산값 또는 최종 출력
2500500
main
- 계산 범위·읽거나 쓰는 곳
- 모든
start()뒤 모든join()을 기다리고values를 합산 - 소스 계산값 또는 최종 출력
total=4501500
각 슬롯의 기록자는 하나입니다. 부분합은 소스에서 계산한 값이며 원문은 최종 합계 한 줄만 출력합니다. 표의 작업자 순서는 실행·완료 순서가 아닙니다.
이 예의 조건
- 작업자 간 실행 사슬이 아닌 독립 슬롯 기록이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 1..1000/1001..2000/2001..3000의 각 슬롯 500500/1500500/2500500을 모두 join의 정상 반환 뒤 4501500으로 합합니다. 각 슬롯에 기록자 한 명이며 작업자 사이의 실행 사슬이 아닙니다. |
| 구분 | 내용 |
|---|---|
| 설명 | 1..1000/1001..2000/2001..3000의 각 슬롯 500500/1500500/2500500을 모두 join의 정상 반환 뒤 4501500으로 합합니다. 각 슬롯에 기록자 한 명이며 작업자 사이의 실행 사슬이 아닙니다. |
| 전체 읽기 정보 | 세 구간은 각자 values 슬롯에 쓰고 main이 합친다 세 구간은 각자 values 슬롯에 쓰고 main이 합친다 ParallelStartThenJoin의 sum-0, sum-1, sum-2는 1부터 3000까지를 나눠 각각 1000개의 정수를 포함하는 서로 다른 구간을 계산합니다. main은 모두 시작하고 모두 join한 뒤 total 4501500을 출력합니다. JOIN · 슬롯별 기록 세 구간은 각자 values 슬롯에 쓰고 main이 합친다 작업자 셋은 겹치지 않는 구간을 각자 values 슬롯에 씁니다. main은 모두 시작하고 모두 회수한 뒤 합칩니다. sum-0 계산 범위·읽거나 쓰는 곳 1..1000 values[0] 에 기록 소스 계산값 또는 최종 출력 500500 sum-1 계산 범위·읽거나 쓰는 곳 1001..2000 values[1] 에 기록 소스 계산값 또는 최종 출력 1500500 sum-2 계산 범위·읽거나 쓰는 곳 2001..3000 values[2] 에 기록 소스 계산값 또는 최종 출력 2500500 main 계산 범위·읽거나 쓰는 곳 모든 start() 뒤 모든 join() 을 기다리고 values 를 합산 소스 계산값 또는 최종 출력 total=4501500 각 슬롯의 기록자는 하나입니다. 부분합은 소스에서 계산한 값이며 원문은 최종 합계 한 줄만 출력합니다. 표의 작업자 순서는 실행·완료 순서가 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 세 범위 분배 각 작업자에게 다른 슬롯을 줍니다. |
|
|
| 부분합 기록 자기 범위 결과를 저장합니다. |
|
|
| 모두 회수 후 main이 세 join 뒤 합을 읽습니다. |
|
|
결과 집계를 위해 배열 자체를 synchronized 컬렉션으로 바꿀 필요가 없습니다.
보이는 이유는 배열의 종류가 아니라 완료 경계입니다. JLS의 happens-before 규칙에 따라 작업자가 종료하기 전 한 쓰기는 성공한 join 이후의 읽기에 보입니다. 각 슬롯의 기록자가 한 명이고 main은 모두 회수한 뒤 읽으므로 이 예제에는 volatile 슬롯이 필요하지 않습니다. join이 작업자 사이에서 같은 칸에 수행한 경쟁적인 갱신까지 원자적으로 합쳐 주는 것은 아닙니다.
이 원문에서 필요한 조건은 슬롯별 기록자 한 명, main의 중간 읽기 없음, 해당 작업자 join의 정상 반환입니다. 1..1000의 합 500500, 1001..2000의 합 1500500, 2001..3000의 합 2500500이 각 칸에 쓰이고 마지막 합은 4501500입니다. 같은 칸에 두 작업자가 +=하도록 바꾸거나 join 전에 main이 읽으면 앞의 설명을 그대로 적용할 수 없습니다.
Thread 하위 타입의 this
Thread를 상속한 run()에서 this는 그 메서드의 대상인 Thread 하위 객체입니다. 직접 run()을 호출하면 실제 실행 스레드는 그 객체와 다를 수 있습니다.
그래서 this.getName()과 Thread.currentThread().getName()은 정상 start 경로에서 같은 값을 냅니다.
하지만 Runnable 인스턴스의 this는 Runnable 객체이고 current 스레드와 다른 식별 정보입니다.
익명 Runnable 안의 this도 익명 작업 객체입니다.
인스턴스 문맥에서 작성한 람다의 this는 바깥 인스턴스를 가리킵니다. static 문맥에는 사용할 this가 없습니다.
동시성 코드를 읽을 때 this 모니터가 어느 객체인지 정확히 식별해야 합니다.
join은 this와 무관하게 참조한 Thread의 종료를 기다립니다.
sleep과 종료 조건의 한계
main이 Thread.sleep(100) 후 결과를 읽는 코드는 작업자가 100ms 안에 끝난다는 환경 가정입니다.
개발 PC에서는 통과해도 CI 부하, GC pause, 네트워크 지연으로 실패합니다.
sleep은 현재 스레드를 일정 시간 쉬게 하는 기능이지 다른 스레드의 완료 확인이 아닙니다.
join, 래치, Future.get은 사건 기반 대기입니다.
무한 대기가 위험하면 시간 제한과 실패 처리를 추가합니다.
시간 제한 뒤에도 대상이 살아 있을 수 있으므로 취소·회수 정책이 필요합니다.
다음 문서에서 시간 제한 join을 다룹니다.
import java.util.List;
public final class DocumentBuildJoin {
private record Result(String path, int headings) {}
public static void main(String[] args) throws InterruptedException {
List<String> paths = List.of("java/thread", "java/memory", "java/lock");
Result[] results = new Result[paths.size()];
Thread[] builders = new Thread[paths.size()];
for (int i = 0; i < paths.size(); i++) {
int slot = i;
builders[i] = new Thread(() -> results[slot] = build(paths.get(slot)), "build-" + slot);
builders[i].start();
}
for (Thread builder : builders) {
builder.join();
}
int headings = 0;
for (Result result : results) {
System.out.println(result);
headings += result.headings();
}
System.out.println("heading-total=" + headings);
}
private static Result build(String path) {
return new Result(path, path.length() % 5 + 2);
}
}이 원문처럼 작업이 정상 완료하면 모든 join 뒤 각 결과가 존재합니다. build는 문서를 읽지 않고 경로 문자열 길이로 가상 제목 수를 계산합니다.
작업자 내부 예외가 발생하면 배열 칸은 null로 남을 수 있으므로 실제 빌드 시스템은 예외 결과도 수집해야 합니다.
join 중 interrupt 보존
join은 검사된 InterruptedException을 던집니다.
main이 무조건 계속 기다려야 하는지, 대상도 interrupt할지, 현재 스레드 플래그를 복원해 상위로 알릴지 정책이 필요합니다.
라이브러리 메서드가 예외를 삼키면 shutdown이 지연됩니다.
아래 도우미는 대기 중 interrupt를 받으면 작업자에도 중단을 요청하고 현재 플래그를 복원한 뒤 false를 반환합니다.
호출자는 incomplete 결과를 사용하지 않습니다.
플래그 복원이 하는 일도 따로 보세요. join에서 InterruptedException을 던지면 기다리던 현재 스레드의 interrupt 상태는 지워집니다. catch의 Thread.currentThread().interrupt()는 그 호출자에게 중단 요청이 있었다는 정보를 다시 표시합니다. worker.interrupt()는 작업자에게 별도로 요청할 뿐이며, 두 호출 모두 그 작업자의 종료를 기다리지는 않습니다.
public final class JoinCancellationPolicy {
public static void main(String[] args) {
Thread worker =
new Thread(
() -> {
try {
Thread.sleep(20);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
},
"short-work");
worker.start();
boolean completed = joinOrCancel(worker);
System.out.println("completed=" + completed + ", alive=" + worker.isAlive());
}
private static boolean joinOrCancel(Thread worker) {
try {
worker.join();
return true;
} catch (InterruptedException e) {
worker.interrupt();
Thread.currentThread().interrupt();
return false;
}
}
}정상 실행에서는 true와 생존=false입니다.
중단 경로는 호출 환경의 interrupt로 따로 테스트해야 합니다. false 반환은 중단 요청과 플래그 복원을 뜻하며, 작업자 종료를 다시 join한 결과가 아니므로 미완료 결과를 사용하지 않습니다.
연습 문제
서로 다른 int 배열 세 개의 최댓값을 각 작업자가 구해 별도 슬롯에 기록하게 하세요.
main은 모든 작업자를 먼저 시작하고 모두 join한 뒤 전체 최댓값을 출력해야 합니다.
해설과 같은 입력은 [3, 8, 2], [11, 4, 6], [7, 15, 1]입니다. 각 배열은 비어 있지 않으며 입력은 작업 중 바뀌지 않습니다. 세 부분 최댓값을 먼저 구하고 그 최댓값을 마지막에 합치는 방식으로 예상 결과를 적으세요. 이를 모두 음수인 새 배열로 바꿀 때도 초기 최댓값을 0으로 두면 안 되는 이유를 설명해 보세요.
정답과 happens-before 설명
작업마다 기록기가 하나이며 join의 완료 경계 뒤 main이 슬롯을 읽습니다.
start 직후 읽는 코드나 임의 sleep은 없습니다.
public final class JoinedMaximumSolution {
public static void main(String[] args) throws InterruptedException {
int[][] inputs = {{3, 8, 2}, {11, 4, 6}, {7, 15, 1}};
int[] partial = new int[inputs.length];
Thread[] workers = new Thread[inputs.length];
for (int i = 0; i < inputs.length; i++) {
int slot = i;
workers[i] =
new Thread(
() -> {
int max = Integer.MIN_VALUE;
for (int value : inputs[slot]) {
max = Math.max(max, value);
}
partial[slot] = max;
},
"max-" + slot);
}
for (Thread worker : workers) {
worker.start();
}
for (Thread worker : workers) {
worker.join();
}
int maximum = Integer.MIN_VALUE;
for (int value : partial) {
maximum = Math.max(maximum, value);
}
System.out.println("maximum=" + maximum);
}
}maximum=15가 출력됩니다.
대상 스레드의 종료 이전 동작은 성공한 join 반환 이후 동작보다 먼저 일어난 것으로 관찰됩니다.
join의 선택 기준은 “시간이 충분히 지났는가”가 아니라 “이 Thread의 종료가 다음 단계의 전제인가”입니다.
여러 작업을 먼저 시작하고 완료를 회수하며 interrupt와 작업자 실패를 별도 결과로 다뤄야 합니다.
완료 경계와 결과를 추적하기
각 상황은 독립적입니다. 입력과 결과 슬롯은 외부에서 바꾸지 않으며, 명시한 인터럽트 상황 외에는 모든 작업이 정상 완료한다고 가정합니다.
- 처음
result=0인 작업자가 실행하면 42를 기록합니다. main이 start 전에worker.join()하고 result를 읽은 뒤,worker.start(); worker.join();하고 다시 읽습니다. 각 읽기는 무엇인가요? 첫 join이 미래 실행을 기다리지 않은 이유를 설명하세요. - 기존 최댓값 연습의 입력을
[-3, -8],[-11, -4],[-7, -15]로 바꿉니다. 모든 작업자를 먼저 시작하고 모두 join한 뒤의 부분 최댓값과 전체 최댓값은 무엇인가요? 초깃값을 0으로 두면 어떤 잘못된 답이 나오나요? - 이미 시작한 작업자는 다른 게이트가 열려야 끝납니다. main의 join은 인터럽트로 예외가 나고, 이 문서의
joinOrCancel이 작업자에게 interrupt를 요청하고 main의 플래그를 복원한 뒤 false를 반환했습니다. 작업자가 요청을 즉시 처리한다는 보장은 없습니다. main은 결과 슬롯을 사용해도 되나요? 복원한 플래그와 별도로 무엇을 결정해야 하나요?
완료와 결과의 해설
- 첫 읽기는 0, 정상 완료를 회수한 두 번째 읽기는 42입니다. 시작하지 않은 대상의 join은 즉시 반환하며, start를 대신하거나 나중의 실행을 예약하지 않습니다.
- 부분 최댓값은 -3, -4, -7이고 전체 최댓값은 -3입니다. 처음부터 0과 비교하면 입력에 없는 0이 끝까지 남습니다. 비어 있지 않은 입력의 첫 원소나
Integer.MIN_VALUE를 초깃값으로 사용해야 합니다. - 미완료 결과를 사용하면 안 됩니다. false는 join이 정상 완료 경계에 도달하지 못했다는 뜻입니다. 복원한 플래그는 main에게 중단 요청이 있었다는 정보입니다. 작업자의 게이트·자원 정리와 종료 회수, 실패 결과 전달을 별도 정책으로 정해야 하며, interrupt를 요청했다는 사실만으로 종료했다고 판정할 수 없습니다.