volatile과 AtomicInteger
가시성과 원자성을 분리해 증가 손실을 재현하고, AtomicInteger 연산으로 공유 카운터의 선형화 지점과 확인 항목을 세웁니다.
두 스레드가 같은 int를 천 번씩 증가시키면 최종값이 2,000일 것이라 기대합니다.
그러나 value++는 읽기, 덧셈, 쓰기의 세 단계이며 두 스레드가 같은 이전 값을 읽으면 한 증가가 덮어써집니다.
volatile은 최신 쓰기를 다른 스레드가 보게 하지만 세 단계를 하나의 사건으로 합치지 않습니다.
공유 값이 단순 카운터라면 전체 메서드를 잠그기보다 AtomicInteger가 의도를 더 직접적으로 표현합니다.
원자 클래스의 증가 메서드는 한 연산처럼 관찰되는 선형화 지점을 제공합니다.
다만 카운터와 다른 필드를 함께 바꿔야 한다면 원자 변수 하나로 복합 불변식까지 보호되지는 않습니다.
volatile 증가와 값 손실
다음 코드는 시작 래치로 두 작업자를 동시에 풀어 충돌 가능성을 높입니다. 정확한 손실량은 실행마다 달라질 수 있지만 최종값이 기대치보다 작을 수 있다는 사실이 중요합니다.
import java.util.concurrent.CountDownLatch;
public final class VolatileLostUpdate {
private volatile int value;
void increment() { value++; }
public static void main(String[] args) throws InterruptedException {
VolatileLostUpdate counter = new VolatileLostUpdate();
CountDownLatch start = new CountDownLatch(1);
Runnable task = () -> {
try { start.await(); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
for (int i = 0; i < 500_000; i++) {
counter.increment();
}
};
Thread a = Thread.ofPlatform().start(task);
Thread b = Thread.ofPlatform().start(task);
start.countDown();
a.join();
b.join();
System.out.println("expected=1000000 actual=" + counter.value);
}
}가시성 문제를 없앴는데도 결과가 틀리는 이유는 동일한 값을 읽은 두 계산이 차례로 같은 다음 값을 쓰기 때문입니다.
synchronized increment()도 해결책이지만 단일 수치 연산에는 원자 클래스가 더 좁은 책임을 갖습니다.
공유 상태 요구의 세 계층
- 가시성은 한 스레드가 쓴 최신 값을 다른 스레드가 언제 볼 수 있는지 다룬다.
- 원자성은 중간 단계가 다른 참여자에게 끼어들 수 없는 한 사건을 뜻한다.
- 순서성은 컴파일러와 CPU 재배치에도 앞뒤 관계가 유지되는지 설명한다.
volatile읽기와 쓰기는 가시성과happens-before구분을 제공하지만 복합 증가를 묶지 않는다.AtomicInteger.incrementAndGet은 증가와 결과 반환을 한 원자 연산으로 제공한다.- 여러 필드의 합계 같은 불변식에는 공통 락이나 하나의 불변 객체 교체가 필요하다.
원자 변수의 메서드 이름도 결과 형식을 드러냅니다.
getAndIncrement는 이전 값을, incrementAndGet은 증가한 값을 반환합니다.
순번 발급에는 이전 값이 필요한지 새 값이 필요한지 먼저 정해야 경계값 오류를 막습니다.
원자 카운터와 총량 검증
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public final class AtomicRequestCounter {
private final AtomicInteger accepted = new AtomicInteger();
private final AtomicInteger rejected = new AtomicInteger();
void record(boolean success) {
if (success) accepted.incrementAndGet();
else rejected.incrementAndGet();
}
int total() { return accepted.get() + rejected.get(); }
public static void main(String[] args) throws InterruptedException {
AtomicRequestCounter counter = new AtomicRequestCounter();
List<Thread> workers = new ArrayList<>();
for (int worker = 0; worker < 4; worker++) {
int id = worker;
workers.add(Thread.ofPlatform().start(() -> {
for (int i = 0; i < 25_000; i++) {
counter.record((i + id) % 5 != 0);
}
}));
}
for (Thread worker : workers) {
worker.join();
}
System.out.println("accepted=" + counter.accepted.get());
System.out.println("rejected=" + counter.rejected.get());
System.out.println("total=" + counter.total());
}
}최종 총량은 100,000이어야 합니다.
두 원자 변수의 개별 증가는 안전하지만 total()이 둘을 읽는 사이 다른 스레드가 기록할 수 있어 실행 중 스냅샷은 한 순간의 일관된 쌍이 아닐 수 있습니다.
모든 작업 종료 후 읽는 검증에서는 문제가 없고, 실시간 일관된 쌍이 필요하면 하나의 상태 객체를 원자적으로 교체합니다.
복합 상태의 불변 스냅샷 교체
import java.util.concurrent.atomic.AtomicReference;
public final class AtomicOutcomeSnapshot {
record Counts(long accepted, long rejected) {
Counts add(boolean success) {
return success ? new Counts(accepted + 1, rejected)
: new Counts(accepted, rejected + 1);
}
long total() { return accepted + rejected; }
}
private final AtomicReference<Counts> state =
new AtomicReference<>(new Counts(0, 0));
void record(boolean success) {
state.updateAndGet(current -> current.add(success));
}
Counts snapshot() { return state.get(); }
public static void main(String[] args) throws InterruptedException {
AtomicOutcomeSnapshot metrics = new AtomicOutcomeSnapshot();
Thread a = Thread.ofPlatform().start(() -> {
for (int i = 0; i < 10_000; i++) {
metrics.record(true);
}
});
Thread b = Thread.ofPlatform().start(() -> {
for (int i = 0; i < 2_000; i++) {
metrics.record(false);
}
});
a.join();
b.join();
Counts counts = metrics.snapshot();
System.out.println(counts);
System.out.println("total=" + counts.total());
}
}상태 한 개를 교체하므로 독자는 수용과 거부가 같은 버전인 스냅샷을 얻습니다. 갱신 함수는 충돌 시 여러 번 실행될 수 있으므로 로그나 외부 I/O 같은 부수 효과를 넣으면 안 됩니다. 순수하게 새 값만 계산해야 합니다.
가시성·원자성·불변식에 맞는 도구 선택
| 요구 | 적합한 도구 | 이유 |
|---|---|---|
| 최신 설정 플래그 | volatile | 단일 읽기·쓰기 가시성 |
| 독립 카운터 증가 | AtomicInteger | 원자 증가 연산 |
| 여러 값 일관 스냅샷 | AtomicReference | 불변 상태 전체 교체 |
| 큰 임계 영역 | Lock | 복수 동작 묶기 |
연습 문제
첫 순번이 100이고 최대 102까지만 발급되는 생성기를 작성하세요. 여러 스레드가 호출해도 100, 101, 102가 각각 한 번만 나오고 이후 호출은 빈 결과여야 합니다.
정답과 해설
상한 검사와 증가를 분리하면 두 스레드가 같은 마지막 값을 통과할 수 있습니다.
getAndUpdate로 상한에서 값을 고정하고, 반환된 이전 값이 범위 안인지 판단합니다.
import java.util.OptionalInt;
import java.util.concurrent.atomic.AtomicInteger;
public final class BoundedSequenceSolution {
private final AtomicInteger next = new AtomicInteger(100);
OptionalInt issue() {
int value = next.getAndUpdate(current -> current <= 102 ? current + 1 : current);
return value <= 102 ? OptionalInt.of(value) : OptionalInt.empty();
}
public static void main(String[] args) throws InterruptedException {
BoundedSequenceSolution sequence = new BoundedSequenceSolution();
Thread[] threads = new Thread[5];
for (int i = 0; i < threads.length; i++) {
threads[i] = Thread.ofPlatform().start(() -> System.out.println(sequence.issue()));
}
for (Thread thread : threads) {
thread.join();
}
}
}출력 순서는 달라도 세 실값과 두 빈 결과가 나와야 합니다. 갱신 함수는 재실행되어도 외부 효과가 없으므로 CAS 재시도에 안전합니다.
공유 카운터의 최종 설계 기준
volatile을 보았다고 복합 연산이 안전하다고 결론내리지 않습니다.
공유 작업을 읽기와 쓰기 단계로 펼쳐 다른 스레드가 끼어들 수 있는지 확인합니다.
단일 수치라면 Atomic, 여러 값의 공동 불변식이라면 잠금 또는 불변 스냅샷을 선택합니다.