wait·notify와 모니터
Object 대기 집합에 들어가기 위한 선행 조건, 락 반납, 신호 뒤 재경쟁을 실행으로 확인하고 단일 슬롯 버퍼를 완성합니다.
Object.wait()는 단순히 스레드를 멈추는 메서드가 아닙니다.
호출 대상 객체의 모니터를 현재 스레드가 소유하고 있을 때만 사용할 수 있고, 대기 진입과 동시에 그 모니터를 반납합니다.
notify()도 같은 모니터를 소유한 코드에서 호출해야 합니다.
이 규칙 덕분에 조건 검사, 대기 등록, 상태 변경, 알림이 하나의 동기화 규약으로 묶입니다.
신호를 받은 스레드는 곧바로 임계 영역을 실행하지 않습니다.
객체의 대기 집합에서 나와 모니터 획득 경쟁에 참여하며, 알림을 보낸 스레드가 synchronized 블록을 끝낸 뒤에야 다시 진입할 수 있습니다.
그러므로 로그에서 notify 다음 줄에 대기자가 즉시 등장하지 않아도 오류가 아닙니다.
락 소유권의 이동을 기준으로 순서를 해석해야 합니다.
모니터를 소유하지 않은 호출의 즉시 실패
아래 코드는 gate를 잠그지 않고 wait를 부릅니다.
컴파일은 정상적으로 되지만 실행 시 IllegalMonitorStateException이 발생합니다.
검사된 예외인 InterruptedException만 처리하면 충분하다고 생각하기 쉬우나, 대기 프로토콜의 선행 조건 위반은 런타임에서 별도로 드러납니다.
public final class WaitWithoutOwnership {
public static void main(String[] args) {
Object gate = new Object();
try {
gate.wait(10);
System.out.println("unreachable");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}실패를 없애려고 메서드 전체에 무작정 synchronized를 붙이는 것도 답은 아닙니다.
어떤 공유 상태의 조건을 기다리는지, 그 상태를 어떤 객체의 모니터가 보호하는지 먼저 정해야 합니다.
큐 크기를 this 모니터로 지킨다면 크기 검사와 this.wait()가 같은 임계 영역 안에 있어야 하며, 생산자가 값을 추가한 뒤 호출하는 알림도 this.notifyAll()이어야 합니다.
대기 집합 진입과 복귀의 다섯 단계
소비자가 빈 단일 슬롯을 만났다고 가정합니다.
먼저 모니터를 가진 상태로 조건을 검사합니다.
wait 호출은 소비자를 해당 객체의 대기 집합에 등록하고 모니터를 놓습니다.
생산자가 모니터를 얻어 값을 저장한 뒤 알림을 보냅니다.
소비자는 대기 집합을 벗어나지만 생산자가 잠금을 반납할 때까지 다시 경쟁합니다.
마지막으로 모니터를 획득한 뒤 조건을 재검사하고 값을 가져갑니다.
- 대기 조건은 반드시 모니터가 보호하는 상태만 읽어야 관찰과 등록 사이 신호 손실을 막습니다.
wait가 반환되었다는 사실은 상태가 원하는 값이라는 보장이 아니므로 조건식을 다시 평가합니다.- 알림은 상태를 먼저 변경한 뒤 보내야 깨어난 주체가 새 상태를 볼 수 있습니다.
notify호출자는 잠금을 유지한 채 나머지 임계 작업을 마치고 블록 끝에서 소유권을 반납합니다.- 인터럽트를 받은 대기자는
InterruptedException으로 복귀하며 호출 계층의 취소 정책을 따라야 합니다. - 공개 API에
InterruptedException을 선언하면 호출자가 중단과 정상 결과를 혼동하지 않고 선택할 수 있습니다.
대기 집합과 락 대기열은 구별해야 합니다.
wait 중인 스레드는 조건 알림을 기다리고, synchronized 입구에서 막힌 스레드는 모니터 소유권을 기다립니다.
알림을 받은 소비자는 첫 번째 대기에서 빠져나온 뒤 두 번째 경쟁을 거칩니다.
상태 전이를 한 줄 로그로 압축하지 말고 두 관문을 따로 기록하면 디버깅이 쉬워집니다.
한 칸 우편함으로 완성하는 모니터 대기
단일 슬롯은 조건을 가장 명확하게 드러냅니다.
message == null이면 생산 가능하고, 값이 있으면 소비 가능합니다.
두 메서드는 같은 모니터로 상태를 보호합니다.
대기는 while로 감싸고, 변경 직후 notifyAll을 호출합니다.
이 구현은 null 메시지를 금지하여 빈 슬롯 표현과 데이터 표현이 겹치지 않게 합니다.
import java.util.Objects;
public final class MonitorMailbox<E> {
private E message;
public synchronized void send(E value) throws InterruptedException {
Objects.requireNonNull(value, "value");
while (message != null) {
wait();
}
message = value;
notifyAll();
}
public synchronized E receive() throws InterruptedException {
while (message == null) {
wait();
}
E result = message;
message = null;
notifyAll();
return result;
}
public synchronized boolean isEmpty() {
return message == null;
}
public static void main(String[] args) throws InterruptedException {
MonitorMailbox<String> mailbox = new MonitorMailbox<>();
Thread receiver = Thread.ofPlatform().name("receiver").start(() -> {
try {
System.out.println("received=" + mailbox.receive());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread sender = Thread.ofPlatform().name("sender").start(() -> {
try {
mailbox.send("chapter-20");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
sender.join();
receiver.join();
System.out.println("empty=" + mailbox.isEmpty());
}
}소비자를 먼저 시작해도 receive는 모니터를 반납하고 잠듭니다.
생산자는 같은 모니터에 들어와 메시지를 저장하고 모두에게 상태 변화를 알립니다.
최종 출력은 received=chapter-20과 empty=true이며 줄 순서는 구현 환경에 따라 달라질 수 있습니다.
중요한 회귀 조건은 두 스레드가 끝나고 슬롯이 비었다는 사실입니다.
취소를 정상 데이터로 위장하지 않는 이유
대기 중 인터럽트를 잡아 null을 반환하면 호출자는 실제 빈 결과와 취소를 구별하지 못합니다.
다음 서비스는 InterruptedException을 상위로 전달하고, 작업 스레드의 가장 바깥 접점에서 인터럽트 상태를 복구합니다.
성공 목록과 취소 여부를 별도로 기록하여 처리된 메시지 수를 검사할 수 있습니다.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
public final class InterruptibleMailboxWorker {
public static void main(String[] args) throws InterruptedException {
MonitorMailbox<String> mailbox = new MonitorMailbox<>();
List<String> handled = new ArrayList<>();
CountDownLatch waiting = new CountDownLatch(1);
Thread worker = Thread.ofPlatform().name("mail-worker").start(() -> {
try {
waiting.countDown();
handled.add(mailbox.receive());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
waiting.await();
while (worker.getState() != Thread.State.WAITING) {
Thread.onSpinWait();
}
worker.interrupt();
worker.join();
System.out.println("handled=" + handled.size());
System.out.println("interrupted=" + worker.isInterrupted());
}
}기대값은 처리 0건과 인터럽트 상태 true입니다.
CountDownLatch는 작업이 시작되기 전에 인터럽트를 보내는 우연을 줄이고, 상태 확인은 실습에서 대기 진입을 관찰하기 위한 장치입니다.
운영 코드가 스레드 상태를 바쁜 반복으로 기다리면 안 되며, 종료 프로토콜에는 별도의 시작·종료 신호나 구조화된 실행기를 사용합니다.
모니터 대기를 적용할 수 있는 경계
| 설계 질문 | 확인 대상 | 결정 |
|---|---|---|
| 조건 상태의 소유자는 누구인가 | 필드와 잠금 객체 | 한 모니터로 일치시킴 |
| 취소가 호출자에게 중요한가 | API 예외 규칙 | InterruptedException 전파 |
| 조건 종류가 하나뿐인가 | 생산 가능·소비 가능 수 | 다수면 Condition 검토 |
| 알림 비용을 감당할 수 있는가 | 대기자 수와 재대기 횟수 | notifyAll 우선 안전성 확보 |
저수준 모니터는 규칙을 이해하는 데 유용하지만 공개 라이브러리를 새로 만들 때 기본 선택은 아닙니다.
이미 검증된 BlockingQueue가 용량, 시간 제한, 인터럽트 처리를 제공합니다.
직접 구현은 상태 조건이 큐 표준 연산으로 표현되지 않거나 학습 목적일 때만 유지 비용을 감수할 근거가 생깁니다.
연습 문제
두 스레드가 한 슬롯을 공유합니다. 클라이언트는 요청을 보내고 같은 번호의 응답을 받을 때까지 기다리며, 서버는 요청을 받은 뒤 응답을 기록합니다. 요청을 연속 두 번 덮어쓰지 못하고 응답을 소비하기 전에 다음 라운드가 시작되지 않도록 모니터 조건을 작성하세요.
정답과 해설
상태를 EMPTY, REQUEST_READY, RESPONSE_READY 세 단계로 나누면 조건이 명확해집니다.
각 메서드는 자신의 선행 상태가 될 때까지 while로 기다린 뒤 상태와 값을 함께 바꿉니다.
전이가 끝날 때 notifyAll을 보내 다른 역할이 새 단계를 확인하게 합니다.
public final class RequestResponseHandshakeSolution {
private enum Phase { EMPTY, REQUEST_READY, RESPONSE_READY }
private Phase phase = Phase.EMPTY;
private int payload;
synchronized void request(int value) throws InterruptedException {
while (phase != Phase.EMPTY) wait();
payload = value;
phase = Phase.REQUEST_READY;
notifyAll();
}
synchronized int awaitRequest() throws InterruptedException {
while (phase != Phase.REQUEST_READY) wait();
return payload;
}
synchronized void respond(int value) {
if (phase != Phase.REQUEST_READY) throw new IllegalStateException("phase=" + phase);
payload = value;
phase = Phase.RESPONSE_READY;
notifyAll();
}
synchronized int awaitResponse() throws InterruptedException {
while (phase != Phase.RESPONSE_READY) wait();
int response = payload;
phase = Phase.EMPTY;
notifyAll();
return response;
}
public static void main(String[] args) throws InterruptedException {
RequestResponseHandshakeSolution channel = new RequestResponseHandshakeSolution();
Thread server = Thread.ofPlatform().start(() -> {
try {
int request = channel.awaitRequest();
channel.respond(request * 2);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
channel.request(21);
System.out.println("response=" + channel.awaitResponse());
server.join();
}
}결과는 response=42입니다.
서버가 요청을 읽었지만 아직 응답을 쓰지 않은 동안 단계는 그대로 유지됩니다.
더 많은 클라이언트를 지원하려면 요청별 식별자와 큐가 필요하므로 이 한 슬롯 프로토콜을 무리하게 확장하지 말고 적절한 동시성 자료구조로 모델을 바꿔야 합니다.
모니터 대기에서 기억할 경계선
wait는 락을 가진 채 호출하고, 잠드는 순간 그 락을 반납합니다.
notifyAll은 조건을 바꾼 뒤 같은 락 안에서 호출하며, 깨운 스레드는 알림 직후가 아니라 모니터를 다시 얻은 뒤 실행합니다.
이 세 문장을 실제 상태 전이로 설명할 수 있으면 모니터 프로토콜의 뼈대가 잡힌 것입니다.
정상 데이터, 빈 상태, 취소는 서로 다른 결과입니다.
하나를 null로 뭉개면 호출 계층이 올바른 복구를 선택할 수 없습니다.
저수준 대기 코드는 상태 모델과 예외 규칙이 분명할 때만 작게 유지해야 합니다.