가시성·원자성·임계 영역
volatile 가시성, 복합 연산 경합, 모니터 락, 최소 임계 영역과 스레드 지역 상태를 하나의 실행 모델로 정리합니다.
여러 스레드가 같은 객체를 읽고 쓸 때는 질문을 셋으로 나눠야 합니다.
최신 write가 보이는가라는 가시성, 여러 단계가 쪼개지지 않는가라는 원자성, 실행 순서가 정해지는가라는 순서입니다.
volatile과 synchronized는 보장 범위가 다르므로 증상 하나에 키워드를 임의로 붙이지 않습니다.
증가 연산 경쟁과 값 손실
count++는 read, add, write의 세 단계입니다.
아래 프로그램은 배리어로 두 작업자가 같은 값을 읽은 뒤 각각 같은 새 값을 쓰게 만듭니다.
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
public final class CoordinatedLostUpdate {
private static final class Counter {
int value;
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
CyclicBarrier readTogether = new CyclicBarrier(2), writeTogether = new CyclicBarrier(2);
Runnable increment =
() -> {
for (int i = 0; i < 100; i++) {
int observed = counter.value;
await(readTogether);
counter.value = observed + 1;
await(writeTogether);
}
};
Thread left = new Thread(increment, "lost-left"),
right = new Thread(increment, "lost-right");
left.start();
right.start();
left.join();
right.join();
System.out.println("expected=200, actual=" + counter.value);
}
private static void await(CyclicBarrier barrier) {
try {
barrier.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
} catch (BrokenBarrierException e) {
throw new IllegalStateException(e);
}
}
}두 작업자가 같은 값을 쓰면 한 회차에 1씩 증가한다
읽기 배리어로 둘 다 n을 읽고 각 n+1을 써 한 번만 증가하는 재현입니다. 작업자 상대 순서는 고정하지 않고 배리어·공유값을 단계로 추적합니다.
CoordinatedLostUpdate의 두 배리어가 한 회차의 읽기와 쓰기를 나눠 같은 값으로 덮어쓰는 과정을 고정합니다.
갱신 유실 · 한 회차
두 작업자가 같은 값을 쓰면 한 회차에 1씩 증가한다
두 작업자가 같은 n을 읽고 같은 n+1을 씁니다. 두 배리어가 다음 회차까지의 경계를 고정합니다.
읽기 배리어 전
- 두 작업자의 동작
- 각자
observed에 같은 값 n을 읽고readTogether에서 기다립니다. - 공유 값
- 두 읽기가 끝나기 전에는 이 회차의 쓰기가 시작되지 않습니다.
읽기 배리어 통과 후
- 두 작업자의 동작
- 각자 n + 1을
value에 씁니다. - 공유 값
- 쓰기 순서와 무관하게 같은 값 n + 1로 덮어씁니다.
쓰기 배리어 통과 후
- 두 작업자의 동작
writeTogether가 두 쓰기의 완료를 모읍니다.- 공유 값
- 다음 회차는 n + 1에서 시작합니다.
초기값 0에서 100회가 정상 완료되면 200번의 증가 요청 뒤 실제 값은 100입니다.
이 예의 조건
- 작업자 사이 실행 순서는 고정하지 않고 두 배리어의 완료만 연결합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 두 읽기 완료를 모은 뒤 두 작업자가 같은 n+1을 쓰고 두 쓰기 완료를 모읍니다. 작업자 사이 사슬이 아니라 두 배리어의 완료 조건을 화살표로 연결합니다. 100회 정상 완료는 실제 100입니다. |
| 16 | n+1 |
| 17 | 요청 둘·증가 하나 |
| 19 | 0에서 100회 정상 완료하면 100 · 200개 증가 요청과 100의 상태 증가를 구별 |
| 구분 | 내용 |
|---|---|
| 설명 | 두 읽기 완료를 모은 뒤 두 작업자가 같은 n+1을 쓰고 두 쓰기 완료를 모읍니다. 작업자 사이 사슬이 아니라 두 배리어의 완료 조건을 화살표로 연결합니다. 100회 정상 완료는 실제 100입니다. |
| 전체 읽기 정보 | 두 작업자가 같은 값을 쓰면 한 회차에 1씩 증가한다 두 작업자가 같은 값을 쓰면 한 회차에 1씩 증가한다 CoordinatedLostUpdate의 두 배리어가 한 회차의 읽기와 쓰기를 나눠 같은 값으로 덮어쓰는 과정을 고정합니다. 갱신 유실 · 한 회차 두 작업자가 같은 값을 쓰면 한 회차에 1씩 증가한다 두 작업자가 같은 n을 읽고 같은 n+1을 씁니다. 두 배리어가 다음 회차까지의 경계를 고정합니다. 읽기 배리어 전 두 작업자의 동작 각자 observed 에 같은 값 n을 읽고 readTogether 에서 기다립니다. 공유 값 두 읽기가 끝나기 전에는 이 회차의 쓰기가 시작되지 않습니다. 읽기 배리어 통과 후 두 작업자의 동작 각자 n + 1을 value 에 씁니다. 공유 값 쓰기 순서와 무관하게 같은 값 n + 1로 덮어씁니다. 쓰기 배리어 통과 후 두 작업자의 동작 writeTogether 가 두 쓰기의 완료를 모읍니다. 공유 값 다음 회차는 n + 1에서 시작합니다. 초깃값 0에서 100회가 정상 완료되면 200번의 증가 요청 뒤 실제 값은 100입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 같은 값을 읽음 left와 right가 각각 n을 읽고 읽기 배리어에 도달합니다. |
|
|
| 같은 값을 씀 각 작업자가 자기 관측값에 1을 더해 씁니다. |
|
|
| 다음 회차 쓰기 배리어 뒤 공유 값은 n+1입니다. |
|
|
두 작업자가 같은 값을 쓰면 한 회차에 1씩 증가한다
읽기 배리어로 둘 다 n을 읽고 각 n+1을 써 한 번만 증가하는 재현입니다. 작업자 상대 순서는 고정하지 않고 배리어·공유값을 단계로 추적합니다.
CoordinatedLostUpdate의 두 배리어가 한 회차의 읽기와 쓰기를 나눠 같은 값으로 덮어쓰는 과정을 고정합니다.
갱신 유실 · 한 회차
두 작업자가 같은 값을 쓰면 한 회차에 1씩 증가한다
두 작업자가 같은 n을 읽고 같은 n+1을 씁니다. 두 배리어가 다음 회차까지의 경계를 고정합니다.
읽기 배리어 전
- 두 작업자의 동작
- 각자
observed에 같은 값 n을 읽고readTogether에서 기다립니다. - 공유 값
- 두 읽기가 끝나기 전에는 이 회차의 쓰기가 시작되지 않습니다.
읽기 배리어 통과 후
- 두 작업자의 동작
- 각자 n + 1을
value에 씁니다. - 공유 값
- 쓰기 순서와 무관하게 같은 값 n + 1로 덮어씁니다.
쓰기 배리어 통과 후
- 두 작업자의 동작
writeTogether가 두 쓰기의 완료를 모읍니다.- 공유 값
- 다음 회차는 n + 1에서 시작합니다.
초기값 0에서 100회가 정상 완료되면 200번의 증가 요청 뒤 실제 값은 100입니다.
이 예의 조건
- 작업자 사이 실행 순서는 고정하지 않고 두 배리어의 완료만 연결합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 두 읽기 완료를 모은 뒤 두 작업자가 같은 n+1을 쓰고 두 쓰기 완료를 모읍니다. 작업자 사이 사슬이 아니라 두 배리어의 완료 조건을 화살표로 연결합니다. 100회 정상 완료는 실제 100입니다. |
| 16 | n+1 |
| 17 | 요청 둘·증가 하나 |
| 19 | 0에서 100회 정상 완료하면 100 · 200개 증가 요청과 100의 상태 증가를 구별 |
| 구분 | 내용 |
|---|---|
| 설명 | 두 읽기 완료를 모은 뒤 두 작업자가 같은 n+1을 쓰고 두 쓰기 완료를 모읍니다. 작업자 사이 사슬이 아니라 두 배리어의 완료 조건을 화살표로 연결합니다. 100회 정상 완료는 실제 100입니다. |
| 전체 읽기 정보 | 두 작업자가 같은 값을 쓰면 한 회차에 1씩 증가한다 두 작업자가 같은 값을 쓰면 한 회차에 1씩 증가한다 CoordinatedLostUpdate의 두 배리어가 한 회차의 읽기와 쓰기를 나눠 같은 값으로 덮어쓰는 과정을 고정합니다. 갱신 유실 · 한 회차 두 작업자가 같은 값을 쓰면 한 회차에 1씩 증가한다 두 작업자가 같은 n을 읽고 같은 n+1을 씁니다. 두 배리어가 다음 회차까지의 경계를 고정합니다. 읽기 배리어 전 두 작업자의 동작 각자 observed 에 같은 값 n을 읽고 readTogether 에서 기다립니다. 공유 값 두 읽기가 끝나기 전에는 이 회차의 쓰기가 시작되지 않습니다. 읽기 배리어 통과 후 두 작업자의 동작 각자 n + 1을 value 에 씁니다. 공유 값 쓰기 순서와 무관하게 같은 값 n + 1로 덮어씁니다. 쓰기 배리어 통과 후 두 작업자의 동작 writeTogether 가 두 쓰기의 완료를 모읍니다. 공유 값 다음 회차는 n + 1에서 시작합니다. 초깃값 0에서 100회가 정상 완료되면 200번의 증가 요청 뒤 실제 값은 100입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 같은 값을 읽음 left와 right가 각각 n을 읽고 읽기 배리어에 도달합니다. |
|
|
| 같은 값을 씀 각 작업자가 자기 관측값에 1을 더해 씁니다. |
|
|
| 다음 회차 쓰기 배리어 뒤 공유 값은 n+1입니다. |
|
|
배리어는 실패를 재현하기 위한 장치이고 해결책은 아닙니다.
한 회차에서 left와 right가 모두 0을 읽으면 각 observed는 0입니다. 둘이 각각 1을 쓰고 쓰기 배리어를 통과해도 공유 값은 1입니다. 다음 회차는 둘 다 1을 읽고 2를 쓰므로 정상 완료한 100회 뒤 실제 값은 100입니다. 읽기와 쓰기의 순서를 이렇게 고정했기 때문에 원 실험의 값 손실은 막연한 “운 나쁜 출력”이 아닙니다. 증가 요청 200개와 저장된 증가 100개를 따로 세세요.
volatile int value로 바꿔도 각 읽기와 쓰기가 보일 뿐 read-modify-write 전체가 하나가 되지 않아 손실 갱신은 남습니다.
이 장에서는 증가 전체를 같은 synchronized 블록에 묶습니다.
실험용 배리어까지 락 안에 옮기지는 않습니다. 두 배리어를 유지한 채 읽기부터 쓰기까지 synchronized(counter)로 감싸면 먼저 락을 잡은 작업자가 readTogether.await()에서 두 번째 참가자를 기다립니다. 다른 작업자는 같은 락을 얻지 못해 그 배리어에 도달할 수 없습니다. 증가를 보호하는 해결 코드는 재현용 배리어를 제거하고 읽기·더하기·쓰기를 하나의 임계 영역으로 묶습니다. 대기 조건이 다른 스레드의 진입을 요구하는지도 락 범위와 함께 확인하세요.
단일 값 전용 원자 연산은 ch21에서 배웁니다.
가시성과 최신 값 관찰
JVM과 CPU는 단일 스레드 의미를 지키는 범위에서 레지스터, 캐시, 명령어 재정렬을 활용할 수 있습니다.
동기화가 없는 데이터 경합에서는 한 스레드가 runFlag=false로 바꿔도 다른 스레드의 반복문이 그 write를 언제 관찰할지 보장되지 않습니다.
sleep이나 콘솔 출력 뒤 우연히 보이는 결과는 메모리 가시성 규칙이 아닙니다.
volatile 필드의 write와 뒤따르는 read 사이에는 happens-before 관계가 생깁니다.
stop 플래그, 최신 구성 스냅샷처럼 하나의 값을 publish하고 읽는 패턴에 적합합니다.
volatile read/write 자체는 보이지만 여러 필드를 함께 갱신하는 트랜잭션을 자동으로 만들지 않습니다.
import java.util.concurrent.CountDownLatch;
public final class VolatileStopSignal {
private static final class Task implements Runnable {
volatile boolean running = true;
long iterations;
private final CountDownLatch started;
Task(CountDownLatch started) {
this.started = started;
}
public void run() {
started.countDown();
long local = 0;
while (running) {
local++;
}
iterations = local;
}
}
public static void main(String[] args) throws InterruptedException {
CountDownLatch started = new CountDownLatch(1);
Task task = new Task(started);
Thread worker = new Thread(task, "volatile-loop");
worker.start();
started.await();
task.running = false;
worker.join();
System.out.println("stopped=" + !worker.isAlive() + ", worked=" + (task.iterations > 0));
}
}중지 요청과 최종 결과는 서로 다른 경계로 공개된다
volatile 중지 공개와 worker 최종 iterations 기록, main join 뒤 읽기가 서로 다른 경계입니다. 요청·반복 종료·최종 결과 회수를 나누면 가시성 의미가 분명합니다.
VolatileStopSignal에서 running은 volatile로, iterations는 작업자 종료를 기다리는 join으로 공개됩니다.
공개 경계 · volatile / join
중지 요청과 최종 결과는 서로 다른 경계로 공개된다
중지 요청은 volatile로 전달합니다. 작업자의 최종 횟수는 종료를 회수한 join 뒤 읽습니다.
running
- 원문의 쓰기와 읽기
main이false를 쓰고 작업자가 반복 조건에서 읽습니다.- 공개 경계
- 같은 volatile 필드의 쓰기와 뒤따르는 읽기
iterations
- 원문의 쓰기와 읽기
- 작업자가 종료 직전에 쓰고
main이 읽습니다. - 공개 경계
- 성공한
join뒤의 읽기이므로 이 필드는 volatile일 필요가 없습니다.
started 신호는 첫 증가보다 먼저입니다. 따라서 완료 후 stopped=true여도 worked는 false 또는 true일 수 있으며 반복 횟수는 고정되지 않습니다.
이 예의 조건
- started 신호가 첫 증가보다 앞서므로 worked와 반복 횟수는 고정되지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | running은 volatile 조건 전달, iterations는 종료한 worker의 마지막 기록을 정상 join 뒤 읽는 공개 경로입니다. started 신호는 첫 증가보다 먼저여서 worked는 가변입니다. |
| 17 | started 신호는 첫 증가보다 먼저 |
| 18 | 순회 횟수나 종료 시각은 고정 아님 |
| 구분 | 내용 |
|---|---|
| 설명 | running은 volatile 조건 전달, iterations는 종료한 worker의 마지막 기록을 정상 join 뒤 읽는 공개 경로입니다. started 신호는 첫 증가보다 먼저여서 worked는 가변입니다. |
| 전체 읽기 정보 | 중지 요청과 최종 결과는 서로 다른 경계로 공개된다 중지 요청과 최종 결과는 서로 다른 경계로 공개된다 VolatileStopSignal에서 running은 volatile로, iterations는 작업자 종료를 기다리는 join으로 공개됩니다. 공개 경계 · volatile / join 중지 요청과 최종 결과는 서로 다른 경계로 공개된다 중지 요청은 volatile로 전달합니다. 작업자의 최종 횟수는 종료를 회수한 join 뒤 읽습니다. running 원문의 쓰기와 읽기 main 이 false 를 쓰고 작업자가 반복 조건에서 읽습니다. 공개 경계 같은 volatile 필드의 쓰기와 뒤따르는 읽기 iterations 원문의 쓰기와 읽기 작업자가 종료 직전에 쓰고 main 이 읽습니다. 공개 경계 성공한 join 뒤의 읽기이므로 이 필드는 volatile일 필요가 없습니다. started 신호는 첫 증가보다 먼저입니다. 따라서 완료 후 stopped=true 여도 worked 는 false 또는 true일 수 있으며 반복 횟수는 고정되지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 중지 요청 main이 volatile running=false를 씁니다. |
|
|
| 마지막 기록 worker가 반복에서 나와 local을 iterations에 기록합니다. |
|
|
| 종료 뒤 읽기 main은 정상 join 뒤 iterations를 조회합니다. |
|
|
중지 요청과 최종 결과는 서로 다른 경계로 공개된다
volatile 중지 공개와 worker 최종 iterations 기록, main join 뒤 읽기가 서로 다른 경계입니다. 요청·반복 종료·최종 결과 회수를 나누면 가시성 의미가 분명합니다.
VolatileStopSignal에서 running은 volatile로, iterations는 작업자 종료를 기다리는 join으로 공개됩니다.
공개 경계 · volatile / join
중지 요청과 최종 결과는 서로 다른 경계로 공개된다
중지 요청은 volatile로 전달합니다. 작업자의 최종 횟수는 종료를 회수한 join 뒤 읽습니다.
running
- 원문의 쓰기와 읽기
main이false를 쓰고 작업자가 반복 조건에서 읽습니다.- 공개 경계
- 같은 volatile 필드의 쓰기와 뒤따르는 읽기
iterations
- 원문의 쓰기와 읽기
- 작업자가 종료 직전에 쓰고
main이 읽습니다. - 공개 경계
- 성공한
join뒤의 읽기이므로 이 필드는 volatile일 필요가 없습니다.
started 신호는 첫 증가보다 먼저입니다. 따라서 완료 후 stopped=true여도 worked는 false 또는 true일 수 있으며 반복 횟수는 고정되지 않습니다.
이 예의 조건
- started 신호가 첫 증가보다 앞서므로 worked와 반복 횟수는 고정되지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | running은 volatile 조건 전달, iterations는 종료한 worker의 마지막 기록을 정상 join 뒤 읽는 공개 경로입니다. started 신호는 첫 증가보다 먼저여서 worked는 가변입니다. |
| 17 | started 신호는 첫 증가보다 먼저 |
| 18 | 순회 횟수나 종료 시각은 고정 아님 |
| 구분 | 내용 |
|---|---|
| 설명 | running은 volatile 조건 전달, iterations는 종료한 worker의 마지막 기록을 정상 join 뒤 읽는 공개 경로입니다. started 신호는 첫 증가보다 먼저여서 worked는 가변입니다. |
| 전체 읽기 정보 | 중지 요청과 최종 결과는 서로 다른 경계로 공개된다 중지 요청과 최종 결과는 서로 다른 경계로 공개된다 VolatileStopSignal에서 running은 volatile로, iterations는 작업자 종료를 기다리는 join으로 공개됩니다. 공개 경계 · volatile / join 중지 요청과 최종 결과는 서로 다른 경계로 공개된다 중지 요청은 volatile로 전달합니다. 작업자의 최종 횟수는 종료를 회수한 join 뒤 읽습니다. running 원문의 쓰기와 읽기 main 이 false 를 쓰고 작업자가 반복 조건에서 읽습니다. 공개 경계 같은 volatile 필드의 쓰기와 뒤따르는 읽기 iterations 원문의 쓰기와 읽기 작업자가 종료 직전에 쓰고 main 이 읽습니다. 공개 경계 성공한 join 뒤의 읽기이므로 이 필드는 volatile일 필요가 없습니다. started 신호는 첫 증가보다 먼저입니다. 따라서 완료 후 stopped=true 여도 worked 는 false 또는 true일 수 있으며 반복 횟수는 고정되지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 중지 요청 main이 volatile running=false를 씁니다. |
|
|
| 마지막 기록 worker가 반복에서 나와 local을 iterations에 기록합니다. |
|
|
| 종료 뒤 읽기 main은 정상 join 뒤 iterations를 조회합니다. |
|
|
실행 시작 신호가 한 번의 작업 완료를 뜻하지는 않습니다. started.countDown()은 while의 첫 조건 검사보다 앞입니다. 그 사이 main이 running=false를 쓰면 작업자는 반복을 한 번도 실행하지 않아 worked=false가 될 수 있고, 정상 join 뒤 stopped=true와 모순되지 않습니다. 중지 조건은 volatile로 worker에 전달하지만, plain 필드 iterations의 최종 기록은 worker의 종료를 확인한 join으로 main에 공개합니다. 첫 신호만 기다리거나 running을 읽는 것만으로 그 이후 최종 기록까지 확보한 것은 아닙니다.
happens-before 규칙
“volatile은 항상 주 메모리를 직접 읽는다”는 설명은 입문용 단순화입니다.
실제 구현에는 메모리 배리어와 JIT 최적화가 있으며 Java Memory Model의 보장이 기준입니다.
코드를 판단할 때 특정 CPU 캐시 반영 시점을 추측하지 말고 happens-before 간선이 있는지 확인합니다.
대표 간선은 한 스레드 안의 프로그램 순서, volatile 쓰기 뒤 같은 변수 읽기, 모니터 해제 뒤 같은 모니터 획득, Thread.start 이전 동작과 새 스레드 동작, 스레드 동작과 성공한 join 이후 동작입니다.
ReentrantLock의 해제와 다음 획득도 가시성을 제공합니다.
final 필드는 생성자가 끝난 뒤 객체가 안전하게 publish될 때 초깃값 가시성에 강한 규칙을 가집니다.
하지만 final 참조가 가리키는 가변 컬렉션의 내부 변경까지 불변으로 만들지는 않습니다.
List.copyOf 같은 소유권 결정이 추가로 필요합니다.
출금 확인과 변경의 임계 영역
잔액 1000에서 두 스레드가 각각 800 출금을 시도할 때 둘 다 확인을 통과한 뒤 차감하면 음수 잔액 또는 손실 갱신이 생깁니다.
불변식은 “잔액이 amount 이상이면 확인과 차감을 연속으로 수행한다”입니다.
이 전체 구간에 같은 모니터 락이 필요합니다.
public final class SynchronizedBankAccount {
private int balance;
public SynchronizedBankAccount(int initial) {
if (initial < 0) throw new IllegalArgumentException("initial");
balance = initial;
}
public synchronized boolean withdraw(int amount) {
if (amount <= 0) throw new IllegalArgumentException("amount");
if (balance < amount) return false;
balance -= amount;
return true;
}
public synchronized int balance() {
return balance;
}
public static void main(String[] args) throws InterruptedException {
SynchronizedBankAccount account = new SynchronizedBankAccount(1000);
boolean[] result = new boolean[2];
Thread first = new Thread(() -> result[0] = account.withdraw(800), "withdraw-a");
Thread second = new Thread(() -> result[1] = account.withdraw(800), "withdraw-b");
first.start();
second.start();
first.join();
second.join();
System.out.println(
"successes="
+ ((result[0] ? 1 : 0) + (result[1] ? 1 : 0))
+ ", balance="
+ account.balance());
}
}어느 스레드가 성공할지는 정하지 않지만 successes=1, balance=200은 고정입니다.
synchronized 인스턴스 메서드는 this 모니터를 얻습니다.
서로 다른 계정 인스턴스는 모니터도 달라 독립 출금이 병렬로 진행될 수 있습니다.
모니터 락과 BLOCKED 상태 연결
모든 Java 객체는 모니터와 연결될 수 있습니다.
스레드가 synchronized 메서드 또는 블록에 들어갈 때 해당 모니터 소유권을 얻습니다.
이미 다른 스레드가 소유하면 BLOCKED이고 소유자가 unlock할 때 다시 경쟁합니다.
대기 순서는 공정하다고 보장되지 않습니다.
같은 스레드가 이미 가진 모니터를 다시 획득할 수 있어 synchronized는 재진입을 허용합니다.
중첩 호출이 같은 객체의 synchronized 메서드를 다시 호출해도 자기 교착 상태가 생기지 않습니다.
획득 횟수만큼 메서드·블록을 빠져나오면 완전히 반납됩니다.
static synchronized 메서드는 인스턴스가 아니라 Class 객체의 모니터를 사용합니다.
인스턴스 메서드 락과 서로 막지 않으므로 같은 static 필드를 보호하려면 락 식별 정보를 통일해야 합니다.
synchronized(new Object())처럼 매번 새 락을 만들면 스레드가 서로 다른 모니터를 얻어 보호가 사라집니다.
임계 영역은 불변식을 보존하는 최소 범위
락 범위를 한 줄로 줄이는 것이 항상 정답은 아닙니다.
확인과 변경을 갈라 놓으면 불변식이 깨집니다.
반대로 네트워크 I/O와 긴 파싱까지 락 안에 넣으면 다른 스레드가 불필요하게 BLOCKED됩니다.
Ledger의 불변식은 한 제목의 등록과 그 제목에 해당하는 조회 수의 누적이 함께 반영된다는 것입니다. titles.add 뒤 totalViews를 더하기 전 상태를 snapshot이 읽으면 새 제목과 이전 조회 수가 한 쌍으로 반환됩니다. 같은 this 모니터로 record와 snapshot을 묶으면 이 중간 상태를 외부에 내놓지 않습니다. strip·toLowerCase는 각 호출의 입력 문자열 계산이므로 밖에 남길 수 있습니다. 원 두 입력 30·40의 검산은 count=2, views=70이며 제목의 저장 순서는 별도 계약이 없습니다.
공유 상태의 읽기·확인·쓰기를 함께 묶고 독립 계산은 밖으로 이동합니다.
import java.util.ArrayList;
import java.util.List;
public final class SynchronizedBoardLedger {
private final List<String> titles = new ArrayList<>();
private int totalViews;
public void record(String title, int addedViews) {
if (title.isBlank() || addedViews <= 0) throw new IllegalArgumentException();
String normalized = title.strip().toLowerCase();
synchronized (this) {
titles.add(normalized);
totalViews += addedViews;
}
}
public synchronized Snapshot snapshot() {
return new Snapshot(List.copyOf(titles), totalViews);
}
private record Snapshot(List<String> titles, int totalViews) {}
public static void main(String[] args) throws InterruptedException {
SynchronizedBoardLedger ledger = new SynchronizedBoardLedger();
Thread a = new Thread(() -> ledger.record(" First Post ", 30));
Thread b = new Thread(() -> ledger.record("Notice", 40));
a.start();
b.start();
a.join();
b.join();
Snapshot s = ledger.snapshot();
System.out.println("count=" + s.titles().size() + ", views=" + s.totalViews());
}
}한 번의 기록과 스냅샷은 같은 모니터로 묶는다
record의 잠금 밖 정규화→같은 this 안 두 필드 갱신, snapshot의 같은 락 안 복사가 핵심입니다. 획득·변경·해제 경계를 단계로 구별하기 좋습니다.
SynchronizedBoardLedger의 정규화, 제목과 조회 수 갱신, 두 필드의 스냅샷을 실제 보호 범위로 나눕니다.
모니터 · 보호 범위
한 번의 기록과 스냅샷은 같은 모니터로 묶는다
정규화는 락 밖에서, 제목·조회 수 갱신과 스냅샷은 같은 this 모니터 안에서 수행합니다.
입력 검사·정규화
- 수행하는 일
- 제목과 조회 수를 검사하고 문자열을 정규화합니다.
- 모니터 범위
- 공유 필드를 건드리지 않아 락 밖에서 수행합니다.
record의 공유 갱신
- 수행하는 일
titles에 제목을 더하고totalViews를 누적합니다.- 모니터 범위
- 두 변경을 같은
this블록 안에서 수행합니다.
snapshot
- 수행하는 일
- 제목 목록을 복사하고 누적 조회 수를 함께 읽습니다.
- 모니터 범위
- 같은
this모니터로 일관된 두 값을 반환합니다.
원문의 두 작업을 join한 뒤 출력하는 값은 count=2, views=70입니다. 제목 목록이나 제목의 순서는 출력하지 않습니다.
이 예의 조건
- record와 snapshot은 같은 this 모니터를 각각 획득합니다. 제목 순서는 가변입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | record의 두 필드 변경과 snapshot의 두 필드 복사는 동일 this 모니터의 서로 다른 획득입니다. 입력 문자열 계산은 공유 상태 밖이며 원 join 뒤 count 2/views 70을 읽습니다. |
| 19 | 원 두 join 뒤 count 2 |
| 20 | views70 |
| 21 | 제목 순서 가변 |
| 구분 | 내용 |
|---|---|
| 설명 | record의 두 필드 변경과 snapshot의 두 필드 복사는 동일 this 모니터의 서로 다른 획득입니다. 입력 문자열 계산은 공유 상태 밖이며 원 join 뒤 count 2/views 70을 읽습니다. |
| 전체 읽기 정보 | 한 번의 기록과 스냅샷은 같은 모니터로 묶는다 한 번의 기록과 스냅샷은 같은 모니터로 묶는다 SynchronizedBoardLedger의 정규화, 제목과 조회 수 갱신, 두 필드의 스냅샷을 실제 보호 범위로 나눕니다. 모니터 · 보호 범위 한 번의 기록과 스냅샷은 같은 모니터로 묶는다 정규화는 락 밖에서, 제목·조회 수 갱신과 스냅샷은 같은 this 모니터 안에서 수행합니다. 입력 검사·정규화 수행하는 일 제목과 조회 수를 검사하고 문자열을 정규화합니다. 모니터 범위 공유 필드를 건드리지 않아 락 밖에서 수행합니다. record 의 공유 갱신 수행하는 일 titles 에 제목을 더하고 totalViews 를 누적합니다. 모니터 범위 두 변경을 같은 this 블록 안에서 수행합니다. snapshot 수행하는 일 제목 목록을 복사하고 누적 조회 수를 함께 읽습니다. 모니터 범위 같은 this 모니터로 일관된 두 값을 반환합니다. 원문의 두 작업을 join한 뒤 출력하는 값은 count=2, views=70입니다. 제목 목록이나 제목의 순서는 출력하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 입력 계산 strip과 toLowerCase를 공유 상태 접근 전에 수행합니다. |
|
|
| 한 건 기록 this를 획득해 titles.add와 totalViews+=를 함께 수행합니다. |
|
|
| 스냅샷 반환 같은 this 아래 두 필드를 복사하고 반환합니다. |
|
|
한 번의 기록과 스냅샷은 같은 모니터로 묶는다
record의 잠금 밖 정규화→같은 this 안 두 필드 갱신, snapshot의 같은 락 안 복사가 핵심입니다. 획득·변경·해제 경계를 단계로 구별하기 좋습니다.
SynchronizedBoardLedger의 정규화, 제목과 조회 수 갱신, 두 필드의 스냅샷을 실제 보호 범위로 나눕니다.
모니터 · 보호 범위
한 번의 기록과 스냅샷은 같은 모니터로 묶는다
정규화는 락 밖에서, 제목·조회 수 갱신과 스냅샷은 같은 this 모니터 안에서 수행합니다.
입력 검사·정규화
- 수행하는 일
- 제목과 조회 수를 검사하고 문자열을 정규화합니다.
- 모니터 범위
- 공유 필드를 건드리지 않아 락 밖에서 수행합니다.
record의 공유 갱신
- 수행하는 일
titles에 제목을 더하고totalViews를 누적합니다.- 모니터 범위
- 두 변경을 같은
this블록 안에서 수행합니다.
snapshot
- 수행하는 일
- 제목 목록을 복사하고 누적 조회 수를 함께 읽습니다.
- 모니터 범위
- 같은
this모니터로 일관된 두 값을 반환합니다.
원문의 두 작업을 join한 뒤 출력하는 값은 count=2, views=70입니다. 제목 목록이나 제목의 순서는 출력하지 않습니다.
이 예의 조건
- record와 snapshot은 같은 this 모니터를 각각 획득합니다. 제목 순서는 가변입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | record의 두 필드 변경과 snapshot의 두 필드 복사는 동일 this 모니터의 서로 다른 획득입니다. 입력 문자열 계산은 공유 상태 밖이며 원 join 뒤 count 2/views 70을 읽습니다. |
| 19 | 원 두 join 뒤 count 2 |
| 20 | views70 |
| 21 | 제목 순서 가변 |
| 구분 | 내용 |
|---|---|
| 설명 | record의 두 필드 변경과 snapshot의 두 필드 복사는 동일 this 모니터의 서로 다른 획득입니다. 입력 문자열 계산은 공유 상태 밖이며 원 join 뒤 count 2/views 70을 읽습니다. |
| 전체 읽기 정보 | 한 번의 기록과 스냅샷은 같은 모니터로 묶는다 한 번의 기록과 스냅샷은 같은 모니터로 묶는다 SynchronizedBoardLedger의 정규화, 제목과 조회 수 갱신, 두 필드의 스냅샷을 실제 보호 범위로 나눕니다. 모니터 · 보호 범위 한 번의 기록과 스냅샷은 같은 모니터로 묶는다 정규화는 락 밖에서, 제목·조회 수 갱신과 스냅샷은 같은 this 모니터 안에서 수행합니다. 입력 검사·정규화 수행하는 일 제목과 조회 수를 검사하고 문자열을 정규화합니다. 모니터 범위 공유 필드를 건드리지 않아 락 밖에서 수행합니다. record 의 공유 갱신 수행하는 일 titles 에 제목을 더하고 totalViews 를 누적합니다. 모니터 범위 두 변경을 같은 this 블록 안에서 수행합니다. snapshot 수행하는 일 제목 목록을 복사하고 누적 조회 수를 함께 읽습니다. 모니터 범위 같은 this 모니터로 일관된 두 값을 반환합니다. 원문의 두 작업을 join한 뒤 출력하는 값은 count=2, views=70입니다. 제목 목록이나 제목의 순서는 출력하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 입력 계산 strip과 toLowerCase를 공유 상태 접근 전에 수행합니다. |
|
|
| 한 건 기록 this를 획득해 titles.add와 totalViews+=를 함께 수행합니다. |
|
|
| 스냅샷 반환 같은 this 아래 두 필드를 복사하고 반환합니다. |
|
|
락은 수치 범위나 자동 롤백까지 제공하지 않습니다. 이 Ledger의 30·40 합계는 70이지만, 양수 두 수라도 int 범위를 넘는 누적은 음수로 넘칠 수 있습니다. 더 넓은 타입이나 Math.addExact와 초과 정책은 별도 결정이며, 실패 가능한 검사를 갱신 뒤에 두면 락을 사용해도 일부 상태만 변경될 수 있습니다. 여기서 두 필드의 일관성은 원 예제의 갱신이 정상 완료하는 조건으로 판단합니다. 또 List.copyOf는 현재 목록의 요소 참조를 복사한 변경 불가 목록입니다. 이 예제의 요소는 불변 String이므로 이전 제목 목록이 이후 record로 늘어나지 않지만, 요소 자체가 가변 객체라면 그 내부 변경까지 깊게 복사하지 않습니다.
지역 변수와 공유 객체 구분
메서드 지역 기본형과 참조 변수 자체는 JVM의 스레드별 프레임에 속합니다.
그러나 지역 참조가 같은 힙 객체를 가리키면 그 객체는 공유됩니다.
“지역 변수라 안전하다”는 말은 변수 슬롯에 대한 것이지 참조 대상까지 포함하지 않습니다.
읽기 전용 불변 값은 여러 스레드가 공유해도 변경 경쟁이 없습니다.
가변 필드를 final로 선언하면 참조 재할당만 막고 대상 내부 변경은 남습니다.
record도 구성 요소가 가변 목록이라면 깊은 불변이 아닙니다.
상태를 한 스레드 안에서만 생성·변경·소비하는 스레드 한정은 락을 피하는 강력한 설계입니다.
결과를 넘길 때 불변 스냅샷으로 공개하면 공유 구간을 줄일 수 있습니다.
synchronized의 선택 기준
잔액과 거래 이력처럼 여러 값을 함께 검증·변경하면 하나의 락이 보호 범위를 분명하게 표현합니다.
volatile은 stop 플래그나 불변 스냅샷 참조처럼 단일 값을 공개할 때 사용합니다.
락 없이 단일 카운터를 갱신하는 원자 타입은 ch21에서 synchronized와 비교합니다.
경합이 낮고 기능이 단순하면 synchronized는 가독성이 좋습니다.
시간 제한, 중단 가능 락 획득, 공정성이 필요하면 다음 문서들의 Lock API를 검토합니다.
어떤 방법이든 락 식별 정보, 보호하는 필드, 임계 영역 불변식을 class 문서에 적습니다.
연습 문제
공유 카운터를 각 스레드가 10,000번 증가시킨 뒤 join하고 20,000을 출력하세요.
증가와 조회가 같은 모니터 규칙을 사용하게 하세요.
해설을 열기 전에 락으로 보호할 대상이 Counter 객체 하나인지 확인하세요. 두 스레드가 각각 new Counter를 만들면 공유 카운터 문제를 해결한 것이 아닙니다. 각 호출이 같은 객체의 value를 한 번 바꾸고 두 join 뒤 총합이 2×10000인지 검사합니다. 새 입력으로 작업자 3명·각 2000회로 바꿔도 같은 구조에서 6000인지 확인해 보세요.
정답과 보호 범위
증가 연산 메서드 전체가 한 번의 read-modify-write를 보호합니다.
main은 join 뒤 synchronized 게터로 최종값을 읽습니다.
public final class SynchronizedCounterSolution {
private static final class Counter {
private int value;
synchronized void increment() {
value++;
}
synchronized int value() {
return value;
}
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable work =
() -> {
for (int i = 0; i < 10_000; i++) {
counter.increment();
}
};
Thread first = new Thread(work, "counter-a"), second = new Thread(work, "counter-b");
first.start();
second.start();
first.join();
second.join();
System.out.println("count=" + counter.value());
}
}출력은 항상 20000입니다.
락을 반복문 바깥에 두면 한 스레드가 10,000회를 모두 수행하는 동안 다른 스레드가 막혀 정확성은 유지되지만 동시성이 줄어듭니다.
동기화 설계는 키워드 선택보다 먼저 가시성, 원자성, 순서 가운데 필요한 보장을 식별하는 일입니다.
공유 불변식을 가장 작은 완전한 임계 영역에 두고, 지역·불변 상태는 락 밖에 남겨야 정확성과 처리량을 함께 설명할 수 있습니다.
추가 연습 — 허용되는 결과와 보호 범위를 검산합니다. 다음 각 경우는 인터럽트·배리어 실패 없이 정상 완료하며 수치 범위를 넘지 않습니다. (1) 손실 갱신 실험을 작업자 3명, 참가자 3명인 두 배리어, 각 작업자 120회로 바꾸고 초깃값은 0입니다. 증가 요청 수와 최종 저장값은 얼마인가요? value만 volatile로 바꾸었을 때와, 재현 배리어를 없애고 각 증가 전체를 같은 락으로 보호했을 때도 비교하세요. (2) 잔액 1000인 계정 하나에 두 스레드가 각각 600 출금을 시도합니다. 성공 수와 최종 잔액을 구하세요. 이어 출금액을 각각 800·300으로 바꾸었을 때 가능한 최종 잔액을 모두 구하고, 모니터가 어떤 스레드의 우선 성공을 보장하는지 판단하세요. (3) Ledger에 제목 하나·조회 수 30을 기록한 뒤 snapshot A를 얻고, 같은 Ledger에 다른 제목·40을 정상 기록한 뒤 snapshot B를 얻습니다. A와 B의 제목 수·조회 수는 무엇인가요? 이 결과와 별도로 VolatileStopSignal에서 시작 신호 직후 중지 요청을 보낸 경우 worked=false가 오류인지 설명하세요.
추가 연습의 풀이 확인
- 증가 요청은 3×120=360개입니다. 각 회차에서 셋 모두 같은 n을 읽은 뒤 n+1을 쓰므로 저장값은 120입니다. 각 읽기·쓰기의 가시성을 volatile로 보강해도 이 분리된 복합 연산은 그대로여서 120입니다. 실험 배리어를 제거하고 같은 락 안에서 각 증가를 완결하면 360입니다. 배리어를 락 안에 남겨 다른 참가자의 진입을 막는 변형은 정상 완료 조건을 만족하지 못합니다.
- 600 출금 한 건만 성공하고 잔액은 400입니다. 800이 먼저 성공하면 잔액 200이라 300은 거절되고, 300이 먼저 성공하면 잔액 700이라 800은 거절됩니다. 따라서 두 번째 조건의 가능한 잔액은 200 또는 700, 성공 수는 둘 다 1입니다. 같은 락은 확인과 차감을 함께 보호하지만 어떤 요청이 먼저 획득하는지는 보장하지 않습니다.
- A는 count=1, views=30이고 B는 count=2, views=70입니다. 복사된 A의 제목 목록과 저장된 합계는 다음 record로 바뀌지 않습니다. 정상 join 뒤의
worked=false는 반복 횟수가 0인 허용된 결과입니다. started 신호는 첫 증가보다 먼저이므로 이 신호와 반복 완료를 같은 사건으로 간주하면 안 됩니다.