안동민 개발노트

본문 시작

volatile과 AtomicInteger

가시성과 원자성을 분리해 증가 손실을 재현하고, AtomicInteger 연산으로 공유 카운터의 선형화 지점과 확인 항목을 세웁니다.

두 스레드가 같은 int를 천 번씩 증가시키면 최종값이 2,000일 것이라 기대합니다.

그러나 value++는 읽기, 덧셈, 쓰기의 세 단계이며 두 스레드가 같은 이전 값을 읽으면 한 증가가 덮어써집니다.

volatile 쓰기는 같은 필드의 뒤따르는 읽기와 happens-before 관계를 만들지만, 읽기·덧셈·쓰기를 하나의 사건으로 합치지는 않습니다. 여기서 앞뒤는 동기화 순서를 기준으로 합니다.

공유 값이 단순 카운터라면 전체 메서드를 잠그기보다 AtomicInteger가 의도를 더 직접적으로 표현합니다.

원자 클래스의 증가 메서드는 한 연산처럼 관찰되는 선형화 지점을 제공합니다.

다만 카운터와 다른 필드를 함께 바꿔야 한다면 원자 변수 하나로 복합 불변식까지 보호되지는 않습니다.


volatile 증가와 값 손실

다음 코드는 두 작업자가 같은 시작 래치를 통과하게 합니다. 실제로 동시에 실행된다는 보장은 없습니다.

이번 실행은 expected=1000000, actual=686911을 출력했습니다. 손실량은 실행마다 달라질 수 있으며, 다른 실행에서 기대 총량이 나와도 ++의 원자성이 증명되는 것은 아닙니다.

bad/VolatileLostUpdate.java
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);
    }
}
두 번의 증가가 1을 남기는 가정 순서

value가0인 상태에서 두 작업자가 각각0을읽고1을쓰는 가능한 끼어듦을 설명합니다. 실제 실행 추적이 아닙니다.

두 번의 증가가 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은 증가한 값을 반환합니다.

순번 발급에는 이전 값이 필요한지 새 값이 필요한지 먼저 정해야 경계값 오류를 막습니다.


원자 카운터와 총량 검증

src/AtomicRequestCounter.java
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()이 둘을 읽는 사이 다른 스레드가 기록할 수 있어 실행 중 스냅샷은 한 순간의 일관된 쌍이 아닐 수 있습니다.

모든 작업 종료 후 읽는 검증에서는 문제가 없고, 실시간 일관된 쌍이 필요하면 하나의 상태 객체를 원자적으로 교체합니다.


복합 상태의 불변 스냅샷 교체

src/AtomicOutcomeSnapshot.java
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());
    }
}
두 개의 get과 하나의 상태 참조를 구별한다

AtomicRequestCounter의 독립된 두 AtomicInteger 읽기와 AtomicOutcomeSnapshot의 하나의 불변 Counts 참조 읽기를 비교합니다.

두 개의 get과 하나의 상태 참조를 구별한다
읽기 경계원문이 읽는 값동시 갱신 중의 의미
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으로 고정합니다.

exercise/BoundedSequenceSolution.java
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();
        }
    }
}
반환할 이전 값과 다음 상태 103을 나눈다

getAndUpdate가 반환하는 이전 값으로100부터102를발급하고103에서는빈결과를반환하는 답안의 경계를 보여줍니다.

반환할 이전 값과 다음 상태 103을 나눈다
getAndUpdate의 이전 값갱신 뒤 nextissue()의 반환
이전 값 100101OptionalInt[100]
이전 값 101102OptionalInt[101]
이전 값 102103OptionalInt[102]
이전 값 103103 · 상태 유지OptionalInt.empty
이전 값 100
갱신 뒤 next: 101
issue()의 반환: OptionalInt[100]
이전 값 101
갱신 뒤 next: 102
issue()의 반환: OptionalInt[101]
이전 값 102
갱신 뒤 next: 103
issue()의 반환: OptionalInt[102]
이전 값 103
갱신 뒤 next: 103 · 상태 유지
issue()의 반환: OptionalInt.empty

이번 출력은 빈 결과 두 개 뒤 102, 101, 100 순이었습니다. 표는 발급 연산의 상태 순서이며 println의 출력 순서가 아닙니다. 정상 완료한 5회 호출의 결과는 실값 세 개와 빈 결과 두 개입니다.

갱신 함수는 재실행되어도 외부 효과가 없으므로 CAS 재시도에 안전합니다.


공유 카운터의 최종 설계 기준

volatile을 보았다고 복합 연산이 안전하다고 결론내리지 않습니다.

공유 작업을 읽기와 쓰기 단계로 펼쳐 다른 스레드가 끼어들 수 있는지 확인합니다.

단일 수치라면 Atomic, 여러 값의 공동 불변식이라면 잠금 또는 불변 스냅샷을 선택합니다.