가시성·원자성·임계 영역
두 스레드가 같은 값을 다룰 때 증가가 사라지거나 바꾼 값이 보이지 않는 이유를 확인하고, synchronized와 volatile, happens-before 규칙으로 이를 바로잡는 방법을 배웁니다.
join을 다룬 글까지의 예제에서는 작업자마다 자기 칸에만 기록했고, main은 작업자가 모두 끝난 뒤에 결과를 읽었습니다. 이렇게 하면 두 스레드가 같은 값을 동시에 건드릴 일이 없습니다. 하지만 실제 프로그램에서는 여러 스레드가 하나의 카운터, 하나의 계좌, 하나의 목록을 동시에 읽고 고치는 일이 흔합니다. 그러면 스레드가 하나일 때는 없던 문제가 생깁니다.
문제는 크게 세 가지입니다. 첫째는 원자성(atomicity)입니다. count++처럼 한 줄로 보이는 코드도 여러 단계로 실행되기 때문에, 그 사이에 다른 스레드가 끼어들 수 있습니다. 둘째는 가시성(visibility)으로, 한 스레드가 필드를 바꿔도 다른 스레드가 그 값을 언제 보게 될지, 보게 되기는 하는지가 정해져 있지 않습니다. 셋째는 순서(ordering)로, 한 스레드가 쓴 순서와 다른 스레드가 보는 순서가 다를 수 있습니다.
이 글에서는 세 문제를 실행 결과로 하나씩 확인하고, 자바의 두 도구 synchronized와 volatile이 각각 어디까지 해결하는지 살펴봅니다. 두 키워드가 해결하는 문제가 다르므로, 증상만 보고 아무 키워드나 붙이면 문제가 그대로 남습니다. 실행 결과는 코어가 2개인 Linux 컴퓨터에서 JDK 21로 얻었습니다. 동시성 문제는 실행할 때마다 결과가 달라지므로 여러 번 실행한 결과를 함께 적었고, 여러분의 컴퓨터에서는 횟수와 값이 다르게 나올 수 있습니다.
두 스레드가 같은 카운터를 올리면 값이 사라진다
다음 프로그램에서는 두 스레드가 Counter 객체 하나를 함께 쓰며 각각 10만 번씩 increment()를 호출합니다. main은 두 스레드가 모두 끝난 뒤에 값을 읽으므로, 증가가 하나도 사라지지 않는다면 200000이 출력되어야 합니다.
public final class RacyCounter {
private static final class Counter {
private int value;
void increment() {
value++;
}
int value() {
return value;
}
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable work =
() -> {
for (int i = 0; i < 100_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());
}
}한 번 실행한 출력의 예는 다음과 같습니다. main은 두 스레드를 모두 join()한 뒤에 값을 읽었으므로, 200000보다 작은 값이 나온다면 기다림이 부족해서가 아니라 증가 자체가 사라진 것입니다.
count=17116020번 실행했더니 13번은 144707부터 197172 사이의 값이 나왔고, 200000이 나온 것은 7번뿐이었습니다. 가장 작은 144707은 증가 요청 20만 번 가운데 5만 번 넘게 사라졌다는 뜻입니다. 사라지는 양이 매번 다르고 가끔은 정확한 값이 나온다는 점이 이 문제를 까다롭게 만듭니다. 몇 번 실행해서 200000이 나왔다고 코드가 맞는 것은 아닙니다.
count++는 세 단계로 실행된다
원인은 value++가 한 번에 일어나는 동작이 아니라는 데 있습니다. 이 한 줄은 필드에서 현재 값을 읽고, 읽은 값에 1을 더하고, 결과를 필드에 쓰는 세 단계로 실행됩니다. 스레드가 하나라면 세 단계가 연달아 실행되니 문제가 없습니다. 그런데 두 스레드가 함께 실행되면 한 스레드가 읽은 뒤 쓰기 전에 다른 스레드가 끼어들 수 있습니다. value가 5일 때 두 스레드 A와 B가 한 번씩 증가시키는 경우를 단계별로 따라가 보겠습니다.
| 순서 | 스레드 A | 스레드 B | value |
|---|---|---|---|
| 1 | value를 읽음: 5 | 5 | |
| 2 | value를 읽음: 5 | 5 | |
| 3 | 5 + 1 = 6을 계산 | 5 | |
| 4 | value에 6을 씀 | 6 | |
| 5 | 5 + 1 = 6을 계산 | 6 | |
| 6 | value에 6을 씀 | 6 |
두 스레드가 한 번씩 증가시켰으니 7이 되어야 하지만 남은 값은 6입니다. B는 2번에서 읽은 5로 계산했기 때문에 4번에서 A가 쓴 6을 반영하지 못하고, 그 위에 같은 6을 덮어씁니다. 이렇게 한 스레드의 갱신이 다른 스레드의 갱신에 덮여 사라지는 현상을 갱신 손실(lost update)이라고 합니다. 위 표는 매번 일어나는 순서가 아니라 일어날 수 있는 순서 가운데 하나입니다. 10만 번씩 반복하는 동안 이런 겹침이 몇 번 생기느냐에 따라 사라지는 양이 달라지고, 한 번도 겹치지 않으면 200000이 나옵니다.
이처럼 결과가 스레드들이 끼어드는 순서에 따라 달라지는 상황을 경쟁 조건(race condition)이라고 합니다. 그리고 여러 단계로 된 동작이 다른 스레드에게는 하나의 동작처럼 보이는 성질을 원자성이라고 합니다. 원자적인 동작이라면 다른 스레드는 동작 전이나 동작 후의 상태만 볼 수 있고, 중간에 끼어들 수 없습니다. value++에는 이 성질이 없습니다.
배리어로 갱신 손실을 매번 재현하기
앞의 프로그램에서는 겹침이 우연에 맡겨져 있어서 결과를 예측할 수 없습니다. 원인을 차분히 따져 보려면 앞의 표와 같은 겹침을 매번 일부러 만들어 내는 편이 좋습니다. 다음 프로그램은 CyclicBarrier를 써서 두 작업자가 같은 값을 읽은 다음에 쓰도록 만듭니다.
CyclicBarrier(2)는 두 스레드가 모두 await()에 도착할 때까지 먼저 온 스레드를 기다리게 하고, 둘이 모이면 함께 통과시킵니다. 통과한 뒤에는 처음 상태로 돌아가 다시 쓸 수 있어서 이름에 '순환(cyclic)'이 붙었습니다. 이 프로그램은 읽기 뒤에 readTogether, 쓰기 뒤에 writeTogether 배리어를 둡니다. await()는 기다리는 도중 인터럽트를 받으면 InterruptedException을, 다른 참여자가 인터럽트나 시간 초과로 빠져 배리어가 깨지면 BrokenBarrierException을 던지므로, 아래쪽 await 메서드가 두 예외를 처리합니다.
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);
}
}
}두 작업자가 각각 100번씩 증가하므로 기대값은 200입니다. 이번에는 배리어가 두 스레드의 읽기와 쓰기를 매 회차 같은 시점에 맞추므로, 사라지는 증가가 앞의 RacyCounter처럼 들쭉날쭉하지 않습니다.
expected=200, actual=10020번 실행해 20번 모두 actual=100이었습니다. 이 결과는 우연이 아닙니다. 한 회차를 따라가 보면, 두 작업자는 각자 counter.value를 읽어 observed에 담은 뒤 readTogether에서 서로를 기다립니다. 배리어가 열리는 시점에는 두 작업자 모두 같은 값 n을 읽은 상태이고, 배리어를 통과한 뒤 각자 n + 1을 씁니다. 누가 먼저 쓰든 같은 값이므로 결과는 n + 1입니다.
이어서 writeTogether가 두 쓰기가 모두 끝나기를 기다린 뒤에야 다음 회차가 시작됩니다. 따라서 회차마다 증가 요청은 두 번이지만 값은 1만 늘어나고, 0에서 시작해 100회를 마치면 실제 값은 100입니다. 증가 요청 200번과 실제로 반영된 증가 100번을 구별해서 세면 무슨 일이 일어났는지 분명해집니다.
두 작업자가 같은 값을 쓰면 한 회차에 1씩 증가한다
읽기 배리어로 둘 다 n을 읽고 각 n+1을 써 한 번만 증가하는 재현입니다. 작업자 상대 순서는 고정하지 않고 배리어·공유값을 단계로 추적합니다.
CoordinatedLostUpdate의 두 배리어가 한 회차의 읽기와 쓰기를 나눠 같은 값으로 덮어쓰는 과정을 고정합니다.
갱신 유실 · 한 회차
두 작업자가 같은 값을 쓰면 한 회차에 1씩 증가한다
두 작업자가 같은 n을 읽고 같은 n+1을 씁니다. 두 배리어가 다음 회차까지의 경계를 고정합니다.
읽기 배리어 전
- 두 작업자의 동작
- 각자
observed에 같은 값 n을 읽고readTogether에서 기다립니다. - 공유 값
- 두 읽기가 끝나기 전에는 이 회차의 쓰기가 시작되지 않습니다.
읽기 배리어 통과 후
- 두 작업자의 동작
- 각자 n + 1을
value에 씁니다. - 공유 값
- 쓰기 순서와 무관하게 같은 값 n + 1로 덮어씁니다.
쓰기 배리어 통과 후
- 두 작업자의 동작
writeTogether가 두 쓰기의 완료를 모읍니다.- 공유 값
- 다음 회차는 n + 1에서 시작합니다.
초기값 0에서 100회가 정상 완료되면 200번의 증가 요청 뒤 실제 값은 100입니다.
이 예의 조건
- 작업자 사이 실행 순서는 고정하지 않고 두 배리어의 완료만 연결합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 두 읽기 완료를 모은 뒤 두 작업자가 같은 n+1을 쓰고 두 쓰기 완료를 모읍니다. 작업자 사이 사슬이 아니라 두 배리어의 완료 조건을 화살표로 연결합니다. 100회 정상 완료는 실제 100입니다. |
| 16 | n+1 |
| 17 | 요청 둘·증가 하나 |
| 19 | 0에서 100회 정상 완료하면 100 · 200개 증가 요청과 100의 상태 증가를 구별 |
| 구분 | 내용 |
|---|---|
| 설명 | 두 읽기 완료를 모은 뒤 두 작업자가 같은 n+1을 쓰고 두 쓰기 완료를 모읍니다. 작업자 사이 사슬이 아니라 두 배리어의 완료 조건을 화살표로 연결합니다. 100회 정상 완료는 실제 100입니다. |
| 전체 읽기 정보 | 두 작업자가 같은 값을 쓰면 한 회차에 1씩 증가한다 두 작업자가 같은 값을 쓰면 한 회차에 1씩 증가한다 CoordinatedLostUpdate의 두 배리어가 한 회차의 읽기와 쓰기를 나눠 같은 값으로 덮어쓰는 과정을 고정합니다. 갱신 유실 · 한 회차 두 작업자가 같은 값을 쓰면 한 회차에 1씩 증가한다 두 작업자가 같은 n을 읽고 같은 n+1을 씁니다. 두 배리어가 다음 회차까지의 경계를 고정합니다. 읽기 배리어 전 두 작업자의 동작 각자 observed 에 같은 값 n을 읽고 readTogether 에서 기다립니다. 공유 값 두 읽기가 끝나기 전에는 이 회차의 쓰기가 시작되지 않습니다. 읽기 배리어 통과 후 두 작업자의 동작 각자 n + 1을 value 에 씁니다. 공유 값 쓰기 순서와 무관하게 같은 값 n + 1로 덮어씁니다. 쓰기 배리어 통과 후 두 작업자의 동작 writeTogether 가 두 쓰기의 완료를 모읍니다. 공유 값 다음 회차는 n + 1에서 시작합니다. 초깃값 0에서 100회가 정상 완료되면 200번의 증가 요청 뒤 실제 값은 100입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 같은 값을 읽음 left와 right가 각각 n을 읽고 읽기 배리어에 도달합니다. |
|
|
| 같은 값을 씀 각 작업자가 자기 관측값에 1을 더해 씁니다. |
|
|
| 다음 회차 쓰기 배리어 뒤 공유 값은 n+1입니다. |
|
|
두 작업자가 같은 값을 쓰면 한 회차에 1씩 증가한다
읽기 배리어로 둘 다 n을 읽고 각 n+1을 써 한 번만 증가하는 재현입니다. 작업자 상대 순서는 고정하지 않고 배리어·공유값을 단계로 추적합니다.
CoordinatedLostUpdate의 두 배리어가 한 회차의 읽기와 쓰기를 나눠 같은 값으로 덮어쓰는 과정을 고정합니다.
갱신 유실 · 한 회차
두 작업자가 같은 값을 쓰면 한 회차에 1씩 증가한다
두 작업자가 같은 n을 읽고 같은 n+1을 씁니다. 두 배리어가 다음 회차까지의 경계를 고정합니다.
읽기 배리어 전
- 두 작업자의 동작
- 각자
observed에 같은 값 n을 읽고readTogether에서 기다립니다. - 공유 값
- 두 읽기가 끝나기 전에는 이 회차의 쓰기가 시작되지 않습니다.
읽기 배리어 통과 후
- 두 작업자의 동작
- 각자 n + 1을
value에 씁니다. - 공유 값
- 쓰기 순서와 무관하게 같은 값 n + 1로 덮어씁니다.
쓰기 배리어 통과 후
- 두 작업자의 동작
writeTogether가 두 쓰기의 완료를 모읍니다.- 공유 값
- 다음 회차는 n + 1에서 시작합니다.
초기값 0에서 100회가 정상 완료되면 200번의 증가 요청 뒤 실제 값은 100입니다.
이 예의 조건
- 작업자 사이 실행 순서는 고정하지 않고 두 배리어의 완료만 연결합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 두 읽기 완료를 모은 뒤 두 작업자가 같은 n+1을 쓰고 두 쓰기 완료를 모읍니다. 작업자 사이 사슬이 아니라 두 배리어의 완료 조건을 화살표로 연결합니다. 100회 정상 완료는 실제 100입니다. |
| 16 | n+1 |
| 17 | 요청 둘·증가 하나 |
| 19 | 0에서 100회 정상 완료하면 100 · 200개 증가 요청과 100의 상태 증가를 구별 |
| 구분 | 내용 |
|---|---|
| 설명 | 두 읽기 완료를 모은 뒤 두 작업자가 같은 n+1을 쓰고 두 쓰기 완료를 모읍니다. 작업자 사이 사슬이 아니라 두 배리어의 완료 조건을 화살표로 연결합니다. 100회 정상 완료는 실제 100입니다. |
| 전체 읽기 정보 | 두 작업자가 같은 값을 쓰면 한 회차에 1씩 증가한다 두 작업자가 같은 값을 쓰면 한 회차에 1씩 증가한다 CoordinatedLostUpdate의 두 배리어가 한 회차의 읽기와 쓰기를 나눠 같은 값으로 덮어쓰는 과정을 고정합니다. 갱신 유실 · 한 회차 두 작업자가 같은 값을 쓰면 한 회차에 1씩 증가한다 두 작업자가 같은 n을 읽고 같은 n+1을 씁니다. 두 배리어가 다음 회차까지의 경계를 고정합니다. 읽기 배리어 전 두 작업자의 동작 각자 observed 에 같은 값 n을 읽고 readTogether 에서 기다립니다. 공유 값 두 읽기가 끝나기 전에는 이 회차의 쓰기가 시작되지 않습니다. 읽기 배리어 통과 후 두 작업자의 동작 각자 n + 1을 value 에 씁니다. 공유 값 쓰기 순서와 무관하게 같은 값 n + 1로 덮어씁니다. 쓰기 배리어 통과 후 두 작업자의 동작 writeTogether 가 두 쓰기의 완료를 모읍니다. 공유 값 다음 회차는 n + 1에서 시작합니다. 초깃값 0에서 100회가 정상 완료되면 200번의 증가 요청 뒤 실제 값은 100입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 같은 값을 읽음 left와 right가 각각 n을 읽고 읽기 배리어에 도달합니다. |
|
|
| 같은 값을 씀 각 작업자가 자기 관측값에 1을 더해 씁니다. |
|
|
| 다음 회차 쓰기 배리어 뒤 공유 값은 n+1입니다. |
|
|
도표의 화살표는 두 배리어가 열리는 조건을 이을 뿐, left와 right 중 누가 먼저 실행되는지는 정하지 않습니다. 결과가 매번 같은 이유는 실행 순서가 고정돼서가 아니라, 어떤 순서로 실행되든 두 작업자가 같은 값을 읽고 같은 값을 쓰도록 배리어가 강제하기 때문입니다. 배리어는 문제를 드러내기 위한 장치이지 해결책이 아닙니다. 실제 프로그램의 스레드는 이렇게 보조를 맞추지 않으므로, RacyCounter처럼 겹침이 드문드문 우연히 생깁니다.
synchronized로 증가를 한 덩어리로 묶기
해결책은 읽기, 더하기, 쓰기를 다른 스레드가 끼어들 수 없는 한 덩어리로 만드는 것입니다. 자바에서 가장 기본적인 방법은 synchronized입니다. 다음은 RacyCounter의 두 메서드에 synchronized를 붙이고 클래스 이름만 바꾼 프로그램입니다.
public final class SynchronizedCounter {
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 < 100_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());
}
}작업 내용과 스레드 구성은 RacyCounter와 똑같고, 달라진 것은 두 메서드에 붙은 synchronized뿐입니다. 출력이 200000에 닿는지 보세요.
count=20000030번 실행해 30번 모두 200000이 나왔습니다. 이번에는 몇 번을 실행하든 결과가 달라지지 않습니다. 결과가 운이 아니라 규칙으로 정해지기 때문입니다.
자바의 모든 객체에는 모니터(monitor)라는 잠금 장치가 하나씩 연결되어 있습니다. synchronized 인스턴스 메서드를 호출한 스레드는 본문을 실행하기 전에 그 메서드가 속한 객체, 즉 this의 모니터를 잠그는데, 이것을 '락(lock)을 얻는다'고 말합니다. 한 모니터는 한 번에 한 스레드만 잠글 수 있습니다. 그래서 A가 counter의 락을 쥐고 increment()를 실행하는 동안 B가 같은 객체의 increment()를 호출하면, B는 A가 메서드를 빠져나가며 락을 놓을 때까지 기다립니다.
결과적으로 A의 읽기, 더하기, 쓰기가 모두 끝난 뒤에야 B가 읽기를 시작하고, B는 언제나 A가 쓴 값을 읽습니다. 이처럼 한 번에 한 스레드만 실행하도록 보호하는 코드 구간을 임계 영역(critical section)이라고 하고, 여러 스레드가 같은 임계 영역에 동시에 들어가지 못하게 막는 것을 상호 배제(mutual exclusion)라고 합니다. 임계 영역 안의 코드는 다른 스레드에게 원자적으로 보입니다. 단, 같은 락을 쓰는 다른 스레드에게만 그렇습니다.
value()에도 synchronized를 붙인 이유가 궁금할 수 있습니다. 이 프로그램은 main이 두 스레드를 join()한 뒤에 값을 읽으므로, join을 다룬 글에서 본 규칙만으로도 최종 값이 보입니다. 하지만 작업자가 실행 중일 때 다른 스레드가 value()를 호출할 수도 있다면, 읽기도 같은 락 아래에서 해야 가장 최근에 쓴 값을 본다는 것이 보장됩니다. 그 이유는 뒤의 happens-before 절에서 설명합니다. 공유 필드를 읽고 쓰는 곳을 모두 같은 락으로 보호한다는 원칙을 지키면 경우마다 따져 볼 일이 줄어듭니다.
메서드 전체가 아니라 일부 구간만 보호하고 싶다면 synchronized 블록을 씁니다. synchronized (counter) { ... }처럼 괄호 안에 락으로 쓸 객체를 적으면, 중괄호 안의 코드만 그 객체의 모니터를 쥔 채 실행됩니다. synchronized 인스턴스 메서드는 본문 전체를 synchronized (this) 블록으로 감싼 것과 같은 락을 씁니다. 블록이나 메서드를 빠져나갈 때는 정상적으로 끝나든 예외로 끝나든 락이 자동으로 풀립니다(JLS 17.1).
락을 너무 넓게 잡아도 결과는 맞습니다. 예를 들어 작업자의 반복문 전체를 synchronized (counter)로 감싸도 20번 모두 200000이 나왔습니다. 하지만 그러면 먼저 락을 얻은 스레드가 10만 번을 모두 마칠 때까지 다른 스레드는 한 번도 증가시키지 못하므로, 스레드를 둘 쓰는 의미가 없어집니다. 반대로 락을 너무 좁게 잡으면 원자성이 깨집니다. 적당한 넓이를 정하는 기준은 뒤의 장부 예제에서 다룹니다.
배리어까지 락 안에 넣으면 멈춘다
CoordinatedLostUpdate를 고치려고 읽기부터 쓰기까지를 synchronized (counter)로 감싸면서 두 배리어를 그대로 남겨 두면 어떻게 될까요? 이렇게 바꾼 프로그램은 아무것도 출력하지 않고 멈춥니다. 5번 실행해 5번 모두 멈췄습니다. 실행 중인 JVM의 모든 스레드가 지금 어느 코드에서 무엇을 기다리는지 찍은 기록을 스레드 덤프(thread dump)라고 하는데, JDK에 들어 있는 jstack 명령으로 멈춘 프로세스의 스레드 덤프를 찍어 보니 두 작업자는 다음 상태였습니다(긴 줄과 필요 없는 줄은 ...로 줄였습니다). 스레드 덤프를 찍고 읽는 방법은 스레드 덤프와 프로파일러로 장애 원인 찾기에서 자세히 다룹니다.
"lost-left" ... waiting for monitor entry
java.lang.Thread.State: BLOCKED (on object monitor)
...
- waiting to lock <0x00000000a851a520> (a CoordinatedLostUpdate$Counter)
...
"lost-right" ... waiting on condition
java.lang.Thread.State: WAITING (parking)
at jdk.internal.misc.Unsafe.park([email protected]/Native Method)
- parking to wait for <0x00000000a851a6c8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at java.util.concurrent.locks.LockSupport.park([email protected]/LockSupport.java:371)
...
at java.util.concurrent.CyclicBarrier.await([email protected]/CyclicBarrier.java:364)
...lost-right가 먼저 락을 얻어 값을 읽은 뒤 readTogether.await()에서 짝을 기다리고 있습니다. 짝인 lost-left는 블록에 들어가려면 같은 counter의 락을 얻어야 하므로 입구에서 BLOCKED 상태로 기다립니다. 락을 쥔 쪽은 배리어가 열리기를, 배리어를 열어 줄 쪽은 락이 풀리기를 기다리니 어느 쪽도 진행하지 못합니다. 이처럼 스레드들이 서로 상대를 기다리며 영원히 멈추는 상태를 교착 상태(deadlock)라고 합니다. 어느 쪽이 먼저 락을 얻느냐에 따라 두 스레드의 자리는 바뀔 수 있지만, 멈춘다는 결과는 같습니다.
교훈은 락을 쥔 채로, 다른 스레드가 같은 락에 들어와야만 채워지는 조건을 기다리면 안 된다는 것입니다. 증가를 보호하는 올바른 코드는 재현용 배리어를 빼고 읽기, 더하기, 쓰기만 임계 영역에 넣은 SynchronizedCounter입니다. 덤프에 보이는 WAITING (parking)과 LockSupport.park는 다음 글에서 다룹니다.
한 스레드가 바꾼 값이 다른 스레드에 보이지 않는다
원자성 다음은 가시성입니다. 다음 프로그램의 작업자는 running이 true인 동안 숫자를 세고, main은 0.5초 뒤 running을 false로 바꿉니다. 작업자가 그다음 반복에서 false를 읽고 멈출 것 같지만, 그렇지 않을 수 있습니다. 작업자를 데몬 스레드로 만든 것은 작업자가 멈추지 않더라도 main이 끝나면 프로그램이 종료되게 하기 위해서입니다(Runnable과 데몬 스레드).
public final class PlainStopFlag {
private static boolean running = true;
public static void main(String[] args) throws InterruptedException {
Thread worker =
new Thread(
() -> {
long count = 0;
while (running) {
count++;
}
System.out.println("worker stopped after " + count + " loops");
},
"plain-loop");
worker.setDaemon(true);
worker.start();
Thread.sleep(500);
running = false;
System.out.println("main: running = false");
worker.join(2_000);
System.out.println("worker alive after 2s = " + worker.isAlive());
}
}첫 줄은 main이 플래그를 바꾼 직후에 출력하고, 둘째 줄은 join(2_000)으로 최대 2초를 기다린 뒤 작업자가 아직 살아 있는지를 출력합니다. 작업자가 플래그를 보고 멈췄다면 둘째 줄은 false여야 합니다.
main: running = false
worker alive after 2s = true10번 실행해 10번 모두 이렇게 나왔습니다. main은 running을 false로 바꿨지만 작업자는 2초를 더 기다려도 멈추지 않았고, worker stopped 줄도 출력되지 않았습니다. 작업자는 데몬 스레드라서 main이 끝날 때 함께 종료됐습니다.
왜 이런 일이 생길까요? 컴파일러와 CPU는 한 스레드 안에서 결과가 달라지지 않는 범위에서 코드를 바꿔 실행할 수 있습니다. 작업자 스레드만 놓고 보면 반복문 안에 running을 바꾸는 코드가 없으므로, 매번 메모리에서 다시 읽지 않고 처음 읽은 값을 계속 써도 결과가 같아 보입니다. 이 최적화가 적용되면 반복문은 사실상 while (true)가 되어, 다른 스레드가 값을 바꿔도 끝나지 않습니다. Java 언어 명세(JLS)도 17.3절에서 일반 필드를 검사하는 반복문을 예로 들어, 컴파일러가 필드를 한 번만 읽고 그 값을 계속 써도 된다고 설명합니다.
중요한 점은 이 프로그램이 '반드시 멈추지 않는다'가 아니라 '멈춘다는 보장이 없다'는 것입니다. JLS는 동기화 없이 일반 필드에 쓴 값이 다른 스레드에 언제 보이는지 정하지 않으므로, JVM이나 실행 환경에 따라 금방 멈출 수도 있고 영원히 돌 수도 있습니다. 한 스레드가 쓴 값을 다른 스레드가 볼 수 있느냐의 문제, 이것이 가시성입니다.
volatile을 붙이면 멈춘다
running 선언에 volatile을 붙이면 문제가 사라집니다. PlainStopFlag에서 바꾸는 것은 다음 필드 선언 한 줄뿐이고, 작업자의 반복문과 main의 코드는 그대로입니다.
private static volatile boolean running = true;이 한 줄만 바꾼 프로그램을 10번 실행해 10번 모두 작업자가 멈췄습니다. 다음은 그중 한 번의 출력입니다. 반복 횟수는 실행마다 다르고, 앞의 두 줄은 순서가 바뀌어 나올 수도 있습니다.
main: running = false
worker stopped after 1317230158 loops
worker alive after 2s = falseJLS는 volatile 필드에 쓴 값이 그 뒤에 같은 필드를 읽는 모든 스레드에 보인다고 정합니다. 그래서 컴파일러는 volatile 필드를 한 번 읽어 두고 재사용하는 최적화를 할 수 없고, 작업자는 반복할 때마다 running을 다시 읽습니다. main이 false를 쓴 뒤의 읽기는 false를 보므로 반복이 끝납니다. 이 규칙의 정확한 형태는 뒤의 happens-before 절에서 다룹니다.
sleep이나 println은 해결책이 아니다
반복문 안에 System.out.println이나 Thread.sleep을 넣었더니 멈추더라는 이야기를 자주 듣습니다. 실제로 PlainStopFlag의 반복문에서 5천만 번마다 한 번씩 System.out.print("")를 호출하게 바꿨더니, 필드는 그대로 일반 필드인데도 5번 모두 멈췄습니다. 하지만 이것은 출력 코드가 JIT 컴파일러(실행 중에 자주 쓰이는 코드를 기계어로 바꾸는 컴파일러)의 최적화에 영향을 주어 우연히 나타난 결과이고, main의 쓰기가 보여야 한다는 규칙이 생긴 것은 아닙니다. JLS 17.3은 Thread.sleep과 Thread.yield에 동기화 효과가 전혀 없다고 명시합니다. 출력이나 지연을 넣어 멈추게 만든 코드는 JVM 버전이나 최적화 방식이 바뀌면 다시 멈추지 않을 수 있으므로, 가시성은 언제나 volatile이나 락처럼 규칙이 있는 도구로 해결해야 합니다.
더 깊이: 인터프리터로 실행하면 멈추는 이유
PlainStopFlag를 java -Xint PlainStopFlag로, 즉 JIT 컴파일러를 끄고 인터프리터로만 실행하면 같은 코드인데도 3번 모두 멈췄습니다. 인터프리터는 반복할 때마다 바이트코드대로 필드를 실제로 읽기 때문입니다. 기본 설정에서는 여러 번 반복되는 코드를 JIT 컴파일러가 기계어로 바꾸면서, 반복문 안에서 바뀌지 않는 필드 읽기를 반복문 밖으로 옮길 수 있고 이 실행에서도 그런 일이 일어난 것으로 보입니다. 이처럼 일반 필드의 가시성 문제는 CPU 캐시보다 컴파일러 최적화에서 먼저 드러나는 경우가 많습니다. 원인을 특정 하드웨어나 최적화로 외우기보다 '동기화가 없으면 보인다는 약속도 없다'는 규칙으로 이해해야, 처음 보는 환경에서도 같은 판단을 할 수 있습니다.
volatile로 중지 신호 보내기
volatile이 잘 맞는 곳은 지금처럼 한 스레드가 값 하나를 바꾸고 다른 스레드는 읽기만 하는 경우입니다. 중지 플래그, 설정이 바뀌었다는 표시, 새로 만든 불변 설정 객체를 가리키는 참조 같은 것이 그렇습니다. 다음 프로그램은 이 패턴을 조금 더 다듬은 것입니다. 작업자는 실행을 시작했다는 신호를 CountDownLatch로 보내고, 반복을 마치면 센 횟수를 일반 필드 iterations에 기록합니다.
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));
}
}실행 결과는 대부분 다음과 같습니다. stopped는 join()이 돌아온 뒤 작업자가 끝났는지를, worked는 작업자가 반복을 한 번 이상 했는지를 나타냅니다.
stopped=true, worked=true하지만 200번 실행했더니 17번은 stopped=true, worked=false가 나왔습니다. 작업자가 한 번도 반복하지 않고 끝났다는 뜻인데, 이것은 버그가 아니라 코드대로 일어날 수 있는 결과입니다. 작업자는 started.countDown()을 호출한 다음에야 while의 첫 조건을 검사합니다. 그 사이에 main이 started.await()에서 깨어나 running = false를 먼저 쓰면, 작업자의 첫 검사가 false를 읽어 반복문에 들어가지 않습니다. '시작했다'는 신호는 '한 번 이상 반복했다'는 뜻이 아닌 것입니다.
중지 요청과 최종 결과는 서로 다른 경계로 공개된다
volatile 중지 공개와 worker 최종 iterations 기록, main join 뒤 읽기가 서로 다른 경계입니다. 요청·반복 종료·최종 결과 회수를 나누면 가시성 의미가 분명합니다.
VolatileStopSignal에서 running은 volatile로, iterations는 작업자 종료를 기다리는 join으로 공개됩니다.
공개 경계 · volatile / join
중지 요청과 최종 결과는 서로 다른 경계로 공개된다
중지 요청은 volatile로 전달합니다. 작업자의 최종 횟수는 종료를 회수한 join 뒤 읽습니다.
running
- 원문의 쓰기와 읽기
main이false를 쓰고 작업자가 반복 조건에서 읽습니다.- 공개 경계
- 같은 volatile 필드의 쓰기와 뒤따르는 읽기
iterations
- 원문의 쓰기와 읽기
- 작업자가 종료 직전에 쓰고
main이 읽습니다. - 공개 경계
- 성공한
join뒤의 읽기이므로 이 필드는 volatile일 필요가 없습니다.
started 신호는 첫 증가보다 먼저입니다. 따라서 완료 후 stopped=true여도 worked는 false 또는 true일 수 있으며 반복 횟수는 고정되지 않습니다.
이 예의 조건
- started 신호가 첫 증가보다 앞서므로 worked와 반복 횟수는 고정되지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | running은 volatile 조건 전달, iterations는 종료한 worker의 마지막 기록을 정상 join 뒤 읽는 공개 경로입니다. started 신호는 첫 증가보다 먼저여서 worked는 가변입니다. |
| 17 | started 신호는 첫 증가보다 먼저 |
| 18 | 순회 횟수나 종료 시각은 고정 아님 |
| 구분 | 내용 |
|---|---|
| 설명 | running은 volatile 조건 전달, iterations는 종료한 worker의 마지막 기록을 정상 join 뒤 읽는 공개 경로입니다. started 신호는 첫 증가보다 먼저여서 worked는 가변입니다. |
| 전체 읽기 정보 | 중지 요청과 최종 결과는 서로 다른 경계로 공개된다 중지 요청과 최종 결과는 서로 다른 경계로 공개된다 VolatileStopSignal에서 running은 volatile로, iterations는 작업자 종료를 기다리는 join으로 공개됩니다. 공개 경계 · volatile / join 중지 요청과 최종 결과는 서로 다른 경계로 공개된다 중지 요청은 volatile로 전달합니다. 작업자의 최종 횟수는 종료를 회수한 join 뒤 읽습니다. running 원문의 쓰기와 읽기 main 이 false 를 쓰고 작업자가 반복 조건에서 읽습니다. 공개 경계 같은 volatile 필드의 쓰기와 뒤따르는 읽기 iterations 원문의 쓰기와 읽기 작업자가 종료 직전에 쓰고 main 이 읽습니다. 공개 경계 성공한 join 뒤의 읽기이므로 이 필드는 volatile일 필요가 없습니다. started 신호는 첫 증가보다 먼저입니다. 따라서 완료 후 stopped=true 여도 worked 는 false 또는 true일 수 있으며 반복 횟수는 고정되지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 중지 요청 main이 volatile running=false를 씁니다. |
|
|
| 마지막 기록 worker가 반복에서 나와 local을 iterations에 기록합니다. |
|
|
| 종료 뒤 읽기 main은 정상 join 뒤 iterations를 조회합니다. |
|
|
중지 요청과 최종 결과는 서로 다른 경계로 공개된다
volatile 중지 공개와 worker 최종 iterations 기록, main join 뒤 읽기가 서로 다른 경계입니다. 요청·반복 종료·최종 결과 회수를 나누면 가시성 의미가 분명합니다.
VolatileStopSignal에서 running은 volatile로, iterations는 작업자 종료를 기다리는 join으로 공개됩니다.
공개 경계 · volatile / join
중지 요청과 최종 결과는 서로 다른 경계로 공개된다
중지 요청은 volatile로 전달합니다. 작업자의 최종 횟수는 종료를 회수한 join 뒤 읽습니다.
running
- 원문의 쓰기와 읽기
main이false를 쓰고 작업자가 반복 조건에서 읽습니다.- 공개 경계
- 같은 volatile 필드의 쓰기와 뒤따르는 읽기
iterations
- 원문의 쓰기와 읽기
- 작업자가 종료 직전에 쓰고
main이 읽습니다. - 공개 경계
- 성공한
join뒤의 읽기이므로 이 필드는 volatile일 필요가 없습니다.
started 신호는 첫 증가보다 먼저입니다. 따라서 완료 후 stopped=true여도 worked는 false 또는 true일 수 있으며 반복 횟수는 고정되지 않습니다.
이 예의 조건
- started 신호가 첫 증가보다 앞서므로 worked와 반복 횟수는 고정되지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | running은 volatile 조건 전달, iterations는 종료한 worker의 마지막 기록을 정상 join 뒤 읽는 공개 경로입니다. started 신호는 첫 증가보다 먼저여서 worked는 가변입니다. |
| 17 | started 신호는 첫 증가보다 먼저 |
| 18 | 순회 횟수나 종료 시각은 고정 아님 |
| 구분 | 내용 |
|---|---|
| 설명 | running은 volatile 조건 전달, iterations는 종료한 worker의 마지막 기록을 정상 join 뒤 읽는 공개 경로입니다. started 신호는 첫 증가보다 먼저여서 worked는 가변입니다. |
| 전체 읽기 정보 | 중지 요청과 최종 결과는 서로 다른 경계로 공개된다 중지 요청과 최종 결과는 서로 다른 경계로 공개된다 VolatileStopSignal에서 running은 volatile로, iterations는 작업자 종료를 기다리는 join으로 공개됩니다. 공개 경계 · volatile / join 중지 요청과 최종 결과는 서로 다른 경계로 공개된다 중지 요청은 volatile로 전달합니다. 작업자의 최종 횟수는 종료를 회수한 join 뒤 읽습니다. running 원문의 쓰기와 읽기 main 이 false 를 쓰고 작업자가 반복 조건에서 읽습니다. 공개 경계 같은 volatile 필드의 쓰기와 뒤따르는 읽기 iterations 원문의 쓰기와 읽기 작업자가 종료 직전에 쓰고 main 이 읽습니다. 공개 경계 성공한 join 뒤의 읽기이므로 이 필드는 volatile일 필요가 없습니다. started 신호는 첫 증가보다 먼저입니다. 따라서 완료 후 stopped=true 여도 worked 는 false 또는 true일 수 있으며 반복 횟수는 고정되지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 중지 요청 main이 volatile running=false를 씁니다. |
|
|
| 마지막 기록 worker가 반복에서 나와 local을 iterations에 기록합니다. |
|
|
| 종료 뒤 읽기 main은 정상 join 뒤 iterations를 조회합니다. |
|
|
이 프로그램에서는 두 필드가 서로 다른 길로 전달됩니다. 도표의 첫 단계처럼 중지 요청 running은 volatile 쓰기와 읽기로 작업자에게 전달됩니다. 반면 최종 횟수 iterations는 일반 필드인데도 main이 정확한 값을 읽습니다. 작업자는 반복을 마친 뒤 iterations를 기록하고 끝나며, main은 worker.join()이 돌아온 뒤에야 이 필드를 읽기 때문입니다. join을 다룬 글에서 본 것처럼 작업자의 모든 동작은 다른 스레드가 그 작업자의 join()에서 돌아오기 전에 일어난 것으로 취급되므로, 그 기록이 보입니다.
stopped=true가 매번 나오는 것도 join()이 작업자가 끝난 뒤에야 돌아오기 때문입니다. 만약 main이 join() 없이 iterations를 읽었다면, 작업자가 아직 기록하기 전일 수도 있고 기록했더라도 그 값이 보인다는 보장이 없습니다. 하나의 프로그램 안에서도 값마다 어떤 규칙으로 전달되는지를 따로 따져야 하는 이유입니다.
volatile은 count++를 원자적으로 만들지 않는다
가시성 문제를 volatile로 해결했으니 카운터에도 붙이면 되지 않을까요? RacyCounter의 필드를 private volatile int value;로 바꿔 20번 실행했더니, 15번은 200000이 나왔지만 5번은 196745, 135446처럼 더 작은 값이 나왔습니다. 배리어로 겹침을 만든 CoordinatedLostUpdate도 volatile int value로 바꿔 20번 실행했는데, 매번 actual=100이었습니다.
volatile이 해 주는 것은 각각의 읽기와 쓰기를 다른 스레드에 보이게 하는 것뿐입니다. 앞의 표에서 B가 5를 읽은 것은 가시성 문제가 아니었습니다. 그 순간에는 아직 아무도 6을 쓰지 않았으므로 5가 최신 값이었습니다. 문제는 B가 읽고 나서 쓰기 전에 A의 쓰기가 끼어든 것이고, volatile은 읽기와 쓰기 사이에 다른 스레드가 끼어드는 것을 막지 않습니다. 읽고, 계산하고, 쓰는 과정을 하나로 묶으려면 synchronized 같은 락이나 volatile과 AtomicInteger에서 다루는 원자 연산이 필요합니다.
happens-before: 다른 스레드의 기록이 보이는 조건
지금까지 '보인다', '보장이 없다'는 말을 여러 번 썼습니다. 이 말의 정확한 뜻을 정하는 규칙이 자바 메모리 모델(Java Memory Model, JMM)이고, JLS 17장 4절에 들어 있습니다. 그 핵심은 happens-before라는 관계입니다. JLS는 동작 x가 동작 y보다 happens-before라면 x의 결과가 y에게 보이고, x가 y보다 앞선 것으로 취급된다고 정합니다(JLS 17.4.5). 반대로 두 동작 사이에 이 관계가 없으면 한쪽이 쓴 값을 다른 쪽이 본다는 보장도 없습니다.
JLS와 java.util.concurrent 패키지 문서가 정한 기본 관계 가운데 이 글에 필요한 것은 다음과 같습니다.
- 같은 스레드 안에서는 코드 순서상 앞선 동작이 뒤의 동작보다 happens-before입니다.
- 모니터의 잠금 해제(
synchronized블록이나 메서드를 빠져나감)는 그 뒤에 같은 모니터를 잠그는 동작보다 happens-before입니다. - volatile 필드에 대한 쓰기는 그 뒤에 같은 필드를 읽는 동작보다 happens-before입니다.
- 어떤 스레드에 대한
start()호출은 그 스레드 안의 모든 동작보다 happens-before입니다. - 한 스레드의 모든 동작은 다른 스레드가 그 스레드에 대한
join()에서 정상적으로 돌아오는 것보다 happens-before입니다.isAlive()가false를 돌려주는 것을 확인한 경우도 같습니다. - x가 y보다, y가 z보다 happens-before라면 x는 z보다 happens-before입니다.
마지막 규칙 덕분에 관계를 사슬처럼 이어 붙일 수 있습니다. SynchronizedCounter에서 A가 increment()로 6을 쓰고 락을 놓은 뒤 B가 같은 락을 얻었다고 해 봅시다. A의 쓰기는 같은 스레드 안에서 A의 잠금 해제보다 앞서고, A의 잠금 해제는 B의 잠금보다 앞서며, B의 잠금은 다시 B의 읽기보다 앞섭니다. 사슬이 이어지므로 A의 쓰기는 B의 읽기보다 happens-before이고, B는 A가 쓴 값을 봅니다.
value()에도 synchronized를 붙인 이유가 여기에 있습니다. 락 없이 읽으면 사슬에서 '잠금 해제 → 같은 모니터의 잠금' 고리가 빠지므로, 작업자가 쓴 값이 보인다는 보장이 사라집니다. main이 join() 뒤에 읽을 때 락 없이도 값이 보였던 것은 join 규칙이 그 고리를 대신해 주었기 때문입니다.
같은 사슬이 글 첫머리에서 말한 순서 문제도 해결합니다. 한 스레드가 일반 필드 port에 값을 쓴 다음 volatile 필드 ready에 true를 쓰고, 다른 스레드가 ready에서 true를 읽은 다음 port를 읽는다고 해 봅시다. 코드 순서와 volatile 규칙이 사슬로 이어지므로 두 번째 스레드는 port에 쓴 값을 봅니다. ready가 일반 필드였다면 이 사슬이 없어서, ready는 true로 보이는데 port는 아직 기본값 0으로 보이는 일도 허용됩니다. 쓴 순서와 보이는 순서가 다를 수 있다는 것이 순서 문제이고, 이 예는 volatile과 AtomicInteger에서 코드와 함께 다시 다룹니다.
데이터 경합과 경쟁 조건
JLS는 같은 변수에 대한 두 접근 가운데 적어도 하나가 쓰기이면 두 접근이 충돌한다(conflicting)고 하고, 충돌하는 두 접근이 happens-before로 이어져 있지 않으면 프로그램에 데이터 경합(data race)이 있다고 합니다. RacyCounter와 PlainStopFlag가 바로 데이터 경합이 있는 프로그램입니다. JLS는 올바르게 동기화된(correctly synchronized) 프로그램, 즉 데이터 경합이 없는 프로그램이라면 모든 실행이 순차적으로 일관된(sequentially consistent) 것처럼 보인다고 약속합니다. 모든 스레드의 동작을 한 줄로 늘어놓고 하나씩 차례로 실행한 것처럼 보인다는 뜻입니다. 데이터 경합이 있으면 이 약속이 없어서, 앞에서 본 것처럼 직관과 다른 결과가 허용됩니다.
데이터 경합을 없앤다고 경쟁 조건까지 사라지는 것은 아닙니다. 예를 들어 잔액을 확인하는 메서드와 차감하는 메서드를 각각 synchronized로 만들고 호출하는 쪽에서 두 메서드를 따로 부르면, 모든 접근이 락 아래에 있으니 데이터 경합은 없습니다. 그래도 두 호출 사이에 다른 스레드가 끼어들 수 있으므로, 결과는 여전히 끼어드는 순서에 달려 있습니다. 가시성은 happens-before로, 원자성은 임계 영역의 범위로 따로 따져야 하는 이유입니다. 이 문제는 뒤의 출금 예제에서 다시 봅니다.
동기화 도구가 만드는 happens-before
java.util.concurrent 패키지의 도구들도 비슷한 관계를 만듭니다. 패키지 문서에 따르면 CountDownLatch.countDown() 이전의 동작은 다른 스레드에서 같은 래치의 await()가 성공적으로 돌아온 뒤의 동작보다 happens-before이고, ReentrantLock 같은 Lock의 unlock()과 그 뒤의 lock() 사이도 마찬가지입니다. CyclicBarrier도 각 스레드가 await()를 부르기 전에 한 동작이 배리어가 열린 뒤 다른 스레드의 동작보다 앞서도록 정합니다. CoordinatedLostUpdate의 다음 회차가 앞 회차에 쓴 n + 1을 정확히 읽는 것도 이 규칙 덕분입니다. 반면 Thread.sleep, Thread.yield, Thread.onSpinWait처럼 기다리기만 하는 메서드는 이런 관계를 만들지 않습니다.
더 깊이: 'volatile은 주 메모리에서 읽는다'는 설명의 한계
volatile을 '캐시를 거치지 않고 주 메모리에서 직접 읽고 쓰는 변수'라고 설명하는 글이 많습니다. 기억하기는 쉽지만 정확하지 않습니다. 오늘날의 CPU는 캐시 일관성 프로토콜로 코어 사이의 캐시 내용을 맞추고, JVM은 volatile 접근 앞뒤에 필요한 메모리 배리어 명령을 넣고 컴파일러의 재배치를 제한하는 방식으로 JLS의 규칙을 구현합니다. 구현 방법은 CPU와 JVM마다 다르지만, 자바 프로그래머가 기댈 수 있는 것은 언제나 happens-before 규칙입니다. 코드를 검토할 때도 '이 값이 캐시에 남아 있을까'를 추측하기보다 '쓰기와 읽기 사이에 happens-before 사슬이 있는가'를 따지는 편이 정확합니다.
더 깊이: final 필드와 long·double의 원자성
JLS에는 동기화와 관련된 특별한 규칙이 두 가지 더 있습니다. 첫째는 final 필드입니다. JLS 17.5에 따르면 생성자가 끝난 뒤에야 객체의 참조를 얻은 스레드는, 참조를 동기화 없이 넘겨받았더라도 그 객체의 final 필드가 올바르게 초기화된 값을 봅니다. 그래서 생성자 안에서 this를 밖으로 내보내지만 않으면, 모든 필드가 final인 불변 객체는 여러 스레드가 안전하게 읽을 수 있습니다. 다만 final은 참조를 다시 대입하지 못하게 할 뿐이므로, final List<String> 필드가 가리키는 목록을 생성자가 끝난 뒤에 고치면 그 변경에는 이 규칙이 적용되지 않습니다.
둘째는 long과 double입니다. JLS 17.7에 따르면 volatile이 아닌 long이나 double에 대한 쓰기 한 번은 32비트씩 두 번 쓰는 것으로 취급될 수 있어서, 다른 스레드가 앞 32비트는 한 쓰기의 값을, 뒤 32비트는 다른 쓰기의 값을 볼 수도 있습니다. 반면 volatile long·double의 읽기와 쓰기, 그리고 참조의 읽기와 쓰기는 언제나 원자적입니다. JLS는 JVM이 가능하면 64비트 값을 나누지 않도록 권하지만, 약속하는 것은 여기까지입니다. 여러 스레드가 함께 쓰는 64비트 값은 volatile로 선언하거나 락으로 보호해야 합니다.
확인과 차감을 한 임계 영역에 넣기
임계 영역이 꼭 한 줄짜리 증가만 감싸는 것은 아닙니다. 은행 계좌에서 출금하려면 잔액이 충분한지 확인하고, 충분하면 잔액을 줄여야 합니다. 잔액이 1000인 계좌에서 두 스레드가 동시에 800씩 출금하려 한다고 해 봅시다. 두 스레드가 모두 '잔액 1000은 800 이상'이라는 확인을 통과한 뒤 차례로 차감하면, 둘 다 성공하고 잔액은 -600이 됩니다. 확인한 결과를 믿고 행동하는 사이에 상태가 바뀌어 생기는 이런 경쟁 조건을 검사 후 행동(check-then-act) 문제라고 합니다.
이 계좌가 지켜야 할 조건은 '잔액은 음수가 되지 않는다'입니다. 이처럼 객체가 살아 있는 동안 늘 참이어야 하는 조건을 불변식(invariant)이라고 합니다. 불변식을 지키려면 확인과 차감을 다른 스레드가 끼어들 수 없는 한 덩어리로 묶어야 합니다.
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());
}
}잔액 1000인 계좌에서 두 스레드가 동시에 800씩 출금하려고 합니다. 둘 다 성공하면 잔액이 음수가 되므로, 불변식이 지켜졌다면 성공은 한 번뿐이어야 합니다.
successes=1, balance=200100번 실행해 100번 모두 같은 결과가 나왔습니다. 어느 스레드가 성공할지는 실행마다 다를 수 있지만, 성공은 언제나 한 번이고 잔액은 언제나 200입니다. withdraw가 synchronized이므로 먼저 락을 얻은 스레드가 확인과 차감을 모두 마치고 락을 놓은 뒤에야 다른 스레드가 확인을 시작합니다. 두 번째 스레드는 이미 200으로 줄어든 잔액을 보고 false를 돌려받습니다.
synchronized를 빼면 어떻게 될까요? withdraw에서 synchronized만 지우고 100번 실행했을 때는 한 번도 문제가 생기지 않았습니다. 확인과 차감 사이의 틈이 아주 짧아서 두 스레드가 정확히 그 사이에 겹치는 일이 드물기 때문입니다. 틈을 넓혀 보려고 다음처럼 확인과 차감 사이에 Thread.sleep(10)을 넣었더니, 10번 모두 그 아래와 같은 결과가 나왔습니다.
public boolean withdraw(int amount) { // synchronized를 지웠다
if (amount <= 0) throw new IllegalArgumentException("amount");
if (balance < amount) return false;
try {
Thread.sleep(10); // 확인과 차감 사이의 틈을 넓힌다
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
balance -= amount;
return true;
}successes=2, balance=-600드물다는 것은 일어나지 않는다는 뜻이 아닙니다. 요청이 많은 서버에서는 드문 겹침도 결국 일어나고, 그때는 음수가 된 잔액처럼 원인을 찾기 어려운 결과로 나타납니다. 그래서 동시성 코드는 테스트를 몇 번 통과했는지가 아니라, 공유 상태에 접근하는 모든 코드가 같은 락으로 보호되는지를 따져서 검토해야 합니다.
synchronized 인스턴스 메서드는 this의 모니터를 쓰므로 락은 계좌 객체마다 따로 있습니다. 계좌 A에서 출금하는 스레드와 계좌 B에서 출금하는 스레드는 서로 다른 모니터를 쓰므로 기다리지 않고 동시에 진행합니다. 같은 계좌를 건드릴 때만 서로 기다립니다.
모니터 락의 규칙
synchronized가 쓰는 락을 모니터 락(monitor lock)이라고 합니다. 한 모니터는 한 번에 한 스레드만 소유할 수 있고, 이미 다른 스레드가 소유한 모니터를 얻으려는 스레드는 BLOCKED 상태로 기다립니다. 소유자가 락을 놓으면 기다리던 스레드들이 다시 락을 얻으려고 경쟁하는데, 먼저 기다린 스레드가 먼저 얻는다는 약속은 없습니다. 순서가 중요하다면 공정성 옵션이 있는 ReentrantLock을 검토할 수 있습니다. BLOCKED는 스레드 정보와 생명 주기에서 본 스레드 상태이며, 앞의 스레드 덤프에서도 waiting to lock과 함께 나타났습니다.
모니터 락은 재진입(reentrant)할 수 있습니다. 이미 어떤 모니터를 소유한 스레드가 같은 모니터를 다시 요청하면 기다리지 않고 바로 얻습니다. 예를 들어 synchronized 메서드 안에서 같은 객체의 다른 synchronized 메서드를 호출해도 자기 자신을 기다리며 멈추지 않습니다. JLS는 한 스레드가 같은 모니터를 여러 번 잠글 수 있고 잠금 해제 한 번이 잠금 한 번을 되돌린다고 정하므로, 잠근 횟수만큼 메서드나 블록을 빠져나와야 락이 완전히 풀립니다.
static synchronized 메서드는 인스턴스가 아니라 그 클래스의 Class 객체(예를 들어 Counter.class)의 모니터를 씁니다. 그래서 static synchronized 메서드와 synchronized 인스턴스 메서드는 서로를 막지 않습니다. 두 메서드가 같은 static 필드를 고친다면 보호가 되지 않으므로, 그 필드에 접근하는 모든 코드가 같은 락 객체를 쓰도록 맞춰야 합니다.
락 객체는 모든 스레드가 함께 쓰는 하나의 객체여야 합니다. 다음처럼 호출할 때마다 새 객체를 만들어 잠그면 스레드마다 서로 다른 모니터를 얻으므로 아무것도 막지 못합니다.
void increment() {
synchronized (new Object()) { // 호출할 때마다 다른 락
value++;
}
}SynchronizedCounter의 increment()를 이렇게 바꿔 20번 실행했더니 18번은 168259, 192387처럼 200000보다 작은 값이 나왔습니다. 문법상으로는 synchronized가 있지만 같은 모니터를 두고 경쟁하는 스레드가 없으니, 사실상 RacyCounter와 같습니다.
임계 영역은 얼마나 넓어야 할까
락을 걸 범위를 정할 때는 두 방향의 실수를 모두 피해야 합니다. 범위가 너무 좁으면, 즉 함께 바뀌어야 하는 상태를 서로 다른 임계 영역에서 고치면 그 사이에 다른 스레드가 반쯤 바뀐 상태를 볼 수 있습니다. 범위가 너무 넓으면, 예를 들어 네트워크 요청이나 오래 걸리는 계산까지 락 안에 넣으면 공유 상태와 상관없는 일을 하는 동안에도 다른 스레드가 BLOCKED 상태로 기다려야 합니다. 기준은 불변식입니다. 불변식이 깨진 중간 상태가 드러날 수 있는 구간 전체를 하나의 임계 영역으로 묶고, 공유 상태를 건드리지 않는 계산은 밖으로 뺍니다.
다음 게시판 장부는 제목 목록 titles와 누적 조회 수 totalViews를 함께 관리합니다. 이 장부의 불변식은 '제목 목록과 누적 조회 수는 언제나 같은 등록 건들을 반영한다'입니다. record는 제목 하나를 추가하면서 그 제목의 조회 수를 더하고, snapshot은 두 값을 함께 복사해 돌려줍니다.
import java.util.ArrayList;
import java.util.List;
public final class SynchronizedBoardLedger {
private final List<String> titles = new ArrayList<>();
private int totalViews;
public void record(String title, int addedViews) {
if (title.isBlank() || addedViews <= 0) throw new IllegalArgumentException();
String normalized = title.strip().toLowerCase();
synchronized (this) {
titles.add(normalized);
totalViews += addedViews;
}
}
public synchronized Snapshot snapshot() {
return new Snapshot(List.copyOf(titles), totalViews);
}
private record Snapshot(List<String> titles, int totalViews) {}
public static void main(String[] args) throws InterruptedException {
SynchronizedBoardLedger ledger = new SynchronizedBoardLedger();
Thread a = new Thread(() -> ledger.record(" First Post ", 30));
Thread b = new Thread(() -> ledger.record("Notice", 40));
a.start();
b.start();
a.join();
b.join();
Snapshot s = ledger.snapshot();
System.out.println("count=" + s.titles().size() + ", views=" + s.totalViews());
}
}두 작업자는 서로 다른 제목 하나씩을 조회 수 30과 40으로 기록합니다. 출력된 제목 개수와 조회 수 합이 같은 등록 건들을 반영하는지 보세요.
count=2, views=7030번 실행해 모두 같은 결과가 나왔습니다. 두 작업자가 어떤 순서로 기록하든 제목은 2개이고 조회 수는 30 + 40 = 70입니다. 제목이 목록에 저장되는 순서는 실행마다 다를 수 있지만, 이 프로그램은 개수만 출력하므로 결과가 같습니다.
한 번의 기록과 스냅샷은 같은 모니터로 묶는다
record의 잠금 밖 정규화→같은 this 안 두 필드 갱신, snapshot의 같은 락 안 복사가 핵심입니다. 획득·변경·해제 경계를 단계로 구별하기 좋습니다.
SynchronizedBoardLedger의 정규화, 제목과 조회 수 갱신, 두 필드의 스냅샷을 실제 보호 범위로 나눕니다.
모니터 · 보호 범위
한 번의 기록과 스냅샷은 같은 모니터로 묶는다
정규화는 락 밖에서, 제목·조회 수 갱신과 스냅샷은 같은 this 모니터 안에서 수행합니다.
입력 검사·정규화
- 수행하는 일
- 제목과 조회 수를 검사하고 문자열을 정규화합니다.
- 모니터 범위
- 공유 필드를 건드리지 않아 락 밖에서 수행합니다.
record의 공유 갱신
- 수행하는 일
titles에 제목을 더하고totalViews를 누적합니다.- 모니터 범위
- 두 변경을 같은
this블록 안에서 수행합니다.
snapshot
- 수행하는 일
- 제목 목록을 복사하고 누적 조회 수를 함께 읽습니다.
- 모니터 범위
- 같은
this모니터로 일관된 두 값을 반환합니다.
원문의 두 작업을 join한 뒤 출력하는 값은 count=2, views=70입니다. 제목 목록이나 제목의 순서는 출력하지 않습니다.
이 예의 조건
- record와 snapshot은 같은 this 모니터를 각각 획득합니다. 제목 순서는 가변입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | record의 두 필드 변경과 snapshot의 두 필드 복사는 동일 this 모니터의 서로 다른 획득입니다. 입력 문자열 계산은 공유 상태 밖이며 원 join 뒤 count 2/views 70을 읽습니다. |
| 19 | 원 두 join 뒤 count 2 |
| 20 | views70 |
| 21 | 제목 순서 가변 |
| 구분 | 내용 |
|---|---|
| 설명 | record의 두 필드 변경과 snapshot의 두 필드 복사는 동일 this 모니터의 서로 다른 획득입니다. 입력 문자열 계산은 공유 상태 밖이며 원 join 뒤 count 2/views 70을 읽습니다. |
| 전체 읽기 정보 | 한 번의 기록과 스냅샷은 같은 모니터로 묶는다 한 번의 기록과 스냅샷은 같은 모니터로 묶는다 SynchronizedBoardLedger의 정규화, 제목과 조회 수 갱신, 두 필드의 스냅샷을 실제 보호 범위로 나눕니다. 모니터 · 보호 범위 한 번의 기록과 스냅샷은 같은 모니터로 묶는다 정규화는 락 밖에서, 제목·조회 수 갱신과 스냅샷은 같은 this 모니터 안에서 수행합니다. 입력 검사·정규화 수행하는 일 제목과 조회 수를 검사하고 문자열을 정규화합니다. 모니터 범위 공유 필드를 건드리지 않아 락 밖에서 수행합니다. record 의 공유 갱신 수행하는 일 titles 에 제목을 더하고 totalViews 를 누적합니다. 모니터 범위 두 변경을 같은 this 블록 안에서 수행합니다. snapshot 수행하는 일 제목 목록을 복사하고 누적 조회 수를 함께 읽습니다. 모니터 범위 같은 this 모니터로 일관된 두 값을 반환합니다. 원문의 두 작업을 join한 뒤 출력하는 값은 count=2, views=70입니다. 제목 목록이나 제목의 순서는 출력하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 입력 계산 strip과 toLowerCase를 공유 상태 접근 전에 수행합니다. |
|
|
| 한 건 기록 this를 획득해 titles.add와 totalViews+=를 함께 수행합니다. |
|
|
| 스냅샷 반환 같은 this 아래 두 필드를 복사하고 반환합니다. |
|
|
한 번의 기록과 스냅샷은 같은 모니터로 묶는다
record의 잠금 밖 정규화→같은 this 안 두 필드 갱신, snapshot의 같은 락 안 복사가 핵심입니다. 획득·변경·해제 경계를 단계로 구별하기 좋습니다.
SynchronizedBoardLedger의 정규화, 제목과 조회 수 갱신, 두 필드의 스냅샷을 실제 보호 범위로 나눕니다.
모니터 · 보호 범위
한 번의 기록과 스냅샷은 같은 모니터로 묶는다
정규화는 락 밖에서, 제목·조회 수 갱신과 스냅샷은 같은 this 모니터 안에서 수행합니다.
입력 검사·정규화
- 수행하는 일
- 제목과 조회 수를 검사하고 문자열을 정규화합니다.
- 모니터 범위
- 공유 필드를 건드리지 않아 락 밖에서 수행합니다.
record의 공유 갱신
- 수행하는 일
titles에 제목을 더하고totalViews를 누적합니다.- 모니터 범위
- 두 변경을 같은
this블록 안에서 수행합니다.
snapshot
- 수행하는 일
- 제목 목록을 복사하고 누적 조회 수를 함께 읽습니다.
- 모니터 범위
- 같은
this모니터로 일관된 두 값을 반환합니다.
원문의 두 작업을 join한 뒤 출력하는 값은 count=2, views=70입니다. 제목 목록이나 제목의 순서는 출력하지 않습니다.
이 예의 조건
- record와 snapshot은 같은 this 모니터를 각각 획득합니다. 제목 순서는 가변입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | record의 두 필드 변경과 snapshot의 두 필드 복사는 동일 this 모니터의 서로 다른 획득입니다. 입력 문자열 계산은 공유 상태 밖이며 원 join 뒤 count 2/views 70을 읽습니다. |
| 19 | 원 두 join 뒤 count 2 |
| 20 | views70 |
| 21 | 제목 순서 가변 |
| 구분 | 내용 |
|---|---|
| 설명 | record의 두 필드 변경과 snapshot의 두 필드 복사는 동일 this 모니터의 서로 다른 획득입니다. 입력 문자열 계산은 공유 상태 밖이며 원 join 뒤 count 2/views 70을 읽습니다. |
| 전체 읽기 정보 | 한 번의 기록과 스냅샷은 같은 모니터로 묶는다 한 번의 기록과 스냅샷은 같은 모니터로 묶는다 SynchronizedBoardLedger의 정규화, 제목과 조회 수 갱신, 두 필드의 스냅샷을 실제 보호 범위로 나눕니다. 모니터 · 보호 범위 한 번의 기록과 스냅샷은 같은 모니터로 묶는다 정규화는 락 밖에서, 제목·조회 수 갱신과 스냅샷은 같은 this 모니터 안에서 수행합니다. 입력 검사·정규화 수행하는 일 제목과 조회 수를 검사하고 문자열을 정규화합니다. 모니터 범위 공유 필드를 건드리지 않아 락 밖에서 수행합니다. record 의 공유 갱신 수행하는 일 titles 에 제목을 더하고 totalViews 를 누적합니다. 모니터 범위 두 변경을 같은 this 블록 안에서 수행합니다. snapshot 수행하는 일 제목 목록을 복사하고 누적 조회 수를 함께 읽습니다. 모니터 범위 같은 this 모니터로 일관된 두 값을 반환합니다. 원문의 두 작업을 join한 뒤 출력하는 값은 count=2, views=70입니다. 제목 목록이나 제목의 순서는 출력하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 입력 계산 strip과 toLowerCase를 공유 상태 접근 전에 수행합니다. |
|
|
| 한 건 기록 this를 획득해 titles.add와 totalViews+=를 함께 수행합니다. |
|
|
| 스냅샷 반환 같은 this 아래 두 필드를 복사하고 반환합니다. |
|
|
record를 보면 입력 검사와 strip().toLowerCase()는 synchronized 블록 밖에 있습니다. 이 계산은 매개변수로 받은 문자열만 다루고 공유 필드를 건드리지 않으므로, 여러 스레드가 동시에 실행해도 문제가 없습니다. 반면 titles.add와 totalViews +=는 같은 블록 안에 있습니다. 둘을 따로 보호하면 titles.add는 끝났지만 totalViews는 아직 더해지지 않은 순간이 생기고, 그때 snapshot이 실행되면 새 제목과 이전 조회 수가 한 쌍으로 반환됩니다. snapshot은 같은 this 모니터를 쓰는 synchronized 메서드이므로, record의 블록이 실행되는 동안에는 시작하지 못하고 블록이 끝난 뒤의 완성된 상태만 읽습니다.
snapshot이 titles를 그대로 돌려주지 않고 List.copyOf로 복사하는 데도 이유가 있습니다. 내부 목록을 그대로 내주면 호출한 쪽이 락 없이 그 목록을 읽는 동안 다른 스레드가 record로 목록을 고칠 수 있습니다. List.copyOf는 복사 시점의 요소들로 변경할 수 없는 새 목록을 만들므로, 돌려받은 스냅샷은 이후의 record에 영향을 받지 않습니다. 다만 요소의 참조만 복사하는 얕은 복사라서, 요소가 가변 객체라면 그 내부 변경까지 막지는 못합니다. 이 예제의 요소는 불변인 String이라 문제가 없습니다.
락이 해 주는 일은 다른 스레드의 끼어들기를 막는 것까지입니다. 예를 들어 totalViews의 합이 int 범위를 넘으면 락이 있어도 값은 음수로 넘칩니다. 또 블록 안에서 titles.add가 성공한 뒤 다음 코드가 예외를 던지면, 락은 자동으로 풀리지만 이미 바뀐 상태를 되돌려 주지는 않습니다. 이 예제가 입력 검사를 블록에 들어가기 전에 마치는 것도 그래서입니다. 실패할 수 있는 검사는 상태를 바꾸기 전에 끝내 두는 편이 안전합니다.
지역 변수, 불변 객체, 스레드 한정
모든 데이터에 동기화가 필요한 것은 아닙니다. 메서드의 지역 변수와 매개변수는 스레드마다 따로 있는 스택에 저장되므로 다른 스레드가 접근할 수 없습니다. VolatileStopSignal의 local이 그런 예입니다. 작업자는 반복할 때마다 지역 변수만 늘리고, 다른 스레드에 보여야 하는 최종 값만 마지막에 한 번 필드에 기록합니다.
하지만 '지역 변수니까 안전하다'는 말은 변수 자체에 대한 이야기입니다. 지역 변수에 담긴 것이 참조라면 그 참조가 가리키는 객체는 힙에 있고, 다른 스레드도 같은 객체를 가리킬 수 있습니다. RacyCounter의 counter가 그런 경우입니다. counter는 main의 지역 변수지만, 두 작업자가 실행하는 람다가 같은 Counter 객체를 가리키므로 경쟁이 생겼습니다.
공유를 피하는 설계도 동기화만큼 중요합니다. 객체를 한 스레드 안에서만 만들고, 고치고, 쓰도록 설계하는 것을 스레드 한정(thread confinement)이라고 합니다. 한정된 객체에는 경쟁할 상대가 없으므로 락이 필요 없고, 상태가 바뀌지 않는 불변 객체도 여러 스레드가 마음껏 함께 읽을 수 있습니다. 그래서 계산은 각 스레드 안에서 하고, 결과를 넘길 때만 불변 스냅샷으로 만들어 공유하는 식으로 공유 범위를 줄이는 경우가 많습니다. 다만 final 필드는 참조를 바꾸지 못하게 할 뿐이고, 레코드도 구성 요소가 가변 목록이면 그 목록의 내용은 바뀔 수 있다는 점을 기억해야 합니다.
synchronized와 volatile 중 무엇을 쓸까
다음 표는 이 글에서 다룬 도구가 무엇을 해결하는지 정리한 것입니다.
| 도구 | 가시성 | 원자성 | 어울리는 곳 |
|---|---|---|---|
synchronized | 같은 락을 쓰는 스레드 사이에서 보장 | 임계 영역 전체를 하나로 묶음 | 여러 단계나 여러 필드를 함께 확인하고 바꿀 때 |
volatile | 쓰기 뒤의 읽기에 보장 | 읽기 하나, 쓰기 하나씩만 | 한 스레드가 바꾸고 다른 스레드는 읽기만 하는 플래그나 참조 |
| 원자 클래스 | 보장 | 증가, 비교 후 교체 같은 정해진 연산 하나 | 락 없이 값 하나를 갱신하는 카운터 |
잔액과 거래 내역처럼 여러 값을 함께 검사하고 바꿔야 한다면 하나의 락으로 묶는 것이 가장 분명합니다. 중지 플래그나 새 설정 객체로 바꿔 끼우는 참조처럼 값 하나를 알리기만 하면 된다면 volatile로 충분합니다. 카운터처럼 값 하나를 읽고 고쳐 써야 한다면 AtomicInteger 같은 원자 클래스가 어울리며, 21장에서 synchronized와 비교합니다.
경쟁이 심하지 않고 기능이 단순하다면 synchronized는 읽기 쉽고 실수할 여지도 적습니다. 락을 기다리는 시간에 제한을 두거나, 기다리는 도중 중단하거나, 공정한 순서가 필요하다면 이 장 뒤쪽에서 다루는 ReentrantLock을 검토합니다. 어떤 방법을 고르든 어떤 락이 어떤 필드를 보호하는지, 그 임계 영역이 어떤 불변식을 지키는지 클래스 주석에 적어 두면 나중에 코드를 고치는 사람이 같은 실수를 하지 않습니다.
정리
count++는 읽기, 더하기, 쓰기의 세 단계라서 여러 스레드가 동시에 실행하면 갱신 손실이 생길 수 있습니다. 손실은 실행마다 다르게 나타나므로 몇 번의 성공으로 코드가 맞다고 판단할 수 없습니다.synchronized는 같은 모니터를 쓰는 스레드들이 임계 영역에 한 번에 하나씩만 들어가게 하고, 락을 놓기 전의 기록이 다음에 같은 락을 얻은 스레드에 보이게 합니다.- 동기화 없이 쓴 일반 필드는 다른 스레드에 언제 보일지 정해져 있지 않습니다.
volatile은 쓰기 뒤의 읽기에 값이 보이게 하지만, 읽기와 쓰기를 하나로 묶지는 않습니다. - 다른 스레드의 기록이 보이는지는 happens-before 사슬로 판단합니다. 같은 스레드의 코드 순서, 모니터 해제와 획득, volatile 쓰기와 읽기,
start,join이 대표적인 고리이고,sleep과yield는 고리가 아닙니다. - 임계 영역은 불변식이 깨진 상태가 드러날 수 있는 구간 전체를 덮고, 공유 상태와 상관없는 계산은 밖으로 뺍니다. 락 객체는 모든 스레드가 함께 쓰는 하나의 객체여야 합니다.
연습 문제
- CoordinatedLostUpdate를 작업자 3명, 참여자 수가 3인 배리어 두 개, 작업자마다 120회로 바꾸고 초깃값 0에서 실행합니다. 증가 요청 수와 최종 값을 예상하세요.
value만volatile로 바꾼 경우와, 배리어를 없애고 각 증가를synchronized로 보호한 경우도 비교하세요.
해설 보기
증가 요청은 3 × 120 = 360번입니다. 회차마다 세 작업자가 모두 같은 n을 읽은 뒤 각자 n + 1을 쓰므로 한 회차에 값은 1만 늘어나고, 최종 값은 120입니다. volatile로 바꿔도 각 읽기와 쓰기가 보일 뿐 세 작업자가 같은 값을 읽는 순서는 그대로이므로 역시 120입니다.
배리어를 없애고 증가 전체를 같은 락으로 보호하면 증가가 하나씩 차례로 일어나므로 360입니다. 세 경우를 각각 20번씩 실행해 모두 이 값이 나왔습니다. 배리어를 남겨 둔 채 락만 씌우면 본문에서 본 것처럼 교착 상태로 멈춘다는 점도 함께 기억해 두세요.
- 잔액이 1000인
SynchronizedBankAccount에서 두 스레드가 각각 600씩 출금을 시도하면 성공 횟수와 최종 잔액은 얼마일까요? 출금액을 800과 300으로 바꾸면 가능한 최종 잔액을 모두 구하고,synchronized가 어느 스레드를 먼저 성공시킬지 정해 주는지도 답하세요.
해설 보기
600 출금은 한 번만 성공하고 잔액은 400입니다. 먼저 락을 얻은 스레드가 1000에서 600을 빼고 나면, 두 번째 스레드는 잔액 400이 600보다 작아 실패합니다.
800과 300이라면 어느 쪽이 먼저 락을 얻느냐에 따라 결과가 갈립니다. 800이 먼저면 잔액 200이 남아 300 출금이 실패하고, 300이 먼저면 잔액 700이 남아 800 출금이 실패합니다. 따라서 가능한 최종 잔액은 200과 700이고, 어느 경우든 성공은 한 번입니다. 실제로 100번 실행했을 때 99번은 200, 1번은 700이 나왔습니다. 먼저 시작한 스레드가 대개 먼저 락을 얻었을 뿐이고, synchronized는 확인과 차감이 끼어들기 없이 실행되게 할 뿐 어느 스레드가 먼저 락을 얻을지는 정하지 않습니다.
SynchronizedBoardLedger에 제목 하나와 조회 수 30을 기록한 뒤 스냅샷 A를 얻고, 다른 제목과 조회 수 40을 기록한 뒤 스냅샷 B를 얻습니다. A와 B의 제목 수와 조회 수는 각각 얼마일까요? B를 얻은 뒤에 A의 값이 바뀔까요?
해설 보기
A는 제목 1개에 조회 수 30이고, B는 제목 2개에 조회 수 70입니다. 실제로 "Hello"와 "World"로 실행하면 A의 목록은 [hello], B의 목록은 [hello, world]였습니다. 제목은 record가 소문자로 바꿔 저장하기 때문입니다.
A는 B를 얻은 뒤에도 바뀌지 않습니다. A의 제목 목록은 List.copyOf로 만든 별도의 변경 불가 목록이고, 조회 수는 레코드에 복사된 int 값이기 때문입니다. 장부 내부의 titles가 늘어나도 이미 돌려준 스냅샷과는 상관이 없습니다.
- VolatileStopSignal을 실행했더니
stopped=true, worked=false가 나왔습니다. 프로그램에 버그가 있는 것일까요? 또iterations를volatile로 바꾸지 않아도 main이 정확한 값을 읽는 이유를 설명하세요.
해설 보기
버그가 아닙니다. 작업자는 started.countDown()을 먼저 호출하고 그다음에 while의 첫 조건을 검사합니다. 그 사이에 main이 running = false를 쓰면 첫 검사가 false를 읽어 반복을 한 번도 하지 않고, iterations에는 0이 기록됩니다. 시작 신호는 반복문에 들어가기 전의 사건일 뿐이므로 worked=false는 코드가 허용하는 결과이고, 200번 실행했을 때 17번 이렇게 나왔습니다.
iterations는 작업자가 반복을 마친 뒤 끝나기 직전에 기록하고, main은 worker.join()이 돌아온 뒤에 읽습니다. 작업자의 모든 동작은 다른 스레드가 그 작업자에 대한 join()에서 정상적으로 돌아오는 것보다 happens-before이므로, 그 기록이 main에 보입니다. volatile이 필요한 것은 작업자가 실행 중일 때 다른 스레드가 바꾸고 작업자가 읽어야 하는 running뿐입니다.
다음 글
LockSupport의 park·unpark에서는 락이 아니라 '기다리기'를 다룹니다. 조건이 채워질 때까지 스레드를 멈춰 두었다가 다른 스레드가 깨우는 가장 낮은 수준의 도구인 LockSupport.park와 unpark를 살펴보고, 이 글에서 본 volatile 조건과 함께 써야 안전한 이유를 확인합니다.