join과 완료 가시성
작업자 결과를 너무 일찍 읽는 실패를 재현하고 `join`, `this`, `sleep` 대기의 의미를 구분해 완료를 조정합니다.
스레드를 시작하는 것과 그 결과를 사용할 수 있는 시점은 다릅니다.
join()은 대상 Thread가 종료될 때까지 현재 스레드를 기다리게 합니다.
단순한 시간 지연과 달리 실제 완료 사건을 기준으로 하며, join이 정상 반환한 뒤에는 대상이 기록한 값도 보인다는 메모리 consistency 효과가 있습니다.
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인 원인은 단지 CPU가 느려서가 아니라 기능 규칙상 작업자 완료를 기다리지 않았기 때문입니다.
필드에 volatile을 붙여도 언젠가 기록될 값이 이미 기록되게 만들 수는 없습니다.
순서 문제와 가시성 문제를 분리해야 합니다.
join 호출자와 대상 상태
worker.join()을 호출하면 현재 스레드가 작업자 종료를 기다립니다.
인자가 없는 join은 시간 제한이 없고 interrupt가 오면 InterruptedException을 던집니다.
대상 스레드를 정지시키거나 CPU를 빼앗는 API가 아닙니다.
여러 작업자를 차례로 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;
}
}총합은 4,501,500입니다.
각 배열 슬롯에는 기록기가 하나뿐이고 main은 join 후 읽습니다.
결과 집계를 위해 배열 자체를 synchronized 컬렉션으로 바꿀 필요가 없습니다.
Thread 하위 타입의 this
Thread를 상속한 run()에서 this는 현재 실행 중인 Thread 하위 객체입니다.
그래서 this.getName()과 Thread.currentThread().getName()은 정상 start 경로에서 같은 값을 냅니다.
하지만 Runnable 인스턴스의 this는 Runnable 객체이고 current 스레드와 다른 식별 정보입니다.
익명 Runnable 안의 this도 익명 작업 객체입니다.
람다의 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 이후입니다.
작업자 내부 예외가 발생하면 배열 칸은 null로 남을 수 있으므로 실제 빌드 시스템은 예외 결과도 수집해야 합니다.
join 중 interrupt 보존
join은 검사된 InterruptedException을 던집니다.
main이 무조건 계속 기다려야 하는지, 대상도 interrupt할지, 현재 스레드 플래그를 복원해 상위로 알릴지 정책이 필요합니다.
라이브러리 메서드가 예외를 삼키면 shutdown이 지연됩니다.
아래 도우미는 대기 중 interrupt를 받으면 작업자에도 중단을 요청하고 현재 플래그를 복원한 뒤 false를 반환합니다.
호출자는 incomplete 결과를 사용하지 않습니다.
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이면 결과를 폐기하거나 보상 작업으로 넘깁니다.
연습 문제
서로 다른 int 배열 세 개의 최대값을 각 작업자가 구해 별도 슬롯에 기록하게 하세요.
main은 모든 작업자를 먼저 시작하고 모두 join한 뒤 전체 최대값을 출력해야 합니다.
정답과 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);
}
}출력은 15입니다.
대상 스레드의 종료 이전 동작은 성공한 join 반환 이후 동작보다 먼저 일어난 것으로 관찰됩니다.
join의 선택 기준은 “시간이 충분히 지났는가”가 아니라 “이 Thread의 종료가 다음 단계의 전제인가”입니다.
여러 작업을 먼저 시작하고 완료를 회수하며 interrupt와 작업자 실패를 별도 결과로 다뤄야 합니다.