CodeGym /행동 /JAVA 25 SELF /StampedLock과 저경합 카운터

StampedLock과 저경합 카운터

JAVA 25 SELF
레벨 58, 레슨 3
사용 가능

1. 왜 ReadWriteLock만으로는 충분하지 않을 때가 있는가

쓰기 경쟁이 높은 경우의 문제

ReadWriteLock(가장 흔하게는 ReentrantReadWriteLock)은 대부분의 스레드가 데이터를 읽기만 하고, 쓰는 스레드가 적을 때 잘 동작합니다. 이 경우 여러 스레드가 동시에 읽을 수 있고, 쓰기는 짧은 순간만 접근을 막습니다.

문제는 쓰기 스레드 수가 예상보다 늘거나, 스레드가 읽기와 쓰기를 자주 오갈 때 발생합니다. 쓰기 연산이 오래 걸리면 스레드가 락 해제를 더 오래 기다리게 됩니다. 그 결과 데이터 접근에 대한 경쟁이 커지고, 읽기와 쓰기가 모두 느려지며, ReadWriteLock의 효율이 떨어집니다.

락 모드 전환의 비용

스레드가 읽기 모드에서 쓰기 모드로(또는 그 반대로) 전환할 때, 내부적으로 복잡한 검사가 수행됩니다. 누군가가 쓰고 있지 않은지, 모든 리더가 빠져나갔는지 확인한 뒤에야 쓰기를 허용합니다.

이런 전환이 자주 일어나면 지연이 생깁니다. 스레드가 대기열에 늘어서기 시작하고, 특히 읽기와 쓰기가 동시에 많이 일어날 때 성능이 떨어집니다.

ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();

// 읽기
readLock.lock();
try {
    // 데이터를 읽음
} finally {
    readLock.unlock();
}

// 쓰기
writeLock.lock();
try {
    // 데이터를 변경
} finally {
    writeLock.unlock();
}

스레드가 readLock과 writeLock 사이를 전환할 때마다, 충돌을 피하기 위한 모든 검사가 수행됩니다. 이런 전환이 많다면 비용이 큽니다.

2. StampedLock: 동기화의 현대적 접근

StampedLock은 Java 8에 도입된 현대적 동기화 메커니즘입니다. ReadWriteLock의 아이디어를 결합하면서, 새 모드인 낙관적 읽기를 추가하고, 락 자체가 아니라 명시적으로 해제해야 하는 “스탬프”(stamps)라는 토큰으로 동작합니다.

핵심 특징:

  • 세 가지 모드: write lock(배타적 쓰기), read lock(공유 읽기), optimistic read(락 없이 낙관적 읽기).
  • 재진입성 없음: 같은 스레드에서 락을 반복해 진입할 수 없습니다.
  • 읽기가 많고 쓰기가 드문 경우 높은 성능.
  • 스탬프(stamp)를 명시적으로 관리해야 함.

낙관적 읽기: tryOptimisticRead + validate

낙관적 읽기는 스레드가 아예 락을 잡지 않고 데이터를 읽는 모드입니다. 그 순간 아무도 쓰지 않기를 “기대”하는 것이죠. 읽은 후에는 validate(stamp) 메서드로 읽는 동안 쓰기가 없었는지 확인해야 합니다.

import java.util.concurrent.locks.StampedLock;

public class Point {
    private double x, y;
    private final StampedLock lock = new StampedLock();

    public double distanceFromOrigin() {
        long stamp = lock.tryOptimisticRead();
        double currentX = x;
        double currentY = y;
        // 읽는 동안 기록이 있었는지 확인
        if (!lock.validate(stamp)) {
            // 기록이 있었다면: 일반 read lock을 획득
            stamp = lock.readLock();
            try {
                currentX = x;
                currentY = y;
            } finally {
                lock.unlockRead(stamp);
            }
        }
        return Math.hypot(currentX, currentY);
    }
}

동작 방식:

  • tryOptimisticRead()는 아무도 쓰고 있지 않은 동안 “유효한” 스탬프(long)를 반환합니다.
  • x와 y 값을 읽습니다.
  • validate(stamp)는 읽기 시작과 끝 사이에 쓰기가 있었는지 검사합니다.
  • 문제가 없으면 읽은 값을 사용하고, 쓰기가 있었다면 일반 readLock을 잡아 다시 읽습니다.

쓰기가 매우 드물고 읽기가 많은 경우 유리합니다. 대부분의 경우 validate는 true를 반환하여 읽기 비용이 거의 들지 않습니다.

“읽기는 많고 쓰기는 적은” 시나리오

  • 데이터는 드물게 바뀌지만 자주 읽힘(예: 캐시, 좌표, 메타데이터).
  • 읽기 지연을 최소화하는 것이 중요함.
  • 쓰기가 끼어들었다면 데이터를 “다시 읽어도” 괜찮음.

read lock으로의 폴백

낙관적 읽기가 실패(validate가 false를 반환)하면, 스레드는 일반 readLock으로 “되돌아갑니다”. 이는 데이터의 정확성을 보장하지만, 드물게 일어납니다.

3. StampedLock의 함정과 주의점

재진입성 없음

ReentrantReadWriteLock과 달리, StampedLock은 재진입을 지원하지 않습니다. 스레드가 이미 락을 보유한 상태에서 다시 획득하려 하면 교착 상태가 발생합니다.

long stamp1 = lock.writeLock();
long stamp2 = lock.writeLock(); // DEADLOCK! 스레드가 자기 자신을 기다립니다

인터럽트 대응에 대한 주의

StampedLock은 고전적인 락처럼 스레드 인터럽트에 동일하게 반응하지 않습니다. 락 대기 중 인터럽트된 스레드는 항상 즉시 “깨어나지” 않을 수 있습니다. 인터럽트에 빠르게 반응해야 하는 작업에는 다른 메커니즘을 사용하세요.

스탬프의 올바른 해제

readLock(), writeLock(), tryOptimisticRead()의 각 호출은 고유한 스탬프(long)를 반환합니다. 반드시 해당 unlock 메서드에 그 스탬프를 전달해 해제해야 합니다:

  • unlockRead(stamp)
  • unlockWrite(stamp)

오류: 스탬프를 바꿔 쓰거나 unlock 호출을 잊으면, 락 누수와 프로그램 정지로 이어집니다.

4. ReentrantReadWriteLock과의 비교

특성 ReentrantReadWriteLock StampedLock
재진입성(Reentrancy) 예 아니요
낙관적 읽기 아니요 예
높은 경쟁에서의 성능 중간 높음(쓰기가 적을 때)
락의 명시적 관리 아니요(자동) 예(스탬프)
인터럽트 반응성 예 항상은 아님
공정성(fairness) 예(설정 가능) 아니요

공정성 모드와 starvation에 대한 영향

  • ReentrantReadWriteLock은 “공정” 모드(fair)를 켤 수 있어, 스레드를 대기열 순서대로 처리합니다. 이는 기아(starvation)를 방지합니다.
  • StampedLock에는 공정성이 없습니다. 다른 스레드가 계속 락을 “가로채면” 더 오래 기다릴 수 있으며, 드물게 기아가 발생할 수 있습니다.

5. 카운터: LongAdder/LongAccumulator vs AtomicLong

높은 경쟁에서의 AtomicLong 문제

AtomicLong은 스레드 안전한 원자적 증가를 제공합니다. 그러나 많은 스레드가 동시에 incrementAndGet()을 호출하면 모두가 하나의 변수에 “집중”하게 되어 성능 저하가 발생합니다.

LongAdder: 스트라이프드(striped) 카운터

LongAdder는 다른 방식으로 문제를 해결합니다. 카운터를 여러 “스트라이프”(stripe)로 분할하고, 각 스트라이프가 자신에게 매핑된 스레드 그룹을 처리합니다. 스레드는 특정 스트라이프를 증가시키고, 최종 값은 모든 스트라이프의 합입니다.

장점:

  • 높은 경쟁에서도 스레드가 서로 거의 방해하지 않습니다.
  • AtomicLong보다 성능이 몇 배 더 좋습니다.
import java.util.concurrent.atomic.LongAdder;

LongAdder adder = new LongAdder();

Runnable task = () -> {
    for (int i = 0; i < 100_000; i++) {
        adder.increment();
    }
};

Thread[] threads = new Thread[8];
for (int i = 0; i < threads.length; i++) {
    threads[i] = new Thread(task);
    threads[i].start();
}
for (Thread t : threads) t.join();

System.out.println("최종 값: " + adder.sum());

LongAccumulator

LongAccumulator는 임의의 누적 함수를 지정할 수 있는 LongAdder의 일반화된 버전입니다(예: 최대, 최소 등).

import java.util.concurrent.atomic.LongAccumulator;

LongAccumulator max = new LongAccumulator(Long::max, Long.MIN_VALUE);

max.accumulate(10);
max.accumulate(42);
max.accumulate(7);

System.out.println("최대값: " + max.get()); // 42

스트라이프드(striped) 락과 경쟁 완화

striped locks 기법은 공용 자원을 여러 개의 독립된 부분(stripe)으로 분할하고, 각 부분을 자체 락 또는 변수로 보호합니다. 스레드는 스트라이프에 고르게 분산되어 경쟁이 줄고 성능이 향상됩니다. 바로 이 접근을 LongAdder와 LongAccumulator가 사용합니다.

6. 실습: 읽기 우위 캐시

문제: 데이터가 들어있는 맵(Map)과 메타데이터(예: 호출 카운터)가 있습니다. 읽기는 자주 발생하고, 쓰기는 드뭅니다.

StampedLock을 사용한 구현:

import java.util.*;
import java.util.concurrent.locks.StampedLock;
import java.util.concurrent.atomic.LongAdder;

public class MetadataCache<K, V> {
    private final Map<K, V> map = new HashMap<>();
    private final StampedLock lock = new StampedLock();
    private final LongAdder hits = new LongAdder();

    public V get(K key) {
        long stamp = lock.tryOptimisticRead();
        V value = map.get(key);
        if (!lock.validate(stamp)) {
            stamp = lock.readLock();
            try {
                value = map.get(key);
            } finally {
                lock.unlockRead(stamp);
            }
        }
        if (value != null) hits.increment();
        return value;
    }

    public void put(K key, V value) {
        long stamp = lock.writeLock();
        try {
            map.put(key, value);
        } finally {
            lock.unlockWrite(stamp);
        }
    }

    public long getHits() {
        return hits.sum();
    }
}
  • 읽기에는 낙관적 모드를 사용: 아무도 쓰지 않으면 읽기 비용이 거의 없습니다.
  • 쓰기는 write lock.
  • 호출 수 집계에는 LongAdder: 높은 경쟁에서도 증가 연산이 서로 방해하지 않습니다.

7. 부하에서의 LongAdder vs AtomicLong 프로파일링

테스트: 스레드 8개, 각 100만 번 증가

import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;

public class CounterBenchmark {
    public static void main(String[] args) throws InterruptedException {
        int threads = 8;
        int increments = 1_000_000;

        // AtomicLong
        AtomicLong atomic = new AtomicLong();
        long start = System.nanoTime();
        Thread[] t1 = new Thread[threads];
        for (int i = 0; i < threads; i++) {
            t1[i] = new Thread(() -> {
                for (int j = 0; j < increments; j++) atomic.incrementAndGet();
            });
            t1[i].start();
        }
        for (Thread t : t1) t.join();
        long timeAtomic = System.nanoTime() - start;

        // LongAdder
        LongAdder adder = new LongAdder();
        start = System.nanoTime();
        Thread[] t2 = new Thread[threads];
        for (int i = 0; i < threads; i++) {
            t2[i] = new Thread(() -> {
                for (int j = 0; j < increments; j++) adder.increment();
            });
            t2[i].start();
        }
        for (Thread t : t2) t.join();
        long timeAdder = System.nanoTime() - start;

        System.out.printf("AtomicLong: %d ms, LongAdder: %d ms%n", timeAtomic / 1_000_000, timeAdder / 1_000_000);
    }
}

전형적인 결과:

AtomicLong: 2500 ms, LongAdder: 200 ms

결론: 높은 경쟁 상황에서 LongAdder는 AtomicLong보다 몇 배 빠릅니다.

8. StampedLock과 LongAdder 사용 시 흔한 실수

오류 1번: unlockRead/unlockWrite 호출을 잊음. 스탬프를 해제하지 않으면 다른 스레드가 영원히 기다리게 됩니다. 반드시 try/finally를 사용하세요!

오류 2번: 재진입 시도. StampedLock은 재진입을 지원하지 않습니다. 한 스레드에서 동일 락을 두 번 획득하지 마세요.

오류 3번: validate의 오사용. tryOptimisticRead 후 validate를 확인하지 않으면 일관성 없는 데이터를 읽을 수 있습니다.

오류 4번: 높은 경쟁에서 AtomicLong 사용. AtomicLong은 1~2개 스레드에서는 좋지만, 8개 이상에서는 “병목”이 됩니다. LongAdder를 사용하세요.

오류 5번: 스트라이프드 락을 간과함. 직접 striped-lock을 구현한다면, 스레드가 스트라이프에 고르게 분산되는지 확인하세요. 그렇지 않으면 일부 스트라이프만 과부하되고 다른 스트라이프는 놀 수 있습니다.

오류 6번: StampedLock에서 공정성을 기대함. StampedLock은 스레드 처리 순서를 보장하지 않습니다. 드물지만 기아가 발생할 수 있습니다.

1
과제
JAVA 25 SELF, 레벨 58, 레슨 3
잠금
비행 관제 센터: 고도 캐시 시스템 🛰️
비행 관제 센터: 고도 캐시 시스템 🛰️
1
과제
JAVA 25 SELF, 레벨 58, 레슨 3
잠금
드론 네비게이터: 낙관적 위치 계산 🚁
드론 네비게이터: 낙관적 위치 계산 🚁
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION