가시성·원자성·임계 영역
`volatile` 가시성, 복합 연산 경합, 모니터 락, 최소 임계 영역과 스레드-지역 상태를 하나의 실행 모델로 정리합니다.
여러 스레드가 같은 객체를 읽고 쓸 때는 질문을 셋으로 나눠야 합니다.
최신 write가 보이는가라는 가시성, 여러 단계가 쪼개지지 않는가라는 atomicity, 실행 순서가 정해지는가라는 순서입니다.
volatile과 synchronized는 보장 범위가 다르므로 증상 하나에 키워드를 임의로 붙이지 않습니다.
increment 경쟁과 값 손실
count++는 read, add, write의 세 단계입니다.
아래 프로그램은 배리어로 두 작업자가 같은 값을 읽은 뒤 각각 같은 새 값을 쓰게 만듭니다.
100회씩 총 200번 증가를 요청하지만 실제 출력은 expected=200, actual=100입니다.
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);
}
}
}배리어는 실패를 재현하기 위한 장치이고 해결책은 아닙니다.
volatile int value로 바꿔도 각 읽기와 쓰기가 보일 뿐 read-modify-write 전체가 하나가 되지 않아 손실 갱신은 남습니다.
이 장에서는 증가 전체를 같은 synchronized 블록에 묶습니다.
단일 값 전용 원자 연산은 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));
}
}iterations는 작업자가 종료 직전에 쓰고 main이 join 후 읽습니다.
이를 volatile로 만들 필요가 없습니다.
한 프로그램에서도 필드마다 공개 구분이 다를 수 있습니다.
happens-before 규칙
“volatile은 항상 main 메모리를 직접 읽는다”는 설명은 입문용 단순화입니다.
실제 구현에는 메모리 배리어와 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됩니다. 공유 상태의 읽기·확인·쓰기를 함께 묶고 독립 계산은 밖으로 이동합니다.
import java.util.ArrayList;
import java.util.List;
public final class SynchronizedStudyLedger {
private final List<String> entries = new ArrayList<>();
private int minutes;
public void record(String topic, int added) {
if (topic.isBlank() || added <= 0) throw new IllegalArgumentException();
String normalized = topic.strip().toLowerCase();
synchronized (this) {
entries.add(normalized);
minutes += added;
}
}
public synchronized Snapshot snapshot() {
return new Snapshot(List.copyOf(entries), minutes);
}
private record Snapshot(List<String> topics, int minutes) {}
public static void main(String[] args) throws InterruptedException {
SynchronizedStudyLedger ledger = new SynchronizedStudyLedger();
Thread a = new Thread(() -> ledger.record(" Thread ", 30));
Thread b = new Thread(() -> ledger.record("Lock", 40));
a.start();
b.start();
a.join();
b.join();
Snapshot s = ledger.snapshot();
System.out.println("count=" + s.topics().size() + ", minutes=" + s.minutes());
}
}문자열 정규화는 공유 필드를 건드리지 않아 락 밖에서 수행합니다.
항목 추가와 분 누적은 한 record라는 불변식이므로 같은 블록 안에 둡니다.
스냅샷도 동일 모니터에서 두 값을 읽어 일관된 시점을 반환합니다.
지역 변수와 공유 객체 구분
메서드 지역 기본형과 참조 변수 자체는 각 스레드 스택에 있습니다. 그러나 지역 참조가 같은 힙 객체를 가리키면 그 객체는 공유됩니다. “지역 변수라 안전하다”는 말은 변수 슬롯에 대한 것이지 참조 대상까지 포함하지 않습니다.
읽기 전용 불변 값은 여러 스레드가 공유해도 변경 경쟁이 없습니다.
가변 필드를 final로 선언하면 참조 재할당만 막고 대상 내부 변경은 남습니다.
record도 구성 요소가 가변 목록이라면 깊은 불변이 아닙니다.
상태를 한 스레드 안에서만 생성·변경·소비하는 스레드 한정은 락을 피하는 강력한 설계입니다. 결과를 넘길 때 불변 스냅샷으로 공개하면 공유 구간을 줄일 수 있습니다.
synchronized의 선택 기준
잔액과 거래 이력처럼 여러 값을 함께 검증·변경하면 하나의 락이 보호 범위를 분명하게 표현합니다.
volatile은 stop 플래그나 불변 스냅샷 참조처럼 단일 값을 공개할 때 사용합니다.
락 없이 단일 카운터를 갱신하는 원자 타입은 ch21에서 synchronized와 비교합니다.
경합이 낮고 기능이 단순하면 synchronized는 가독성이 좋습니다.
시간 제한, 중단 가능 락 획득, 공정성이 필요하면 다음 문서들의 Lock API를 검토합니다.
어떤 방법이든 락 식별 정보, 보호하는 필드, 임계 영역 불변식을 class 문서에 적습니다.
연습 문제
공유 카운터를 각 스레드가 10,000번 증가시킨 뒤 join하고 20,000을 출력하세요.
증가와 조회가 같은 모니터 규칙을 사용하게 하세요.
정답과 보호 범위
증가 연산 메서드 전체가 한 번의 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회를 모두 수행하는 동안 다른 스레드가 막혀 정확성은 유지되지만 동시성이 줄어듭니다.
동기화 설계는 키워드 선택보다 먼저 가시성, atomicity, 순서 가운데 필요한 보장을 식별하는 일입니다. 공유 불변식을 가장 작은 완전한 임계 영역에 두고, 지역·불변 상태는 락 밖에 남겨야 정확성과 처리량을 함께 설명할 수 있습니다.