F-LAB JAVA · 5주차 · Phase 2 · 동시성 안전 도구 3종 비교
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
volatile 은 변수를 메인 메모리에서 직접 읽고 쓰게 하여 가시성을 보장하지만, 복합 연산 (read-modify-write) 의 원자성은 보장하지 못하므로, "한 스레드만 쓰고 여러 스레드가 읽는 플래그" 에 적합하다.
가시성 보장 — volatile 변수는 CPU 캐시를 거치지 않고 메인 메모리에 직접 읽고 쓰므로, 한 스레드의 변경이 다른 스레드에 즉시 보인다.
원자성 미보장 —count++는 읽기-증가-쓰기의 3단계 복합 연산인데, volatile 은 각 단계의 가시성만 보장할 뿐 세 단계를 하나로 묶지 못하므로 여전히 갱신 손실이 발생한다.
바이트코드 수준에서count++는getfield → iconst_1 → iadd → putfield로 분리되며, 이 사이에 다른 스레드가 끼어들 수 있어 volatile 로도 막을 수 없다.
따라서 volatile 은 단일 쓰기 + 다중 읽기 플래그 (예:shutdown,running), 상태 발행 (한 번 쓰고 읽기), 이중 검사 락의 인스턴스 참조 같은 시나리오에 적합하다.
volatile = 실시간 전광판:
가시성 (즉시 반영):
- 전광판에 직접 표시 (메인 메모리)
- 모두가 즉시 봄
- 자기 수첩(캐시) 안 봄
vs 일반 변수:
- 각자 수첩에 적음 (캐시)
- 갱신 안 보일 수 있음
원자성 X (복합 연산):
- "관람객 수 +1" 을 두 명이 동시에
- A: 전광판 100 읽음 → 101 표시 준비
- B: 전광판 100 읽음 (동시) → 101 표시 준비
- 둘 다 101 표시 (102 여야 하는데)
→ 전광판이라도 "읽고+1하고쓰기" 는 안 묶임
적합한 용도:
- 단순 상태 표시 (영업중/마감)
- 한 명만 바꾸고 (점장)
- 여러 명이 봄 (손님)
→ 플래그
→ volatile = 메인 메모리 직접 (가시성), 복합 연산 원자성 X, 플래그에 적합.
1. 가시성 보장 원리
2. 원자성 미보장
3. count++ 분석 (바이트코드)
4. 적합한 사용처 (플래그)
5. 적합한 시나리오 3가지
6. 메모리 배리어
7. 순서 보장
8. DCL과 volatile
9. 면접 + 자기 점검
volatile 가시성:
메인 메모리에서 직접 R/W:
- CPU 캐시 우회
- 쓰기: 즉시 메인 메모리
- 읽기: 메인 메모리에서
→ 변경 즉시 보임
캐시 우회:
일반 변수:
- 캐시에 저장 (성능)
- 변경이 캐시에만
- 다른 코어 못 봄
volatile:
- 메인 메모리 직접
- 모든 코어 최신
// volatile 가시성
private volatile boolean running = true;
// 스레드 1
while (running) { // 메인 메모리에서 읽기 (최신)
work();
}
// 스레드 2
running = false; // 메인 메모리에 쓰기 (즉시 보임)
// 스레드 1 이 즉시 봄 → 루프 종료
volatile = 가시성만:
✅ 가시성
❌ 원자성
단일 읽기/쓰기는 원자적:
- 읽기 1회, 쓰기 1회
복합 연산은 X:
- count++ (3단계)
@Service
public class VolatileVisibility {
// volatile — 가시성 보장
private volatile boolean shutdownRequested = false;
public void worker() {
while (!shutdownRequested) { // 메인 메모리 (최신)
processNext();
}
log.info("Worker stopped");
}
public void requestShutdown() {
shutdownRequested = true; // 즉시 보임
// worker 가 다음 확인 시 종료
}
private void processNext() { }
}
volatile이 가시성을 보장하는 원리는?
답:
1. 메인 메모리 직접:
캐시 우회:
예시:
가시성만:
volatile 원자성 미보장:
복합 연산 (RMW):
- count++
- 각 단계 가시성만
- 묶지 못함 (원자성 X)
→ 손실 가능
왜 원자성 X:
volatile 은:
- 각 읽기/쓰기 가시성
- 하지만 "읽고+1하고쓰기" 를
- 하나로 묶지 X
중간에 다른 스레드 개입 가능
// volatile 로도 손실
private volatile int count = 0;
count++;
// 1. count 읽기 (가시성 O)
// 2. +1
// 3. count 쓰기 (가시성 O)
// 1~3 사이 다른 스레드 개입 가능
// → 손실
volatile count++ 손실:
스레드 A 스레드 B count(메인)
읽기(5) 5
읽기(5) 5 ← 둘 다 5 (가시성 OK여도)
+1 (6)
+1 (6)
쓰기(6) 6
쓰기(6) 6 ← 손실
가시성은 OK (둘 다 5 정확히 읽음)
하지만 원자성 X (손실)
// 단일 읽기/쓰기는 원자적 (volatile)
private volatile boolean flag;
private volatile int value;
flag = true; // 단일 쓰기 (원자적 + 가시성)
boolean f = flag; // 단일 읽기 (원자적 + 가시성)
value = 10; // 단일 쓰기 OK
// 복합만 문제
value++; // 복합 (원자성 X)
@Service
public class VolatileNoAtomicity {
// ❌ volatile count++ (손실)
private volatile int processedCount = 0;
public void processWrong(Shipment shipment) {
doProcess(shipment);
processedCount++; // 가시성 O, 원자성 X → 손실
}
// ✓ 단일 쓰기는 OK
private volatile long lastProcessedTime;
public void updateTime() {
lastProcessedTime = System.currentTimeMillis(); // 단일 쓰기 OK
}
// ✓ 카운터는 AtomicInteger
private final AtomicInteger correctCount = new AtomicInteger();
public void processCorrect(Shipment shipment) {
doProcess(shipment);
correctCount.incrementAndGet(); // 원자적
}
private void doProcess(Shipment s) { }
}
volatile이 원자성을 보장하지 못하는 이유는?
답:
1. 원자성 X:
이유:
count++ 손실:
단일 OK:
// count++ 의 바이트코드
count++;
// 바이트코드:
// getfield // count 읽기 (스택에 push)
// iconst_1 // 1 push
// iadd // 더하기
// putfield // count 쓰기
// → 4개 명령 (분리)
count++ 3단계:
1. read: count 값 읽기
2. modify: +1 계산
3. write: count 에 쓰기
volatile:
- 1, 3 의 가시성
- 하지만 1~3 사이 개입 가능
인터리빙 (interleaving):
스레드 A: getfield (5)
스레드 B: getfield (5) ← A 와 B 사이
스레드 A: iadd (6)
스레드 A: putfield (6)
스레드 B: iadd (6)
스레드 B: putfield (6) ← 덮어씀
명령 사이 끼어듦
→ 손실
원자적이려면:
getfield ~ putfield 를
하나로 묶어야:
- synchronized (락)
- Atomic (CAS)
volatile 은 각 명령 가시성만
→ 묶지 못함
// 모든 복합 연산 위험 (volatile)
volatile int v;
v++; // read-modify-write
v += 5; // read-modify-write
v = v * 2; // read-modify-write
if (v == 0) v = 1; // check-then-act
// 모두 원자성 X (volatile 부족)
@Service
public class CountIncrementAnalysis {
private volatile int counter = 0;
// 바이트코드 수준 위험
public void increment() {
counter++;
// getfield counter
// iconst_1
// iadd
// putfield counter
// → 4명령 분리, 인터리빙 가능
}
// 올바른 방법
private final AtomicInteger atomicCounter = new AtomicInteger();
public void incrementCorrect() {
atomicCounter.incrementAndGet();
// CAS 로 원자적 (단일 연산처럼)
}
// 측정: increment() 를 여러 스레드로 호출하면
// 최종 값 < 기대값 (손실 확인)
}
count++가 volatile로도 안전하지 않은 이유는?
답:
1. 바이트코드:
3단계:
인터리빙:
원자적이려면:
volatile 적합 — 플래그:
한 스레드만 쓰고
여러 스레드가 읽는 boolean:
- 단일 쓰기 (원자성 OK)
- 가시성 필요 (volatile)
// shutdown 플래그 (대표)
private volatile boolean shutdown = false;
// 한 곳에서만 쓰기
public void stop() {
shutdown = true; // 단일 쓰기
}
// 여러 스레드 읽기
public void worker() {
while (!shutdown) { // 가시성 (즉시 종료)
work();
}
}
왜 플래그에 적합:
- 단일 쓰기 (원자성 문제 없음)
- 여러 읽기 (가시성 필요)
- 복합 연산 아님
→ volatile 로 충분
→ synchronized 불필요 (가벼움)
조건 — 단일 쓰기:
volatile 안전 조건:
- 쓰기가 다른 값에 의존 X
- 단순 대입 (= true)
- count++ 같은 RMW X
→ 단일 쓰기만 안전
@Service
public class VolatileFlagUsage {
// 적합한 플래그들 (단일 쓰기, 여러 읽기)
private volatile boolean running = true;
private volatile boolean maintenanceMode = false;
private volatile String currentPhase = "INIT";
// 단일 쓰기 (안전)
public void shutdown() {
running = false; // 한 곳에서만
}
public void enableMaintenance() {
maintenanceMode = true; // 단일 쓰기
}
public void setPhase(String phase) {
currentPhase = phase; // 단일 쓰기 (참조 대입)
}
// 여러 워커 스레드가 읽기
public void worker() {
while (running && !maintenanceMode) {
processNext();
}
}
private void processNext() { }
}
volatile의 적합한 사용처 (플래그) 는?
답:
1. 플래그:
shutdown:
왜 적합:
조건:
시나리오 1 — 상태 플래그:
한 스레드 쓰기, 여러 읽기:
- shutdown
- running
- initialized
단일 쓰기 + 가시성
// 시나리오 2 — 상태 발행 (publish)
private volatile Config config; // 참조
// 한 번 설정 (발행)
public void publish(Config newConfig) {
config = newConfig; // 단일 쓰기 (참조)
}
// 여러 스레드 읽기
public Config getConfig() {
return config; // 가시성 (최신)
}
// 불변 객체 참조 발행에 적합
// 시나리오 3 — Double-Checked Locking
public class Singleton {
private static volatile Singleton instance; // volatile 필수
public static Singleton getInstance() {
if (instance == null) { // 1차 (락 없이)
synchronized (Singleton.class) {
if (instance == null) { // 2차 (락 안)
instance = new Singleton();
}
}
}
return instance;
}
}
// volatile 로 재정렬 방지 (다음 섹션)
3 시나리오 공통:
- 단일 쓰기 (또는 발행)
- 복합 연산 X
- 가시성 필요
→ volatile 적합
부적합 — 카운터:
count++ 같은 복합 연산:
- volatile X
- Atomic / synchronized
→ RMW 는 volatile 부적합
@Service
public class VolatileScenarios {
// 1. 상태 플래그
private volatile boolean systemActive = true;
// 2. 상태 발행 (불변 설정)
private volatile FreightConfig freightConfig;
public void updateConfig(FreightConfig config) {
this.freightConfig = config; // 발행 (단일 쓰기)
}
public BigDecimal calculate(Shipment shipment) {
FreightConfig config = this.freightConfig; // 읽기 (최신)
return config.calculate(shipment);
}
// 3. DCL 싱글톤 (지연 초기화)
private static volatile FreightCalculator calculator;
public static FreightCalculator getCalculator() {
if (calculator == null) {
synchronized (VolatileScenarios.class) {
if (calculator == null) {
calculator = new FreightCalculator();
}
}
}
return calculator;
}
record FreightConfig() {
BigDecimal calculate(Shipment s) { return s.getWeight(); }
}
static class FreightCalculator {}
}
volatile이 적합한 시나리오 3가지는?
답:
1. 상태 플래그:
상태 발행:
DCL 인스턴스:
공통:
메모리 배리어 (Memory Barrier):
CPU 명령 재정렬/캐시를 제어하는 장벽.
- volatile 이 삽입
- 가시성 + 순서 보장
volatile 배리어:
volatile 쓰기 전후:
- StoreStore (이전 쓰기 완료)
- StoreLoad (메인 메모리 반영)
volatile 읽기 전후:
- LoadLoad
- LoadStore
→ 재정렬 방지 + 가시성
재정렬 방지:
컴파일러/CPU 최적화:
- 명령 순서 바꿈 (성능)
volatile 배리어:
- 배리어 넘어 재정렬 X
- 순서 보장
성능 영향:
배리어:
- 캐시 플러시
- 재정렬 제한
- 약간의 비용
하지만:
- synchronized 보다 가벼움
- 락 없음
@Service
public class MemoryBarrier {
private int data = 0; // 일반
private volatile boolean ready = false; // volatile (배리어)
// 발행자
public void publish(int value) {
data = value; // 1. 일반 쓰기
ready = true; // 2. volatile 쓰기 (배리어)
// 배리어: 1 이 2 보다 먼저 (재정렬 방지)
// → data 쓰기가 ready 전에 완료
}
// 소비자
public int consume() {
if (ready) { // volatile 읽기 (배리어)
return data; // data 최신 (배리어로 보장)
}
return -1;
}
// volatile 이 data 가시성까지 보장 (배리어)
}
volatile과 메모리 배리어의 관계는?
답:
1. 메모리 배리어:
배리어 종류:
재정렬 방지:
성능:
순서 (ordering) 문제:
컴파일러/CPU 재정렬:
- 명령 순서 변경 (최적화)
- 단일 스레드는 문제 X
- 멀티 스레드는 문제
volatile 순서 보장:
volatile 쓰기 이전 연산:
- volatile 쓰기보다 먼저 (재정렬 X)
volatile 읽기 이후 연산:
- volatile 읽기보다 나중
→ happens-before
// 발행 패턴 (순서 보장)
private int[] data;
private volatile boolean initialized = false;
// 초기화
public void init() {
data = loadData(); // 1
initialized = true; // 2 (volatile)
// 1 이 2 보다 먼저 (재정렬 X)
}
// 사용
public void use() {
if (initialized) { // volatile 읽기
process(data); // data 완전 초기화됨 (보장)
}
}
재정렬 없으면 (volatile):
initialized = true 가
data 초기화보다 먼저 실행 X
→ data 준비 전에 true X
→ 안전한 발행
@Service
public class OrderingGuarantee {
private FreightTable table; // 일반
private volatile boolean ready = false; // volatile
// 초기화 (순서 보장)
public void initialize() {
table = loadFreightTable(); // 1. 무거운 초기화
ready = true; // 2. volatile (배리어)
// 1 이 2 보다 먼저 보장
// table 완전 로드 후 ready
}
// 사용
public BigDecimal calculate(Shipment shipment) {
if (ready) { // volatile 읽기
return table.lookup(shipment); // table 완전 로드됨
}
return BigDecimal.ZERO;
}
private FreightTable loadFreightTable() { return new FreightTable(); }
static class FreightTable {
BigDecimal lookup(Shipment s) { return s.getWeight(); }
}
}
volatile과 순서 보장은?
답:
1. 순서 문제:
순서 보장:
발행 패턴:
happens-before:
DCL (Double-Checked Locking):
지연 초기화 + 성능:
- 1차 확인 (락 없이)
- 락
- 2차 확인 (락 안)
- 초기화
// ❌ volatile 없는 DCL (위험)
private static Singleton instance; // volatile 없음
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
// 문제: 재정렬 가능
// 1. 메모리 할당
// 2. instance 에 참조 대입 (재정렬!)
// 3. 생성자 실행
// → 다른 스레드가 미완성 객체 봄
}
}
}
return instance;
}
DCL 재정렬 문제:
new Singleton() 은:
1. 메모리 할당
2. 생성자 실행
3. instance 에 참조 대입
재정렬 시 (1 → 3 → 2):
- instance 에 참조 먼저
- 생성자 아직 (미완성)
- 다른 스레드가 미완성 객체 봄
// ✓ volatile DCL (안전)
private static volatile Singleton instance; // volatile
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
// volatile 배리어로 재정렬 방지
// 생성자 완료 후 참조 대입
}
}
}
return instance;
}
// 더 나은 방법 — Holder 패턴
public class Singleton {
private Singleton() {}
private static class Holder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
// 클래스 로딩 시 초기화 (JVM 보장)
// volatile 불필요, 간단, 안전
}
}
// 또는 enum 싱글톤
@Service
public class DCLExample {
// DCL + volatile (지연 초기화)
private static volatile FreightCalculator calculator;
public static FreightCalculator getCalculator() {
if (calculator == null) {
synchronized (DCLExample.class) {
if (calculator == null) {
calculator = new FreightCalculator(); // volatile 안전
}
}
}
return calculator;
}
// 권장 — Holder 패턴 (더 간단)
private static class CalculatorHolder {
static final FreightCalculator INSTANCE = new FreightCalculator();
}
public static FreightCalculator getCalculatorBetter() {
return CalculatorHolder.INSTANCE; // volatile 불필요
}
static class FreightCalculator {}
}
DCL과 volatile은?
답:
1. DCL:
volatile 없으면:
재정렬 문제:
해결:
| Q | 핵심 답변 |
|---|---|
| volatile 가시성? | 메인 메모리 직접 |
| 원자성? | X (복합 연산) |
| count++ 위험? | RMW 3단계 분리 |
| 적합 사용처? | 단일 쓰기 플래그 |
| 시나리오 3? | 플래그/발행/DCL |
| 메모리 배리어? | 재정렬 방지 |
| 순서 보장? | happens-before |
| DCL volatile? | 재정렬 방지 |
| 단일 쓰기? | 원자적 |
| 한계? | 복합 연산 X |
답:
답:
답:
답:
답:
1. 가시성만
2. count++ 위험
3. 적합 시나리오
이번 Unit에서 volatile 을 봤다면, 다음은 Atomic + CAS (★ 마스터, 면접 ★★★).
🚀 Phase 2 — 동시성 안전 도구 3종 비교
✅ Unit 2.1 두 가지 동시성 문제 구분
✅ Unit 2.2 synchronized
✅ Unit 2.3 volatile ← 여기
⏭ Unit 2.4 Atomic + CAS (★ 마스터) — Phase 2 완주
✅ Phase 1 — 스레드 풀 필요성 (3 Unit)
🚀 Phase 2 — 동시성 안전 도구 (3/4 진행)
총: 6/26 Unit