1. ReentrantReadWriteLock가 필요한 이유
ReentrantLock이나 synchronized는 한 시점에 하나의 스레드만 임계 구역에 진입할 수 있는 상호 배제(mutual exclusion) 방식이다. 읽기 연산이 압도적으로 많은 환경에서 모든 접근을 서로 배제하면 목이 발생한다. ReentrantReadWriteLock은 읽기 락(read lock)을 공유하고 쓰기 락(write lock)을 배제함으로써 이 문제를 해결한다. 다수의 읽기 스레드는 동시에 임계 구역에 진입할 수 있지만, 쓰기 스레드는 기존의 독점 락과 동일하게 하나만 허용된다.
2. 설계 개요
이 락도 AbstractQueuedSynchronizer(AQS)를 기반으로 한다. 핵심 차이점은 state 하나를 두 부분으로 쪼개 사용하는 것이다.
- 하위 16비트: 쓰기 보유 횟수
- 상위 16비트: 읽기 락을 보유한 스레드 수(모든 읽기 스레드의 합)
따라서 state & 0x0000FFFF로 쓰기 상태를, state >>> 16으로 읽기 상태를 는다.
3. 쓰기 락 득
쓰기 락은 기본적으로 ReentrantLock과 같은 독점 락이다. 단, state의 하위 16비트만 조작한다.
// 변경된 메서드명과 변수명으로 재구성한 예시
protected final boolean tryWriteLock(int delta) {
Thread t = Thread.currentThread();
int s = getState();
int writerCount = exclusiveCount(s);
if (s != 0) {
// 다른 스레드가 읽기/쓰기 중이거나, 현재 스레드가 쓰기 락 소유자가 아니면 실패
if (writerCount == 0 || getExclusiveOwnerThread() != t)
return false;
if (writerCount + exclusiveCount(delta) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
setState(s + delta);
return true;
}
if (writerShouldBlock() || !compareAndSetState(s, s + delta))
return false;
setExclusiveOwnerThread(t);
return true;
}
writerShouldBlock은 공평/불공평 정에 따라 다르게 동작한다. 공평 모드에서는 대기열에 먼저 온 노드가 있으면 보하고, 불공평 모드에서는 즉시 CAS를 시도한다.
4. 기 락 해제
쓰기 해제는 state에서 하위 16비트를 감소시킨다. 카운트가 0이 되어야 비로소 독점 소유자를 해제한다.
protected final boolean tryWriteUnlock(int delta) {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
int next = getState() - delta;
boolean fullyReleased = exclusiveCount(next) == 0;
if (fullyReleased)
setExclusiveOwnerThread(null);
setState(next);
return fullyReleased;
}
5. 읽기 획득
읽기 은 공유 락이므로 tryAcquireShared를 사용한다. 먼저 다른 스레드가 쓰기 락을 가지고 있는지 확인한다.
protected final int tryReadLock(int ignored) {
Thread t = Thread.currentThread();
int s = getState();
if (exclusiveCount(s) != 0 && getExclusiveOwnerThread() != t)
return -1; // 쓰기 락을 다른 스레드가 소유 중
int readerCount = sharedCount(s);
if (!readerShouldBlock() &&
readerCount < MAX_COUNT &&
compareAndSetState(s, s + SHARED_UNIT)) {
trackReadHold(t, readerCount);
return 1;
}
return fullTryReadLock(t);
}
여기서 readerShouldBlock은 기 스레드의 기아(starvation)를 막기 위해 에 쓰기 노드가 있을 때 읽기 시도를 제한한다. 성공하면 상위 16비트를 증가시키고, 각 스레드별 재진입 횟수를 기록한다.
6. 읽기 락 재진입 기록
읽기 락은 동시에 여러 스레드가 보유할 수 있어, 각 스레드의 재진입 횟수를 ThreadLocal에 저장해야 한다. 내부에는 HoldCounter 객체를 두고, 첫 번째 읽기 스레드는 추가 최적화 필드에 캐싱한다.
static final class HoldCounter {
int count = 0;
final long tid = getThreadId(Thread.currentThread());
}
static final class ThreadLocalHoldCounter extends ThreadLocal<HoldCounter> {
@Override
public HoldCounter initialValue() {
return new HoldCounter();
}
}
private transient ThreadLocalHoldCounter readHolds;
private transient HoldCounter cachedHoldCounter;
private transient Thread firstReader;
private transient int firstReaderHoldCount;
재진입 기록 로직은 다음과 같다.
private void trackReadHold(Thread t, int prevReaders) {
if (prevReaders == 0) {
firstReader = t;
firstReaderHoldCount = 1;
} else if (firstReader == t) {
firstReaderHoldCount++;
} else {
HoldCounter last = cachedHoldCounter;
if (last == null || last.tid != getThreadId(t)) {
cachedHoldCounter = last = readHolds.get();
} else if (last.count == 0) {
readHolds.set(last);
}
last.count++;
}
}
7. 풀 시도 로직
른 경로에서 실패하면 fullTryReadLock이 실행된다. 이 메서드는 CAS 실패나 공평성 검사 실패 시 루프를 돌며 재시도한다.
final int fullTryReadLock(Thread t) {
HoldCounter holder = null;
for (;;) {
int s = getState();
if (exclusiveCount(s) != 0) {
if (getExclusiveOwnerThread() != t)
return -1;
} else if (readerShouldBlock()) {
if (firstReader != t) {
if (holder == null) {
holder = cachedHoldCounter;
if (holder == null || holder.tid != getThreadId(t)) {
holder = readHolds.get();
if (holder.count == 0)
readHolds.remove();
}
}
if (holder.count == 0)
return -1;
}
}
if (sharedCount(s) == MAX_COUNT)
throw new Error("Maximum lock count exceeded");
if (compareAndSetState(s, s + SHARED_UNIT)) {
if (sharedCount(s) == 0) {
firstReader = t;
firstReaderHoldCount = 1;
} else if (firstReader == t) {
firstReaderHoldCount++;
} else {
if (holder == null)
holder = cachedHoldCounter;
if (holder == null || holder.tid != getThreadId(t))
holder = readHolds.get();
else if (holder.count == 0)
readHolds.set(holder);
holder.count++;
cachedHoldCounter = holder;
}
return 1;
}
}
}
8. 큐에서의 전파
읽기 락은 대부분 CAS로 즉시 획하지만, 쓰기 스레드가 큐에 진입하면 후속 읽기 스레드도 대기해야 한다. 읽기 노드가 깨어나면 단일 노드만喚醒하지 않고, 연속된 공유 노드를 모두 깨우는 propagation 메커니즘이 동작한다.
private void doAcquireShared(int arg) {
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node prev = node.predecessor();
if (prev == head) {
int result = tryAcquireShared(arg);
if (result >= 0) {
setHeadAndPropagate(node, result);
prev.next = null;
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
if (shouldParkAfterFailedAcquire(prev, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
9. 읽기 해제
읽기 락 해제는 스레드별 재진입 카운트를 먼저 조정하고, state 상위 16비트를 감소시킨다. 이후 doReleaseShared로 후속 노드를 깨운다.
protected final boolean tryReadUnlock(int unused) {
Thread t = Thread.currentThread();
if (firstReader == t) {
if (firstReaderHoldCount == 1)
firstReader = null;
else
firstReaderHoldCount--;
} else {
HoldCounter holder = cachedHoldCounter;
if (holder == null || holder.tid != getThreadId(t))
holder = readHolds.get();
int cnt = holder.count;
if (cnt <= 1) {
readHolds.remove();
if (cnt <= 0)
throw new IllegalMonitorStateException();
}
holder.count--;
}
for (;;) {
int s = getState();
int next = s - SHARED_UNIT;
if (compareAndSetState(s, next))
return next == 0;
}
}
이 메서드가 true를 반환하면, 모든 읽기/쓰기 락이 해제되면 AQS 대기 큐喚醒이 이어진다.