동시성 유틸리티 심화 — Lock, StampedLock, Atomic의 내부 동작
synchronized만으로도 동기화는 되는데, 왜Lock이라는 별도의 API가 필요할까?
synchronized에는 타임아웃이 없고, 읽기와 쓰기를 구분할 수 없고, 대기 조건을 분리할 수 없다. 이런 한계를 해결하기 위해 java.util.concurrent.locks 패키지가 등장했다. 이 글에서는 ReentrantLock, StampedLock, Atomic 클래스, 그리고 동기화 유틸리티의 내부 동작과 선택 기준을 다룬다.
ReentrantLock — synchronized의 확장판
기본 사용법
private final ReentrantLock lock = new ReentrantLock();
public void transfer(Account from, Account to, int amount) {
lock.lock();
try {
from.debit(amount);
to.credit(amount);
} finally {
lock.unlock(); // 반드시 finally에서 해제
}
}
synchronized와 달리 lock()과 unlock()을 명시적으로 호출합니다. 이게 번거로워 보이지만, 그만큼 유연성을 얻습니다.
synchronized와의 차이점
| 특성 | synchronized | ReentrantLock |
|---|---|---|
| 락 해제 | 블록 끝에서 자동 | unlock() 수동 호출 |
| 타임아웃 | 불가 | tryLock(timeout) 가능 |
| 인터럽트 대응 | 불가 | lockInterruptibly() 가능 |
| 공정성 | 비공정 | new ReentrantLock(true) 공정 모드 지원 |
| Condition | wait()/notify() 하나뿐 | 다중 Condition 가능 |
tryLock — 데드락을 피하는 전략
데드락은 두 스레드가 서로의 락을 기다릴 때 발생한다. tryLock은 락을 제한 시간 내에 획득하지 못하면 false를 반환하므로, 양쪽 락을 모두 잡지 못하면 포기하고 재시도하는 패턴을 만들 수 있다.
public boolean transferSafe(Account from, Account to, int amount) {
boolean fromLocked = false;
boolean toLocked = false;
try {
fromLocked = from.getLock().tryLock(1, TimeUnit.SECONDS);
toLocked = to.getLock().tryLock(1, TimeUnit.SECONDS);
if (fromLocked && toLocked) {
from.debit(amount);
to.credit(amount);
return true;
}
return false; // 락 획득 실패 → 재시도 가능
} finally {
if (toLocked) to.getLock().unlock();
if (fromLocked) from.getLock().unlock();
}
}
finally에서 반드시 획득한 락만 해제하는 것이 핵심이다. 획득하지 않은 락을 해제하면 IllegalMonitorStateException이 발생한다.
Condition — 세밀한 대기/통지
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
public void put(Object item) throws InterruptedException {
lock.lock();
try {
while (count == items.length)
notFull.await(); // 가득 찼으면 대기
items[putIndex] = item;
count++;
notEmpty.signal(); // 비어있지 않음을 알림
} finally {
lock.unlock();
}
}
synchronized의 wait()/notify()는 하나의 대기 집합만 가지지만, Condition은 여러 개를 만들어 "가득 참"과 "비어 있음"을 분리할 수 있습니다.
StampedLock — 읽기 성능을 극대화하는 락
세 가지 모드
StampedLock은 Java 8에서 도입되었고, 세 가지 잠금 모드를 제공합니다.
- 쓰기 락(writeLock) — 배타적 접근
- ** 읽기 락(readLock)** — 공유 접근 (여러 스레드 동시 가능)
- ** 낙관적 읽기(tryOptimisticRead)** — 락 없이 읽기 시도
낙관적 읽기 패턴
private final StampedLock sl = new StampedLock();
private double x, y;
public double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead(); // 락을 잡지 않고 스탬프만 획득
double currentX = x;
double currentY = y;
if (!sl.validate(stamp)) {
// 낙관적 읽기 실패 — 읽기 락으로 전환
stamp = sl.readLock();
try {
currentX = x;
currentY = y;
} finally {
sl.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
읽기가 압도적으로 많은 상황에서 StampedLock의 낙관적 읽기는 락 경합 없이 데이터를 읽을 수 있어 성능이 크게 향상됩니다. 다만 재진입(reentrant)을 지원하지 않으므로 주의해야 합니다.
Atomic 클래스 — 락 없는 원자적 연산
CAS(Compare-And-Swap) 원리
Atomic 클래스들은 내부적으로 CAS 연산을 사용합니다.
CAS(메모리주소, 예상값, 새값)
→ 현재 메모리의 값이 예상값과 같으면 새값으로 교체하고 true 반환
→ 다르면 아무것도 하지 않고 false 반환
이 과정이 CPU 수준에서 원자적으로 실행되므로, 락 없이도 스레드 안전한 갱신이 가능합니다.
AtomicInteger 예시
private final AtomicInteger counter = new AtomicInteger(0);
// 단순 증가
counter.incrementAndGet(); // 원자적으로 +1
// CAS를 직접 사용하는 패턴
int expected, updated;
do {
expected = counter.get();
updated = expected * 2; // 현재 값의 두 배로 갱신
} while (!counter.compareAndSet(expected, updated));
// Java 8+ 간편 메서드
counter.updateAndGet(v -> v * 2);
LongAdder — 높은 경합 상황에서의 대안
// AtomicLong — 경합이 심하면 CAS 재시도가 많아져 성능 저하
private final AtomicLong atomicCounter = new AtomicLong();
// LongAdder — 내부적으로 여러 Cell에 분산 저장
private final LongAdder adder = new LongAdder();
public void increment() {
adder.increment(); // 경합 시 다른 Cell에 기록
}
public long getCount() {
return adder.sum(); // 모든 Cell의 합산 (정확한 스냅샷은 아님)
}
LongAdder는 정확한 시점의 값보다 높은 쓰기 처리량 이 중요한 통계 카운터에 적합합니다.
AtomicReference — 객체 참조의 원자적 갱신
private final AtomicReference<ImmutableConfig> config =
new AtomicReference<>(ImmutableConfig.DEFAULT);
public void updateConfig(Function<ImmutableConfig, ImmutableConfig> updater) {
config.updateAndGet(updater); // 불변 객체를 원자적으로 교체
}
동기화 유틸리티 — 스레드 간 협력
CountDownLatch — 한 번만 쓰는 게이트
CountDownLatch latch = new CountDownLatch(3);
// 작업 스레드 3개
for (int i = 0; i < 3; i++) {
executor.submit(() -> {
try {
doWork();
} finally {
latch.countDown(); // 카운트 감소
}
});
}
latch.await(); // 카운트가 0이 될 때까지 대기
System.out.println("모든 작업 완료");
CyclicBarrier — 재사용 가능한 동기 지점
CyclicBarrier barrier = new CyclicBarrier(3, () -> {
System.out.println("모든 스레드 도착, 다음 단계 진행");
});
for (int i = 0; i < 3; i++) {
executor.submit(() -> {
for (int phase = 0; phase < 5; phase++) {
doPhaseWork(phase);
barrier.await(); // 모든 스레드가 도착할 때까지 대기
}
});
}
Phaser — 유연한 다단계 동기화
Phaser phaser = new Phaser(3); // 3개 참여자
executor.submit(() -> {
for (int phase = 0; phase < 3; phase++) {
doWork(phase);
phaser.arriveAndAwaitAdvance(); // 현재 페이즈 완료, 다음 대기
}
phaser.arriveAndDeregister(); // 참여 해제
});
Phaser는 참여자를 동적으로 등록/해제할 수 있어, CyclicBarrier보다 유연합니다.
어떤 도구를 언제 쓸까?
| 상황 | 추천 도구 |
|---|---|
| 단순 임계 영역 보호 | synchronized |
| 타임아웃, 조건 분기 필요 | ReentrantLock |
| 읽기 >> 쓰기 | StampedLock 낙관적 읽기 |
| 단순 카운터/플래그 | AtomicInteger / AtomicBoolean |
| 높은 경합의 카운터 | LongAdder |
| 모든 작업 완료 대기 | CountDownLatch |
| 반복적 동기 지점 | CyclicBarrier |
| 동적 참여자 동기화 | Phaser |
주의할 점
ReentrantLock의 unlock을 finally에서 빼먹는 실수
synchronized는 블록을 벗어나면 자동으로 락이 풀리지만, ReentrantLock은 unlock()을 직접 호출해야 한다. finally 블록에서 빼먹으면 다른 스레드가 영원히 대기하게 된다. 예외가 발생해도 반드시 락이 풀리도록 finally에서 해제하는 패턴을 습관화해야 한다.
LongAdder의 sum()은 정확한 스냅샷이 아니다
LongAdder는 여러 Cell에 값을 분산 저장하고 sum()에서 합산한다. sum() 호출 시점에 다른 스레드가 동시에 increment하고 있으면 결과가 정확하지 않을 수 있다. 정확한 시점의 값이 아닌 통계적 카운터 에 적합하다.
StampedLock 낙관적 읽기에서 validate 전에 데이터를 사용하는 실수
tryOptimisticRead()로 stamp를 받고 데이터를 읽은 뒤 반드시 validate(stamp)로 검증해야 한다. 검증 없이 읽은 값을 사용하면 쓰기가 끼어든 불일치 데이터를 참조할 수 있다.
정리
| 도구 | 핵심 역할 | 주요 주의사항 |
|---|---|---|
ReentrantLock | synchronized + 타임아웃/Condition/공정성 | finally에서 반드시 unlock |
StampedLock | 읽기 위주 워크로드 최적화 | 재진입 불가, interrupt 취약 |
AtomicInteger | CAS 기반 락 없는 원자적 연산 | 경합 심하면 LongAdder 고려 |
LongAdder | 높은 쓰기 처리량 카운터 | sum()은 정확한 스냅샷 아님 |
CountDownLatch | N개 완료 대기 (일회용) | 재사용 불가 |
CyclicBarrier | 반복적 동기 지점 | 고정 참여자 |
Phaser | 동적 참여자 + 다단계 | 가장 유연, 가장 복잡 |