본문으로 건너뛰기
김신건의 로그

[Java] ReentrantReadWriteLock

· 수정 · 📖 약 2분 · 860자/단어 #java #concurrent #lock #read-write
ReentrantReadWriteLock, java.util.concurrent.locks.ReentrantReadWriteLock, RRWLock, 읽기쓰기락, ReadWriteLock

정의

java.util.concurrent.locks.ReentrantReadWriteLock읽기 락과 쓰기 락을 분리ReentrantLock 의 확장.

핵심 규칙:

  • 여러 reader 가 동시에 read lock 획득 가능 (병렬 읽기)
  • writer 는 배타적: write lock 잡혀 있으면 reader 도 새 writer 도 대기
  • 재진입 가능 (같은 스레드)

사용 상황

읽기 비율이 압도적으로 높고 쓰기가 드문 자료구조에 적합.

  • 설정 캐시: 수천 번 읽고 드물게 reload
  • 인메모리 인덱스: 검색은 병렬, 갱신은 배타
  • 참조 데이터 테이블: 국가 코드, 환율 등 거의 변하지 않는 테이블

쓰기 비율이 높으면 오히려 ReentrantLock 단일 락보다 느리다.

시각화: 락 상태 전이

flowchart TD
    FREE["락 없음\n(readers=0, writer=0)"]
    READING["읽기 중\n(readers=N, writer=0)"]
    WRITING["쓰기 중\n(readers=0, writer=1)"]

    FREE -->|"readLock.lock()"| READING
    READING -->|"readLock.lock() 추가"| READING
    READING -->|"모든 readLock.unlock()"| FREE
    FREE -->|"writeLock.lock()"| WRITING
    WRITING -->|"writeLock.unlock()"| FREE
    READING -->|"writeLock.lock() 시도"| BLOCK_W["write 대기\n(readers 종료까지)"]
    BLOCK_W -->|"모든 reader 완료"| WRITING

사용

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

// Reader: 여러 스레드가 동시에 실행 가능
readLock.lock();
try {
    return data.get();
} finally {
    readLock.unlock();
}

// Writer: 배타적 실행
writeLock.lock();
try {
    data.update();
} finally {
    writeLock.unlock();
}

내부 구조: AQS 기반

ReentrantReadWriteLockAbstractQueuedSynchronizer (AQS) 의 단일 int state 를 두 영역으로 분리해 사용한다.

state (32bit)
┌─────────────────┬──────────────────┐
│  상위 16bit     │  하위 16bit      │
│  read lock 수   │  write lock 수   │
└─────────────────┴──────────────────┘
// JDK 소스 발췌 (단순화)
static final int SHARED_SHIFT   = 16;
static final int SHARED_UNIT    = (1 << SHARED_SHIFT);
static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1;  // 65535
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;

static int sharedCount(int c)    { return c >>> SHARED_SHIFT; }
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }

read lock 하나 획득 = state += SHARED_UNIT, write lock 하나 획득 = state += 1.

언제 유리한가

읽기 >> 쓰기 인 시나리오. 여러 reader 가 동시에 읽을 수 있으니 throughput 이 향상.

쓰기가 잦으면 오히려 손해. ReentrantLock 보다 오버헤드가 크고, write lock 대기로 reader 도 같이 멈춘다.

Read 80%, Write 20% 이상 -> ReentrantLock 이 더 빠를 가능성
Read 99%, Write 1% -> ReentrantReadWriteLock 유리

공정성

ReentrantReadWriteLock fair = new ReentrantReadWriteLock(true);
  • non-fair (기본): writer starvation 가능 (계속 reader 가 들어와 writer 가 기다림)
  • fair: FIFO, writer 기아 방지하지만 throughput 저하

downgrading 과 upgrading

락 다운그레이드 (write → read): 가능

writeLock.lock();
try {
    data.update();
    readLock.lock();      // write 락 유지한 채 read 락도 획득
} finally {
    writeLock.unlock();   // write 만 풀어 reader 로 강등
}
try {
    return data.get();    // 이제 read 락만 잡고 있음
} finally {
    readLock.unlock();
}

다운그레이드 패턴의 이점: 갱신 후 결과를 읽는 동안 다른 writer 가 끼어들지 못하게 한다.

락 업그레이드 (read → write): 직접 불가

readLock.lock();
writeLock.lock();    // ❌ deadlock 발생 가능

두 reader 가 동시에 업그레이드를 시도하면 서로가 상대방의 read lock 을 기다려 deadlock. 해결책은 read lock 을 풀고 write lock 을 새로 잡는 것 (그 사이 상태 변경 가능성 고려 필요).

실전 코드: 캐시 패턴

// Java 17+ 패턴
public class ConfigCache {
    private final Map<String, String> cache = new HashMap<>();
    private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final Lock readLock = rwLock.readLock();
    private final Lock writeLock = rwLock.writeLock();

    public String get(String key) {
        readLock.lock();
        try {
            return cache.get(key);
        } finally {
            readLock.unlock();
        }
    }

    public void reload(Map<String, String> newConfig) {
        writeLock.lock();
        try {
            cache.clear();
            cache.putAll(newConfig);
        } finally {
            writeLock.unlock();
        }
    }

    // 쓰기 후 바로 읽기: 다운그레이드 패턴
    public String putAndGet(String key, String value) {
        writeLock.lock();
        try {
            cache.put(key, value);
            readLock.lock();          // 다운그레이드 준비
        } finally {
            writeLock.unlock();       // write 락 해제 (read 락은 유지)
        }
        try {
            return cache.get(key);
        } finally {
            readLock.unlock();
        }
    }
}

StampedLock 과의 비교

StampedLock (JDK 1.8) 은 추가로 optimistic read 를 지원. read lock 없이 시도 후 검증. 변경 가능성이 낮을 때 더 빠르다.

| 항목 | ReentrantReadWriteLock | StampedLock | |:---|:---|:---| | 재진입 | ✓ | ✗ | | 조건 변수 | ✓ | ✗ | | optimistic read | ✗ | ✓ | | 인터럽트 응답 | ✓ | 메서드별 다름 | | 도입 | JDK 1.5 | JDK 1.8 | | 코드 복잡도 | 단순 | 복잡 |

함정

1. writer starvation (non-fair 기본)

// non-fair 모드: reader 가 계속 들어오면 writer 가 영원히 못 잡음
// 해결: new ReentrantReadWriteLock(true) 또는 쓰기 빈도 재검토

WARNING

non-fair 기본 설정에서 읽기 비율이 매우 높으면 writer 가 starve 될 수 있다. 실제 서비스 환경에서 관찰된 경우에만 fair 모드로 전환.

2. 읽기 Condition 미지원

read lock 에는 Condition 을 만들 수 없다. write lock 에만 가능.

Condition cond = rwLock.writeLock().newCondition();  // ✓
Condition cond = rwLock.readLock().newCondition();   // ❌ UnsupportedOperationException

3. 재진입 횟수 상한

read lock 의 재진입 횟수는 65535 회. 이를 초과하면 Error 발생. 실무에서 도달하기 어렵지만 재귀 깊이 관리 필요.

CAUTION

읽기/쓰기 비율 80/20 이하 시나리오에서는 단일 ReentrantLock 이나 synchronized 가 더 빠른 경우가 많다. 반드시 프로파일링 후 도입.

성능 가이드라인

읽기 비율   쓰기 비율   추천
≥ 99%      ≤ 1%       StampedLock optimistic read
≥ 90%      ≤ 10%      ReentrantReadWriteLock
≥ 70%      ≤ 30%      ReentrantLock or synchronized
< 70%                  synchronized (단순, JVM 최적화)

프로파일링 없이 최적화 도구를 선택하지 말 것. synchronized 는 JVM 이 biased-locking, adaptive spinning 등으로 최적화하므로 실측 우선.

TIP

JDK 15+ 에서 biased-locking 이 deprecated 되었고 JDK 21 에서 제거됐다. 저경합 시나리오에서 synchronized 성능이 이전보다 약간 달라졌으므로 측정 환경의 JDK 버전도 확인할 것.

관련 위키

이 글의 용어 (6개)
[Java] AtomicReferencejava
정의 는 객체 참조를 atomic 하게 갱신 하는 래퍼. 참조 + CAS (Compare-And-Swap) 의 결합. 의 객체 버전. 불변 객체 패턴, 상태 머신, lock-fr…
[Java] ConcurrentHashMapjava
정의 는 고동시성 환경에서 사용 가능한 구현. JSR-166 (Java 5) 도입, Java 8 에서 내부 구조가 크게 재작성됐다. 과 같은 인터페이스를 제공하면서 thread-…
[Java] CopyOnWriteArrayListjava
정의 는 쓰기 시 배열 전체를 복사 하는 thread-safe 구현. 읽기에는 lock 이 전혀 없고, 쓰기에는 으로 직렬화한다. (JSR-166) 의 컬렉션. 읽기 압도적 다,…
[Java] ReentrantLockjava
정의 는 키워드와 같은 상호 배제 (mutual exclusion) 를 제공하는 클래스 기반 락. JSR-166 (Java 5) 에서 추가됐다. "재진입 (reentrant)" …
[Java] StampedLockjava
정의 는 JDK 1.8 도입의 새 락. 세 가지 모드 제공. 1. Write lock (배타) 2. Read lock (공유) 3. Optimistic read (락 없이 시도,…
[Java] volatilejava
정의 은 Java 의 키워드. 필드에 붙이면 두 가지를 보장한다. 1. 가시성 (visibility): 한 스레드의 쓰기가 다른 모든 스레드에 즉시 보인다. CPU 캐시에 머무르…

이 개념을 다룬 위키 페이지 (1)

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기