volatile과 AtomicInteger
volatile 필드에 count++를 해도 증가가 사라지는 이유를 확인하고, AtomicInteger로 카운터를 원자적으로 증가시키는 방법과 원자 변수 여러 개를 함께 다룰 때의 주의점을 배웁니다.
가시성과 원자성을 다룬 글에서 두 스레드가 같은 카운터를 증가시키면 증가가 사라질 수 있다는 것을 보았습니다. count++는 한 줄짜리 코드지만 실제로는 값을 읽고, 1을 더하고, 다시 쓰는 세 단계로 실행되기 때문입니다. 그때는 증가 전체를 synchronized로 묶어 해결했습니다. 그런데 숫자 하나를 늘리려고 매번 락을 잡고 놓는 것은 번거롭게 느껴집니다. 필드에 volatile만 붙이면 되지 않을까 하는 생각도 자연스럽게 듭니다.
이 글에서는 먼저 volatile을 붙여도 증가가 왜 여전히 사라지는지 직접 확인합니다. 그다음 AtomicInteger가 이 문제를 어떻게 푸는지 보고, 원자 변수가 여러 개일 때 남는 함정과 그 해결 방법까지 살펴봅니다. 이 글의 실행 결과는 코어가 2개인 컴퓨터에서 JDK 21로 얻은 것입니다.
volatile을 붙여도 증가가 사라진다
다음 프로그램은 volatile int value 필드를 두 스레드가 50만 번씩 증가시킵니다. 증가가 하나도 사라지지 않는다면 마지막에 100만이 출력되어야 합니다.
CountDownLatch는 두 스레드를 출발선에 세워 두는 장치입니다. 두 스레드는 start.await()에서 기다리다가 main이 countDown()을 호출하면 함께 출발합니다. 이렇게 하지 않으면 먼저 시작한 스레드가 50만 번을 다 끝낸 뒤에야 두 번째 스레드가 시작할 수도 있고, 그러면 두 스레드가 겹치지 않아 문제가 잘 드러나지 않습니다. 다만 래치는 출발 신호를 함께 받게 할 뿐이고, 두 스레드가 실제로 같은 순간에 실행된다는 보장은 없습니다.
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);
}
}이 프로그램을 30번 실행해 보니 28번은 100만보다 작은 값이 나왔습니다. 가장 작은 값은 755,937이었고, 999,413처럼 100만에 아주 가까운 때도 있었습니다. 첫 번째 실행의 출력은 다음과 같았습니다.
expected=1000000 actual=857832나머지 두 번은 정확히 100만이 나왔습니다. 같은 컴퓨터에서 따로 20번 실행했을 때는 그 절반인 10번이 100만이었을 만큼, 값을 잃는 빈도도 실행 묶음마다 크게 달랐습니다. 하지만 100만이 나온 실행도 코드가 옳다는 뜻은 아닙니다. 그 실행에서는 두 스레드의 읽기와 쓰기가 우연히 위험하게 겹치지 않았을 뿐이고, 같은 코드가 다음 실행에서는 다시 값을 잃습니다. 동시성 버그는 실행할 때마다 나타나지 않을 수 있으므로, 결과 몇 번이 맞았다는 것으로 코드가 안전하다고 판단할 수 없습니다.
두 번 증가했는데 1이 남는 순서
value++를 풀어 쓰면 int tmp = value; tmp = tmp + 1; value = tmp;입니다. 필드를 읽는 일과 쓰는 일이 따로 일어나므로, 한 스레드가 읽은 뒤 쓰기 전에 다른 스레드가 끼어들 수 있습니다. value가 0일 때 두 스레드 T1과 T2가 다음 순서로 실행되었다고 해 봅시다.
| 순서 | T1 | T2 | value |
|---|---|---|---|
| 1 | value를 읽음 → 0 | 0 | |
| 2 | value를 읽음 → 0 | 0 | |
| 3 | 읽은 0에 1을 더해 1을 씀 | 1 | |
| 4 | 읽은 0에 1을 더해 1을 씀 | 1 |
두 번의 증가가 1을 남기는 가정 순서
두 읽기 0 뒤 두 쓰기 1로 증가가 유실되는 가능한 끼어듦입니다. 각 스레드의 관측값과 공유 값 0→1→1을 단계로 추적합니다.
value가0인 상태에서 두 작업자가 각각0을읽고1을쓰는 가능한 끼어듦을 설명합니다. 실제 실행 추적이 아닙니다.
가능한 끼어듦 · 읽기와 쓰기
두 번의 증가가 1을 남기는 가정 순서
두 작업자가 같은 0을 읽고 각각 1을 쓰면 두 증가 뒤에도 값은 1입니다.
T1 읽기
- 각 작업자의 읽기·쓰기
- 0을 읽어 자기 계산에 사용
- 그 직후 공유 value
- 0
T2 읽기
- 각 작업자의 읽기·쓰기
- T1이 쓰기 전에 0을 읽음
- 그 직후 공유 value
- 0
T1 쓰기
- 각 작업자의 읽기·쓰기
- 읽었던 0에 1을 더해 기록
- 그 직후 공유 value
- 1
T2 쓰기
- 각 작업자의 읽기·쓰기
- 자신도 0에 1을 더해 기록
- 그 직후 공유 value
- 1 · 두 증가 중 하나가 반영되지 않음
원문은 두 작업자가 각 500,000번 증가한 최종값만 출력합니다. 이 네 단계는 가능한 끼어듦의 예이며, 실제 손실량이나 손실 발생을 고정하지 않습니다.
이 예의 조건
- 실제 로그가 아닌 가능한 끼어듦 순서입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 시간은 위에서 아래로 흐른다. 화살표는 읽은 값 또는 쓴 값의 이동을 나타낸다. 공유 값 0에서 T1과 T2가 각각 0을 읽고 각각 1을 써서 최종 1을 남긴다. 실제 로그가 아닌 가능한 순서다. |
| 구분 | 내용 |
|---|---|
| 설명 | 시간은 위에서 아래로 흐른다. 화살표는 읽은 값 또는 쓴 값의 이동을 나타낸다. 공유 값 0에서 T1과 T2가 각각 0을 읽고 각각 1을 써서 최종 1을 남긴다. 실제 로그가 아닌 가능한 순서다. |
| 전체 읽기 정보 | 두 번의 증가가 1을 남기는 가정 순서 두 번의 증가가 1을 남기는 가정 순서 value가 0인 상태에서 두 작업자가 각각 0을 읽고 1을 쓰는 가능한 끼어듦을 설명합니다. 실제 실행 추적이 아닙니다. 가능한 끼어듦 · 읽기와 쓰기 두 번의 증가가 1을 남기는 가정 순서 두 작업자가 같은 0을 읽고 각각 1을 쓰면 두 증가 뒤에도 값은 1입니다. T1 읽기 각 작업자의 읽기·쓰기 0을 읽어 자기 계산에 사용 그 직후 공유 value 0 T2 읽기 각 작업자의 읽기·쓰기 T1이 쓰기 전에 0을 읽음 그 직후 공유 value 0 T1 쓰기 각 작업자의 읽기·쓰기 읽었던 0에 1을 더해 기록 그 직후 공유 value 1 T2 쓰기 각 작업자의 읽기·쓰기 자신도 0에 1을 더해 기록 그 직후 공유 value 1 · 두 증가 중 하나가 반영되지 않음 원문은 두 작업자가 각 500,000번 증가한 최종값만 출력합니다. 이 네 단계는 가능한 끼어듦의 예이며, 실제 손실량이나 손실 발생을 고정하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 두 읽기 T1과 T2가 쓰기 전에 공유 값 0을 각각 읽습니다. |
|
|
| 첫 쓰기 T1이 관측값에 1을 더해 씁니다. |
|
|
| 뒤 쓰기 T2도 1을 씁니다. |
|
|
두 번의 증가가 1을 남기는 가정 순서
두 읽기 0 뒤 두 쓰기 1로 증가가 유실되는 가능한 끼어듦입니다. 각 스레드의 관측값과 공유 값 0→1→1을 단계로 추적합니다.
value가0인 상태에서 두 작업자가 각각0을읽고1을쓰는 가능한 끼어듦을 설명합니다. 실제 실행 추적이 아닙니다.
가능한 끼어듦 · 읽기와 쓰기
두 번의 증가가 1을 남기는 가정 순서
두 작업자가 같은 0을 읽고 각각 1을 쓰면 두 증가 뒤에도 값은 1입니다.
T1 읽기
- 각 작업자의 읽기·쓰기
- 0을 읽어 자기 계산에 사용
- 그 직후 공유 value
- 0
T2 읽기
- 각 작업자의 읽기·쓰기
- T1이 쓰기 전에 0을 읽음
- 그 직후 공유 value
- 0
T1 쓰기
- 각 작업자의 읽기·쓰기
- 읽었던 0에 1을 더해 기록
- 그 직후 공유 value
- 1
T2 쓰기
- 각 작업자의 읽기·쓰기
- 자신도 0에 1을 더해 기록
- 그 직후 공유 value
- 1 · 두 증가 중 하나가 반영되지 않음
원문은 두 작업자가 각 500,000번 증가한 최종값만 출력합니다. 이 네 단계는 가능한 끼어듦의 예이며, 실제 손실량이나 손실 발생을 고정하지 않습니다.
이 예의 조건
- 실제 로그가 아닌 가능한 끼어듦 순서입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 시간은 위에서 아래로 흐른다. 화살표는 읽은 값 또는 쓴 값의 이동을 나타낸다. 공유 값 0에서 T1과 T2가 각각 0을 읽고 각각 1을 써서 최종 1을 남긴다. 실제 로그가 아닌 가능한 순서다. |
| 구분 | 내용 |
|---|---|
| 설명 | 시간은 위에서 아래로 흐른다. 화살표는 읽은 값 또는 쓴 값의 이동을 나타낸다. 공유 값 0에서 T1과 T2가 각각 0을 읽고 각각 1을 써서 최종 1을 남긴다. 실제 로그가 아닌 가능한 순서다. |
| 전체 읽기 정보 | 두 번의 증가가 1을 남기는 가정 순서 두 번의 증가가 1을 남기는 가정 순서 value가 0인 상태에서 두 작업자가 각각 0을 읽고 1을 쓰는 가능한 끼어듦을 설명합니다. 실제 실행 추적이 아닙니다. 가능한 끼어듦 · 읽기와 쓰기 두 번의 증가가 1을 남기는 가정 순서 두 작업자가 같은 0을 읽고 각각 1을 쓰면 두 증가 뒤에도 값은 1입니다. T1 읽기 각 작업자의 읽기·쓰기 0을 읽어 자기 계산에 사용 그 직후 공유 value 0 T2 읽기 각 작업자의 읽기·쓰기 T1이 쓰기 전에 0을 읽음 그 직후 공유 value 0 T1 쓰기 각 작업자의 읽기·쓰기 읽었던 0에 1을 더해 기록 그 직후 공유 value 1 T2 쓰기 각 작업자의 읽기·쓰기 자신도 0에 1을 더해 기록 그 직후 공유 value 1 · 두 증가 중 하나가 반영되지 않음 원문은 두 작업자가 각 500,000번 증가한 최종값만 출력합니다. 이 네 단계는 가능한 끼어듦의 예이며, 실제 손실량이나 손실 발생을 고정하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 두 읽기 T1과 T2가 쓰기 전에 공유 값 0을 각각 읽습니다. |
|
|
| 첫 쓰기 T1이 관측값에 1을 더해 씁니다. |
|
|
| 뒤 쓰기 T2도 1을 씁니다. |
|
|
두 스레드가 한 번씩 증가시켰으니 2가 되어야 하지만 남은 값은 1입니다. T2는 2번 순서에서 읽은 0으로 계산했기 때문에, 3번에서 T1이 쓴 1을 다시 보지 않고 그 위에 같은 1을 덮어씁니다. 이렇게 한 스레드의 갱신이 다른 스레드의 갱신에 덮여 사라지는 현상을 갱신 손실(lost update)이라고 합니다. 이 표는 매번 일어나는 순서가 아니라 일어날 수 있는 순서의 한 예입니다. 50만 번씩 반복하는 동안 이런 겹침이 몇 번 일어나느냐에 따라 사라지는 양이 실행마다 달라집니다.
volatile이 해 주는 일과 해 주지 않는 일
volatile이 아무 일도 하지 않은 것은 아닙니다. 여러 스레드가 같은 값을 다룰 때 따져야 할 성질은 가시성, 순서, 원자성 세 가지입니다. volatile은 그중 가시성과 순서를 해결하지만, 원자성은 일부만 해결합니다. 하나씩 보겠습니다.
첫째는 가시성(visibility), 즉 한 스레드가 쓴 값을 다른 스레드가 볼 수 있느냐입니다. 컴파일러와 CPU는 한 스레드 안에서 결과가 달라지지 않는 한 필드 읽기를 줄이거나 명령 순서를 바꾸는 최적화를 할 수 있습니다. 그래서 동기화하지 않은 일반 필드는 한 스레드가 값을 바꿔도 다른 스레드가 그 값을 언제 보게 될지 정해져 있지 않고, 끝내 보지 못할 수도 있습니다. volatile 필드는 다릅니다. JLS 17.4.5는 volatile 필드에 대한 쓰기가 그 필드에 대한 이후의 모든 읽기보다 먼저 일어난다(happens-before)고 정합니다. 따라서 한 스레드가 쓴 값은 그 뒤에 같은 필드를 읽는 다른 스레드에게 보입니다.
둘째는 순서(ordering)입니다. 명령 순서를 바꾸는 최적화 때문에, 다른 스레드에서 보면 나중에 쓴 값이 먼저 보이는 일이 생길 수 있습니다. volatile은 이 순서도 잡아 줍니다. 다음 코드에서 쓰는 스레드는 port를 먼저 쓰고 ready를 나중에 씁니다.
final class Config {
int port; // 일반 필드
volatile boolean ready; // 준비 신호로 쓰는 volatile 필드
void publish() { // 쓰는 스레드가 호출
port = 8080;
ready = true;
}
void use() { // 읽는 스레드가 호출
if (ready) {
System.out.println(port);
}
}
}읽는 스레드가 ready에서 true를 읽었다면 port는 반드시 8080으로 보입니다. 쓰는 스레드 안에서 port = 8080은 ready = true보다 먼저 일어나고, ready 쓰기는 그 값을 본 읽기보다 먼저 일어나며, 그 읽기는 다시 같은 스레드의 port 읽기보다 먼저 일어납니다. happens-before는 이렇게 이어지므로 port = 8080도 port 읽기보다 먼저 일어난 것이 됩니다. ready가 일반 필드였다면 이런 연결이 없어서, ready는 true로 보이는데 port는 0으로 보이는 일도 허용됩니다.
셋째는 원자성(atomicity)입니다. 연산이 원자적(atomic)이라는 말은 그 연산이 중간 단계 없이 한 번에 일어나는 것처럼 보인다는 뜻입니다. 다른 스레드는 연산 전의 상태나 연산 후의 상태만 볼 수 있고, 반쯤 진행된 상태를 보거나 그 사이에 끼어들 수 없습니다. volatile 필드 하나를 읽는 일과 쓰는 일은 각각 원자적입니다. 하지만 value++는 읽기와 쓰기라는 두 동작이고, volatile은 두 동작을 하나로 묶어 주지 않습니다.
앞의 표를 다시 보면 가시성에는 아무 문제가 없었습니다. T2가 0을 읽은 것은 그 순간까지 아무도 1을 쓰지 않았기 때문이고, 그 시점에서는 0이 최신 값이었습니다. 문제는 T2가 읽은 뒤 쓰기 전에 T1의 쓰기가 끼어든 데 있습니다. 즉 volatile은 읽는 순간의 최신 값을 보여 줄 뿐, 읽은 값이 쓸 때까지 그대로라는 것까지 약속하지는 않습니다. 읽고 계산하고 쓰는 과정을 하나로 묶으려면 락이나 원자 연산이 필요합니다.
더 깊이: 동기화 순서로 본 갱신 손실
JLS 17.4장은 volatile 읽기·쓰기와 락 획득·해제 같은 동기화 동작(synchronization action)이 실행마다 하나의 전체 순서를 이룬다고 정합니다. 이를 동기화 순서(synchronization order)라고 하며, 이 순서는 각 스레드의 프로그램 순서와 어긋나지 않아야 합니다. volatile 변수를 읽으면 동기화 순서에서 그 읽기보다 앞선 마지막 쓰기의 값을 봅니다. 앞의 표에 나온 T1 읽기(0), T2 읽기(0), T1 쓰기(1), T2 쓰기(1)는 이 조건을 모두 만족합니다. 두 읽기 앞에는 증가로 생긴 쓰기가 하나도 없으니 둘 다 초깃값 0을 보고, 각 스레드 안에서도 읽기가 쓰기보다 앞서기 때문입니다. 그래서 모든 접근이 volatile이어도 메모리 모델은 이 결과를 허용합니다.
volatile이 주는 원자성에도 한 가지 덧붙일 점이 있습니다. JLS 17.7에 따르면 volatile이 아닌 long과 double은 쓰기 한 번이 32비트씩 두 번의 쓰기로 나뉘어 일어날 수 있지만, volatile long과 double의 읽기와 쓰기는 언제나 원자적입니다. volatile이 원자성을 주는 범위는 이렇게 읽기 한 번, 쓰기 한 번까지이고, 둘을 엮은 복합 연산에는 미치지 않습니다.
AtomicInteger로 증가를 한 번에 처리하기
이제 같은 프로그램에서 필드만 AtomicInteger로 바꿔 보겠습니다. AtomicInteger는 java.util.concurrent.atomic 패키지에 있는 클래스로, int 값 하나를 감싸고 그 값을 원자적으로 바꾸는 메서드를 제공합니다. 그중 incrementAndGet()은 값을 1 늘린 뒤 늘어난 값을 돌려주는데, 읽기와 덧셈과 쓰기가 하나의 원자 연산으로 일어납니다.
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
public final class AtomicLostUpdateFix {
private final AtomicInteger value = new AtomicInteger();
void increment() { value.incrementAndGet(); }
public static void main(String[] args) throws InterruptedException {
AtomicLostUpdateFix counter = new AtomicLostUpdateFix();
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.get());
}
}달라진 곳은 필드 선언, increment(), 마지막에 값을 읽는 counter.value.get() 세 군데뿐입니다. 20번 실행해 20번 모두 같은 결과를 얻었습니다.
expected=1000000 actual=1000000이번 결과는 운이 아닙니다. 100만 번의 증가가 하나하나 원자적으로 일어나므로 어떤 증가도 다른 증가에 덮이지 않고, main은 두 스레드를 모두 join()한 뒤에 값을 읽습니다. 따라서 두 스레드가 정상적으로 끝나기만 하면 몇 번을 실행해도 100만이 나옵니다. 앞의 volatile 버전에서 100만이 나온 것과는 의미가 전혀 다릅니다.
AtomicInteger의 get()과 set()은 volatile 필드를 읽고 쓰는 것과 같은 메모리 효과를 냅니다. 그러니 가시성과 순서는 volatile 필드와 같고, 여기에 incrementAndGet() 같은 원자적인 복합 연산이 더해진 것이라고 볼 수 있습니다. 숫자 하나를 세는 일이라면 synchronized 메서드보다 코드가 짧고, 값을 바꾸는 동안 다른 스레드가 락을 기다리며 멈추는 일도 없습니다. 같은 패키지에는 long을 위한 AtomicLong, boolean을 위한 AtomicBoolean, 객체 참조를 위한 AtomicReference도 있습니다.
원자 변수를 쓰면서 흔히 하는 실수는 읽기와 쓰기를 따로 호출하는 것입니다.
void increment() { value.set(value.get() + 1); } // 원자적이지 않다get()과 set()은 각각 원자적이지만, 둘 사이에 다른 스레드가 끼어들 수 있다는 점은 volatile 필드의 value++와 똑같습니다. 앞의 프로그램에서 increment()만 이렇게 바꿔 10번 실행했더니 10번 모두 100만보다 작은 값이 나왔습니다(775,293부터 998,055까지). 원자성은 메서드 호출 하나 단위로 주어지므로, 여러 단계를 하나로 묶으려면 그 묶음을 한 번에 처리하는 메서드를 써야 합니다.
이전 값을 받을까, 바뀐 값을 받을까
AtomicInteger의 메서드 이름은 무엇을 돌려주는지 알려 줍니다. get이 앞에 붙은 getAndIncrement()는 바뀌기 전의 값을, get이 뒤에 붙은 incrementAndGet()은 바뀐 뒤의 값을 돌려줍니다. 값이 5일 때 각 메서드를 호출하면 다음과 같습니다. 표의 값은 호출할 때마다 값을 5로 되돌려 가며 실제로 실행해 확인했습니다.
| 메서드 | 하는 일 | 반환값 | 호출 뒤 값 |
|---|---|---|---|
incrementAndGet() | 1을 더함 | 6 | 6 |
getAndIncrement() | 1을 더함 | 5 | 6 |
addAndGet(10) | 10을 더함 | 15 | 15 |
getAndAdd(10) | 10을 더함 | 5 | 15 |
updateAndGet(x -> x * 2) | 주어진 함수를 적용 | 10 | 10 |
getAndUpdate(x -> x * 2) | 주어진 함수를 적용 | 5 | 10 |
accumulateAndGet(9, Math::max) | 현재 값과 9에 함수를 적용 | 9 | 9 |
어느 쪽을 쓸지는 호출한 쪽에 필요한 값이 무엇이냐에 달려 있습니다. 예를 들어 번호표를 1번부터 나눠 준다면, 0에서 시작해 incrementAndGet()으로 1, 2, 3을 받거나 1에서 시작해 getAndIncrement()로 1, 2, 3을 받을 수 있습니다. 시작값과 메서드를 엇갈려 고르면 번호가 0부터 시작하거나 1번을 건너뛰는 실수가 생깁니다. 어느 쪽이든 증가와 반환이 한 번의 원자 연산이므로 두 스레드가 같은 번호를 받는 일은 없습니다.
원자 변수 두 개는 한 쌍으로 읽히지 않는다
원자 변수 하나하나가 안전하다고 해서, 원자 변수 여러 개를 함께 쓰는 코드까지 안전해지지는 않습니다. 다음 프로그램은 요청 결과를 성공(accepted)과 거절(rejected)로 나눠 세는 카운터입니다. 작업자 네 개가 25,000번씩 기록하는데, (i + id) % 5 != 0이면 성공으로 기록하므로 다섯 번에 한 번은 거절이 됩니다.
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());
}
}이 프로그램은 실행할 때마다 같은 결과를 출력합니다. 각 작업자의 25,000번 가운데 정확히 5,000번이 거절이므로 네 작업자를 합하면 거절이 20,000번, 성공이 80,000번입니다. 증가는 모두 원자적이고 main은 모든 작업자를 join()한 뒤에 읽으니, 결과가 달라질 이유가 없습니다.
accepted=80000
rejected=20000
total=100000문제는 작업자들이 아직 기록하는 도중에 total()을 부를 때 생깁니다. total()은 accepted.get()과 rejected.get()을 차례로 호출하는데, 두 호출은 별개의 연산이라 그 사이에 다른 스레드가 기록할 수 있습니다. 예를 들어 실제 상태가 (성공 1, 거절 0) → (2, 0) → (2, 1)로 바뀌었다고 해 봅시다. total()이 첫 상태에서 accepted를 1로 읽고 마지막 상태에서 rejected를 1로 읽으면, (1, 1)을 더한 2를 돌려줍니다. 두 읽기는 각각 그 순간의 정확한 값이었지만, (1, 1)이라는 상태는 한 번도 존재한 적이 없습니다. 같은 방식으로 두 값을 읽어 성공률을 계산하면 1 / 2, 즉 50%가 나옵니다. 실제 성공률은 100%, 100%, 약 67%로 바뀌었을 뿐 50%였던 적이 없습니다.
위 프로그램은 모든 작업이 끝난 뒤에만 읽으므로 이런 일이 생기지 않고, 이 중간 순서를 실제로 만들어 보여 주지도 않습니다. 하지만 실행 중에 성공률 같은 값을 계산해 화면에 보여 준다면, 서로 다른 순간에 읽은 값이 섞여 계산될 수 있습니다. 두 값이 언제나 같은 순간의 짝으로 읽혀야 한다면, 두 값을 객체 하나에 담아 그 객체를 통째로 바꾸는 방법을 씁니다.
더 깊이: 합계만 보면 문제가 드러나지 않는 이유
앞의 예에서 total()이 돌려준 2는 사실 (2, 0) 상태의 실제 합계와 같습니다. 우연이 아닙니다. 기록 한 번마다 두 카운터 가운데 하나만 정확히 1 늘어나므로 실제 합계는 1씩 커집니다. 그리고 accepted를 먼저, rejected를 나중에 읽어 더한 값은 첫 읽기 순간의 합계보다 작지 않고 둘째 읽기 순간의 합계보다 크지 않습니다. 실제 합계는 그 사이의 정수를 하나도 빠짐없이 거쳐 가므로, total()의 결과는 언제나 호출 도중 어느 순간의 실제 합계와 같습니다. 하지만 이것은 이 카운터에만 있는 특별한 성질입니다. 성공률처럼 두 값의 관계를 쓰는 계산이나 값이 줄기도 하는 상태에서는, 어긋난 짝이 그대로 잘못된 결과가 됩니다.
두 값을 불변 객체 하나에 담아 함께 바꾸기
다음 프로그램은 성공 수와 거절 수를 Counts라는 레코드 하나에 담습니다. 레코드는 필드를 바꿀 수 없는 불변 객체이므로, 기록할 때마다 기존 객체를 고치는 대신 값이 하나 늘어난 새 Counts를 만듭니다. 그리고 AtomicReference<Counts>가 현재 상태를 가리키는 참조 하나를 원자적으로 바꿉니다.
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());
}
}두 스레드가 각각 성공 10,000번과 거절 2,000번을 기록하므로, 모든 기록이 반영되면 다음과 같이 출력됩니다. 이 결과도 실행할 때마다 같습니다.
Counts[accepted=10000, rejected=2000]
total=12000AtomicRequestCounter의 독립된 두 AtomicInteger 읽기와 AtomicOutcomeSnapshot의 하나의 불변 Counts 참조 읽기를 비교합니다.
원자적 읽기 · 상태의 묶음
두 개의 get과 하나의 상태 참조를 구별한다
두 카운터의 개별 읽기와 불변 Counts 한 개의 읽기는 서로 다른 경계입니다.
AtomicRequestCounter의 total()
- 원문이 읽는 값
- accepted.get() 뒤 rejected.get()
- 동시 갱신 중의 의미
- 두 읽기는 별도 연산이므로 한 시점의 쌍을 보장하지 않음
AtomicOutcomeSnapshot의 snapshot()
- 원문이 읽는 값
- state.get()이 반환한 Counts 한 개
- 동시 갱신 중의 의미
- accepted와 rejected가 같은 불변 객체에 들어 있음
두 main은 모든 작업자를 join한 뒤 최종값을 읽습니다. 실행 중 쌍의 불일치나 갱신 함수의 재시도 횟수를 관측하는 코드는 없습니다.
핵심은 snapshot()이 state.get() 한 번으로 Counts 객체 하나를 얻는다는 점입니다. 그 객체의 accepted와 rejected는 같은 순간에 함께 만들어진 값이고, 객체가 불변이므로 읽는 도중에 바뀌지도 않습니다. 반면 앞의 total()은 서로 다른 원자 변수에 get()을 두 번 호출했습니다. 그림은 이 두 가지 읽기, 즉 get() 두 번과 상태 참조 한 번을 나란히 비교합니다.
record()가 호출하는 updateAndGet()은 현재 Counts를 읽고, 넘겨받은 함수로 새 Counts를 만든 뒤, 그사이에 다른 스레드가 상태를 바꾸지 않았을 때만 새 객체로 교체합니다. 다른 스레드가 먼저 바꿨다면 바뀐 상태를 다시 읽고 함수를 다시 실행합니다. 그래서 이 함수는 기록 한 번에 여러 번 실행될 수 있고, 공식 문서도 함수에 부수 효과(side effect)가 없어야 한다고 적고 있습니다. 함수 안에서 로그를 남기거나 외부에 요청을 보내면, 기록은 한 번인데 로그나 요청은 여러 번 나갈 수 있습니다. 함수는 받은 값으로 새 값을 계산해 돌려주는 일만 해야 합니다.
이 방식은 기록할 때마다 새 객체를 하나 만들고, 다른 스레드와 자주 부딪히면 함수를 여러 번 실행합니다. 그러므로 상태가 크거나 새 값을 계산하는 데 오래 걸린다면 락이 더 나을 수 있습니다. '바뀌지 않았을 때만 교체한다'는 원리는 다음 글에서, 락과의 비교는 락과 CAS를 비교하는 글에서 자세히 다룹니다.
상황에 맞는 도구 고르기
지금까지 본 도구는 해결하는 문제가 서로 다릅니다. 공유 값에 필요한 것이 가시성뿐인지, 값 하나의 원자적 갱신인지, 여러 값을 한꺼번에 바꾸는 일인지에 따라 다음과 같이 고릅니다.
| 필요한 것 | 알맞은 도구 | 이유 |
|---|---|---|
| 종료 플래그처럼 현재 값과 상관없이 덮어쓰는 값 | volatile 필드 | 쓴 값과 그 전에 한 쓰기가 다른 스레드에 보임 |
| 독립된 카운터 하나 | AtomicInteger, AtomicLong | 증가가 한 번의 원자 연산 |
| 여러 값을 같은 순간의 짝으로 읽고 바꾸기 | 불변 객체와 AtomicReference | 상태 전체를 참조 하나로 교체 |
| 여러 동작을 묶는 큰 임계 영역 | synchronized, ReentrantLock | 여러 단계를 한 스레드씩만 실행 |
| 여러 스레드가 아주 자주 더하는 통계 합계 | LongAdder | 경합을 나눠 처리하지만 sum()은 원자 스냅샷이 아님 |
volatile 필드 하나로 충분하려면 두 조건을 함께 만족해야 합니다. 쓰는 값이 현재 값에 의존하지 않거나 값을 쓰는 스레드가 하나뿐이어야 하고, 그 값이 다른 변수와 함께 맞춰야 하는 짝이 아니어야 합니다. 종료 플래그를 false로 바꾸는 일은 이전 값을 읽을 필요가 없으므로 여러 스레드가 해도 괜찮습니다. 반대로 value++처럼 이전 값을 읽어 새 값을 정하는 일을 여러 스레드가 한다면 원자 연산이나 락이 필요합니다.
마지막 줄의 LongAdder는 여러 스레드가 같은 합계를 아주 자주 갱신할 때를 위한 클래스입니다. 경합이 생기면 내부의 칸을 여러 개로 늘려 스레드들이 서로 다른 칸에 나눠 더하게 하고, sum()을 호출할 때 칸들을 합칩니다. 그래서 경합이 심할 때 AtomicLong보다 처리량이 높지만, 공식 문서에 따르면 갱신이 진행되는 도중에 호출한 sum()은 원자 스냅샷이 아닙니다. 계산하는 동안 일어난 갱신이 반영되지 않을 수 있다는 뜻입니다. 따라서 통계를 모으는 데는 알맞지만, 합계를 보고 다음 동작을 정하는 동기화 용도로는 맞지 않습니다.
정리
volatile은 가시성과 순서를 해결합니다. volatile 필드에 쓴 값은 그 뒤에 같은 필드를 읽는 스레드에 보이고, 그 쓰기 전에 한 다른 쓰기도 함께 보입니다.value++는 읽기와 쓰기 두 동작이므로 volatile 필드여도 갱신 손실이 생깁니다. 실행 결과가 몇 번 맞았다고 해서 코드가 안전한 것은 아닙니다.AtomicInteger.incrementAndGet()은 읽기, 더하기, 쓰기를 하나의 원자 연산으로 처리합니다. 하지만get()과set()을 따로 호출하면 다시 원자적이지 않게 됩니다.getAndXxx는 바뀌기 전의 값을,xxxAndGet은 바뀐 뒤의 값을 돌려줍니다.- 원자 변수 여러 개를 차례로 읽으면 같은 순간의 짝이 아닐 수 있습니다. 짝으로 다뤄야 하는 값은 불변 객체에 담아
AtomicReference로 통째로 바꿉니다. updateAndGet()에 넘기는 함수는 여러 번 실행될 수 있으므로 부수 효과가 없어야 합니다.
연습 문제
-
다음 코드가 출력하는 네 줄을 예측하세요.
AtomicInteger n = new AtomicInteger(10); System.out.println(n.getAndAdd(5)); System.out.println(n.incrementAndGet()); System.out.println(n.getAndIncrement()); System.out.println(n.get()); -
다음 가운데
volatile필드 하나만으로 충분한 경우를 모두 고르고, 나머지가 왜 부족한지 설명하세요.- (가) main이
running을false로 바꾸면 작업자가 반복을 멈춘다. - (나) 여러 요청 처리 스레드가 방문 수를 1씩 늘린다.
- (다) 스레드 하나만 처리 건수를 1씩 늘리고, 다른 스레드들은 그 값을 읽어 화면에 보여 주기만 한다.
- (라) 여러 스레드가
if (v > max) max = v;로 최댓값을 갱신한다.
- (가) main이
-
첫 번호가 100이고 102까지만 발급하는 번호 생성기를 만드세요. 여러 스레드가 동시에 호출해도 100, 101, 102가 정확히 한 번씩 나와야 하고, 그 뒤의 호출은 빈 결과(
OptionalInt.empty())를 돌려줘야 합니다.
해설 보기
1번. 출력은 10, 16, 16, 17입니다. getAndAdd(5)는 바뀌기 전의 10을 돌려주고 값을 15로 만듭니다. incrementAndGet()은 값을 16으로 만든 뒤 16을 돌려줍니다. getAndIncrement()는 바뀌기 전의 16을 돌려주고 값을 17로 만들므로, 마지막 get()은 17입니다. 실제로 실행해도 같은 네 줄이 나옵니다.
2번. (가)와 (다)입니다. (가)는 이전 값을 읽지 않고 false를 덮어쓸 뿐이므로, 필요한 것은 작업자가 바뀐 값을 보는 가시성뿐입니다. (다)는 count++이지만 쓰는 스레드가 하나뿐이라 읽기와 쓰기 사이에 끼어들 다른 쓰기가 없고, 읽는 스레드들은 volatile 덕분에 최신 값을 봅니다. (나)는 본문의 VolatileLostUpdate와 같은 상황이라 갱신 손실이 생기며, AtomicInteger가 필요합니다. (라)는 비교와 쓰기가 나뉜 두 단계입니다. T1이 max가 5일 때 9를 비교하는 사이 T2가 12를 써도, T1은 비교를 이미 통과했으므로 9를 써서 12를 덮어쓸 수 있습니다. accumulateAndGet(v, Math::max)처럼 비교와 쓰기를 한 번에 하는 메서드를 써야 합니다.
3번. 확인과 증가를 따로 하면 안 됩니다. 다음 코드는 그럴듯해 보이지만, 두 스레드가 모두 next가 102일 때 확인을 통과하면 한 스레드는 102를, 다른 스레드는 103을 받습니다.
OptionalInt issue() { // 잘못된 방법
if (next.get() <= 102) {
return OptionalInt.of(next.getAndIncrement());
}
return OptionalInt.empty();
}확인과 증가를 getAndUpdate() 한 번으로 처리하면 이 틈이 사라집니다. 함수는 현재 값이 102 이하이면 1을 더하고, 이미 103이면 그대로 둡니다. getAndUpdate()는 바뀌기 전의 값을 돌려주므로, 그 값이 102 이하이면 이번 호출이 발급받은 번호입니다.
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();
}
}
}다섯 스레드가 한 번씩 호출하므로 OptionalInt[100], OptionalInt[101], OptionalInt[102]가 한 줄씩, OptionalInt.empty가 두 줄 출력됩니다. 줄의 순서는 실행마다 달라집니다. 번호를 받은 순서와 println을 실행한 순서가 다를 수 있기 때문입니다. 30번 실행했을 때 100, 101, 102, 빈 결과, 빈 결과 순서로 나온 것은 14번이었고, 나머지 16번은 순서가 섞여 있었습니다. 아래 그림에 적힌 실행처럼 빈 결과가 먼저 출력된 경우도 있었습니다. 하지만 어떤 실행에서도 세 번호가 한 번씩, 빈 결과가 두 번 나왔습니다.
반환할 이전 값과 다음 상태 103을 나눈다
반환 100/101/102와 다음 상태 101/102/103, 이후 빈 결과·상태 유지입니다. 출력 순서와 분리된 선형화 상태를 같은 카운터로 추적합니다.
getAndUpdate가 반환하는 이전 값으로100부터102를발급하고103에서는빈결과를반환하는 답안의 경계를 보여줍니다.
발급 순서 · 이전 값과 다음 상태
반환할 이전 값과 다음 상태 103을 나눈다
100·101·102는 한 번씩 발급하고, 다음 상태 103에서는 더 발급하지 않습니다.
설명과 원래 예시 값
이전 값 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회 호출의 결과는 실값 세 개와 빈 결과 두 개입니다.
이 예의 조건
- 발급 연산 상태 순서이며 println 순서는 아닙니다. 정상 5회 호출입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | getAndUpdate가 반환하는 이전 값으로 100부터 102를 발급하고 103에서는 빈 결과를 반환하는 답안의 경계를 보여줍니다. |
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 반환할 이전 값과 다음 상태 103을 나눈다 반환할 이전 값과 다음 상태 103을 나눈다 getAndUpdate가 반환하는 이전 값으로 100부터 102를 발급하고 103에서는 빈 결과를 반환하는 답안의 경계를 보여줍니다. 발급 순서 · 이전 값과 다음 상태 반환할 이전 값과 다음 상태 103을 나눈다 100·101·102는 한 번씩 발급하고, 다음 상태 103에서는 더 발급하지 않습니다. 이전 값 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회 호출의 결과는 실값 세 개와 빈 결과 두 개입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 두 발급 getAndUpdate의 이전 값을 반환합니다. |
|
|
| 마지막 실값 이전 102를 반환하고 next를 103으로 둡니다. |
|
|
| 빈 결과 이전 103에서 상태를 유지하며 두 빈 결과를 반환합니다. |
|
|
반환할 이전 값과 다음 상태 103을 나눈다
반환 100/101/102와 다음 상태 101/102/103, 이후 빈 결과·상태 유지입니다. 출력 순서와 분리된 선형화 상태를 같은 카운터로 추적합니다.
getAndUpdate가 반환하는 이전 값으로100부터102를발급하고103에서는빈결과를반환하는 답안의 경계를 보여줍니다.
발급 순서 · 이전 값과 다음 상태
반환할 이전 값과 다음 상태 103을 나눈다
100·101·102는 한 번씩 발급하고, 다음 상태 103에서는 더 발급하지 않습니다.
설명과 원래 예시 값
이전 값 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회 호출의 결과는 실값 세 개와 빈 결과 두 개입니다.
이 예의 조건
- 발급 연산 상태 순서이며 println 순서는 아닙니다. 정상 5회 호출입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | getAndUpdate가 반환하는 이전 값으로 100부터 102를 발급하고 103에서는 빈 결과를 반환하는 답안의 경계를 보여줍니다. |
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 반환할 이전 값과 다음 상태 103을 나눈다 반환할 이전 값과 다음 상태 103을 나눈다 getAndUpdate가 반환하는 이전 값으로 100부터 102를 발급하고 103에서는 빈 결과를 반환하는 답안의 경계를 보여줍니다. 발급 순서 · 이전 값과 다음 상태 반환할 이전 값과 다음 상태 103을 나눈다 100·101·102는 한 번씩 발급하고, 다음 상태 103에서는 더 발급하지 않습니다. 이전 값 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회 호출의 결과는 실값 세 개와 빈 결과 두 개입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 두 발급 getAndUpdate의 이전 값을 반환합니다. |
|
|
| 마지막 실값 이전 102를 반환하고 next를 103으로 둡니다. |
|
|
| 빈 결과 이전 103에서 상태를 유지하며 두 빈 결과를 반환합니다. |
|
|
그림은 println의 출력 순서가 아니라 발급이 실제로 일어난 순서대로 상태를 따라갑니다. 앞의 세 호출은 이전 값 100, 101, 102를 돌려받고 next를 101, 102, 103으로 늘립니다. 그 뒤의 호출은 103을 그대로 두고 빈 결과를 받습니다. next가 103에서 멈추므로 호출이 아무리 많아도 값이 넘칠 걱정이 없습니다. 또 함수는 새 값을 계산하는 일 말고는 아무것도 하지 않으므로, 경쟁 때문에 여러 번 실행되어도 문제가 없습니다.
다음 글
CAS와 재시도 루프에서는 updateAndGet()과 getAndUpdate()의 바탕에 있는 비교 후 교체(CAS) 연산을 직접 사용합니다. CAS가 실패했을 때 최신 값을 다시 읽어 재시도하는 반복문을 만들고, 그 반복문 안에 부수 효과를 넣으면 왜 위험한지 확인합니다.