F-LAB JAVA · 4주차 · Phase 4 · 동기화: synchronized와 메모리 가시성
🚀 Phase 4 시작 — ★ 4주차 1차 정점 진입
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
임계 영역 (Critical Section) 은 여러 스레드가 동시에 접근할 때 데이터 불일치가 발생할 수 있는 코드 영역으로, 일반적으로 공유 자원 (인스턴스 변수, static 변수, 공유 객체) 을 수정하는 부분이다.
겉보기에 단순한count++도 사실 읽기 (read) → 1 더하기 (modify) → 쓰기 (write) 의 3단계로 이루어진 비원자적 (non-atomic) 연산이다.
두 스레드가 동시에count++를 실행하면, 둘 다 같은 값을 읽고 각자 1을 더한 뒤 같은 값을 쓰는 경쟁 조건 (Race Condition) 이 발생하여 결과가 손실 된다 (2 증가해야 하는데 1만 증가).
원자성 (Atomicity) 은 연산이 더 이상 쪼개지지 않는 단일 단위로 실행되는 성질이며, 동시성 문제를 막으려면 임계 영역을 원자적으로 만들어야 한다.
이를 위해 동기화 (Synchronization) — 한 번에 하나의 스레드만 임계 영역에 진입하도록 보장하는 메커니즘 (synchronized, Lock 등) 이 필요하다.
공유 자원 = 공동 통장 (잔액):
count++ 의 3단계 = 입금 과정:
1. 읽기: 현재 잔액 확인 (100원)
2. 더하기: +50원 계산 (150원)
3. 쓰기: 새 잔액 기록 (150원)
경쟁 조건 (동시 입금):
- A: 잔액 확인 (100원)
- B: 잔액 확인 (100원, A 와 동시)
- A: +50 계산 (150원)
- B: +30 계산 (130원)
- A: 150원 기록
- B: 130원 기록 (A 의 150 덮어씀!)
→ 결과 130원 (180원이어야 하는데 A 의 50 손실)
동기화 = 한 명씩 ATM 사용:
- A 입금 완료 후 B
- 손실 없음 (180원)
→ count++ 는 3단계, 동시 실행 시 손실, 동기화로 해결.
1. 임계 영역의 정의
2. 공유 자원과 임계 영역
3. count++의 3단계
4. 어셈블리/바이트코드 수준
5. 경쟁 조건 (Race Condition)
6. 결과 손실 시나리오
7. 원자성 (Atomicity)
8. 동기화의 필요성
9. 면접 + 자기 점검
임계 영역 (Critical Section):
여러 스레드가 동시에 접근할 때
데이터 불일치 (race condition) 가
발생할 수 있는 코드 영역.
특징:
- 공유 자원 접근/수정
- 한 번에 하나의 스레드만 안전
- 동기화 필요
class Counter {
int count = 0;
void increment() {
count++; // ← 임계 영역 (공유 자원 수정)
}
}
임계 영역 식별:
공유 자원을 수정하는 코드.
예:
- 공유 변수 증감 (count++)
- 공유 컬렉션 수정 (list.add)
- 공유 객체 상태 변경 (obj.setX)
- 복합 연산 (확인 후 수정)
식별 핵심:
- "공유 + 수정"
- 여러 스레드 접근 가능
읽기 전용은 임계 영역?:
읽기만 (불변):
- 동시 읽기 OK
- 임계 영역 X (보통)
읽기 + 쓰기:
- 한 스레드라도 쓰면
- 다른 스레드 읽기/쓰기와 충돌
- 임계 영역 O
핵심:
- 쓰기가 있으면 임계 영역
- 불변 데이터는 안전
임계 영역:
여러 스레드 → ┌─────────────────┐
스레드 A ───→ │ 임계 영역 │
스레드 B ───→ │ count++ │ ← 동시 접근 위험
스레드 C ───→ │ (공유 자원 수정) │
└─────────────────┘
→ 한 번에 하나만 들어가야 안전
→ 동기화 필요
@Service
public class ShipmentCounterService {
private int totalProcessed = 0; // 공유 자원 (싱글톤)
private final List<Shipment> processedList = new ArrayList<>(); // 공유
public void process(Shipment shipment) {
service.doProcess(shipment);
// ↓ 임계 영역 (공유 자원 수정)
totalProcessed++; // 위험!
processedList.add(shipment); // 위험!
// ↑ 여러 스레드 동시 → 데이터 손실
}
// 읽기 (불변이면 안전)
public int getCount() {
return totalProcessed; // 읽기 (단, 가시성 이슈 — Unit 4.5)
}
}
임계 영역의 정의는?
답:
1. 정의:
식별:
읽기만:
해결:
공유 자원 (Shared Resource):
여러 스레드가 함께 접근하는 데이터.
종류:
- 인스턴스 변수 (객체 공유 시)
- static 변수
- 공유 객체 (컬렉션 등)
- 파일, DB 등 외부 자원
임계 영역 = 공유 자원 수정:
공유 안 됨 (지역 변수):
- 임계 영역 X
- 안전
공유됨 (인스턴스/static):
- 수정 시 임계 영역
- 동기화 필요
(Unit 1.3 의 "스레드 안전 = 공유되는가?" 와 연결)
public class SharedResources {
// 1. 인스턴스 변수 (객체 공유 시)
private int instanceVar = 0; // 공유 가능
// 2. static 변수 (항상 공유)
private static int staticVar = 0; // 공유
// 3. 공유 컬렉션
private final List<String> list = new ArrayList<>(); // 공유
public void method() {
// 4. 지역 변수 (공유 X)
int local = 0; // 안전
// 임계 영역 (공유 자원 수정)
instanceVar++; // 위험
staticVar++; // 위험
list.add("x"); // 위험
local++; // 안전 (지역)
}
}
// 여러 변수의 복합 상태도 임계 영역
public class BankAccount {
private int balance = 1000;
private List<Transaction> history = new ArrayList<>();
public void withdraw(int amount) {
// 임계 영역 (balance + history 일관성)
if (balance >= amount) { // 확인
balance -= amount; // 수정
history.add(new Transaction(amount)); // 수정
}
// 두 변수가 일관되어야 함
// 동시 접근 시 불일치 위험
}
}
외부 자원도 임계 영역:
파일:
- 동시 쓰기 충돌
- 손상 가능
DB:
- 동시 수정 (트랜잭션으로 보호)
- 락 메커니즘
외부 API:
- 상태 변경 시
- 멱등성 고려
@Service
public class ShipmentSharedResources {
// 공유 자원들
private final AtomicInteger counter = new AtomicInteger(); // 안전 (Atomic)
private final Map<Long, Shipment> cache = new ConcurrentHashMap<>(); // 안전
private final List<Shipment> pending = new ArrayList<>(); // 위험!
public void process(Shipment shipment) {
// 안전한 공유 자원
counter.incrementAndGet();
cache.put(shipment.getId(), shipment);
// 위험한 공유 자원 (임계 영역)
synchronized (pending) { // 동기화 필요
pending.add(shipment);
}
// 지역 변수 (안전)
BigDecimal freight = calculate(shipment); // 안전
}
private BigDecimal calculate(Shipment s) {
return s.getWeight();
}
}
공유 자원과 임계 영역은?
답:
1. 공유 자원:
공유 여부:
복합 자원:
외부 자원:
count++ 의 실제:
겉보기:
count++ // 한 줄, 한 동작?
실제 (3단계):
1. 읽기 (read): count 값 읽기
2. 더하기 (modify): 읽은 값 + 1
3. 쓰기 (write): 결과를 count 에
→ 비원자적 (3개의 분리된 연산)
// count++ 는 사실:
int temp = count; // 1. 읽기
temp = temp + 1; // 2. 더하기
count = temp; // 3. 쓰기
// 또는:
count = count + 1;
// ↑읽기 ↑더하기
// ↑쓰기
3단계가 문제인 이유:
각 단계 사이에 다른 스레드가 끼어들 수 있음.
스레드 A: 읽기 (count=0)
→ 컨텍스트 스위칭
스레드 B: 읽기 (count=0) ← A 의 결과 아직 안 쓰임
스레드 B: 더하기 (1)
스레드 B: 쓰기 (count=1)
→ 스위칭
스레드 A: 더하기 (1) ← 여전히 0 기반
스레드 A: 쓰기 (count=1) ← B 의 1 덮어씀
결과: count=1 (2여야 하는데!)
count++ 동시 실행 (count=0 시작):
스레드 A 스레드 B
읽기 (0)
읽기 (0) ← A 결과 못 봄
+1 (1)
+1 (1)
쓰기 (1)
쓰기 (1) ← A 의 1 덮어씀
결과: count = 1
기대: count = 2
→ 1 손실!
// 비원자 연산들 (모두 위험)
count++; // 읽기-더하기-쓰기
count--; // 읽기-빼기-쓰기
count += 5; // 읽기-더하기-쓰기
count = count * 2; // 읽기-곱하기-쓰기
// 복합 연산
if (count == 0) count = 1; // 확인-후-수정 (check-then-act)
list.add(item); // 내부적으로 여러 단계
public class CounterProblem {
private int processedCount = 0;
// ❌ 위험 — count++ 3단계
public void incrementUnsafe() {
processedCount++;
// 1. 읽기
// 2. 더하기
// 3. 쓰기
// 동시 실행 시 손실
}
// 데모 — 손실 확인
public void demonstrateLoss() throws InterruptedException {
processedCount = 0;
int threads = 100;
int incrementsPerThread = 1000;
List<Thread> workers = new ArrayList<>();
for (int i = 0; i < threads; i++) {
Thread t = new Thread(() -> {
for (int j = 0; j < incrementsPerThread; j++) {
incrementUnsafe(); // 위험
}
});
t.start();
workers.add(t);
}
for (Thread t : workers) t.join();
// 기대: 100,000
// 실제: < 100,000 (손실)
log.info("Expected: {}, Actual: {}",
threads * incrementsPerThread, processedCount);
}
}
count++가 사실 3단계인 이유는?
답:
1. 3단계:
비원자:
문제:
다른 예:
count++ 의 바이트코드 (instanceVar):
getfield count // 1. 필드 읽기 (스택에)
iconst_1 // 2. 상수 1
iadd // 3. 더하기
putfield count // 4. 필드 쓰기
→ 여러 바이트코드 명령
→ 각 명령 사이 스위칭 가능
# 컴파일 후 바이트코드 확인
$ javap -c Counter.class
# increment() 메서드:
void increment();
Code:
0: aload_0
1: dup
2: getfield #2 // Field count:I ← 읽기
5: iconst_1 ← 1
6: iadd ← 더하기
7: putfield #2 // Field count:I ← 쓰기
10: return
CPU 명령 수준 (개념):
count++ →
MOV reg, [count] // 메모리 → 레지스터 (읽기)
ADD reg, 1 // 레지스터 + 1 (더하기)
MOV [count], reg // 레지스터 → 메모리 (쓰기)
3개의 기계어 명령
각 명령 사이 인터럽트/스위칭 가능
일부 CPU 는 원자적 증감 명령:
LOCK INC [count] // x86 의 원자적 증가
- 단일 명령
- 인터럽트 안 됨
- 하지만 자바 count++ 는 이걸 안 씀
자바:
- count++ 는 3단계 바이트코드
- 원자적 보장 X
- AtomicInteger 가 CAS 사용 (원자적)
메모리 접근 관점:
count++ 시:
1. 메인 메모리 (또는 캐시) 에서 읽기
2. 레지스터에서 계산
3. 메모리 (또는 캐시) 에 쓰기
문제:
- 읽기와 쓰기 사이 시간
- 다른 스레드 개입
- 캐시 일관성 (Unit 4.5)
public class BytecodeLevel {
private int count = 0;
// count++ 의 실제 동작
public void increment() {
count++;
// 바이트코드:
// getfield count (읽기)
// iconst_1
// iadd (더하기)
// putfield count (쓰기)
// → 3단계, 위험
}
// 원자적 대안 (AtomicInteger)
private final AtomicInteger atomicCount = new AtomicInteger();
public void incrementAtomic() {
atomicCount.incrementAndGet();
// 내부적으로 CAS (Compare-And-Swap)
// 원자적 (하드웨어 지원)
}
}
count++의 어셈블리/바이트코드 수준 동작은?
답:
1. 바이트코드:
CPU 명령:
확인:
원자적 대안:
경쟁 조건 (Race Condition):
여러 스레드가 공유 자원에 동시 접근할 때,
실행 순서 (타이밍) 에 따라
결과가 달라지는 현상.
특징:
- 비결정적 (실행마다 다름)
- 재현 어려움
- 디버깅 어려움
경쟁 조건 발생 조건:
1. 공유 자원
- 여러 스레드 접근
2. 동시 접근
- 적어도 하나 쓰기
3. 비원자 연산
- 여러 단계
세 조건 충족 시 경쟁 조건
경쟁 조건 유형:
1. Read-Modify-Write
- count++
- 읽고 수정하고 쓰기
- 중간 개입
2. Check-Then-Act
- if (x == null) x = new ...
- 확인 후 행동
- 확인과 행동 사이 개입
// ❌ Check-Then-Act 경쟁 조건
public class LazyInit {
private Resource resource;
public Resource getResource() {
if (resource == null) { // 확인
resource = new Resource(); // 행동
}
return resource;
// 두 스레드가 동시에 null 확인
// → 둘 다 new (인스턴스 2개)
}
}
// ✓ 동기화
public synchronized Resource getResourceSafe() {
if (resource == null) {
resource = new Resource();
}
return resource;
}
경쟁 조건의 비결정성:
같은 코드를 여러 번 실행:
- 실행 1: 결과 100
- 실행 2: 결과 98
- 실행 3: 결과 100
- 실행 4: 결과 95
타이밍에 따라 다름
→ 재현 어려움
→ "가끔 버그"
public class RaceConditionExample {
private int stock = 100;
// ❌ 경쟁 조건 (재고 차감)
public boolean reserveStock(int quantity) {
if (stock >= quantity) { // 확인
// 다른 스레드가 여기서 차감 가능
stock -= quantity; // 행동
return true;
}
return false;
// 두 스레드가 동시에 stock=100 확인
// 둘 다 통과 → 음수 재고 가능
}
// ✓ 동기화
public synchronized boolean reserveStockSafe(int quantity) {
if (stock >= quantity) {
stock -= quantity;
return true;
}
return false;
// 한 번에 하나만 → 안전
}
}
경쟁 조건의 정의는?
답:
1. 정의:
조건:
유형:
특징:
결과 손실 (count=0, A·B 가 각 +1):
시간 스레드 A 스레드 B count
t1 읽기 (0) 0
t2 읽기 (0) 0
t3 +1 → 1 0
t4 +1 → 1 0
t5 쓰기 (1) 1
t6 쓰기 (1) 1 ← A 의 1 덮어씀
결과: count = 1
기대: count = 2
→ A 의 증가 손실 (lost update)
손실 빈도:
적은 동시성:
- 가끔 손실
- 운 좋으면 정상
높은 동시성:
- 자주 손실
- 100만 증가 → 90만대
핵심:
- 동시성 높을수록 손실 ↑
- 비결정적
public class LostUpdateDemo {
private int count = 0;
public static void main(String[] args) throws InterruptedException {
LostUpdateDemo demo = new LostUpdateDemo();
int threadCount = 10;
int incrementsPerThread = 100_000;
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < threadCount; i++) {
Thread t = new Thread(() -> {
for (int j = 0; j < incrementsPerThread; j++) {
demo.count++; // 비동기화
}
});
t.start();
threads.add(t);
}
for (Thread t : threads) t.join();
// 기대: 1,000,000
// 실제: 보통 < 1,000,000 (예: 870,000)
System.out.println("Expected: " + (threadCount * incrementsPerThread));
System.out.println("Actual: " + demo.count);
}
}
동시성 문제 (손실 외):
1. 잃어버린 갱신 (Lost Update)
- count++ 손실
2. 더티 읽기 (Dirty Read)
- 미완성 데이터 읽음
3. 비일관 상태
- 복합 연산 중간 상태
4. 가시성 문제
- 변경이 안 보임 (Unit 4.5)
정상 (동기화):
A: 읽기(0)→+1→쓰기(1)
B: 읽기(1)→+1→쓰기(2)
결과: 2 ✓
손실 (비동기화):
A: 읽기(0)→+1→쓰기(1)
B: 읽기(0)→+1→쓰기(1)
결과: 1 ✗ (손실)
public class ResultLossExample {
private int totalShipments = 0;
private BigDecimal totalRevenue = BigDecimal.ZERO;
// ❌ 손실 발생
public void recordShipment(Shipment shipment) {
totalShipments++; // 손실 가능
totalRevenue = totalRevenue.add(shipment.getRevenue()); // 손실 가능
// 여러 스레드 동시 → 일부 누락
// 통계 부정확
}
// ✓ 동기화
private final Object lock = new Object();
public void recordShipmentSafe(Shipment shipment) {
synchronized (lock) {
totalShipments++;
totalRevenue = totalRevenue.add(shipment.getRevenue());
}
// 정확한 통계
}
// ✓ Atomic
private final AtomicInteger atomicCount = new AtomicInteger();
public void recordCount() {
atomicCount.incrementAndGet(); // 원자적
}
}
결과 손실은 어떻게 발생하는가?
답:
1. 시나리오:
빈도:
다른 문제:
해결:
원자성 (Atomicity):
연산이 더 이상 쪼개지지 않는
단일 단위로 실행되는 성질.
특징:
- 전부 실행 또는 전혀 안 함
- 중간 상태 없음
- 다른 스레드 개입 불가
원자적 vs 비원자적:
원자적 (Atomic):
- 단일 단위
- 중간 개입 X
- 예: 기본 타입 읽기/쓰기 (int)
- AtomicInteger.incrementAndGet()
비원자적 (Non-atomic):
- 여러 단계
- 중간 개입 가능
- 예: count++ (3단계)
- long/double 쓰기 (64비트, 일부)
자바에서 원자적인 것:
원자적:
- 기본 타입 읽기/쓰기 (int, boolean 등)
- 참조 읽기/쓰기
- volatile long/double 읽기/쓰기
비원자적:
- count++ (읽기-수정-쓰기)
- long/double 쓰기 (volatile 없으면, 일부 JVM)
- 복합 연산
long/double 의 비원자성:
64비트 값.
일부 32비트 JVM 에서:
- 상위 32비트, 하위 32비트 분리 쓰기
- 중간에 다른 스레드 읽으면
- 깨진 값 (word tearing)
해결:
- volatile long/double
- → 원자적 보장
// 1. synchronized
public synchronized void increment() {
count++; // 전체가 원자적 (락)
}
// 2. AtomicInteger
private final AtomicInteger count = new AtomicInteger();
public void increment() {
count.incrementAndGet(); // CAS 원자적
}
// 3. Lock
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public class AtomicityExample {
// 비원자 (위험)
private int counter = 0;
public void unsafe() {
counter++; // 비원자
}
// 원자적 방법들
// 1. AtomicInteger
private final AtomicInteger atomicCounter = new AtomicInteger();
public void atomic() {
atomicCounter.incrementAndGet(); // 원자적
}
// 2. AtomicLong (큰 수)
private final AtomicLong revenue = new AtomicLong();
public void addRevenue(long amount) {
revenue.addAndGet(amount); // 원자적
}
// 3. synchronized (복합 연산)
private int stock = 100;
public synchronized boolean reserve(int qty) {
if (stock >= qty) { // 확인 + 차감이 원자적
stock -= qty;
return true;
}
return false;
}
}
원자성의 의미는?
답:
1. 정의:
원자적 (자바):
비원자:
만들기:
동기화 (Synchronization):
여러 스레드의 임계 영역 접근을 제어하여
한 번에 하나의 스레드만 진입하도록 보장.
목적:
- 경쟁 조건 방지
- 데이터 일관성
- 원자성 보장
동기화의 효과:
상호 배제 (Mutual Exclusion):
- 한 번에 하나만 임계 영역
- 동시 접근 차단
가시성 (Visibility):
- 변경이 다른 스레드에 보임
- (synchronized, volatile)
순서 (Ordering):
- 연산 순서 보장
- happens-before
자바의 동기화 도구:
1. synchronized
- 메서드/블록
- 모니터 락
- Unit 4.2~4.4
2. volatile
- 가시성
- Unit 4.5
3. Lock (ReentrantLock 등)
- 명시적 락
- Phase 5
4. Atomic 클래스
- CAS 기반
- 원자적 연산
5. 동시성 컬렉션
- ConcurrentHashMap 등
동기화의 비용:
성능:
- 락 획득/반납 오버헤드
- 대기 (BLOCKED)
- 직렬화 (병렬성 ↓)
따라서:
- 필요한 곳만
- 범위 최소화
- 적절한 도구
트레이드오프:
- 안전 vs 성능
동기화 대안 — 무상태:
동기화:
- 공유 상태 보호
- 락 비용
무상태:
- 공유 상태 없음
- 동기화 불필요
- 가장 안전
권장:
- 가능하면 무상태
- 불가피하면 동기화
@Service
public class SynchronizationNeed {
// 방법 1: 무상태 (권장)
public BigDecimal calculate(Shipment shipment) {
BigDecimal weight = shipment.getWeight(); // 지역
return weight.multiply(RATE); // 공유 상태 X
}
// 방법 2: Atomic (단순 카운터)
private final AtomicInteger counter = new AtomicInteger();
public void count() {
counter.incrementAndGet();
}
// 방법 3: 동시성 컬렉션
private final Map<Long, Shipment> cache = new ConcurrentHashMap<>();
public void cache(Shipment s) {
cache.put(s.getId(), s);
}
// 방법 4: synchronized (복합 연산)
private int stock = 100;
public synchronized boolean reserve(int qty) {
if (stock >= qty) {
stock -= qty;
return true;
}
return false;
}
private static final BigDecimal RATE = BigDecimal.valueOf(0.15);
}
동기화가 필요한 이유는?
답:
1. 목적:
효과:
도구:
비용:
| Q | 핵심 답변 |
|---|---|
| 임계 영역? | 동시 접근 시 불일치 가능 코드 |
| 공유 자원? | 여러 스레드 접근 데이터 |
| count++ 단계? | 읽기-더하기-쓰기 (3단계) |
| 바이트코드? | getfield-iadd-putfield |
| 경쟁 조건? | 순서에 따라 결과 변함 |
| 결과 손실? | 둘 다 읽고 덮어씀 |
| Check-Then-Act? | 확인-행동 사이 개입 |
| 원자성? | 쪼개지지 않는 단위 |
| 자바 원자적? | 기본 타입 읽기/쓰기 |
| 동기화 필요? | 경쟁 조건 방지 |
답:
답:
답:
답:
답:
1. 임계 영역과 count++
2. 경쟁 조건과 손실
3. 원자성과 동기화
이번 Unit에서 동기화의 필요성을 봤다면, 다음은 synchronized 메서드.
🚀 Phase 4 — synchronized & volatile (★ 1차 정점)
✅ Unit 4.1 임계 영역과 동기화의 필요성 ← 여기
⏭ Unit 4.2 synchronized 메서드
⏭ Unit 4.3 synchronized 블록
⏭ Unit 4.4 모니터 락 (★ 마스터)
⏭ Unit 4.5 volatile (★ 마스터)
✅ Phase 1 — 동시성의 기초 (4 Unit)
✅ Phase 2 — 4분면 매트릭스 (3 Unit)
✅ Phase 3 — 스레드 다루기 (5 Unit)
🚀 Phase 4 — synchronized & volatile (1/5 진행) ★ 1차 정점
총: 13/35 Unit