volatile과 AtomicInteger
가시성과 원자성을 분리해 증가 손실을 재현하고, AtomicInteger 연산으로 공유 카운터의 선형화 지점과 확인 항목을 세웁니다.
두 스레드가 같은 int를 천 번씩 증가시키면 최종값이 2,000일 것이라 기대합니다.
그러나 value++는 읽기, 덧셈, 쓰기의 세 단계이며 두 스레드가 같은 이전 값을 읽으면 한 증가가 덮어써집니다.
volatile 쓰기는 같은 필드의 뒤따르는 읽기와 happens-before 관계를 만들지만, 읽기·덧셈·쓰기를 하나의 사건으로 합치지는 않습니다. 여기서 앞뒤는 동기화 순서를 기준으로 합니다.
공유 값이 단순 카운터라면 전체 메서드를 잠그기보다 AtomicInteger가 의도를 더 직접적으로 표현합니다.
원자 클래스의 증가 메서드는 한 연산처럼 관찰되는 선형화 지점을 제공합니다.
다만 카운터와 다른 필드를 함께 바꿔야 한다면 원자 변수 하나로 복합 불변식까지 보호되지는 않습니다.
volatile 증가와 값 손실
다음 코드는 두 작업자가 같은 시작 래치를 통과하게 합니다. 실제로 동시에 실행된다는 보장은 없습니다.
이번 실행은 expected=1000000, actual=686911을 출력했습니다. 손실량은 실행마다 달라질 수 있으며, 다른 실행에서 기대 총량이 나와도 ++의 원자성이 증명되는 것은 아닙니다.
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);
}
}value가0인 상태에서 두 작업자가 각각0을읽고1을쓰는 가능한 끼어듦을 설명합니다. 실제 실행 추적이 아닙니다.
| 가정한 순서 | 각 작업자의 읽기·쓰기 | 그 직후 공유 value |
|---|---|---|
| T1 읽기 | 0을 읽어 자기 계산에 사용 | 0 |
| T2 읽기 | T1이 쓰기 전에 0을 읽음 | 0 |
| T1 쓰기 | 읽었던 0에 1을 더해 기록 | 1 |
| T2 쓰기 | 자신도 0에 1을 더해 기록 | 1 · 두 증가 중 하나가 반영되지 않음 |
- T1 읽기
- 각 작업자의 읽기·쓰기: 0을 읽어 자기 계산에 사용그 직후 공유 value: 0
- T2 읽기
- 각 작업자의 읽기·쓰기: T1이 쓰기 전에 0을 읽음그 직후 공유 value: 0
- T1 쓰기
- 각 작업자의 읽기·쓰기: 읽었던 0에 1을 더해 기록그 직후 공유 value: 1
- T2 쓰기
- 각 작업자의 읽기·쓰기: 자신도 0에 1을 더해 기록그 직후 공유 value: 1 · 두 증가 중 하나가 반영되지 않음
원문은 두 작업자가 각 500,000번 증가한 최종값만 출력합니다. 이 네 단계는 가능한 끼어듦의 예이며, 실제 손실량이나 손실 발생을 고정하지 않습니다.
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());
}
}이번 출력은 accepted=80000, rejected=20000, total=100000입니다.
두 원자 변수의 개별 증가는 안전하지만 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());
}
}AtomicRequestCounter의 독립된 두 AtomicInteger 읽기와 AtomicOutcomeSnapshot의 하나의 불변 Counts 참조 읽기를 비교합니다.
| 읽기 경계 | 원문이 읽는 값 | 동시 갱신 중의 의미 |
|---|---|---|
| AtomicRequestCounter의 total() | accepted.get() 뒤 rejected.get() | 두 읽기는 별도 연산이므로 한 시점의 쌍을 보장하지 않음 |
| AtomicOutcomeSnapshot의 snapshot() | state.get()이 반환한 Counts 한 개 | accepted와 rejected가 같은 불변 객체에 들어 있음 |
- AtomicRequestCounter의 total()
- 원문이 읽는 값: accepted.get() 뒤 rejected.get()동시 갱신 중의 의미: 두 읽기는 별도 연산이므로 한 시점의 쌍을 보장하지 않음
- AtomicOutcomeSnapshot의 snapshot()
- 원문이 읽는 값: state.get()이 반환한 Counts 한 개동시 갱신 중의 의미: accepted와 rejected가 같은 불변 객체에 들어 있음
두 main은 모든 작업자를 join한 뒤 최종값을 읽습니다. 실행 중 쌍의 불일치나 갱신 함수의 재시도 횟수를 관측하는 코드는 없습니다.
이번 출력은 Counts[accepted=10000, rejected=2000]과 total=12000입니다.
갱신 함수는 충돌 시 여러 번 실행될 수 있으므로 로그나 외부 I/O 같은 부수 효과를 넣으면 안 됩니다.
순수하게 새 값만 계산해야 합니다.
가시성·원자성·불변식에 맞는 도구 선택
| 요구 | 적합한 도구 | 이유 |
|---|---|---|
| 공유 설정 플래그 | volatile | 같은 필드의 읽기·쓰기 동기화 |
| 독립 카운터 증가 | AtomicInteger | 원자 증가 연산 |
| 여러 값 일관 스냅샷 | AtomicReference | 불변 상태 전체 교체 |
| 큰 임계 영역 | Lock | 복수 동작 묶기 |
| 경합이 많은 통계 누적 | LongAdder | 동시 갱신 중 sum은 원자 스냅샷이 아님 |
연습 문제
첫 순번이 100이고 최대 102까지만 발급되는 생성기를 작성하세요.
여러 스레드가 호출해도 100, 101, 102가 각각 한 번만 나오고 이후 호출은 빈 결과여야 합니다.
정답과 해설
상한 검사와 증가를 분리하면 두 스레드가 같은 마지막 값을 통과할 수 있습니다.
getAndUpdate가 반환한 이전 값이 범위 안인지 판단합니다. 발급 상한 102를 넘긴 다음 상태는 103으로 고정합니다.
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();
}
}
}getAndUpdate가 반환하는 이전 값으로100부터102를발급하고103에서는빈결과를반환하는 답안의 경계를 보여줍니다.
| getAndUpdate의 이전 값 | 갱신 뒤 next | issue()의 반환 |
|---|---|---|
| 이전 값 100 | 101 | OptionalInt[100] |
| 이전 값 101 | 102 | OptionalInt[101] |
| 이전 값 102 | 103 | OptionalInt[102] |
| 이전 값 103 | 103 · 상태 유지 | OptionalInt.empty |
- 이전 값 100
- 갱신 뒤 next: 101issue()의 반환: OptionalInt[100]
- 이전 값 101
- 갱신 뒤 next: 102issue()의 반환: OptionalInt[101]
- 이전 값 102
- 갱신 뒤 next: 103issue()의 반환: OptionalInt[102]
- 이전 값 103
- 갱신 뒤 next: 103 · 상태 유지issue()의 반환: OptionalInt.empty
이번 출력은 빈 결과 두 개 뒤 102, 101, 100 순이었습니다. 표는 발급 연산의 상태 순서이며 println의 출력 순서가 아닙니다. 정상 완료한 5회 호출의 결과는 실값 세 개와 빈 결과 두 개입니다.
갱신 함수는 재실행되어도 외부 효과가 없으므로 CAS 재시도에 안전합니다.
공유 카운터의 최종 설계 기준
volatile을 보았다고 복합 연산이 안전하다고 결론내리지 않습니다.
공유 작업을 읽기와 쓰기 단계로 펼쳐 다른 스레드가 끼어들 수 있는지 확인합니다.
단일 수치라면 Atomic, 여러 값의 공동 불변식이라면 잠금 또는 불변 스냅샷을 선택합니다.