-
Notifications
You must be signed in to change notification settings - Fork 1
ReentrantLock vs ReentrantReadWriteLock
IllegalMonitorStateException
은 자바 동시성 프로그래밍에서 자주 만나게 되는 예외로, 주로 모니터(monitor) 상태가 잘못된 상황에서 발생한다. 이는 주로 락(lock)의 획득과 해제 과정에서 문제가 있을 때 발생한다.
ReentrantLock
을 사용할 때 이 예외가 발생하는 주요 원인은 다음과 같다:
-
락을 획득하지 않고 해제 시도: 스레드가
unlock()
메서드를 호출했으나 이전에lock()
으로 락을 획득하지 않은 경우 - 다른 스레드의 락 해제 시도: 스레드 A가 획득한 락을 스레드 B가 해제하려고 시도하는 경우
-
Condition 객체 사용 오류: 락을 보유하지 않은 상태에서
Condition.await()
,Condition.signal()
또는Condition.signalAll()
호출
import java.util.concurrent.locks.ReentrantLock;
public class IllegalMonitorStateExample {
private static final ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
// 예외 발생 예제 1: 락 획득 없이 해제 시도
try {
// lock() 호출 없음
lock.unlock(); // IllegalMonitorStateException 발생
} catch (IllegalMonitorStateException e) {
System.out.println("예외 발생: " + e.getMessage());
}
// 예외 발생 예제 2: 다른 스레드의 락 해제 시도
lock.lock(); // 메인 스레드가 락 획득
Thread otherThread = new Thread(() -> {
try {
lock.unlock(); // 메인 스레드가 획득한 락을 다른 스레드가 해제 시도
} catch (IllegalMonitorStateException e) {
System.out.println("다른 스레드에서 예외 발생: " + e.getMessage());
}
});
otherThread.start();
try {
otherThread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
lock.unlock(); // 정상적인 락 해제
}
}
IllegalMonitorStateException
을 방지하기 위한 모범 사례:
-
try-finally 블록 사용: 락 획득과 해제를 적절히 관리
ReentrantLock lock = new ReentrantLock(); public void safeMethod() { lock.lock(); try { // 임계 영역 코드 } finally { lock.unlock(); // 항상 락 해제 보장 } }
-
락 소유권 검사: 필요한 경우
isHeldByCurrentThread()
메서드로 검사public void conditionalUnlock() { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }
-
락 관리 추적: 복잡한 동시성 코드에서는 락 획득/해제 로깅
ReadWriteLock
은 읽기 작업과 쓰기 작업에 대해 다른 락을 제공하는 고급 락 메커니즘이다. 자바에서는 java.util.concurrent.locks
패키지의 ReadWriteLock
인터페이스를 통해 제공되며, 가장 일반적인 구현체는 ReentrantReadWriteLock
이다.
- 읽기 락(Read Lock): 공유 락으로, 여러 스레드가 동시에 획득 가능
- 쓰기 락(Write Lock): 배타적 락으로, 한 번에 하나의 스레드만 획득 가능
-
읽기-쓰기 상호작용:
- 읽기 락이 활성화된 상태에서는 다른 읽기 락은 획득 가능하지만 쓰기 락은 획득 불가
- 쓰기 락이 활성화된 상태에서는 다른 읽기 락과 쓰기 락 모두 획득 불가
- 재진입 가능(Reentrant): 동일 스레드가 이미 획득한 락을 다시 획득 가능
- 락 다운그레이드: 쓰기 락을 보유한 상태에서 읽기 락을 획득한 후 쓰기 락을 해제하여 읽기 락으로 '다운그레이드' 가능
- 락 업그레이드: 기본적으로 읽기 락에서 쓰기 락으로 직접 '업그레이드'는 불가능 (데드락 위험)
- 공정성 정책: 공정(fair) 또는 비공정(non-fair) 모드 지원
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteLockExample {
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private final java.util.concurrent.locks.Lock readLock = rwLock.readLock();
private final java.util.concurrent.locks.Lock writeLock = rwLock.writeLock();
private int data = 0; // 공유 데이터
// 읽기 작업: 여러 스레드가 동시에 데이터 읽기 가능
public int readData() {
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 읽기 작업 수행: " + data);
return data;
} finally {
readLock.unlock();
}
}
// 쓰기 작업: 한 번에 하나의 스레드만 데이터 수정 가능
public void writeData(int newValue) {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 쓰기 작업 수행: " + newValue);
data = newValue;
} finally {
writeLock.unlock();
}
}
public static void main(String[] args) {
ReadWriteLockExample example = new ReadWriteLockExample();
// 여러 읽기 스레드 생성
for (int i = 0; i < 5; i++) {
new Thread(() -> {
for (int j = 0; j < 3; j++) {
example.readData();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "Reader-" + i).start();
}
// 쓰기 스레드 생성
for (int i = 0; i < 2; i++) {
final int writerIndex = i;
new Thread(() -> {
for (int j = 0; j < 3; j++) {
example.writeData(writerIndex * 10 + j);
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "Writer-" + i).start();
}
}
}
락 다운그레이드는 쓰기 작업 후 읽기 작업을 안전하게 수행하고자 할 때 유용하다.
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class LockDowngradeExample {
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
private final ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();
private int data = 0;
private boolean cacheValid = false;
private int cachedData;
public int processData() {
// 캐시가 유효한지 확인
readLock.lock();
if (!cacheValid) {
// 캐시가 유효하지 않으면 읽기 락 해제, 쓰기 락 획득
readLock.unlock();
writeLock.lock();
try {
// 다른 스레드가 이미 캐시를 업데이트했는지 다시 확인
if (!cacheValid) {
// 데이터 계산 및 캐시 업데이트
data = calculateExpensiveData();
cachedData = data;
cacheValid = true;
}
// 쓰기 락을 보유한 상태에서 읽기 락 획득 (다운그레이드)
readLock.lock();
} finally {
// 쓰기 락 해제 (읽기 락은 유지)
writeLock.unlock();
}
}
try {
// 읽기 락만 보유한 상태에서 캐시된 데이터 사용
return cachedData;
} finally {
readLock.unlock();
}
}
private int calculateExpensiveData() {
// 복잡한 계산 시뮬레이션
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return (int) (Math.random() * 1000);
}
public static void main(String[] args) {
LockDowngradeExample example = new LockDowngradeExample();
// 여러 스레드에서 동시에 데이터 처리
for (int i = 0; i < 5; i++) {
new Thread(() -> {
for (int j = 0; j < 3; j++) {
int result = example.processData();
System.out.println(Thread.currentThread().getName() +
" 처리 결과: " + result);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "Thread-" + i).start();
}
}
}
Java 8에서 도입된 StampedLock
은 ReadWriteLock
의 개선된 버전으로, 낙관적 읽기(optimistic reading) 기능을 제공한다.
import java.util.concurrent.locks.StampedLock;
public class StampedLockExample {
private final StampedLock lock = new StampedLock();
private double x, y;
// 좌표 쓰기 (배타적 락 사용)
public void move(double deltaX, double deltaY) {
long stamp = lock.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
lock.unlockWrite(stamp);
}
}
// 좌표 간 거리 계산 (낙관적 읽기 락 사용)
public double distanceFromOrigin() {
// 낙관적 읽기 모드로 시작
long stamp = lock.tryOptimisticRead();
double currentX = x;
double currentY = y;
// 읽은 후 값이 변경되었는지 확인
if (!lock.validate(stamp)) {
// 변경되었다면 일반 읽기 락으로 다시 시도
stamp = lock.readLock();
try {
currentX = x;
currentY = y;
} finally {
lock.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}
-
사용 적합성:
- 읽기 작업이 쓰기 작업보다 훨씬 많을 때 효과적
- 읽기와 쓰기 작업이 균등하게 분포되어 있다면 일반
ReentrantLock
이 더 효율적일 수 있음
-
성능 고려사항:
- 읽기 락은 비용이 낮지만, 락 관리에 따른 오버헤드 존재
- 낙관적 읽기(
StampedLock
)는 경합이 낮은 경우 더 높은 성능 제공
-
데드락 방지:
- 읽기-쓰기 락 사용 시 데드락에 주의해야 함
- 특히 읽기 락에서 쓰기 락으로 업그레이드를 시도할 때 주의
ReentrantReadWriteLock
은 공정 모드와 비공정 모드를 지원한다:
// 공정 모드 락 생성
ReentrantReadWriteLock fairLock = new ReentrantReadWriteLock(true);
// 비공정 모드 락 생성 (기본값)
ReentrantReadWriteLock unfairLock = new ReentrantReadWriteLock(false);
-
비공정 모드:
- 성능이 더 좋음
- 읽기 스레드가 많으면 쓰기 스레드가 기아 상태에 빠질 수 있음
-
공정 모드:
- 스레드들이 요청한 순서대로 락을 획득
- 성능은 다소 떨어지지만 모든 스레드에 공정한 기회 제공
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ConcurrentCache<K, V> {
private final Map<K, V> cache = new HashMap<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private final java.util.concurrent.locks.Lock readLock = rwLock.readLock();
private final java.util.concurrent.locks.Lock writeLock = rwLock.writeLock();
// 캐시에서 값 조회 (읽기 작업)
public V get(K key) {
readLock.lock();
try {
return cache.get(key);
} finally {
readLock.unlock();
}
}
// 캐시에 값 추가 (쓰기 작업)
public V put(K key, V value) {
writeLock.lock();
try {
return cache.put(key, value);
} finally {
writeLock.unlock();
}
}
// 캐시에서 값 제거 (쓰기 작업)
public V remove(K key) {
writeLock.lock();
try {
return cache.remove(key);
} finally {
writeLock.unlock();
}
}
// 캐시 내용 클리어 (쓰기 작업)
public void clear() {
writeLock.lock();
try {
cache.clear();
} finally {
writeLock.unlock();
}
}
// 캐시에 키가 존재하는지 확인 (읽기 작업)
public boolean containsKey(K key) {
readLock.lock();
try {
return cache.containsKey(key);
} finally {
readLock.unlock();
}
}
// 캐시 크기 확인 (읽기 작업)
public int size() {
readLock.lock();
try {
return cache.size();
} finally {
readLock.unlock();
}
}
// "compute if absent" 패턴 구현 (읽기 후 필요시 쓰기)
public V computeIfAbsent(K key, java.util.function.Function<K, V> mappingFunction) {
// 먼저 읽기 락으로 확인
readLock.lock();
try {
V value = cache.get(key);
if (value != null) {
return value;
}
} finally {
readLock.unlock();
}
// 값이 없으면 쓰기 락 획득
writeLock.lock();
try {
// 다른 스레드가 이미 값을 추가했는지 다시 확인
V value = cache.get(key);
if (value == null) {
value = mappingFunction.apply(key);
cache.put(key, value);
}
return value;
} finally {
writeLock.unlock();
}
}
}
- 락 획득-해제 패턴 유지: 항상 같은 스레드에서 락 획득과 해제
- try-finally 블록 사용: 예외 발생 시에도 락 해제 보장
-
락 상태 검증: 불확실한 경우
isHeldByCurrentThread()
메서드로 확인 - 명확한 책임 범위: 락을 획득한 메서드가 해제할 책임을 가짐
시나리오 | 권장 락 유형 |
---|---|
간단한 상호 배제 |
synchronized 또는 ReentrantLock
|
복잡한 락 제어 필요 | ReentrantLock |
타임아웃 또는 인터럽트 필요 | ReentrantLock |
읽기 작업이 대부분인 경우 | ReadWriteLock |
고성능 낙관적 동시성 필요 | StampedLock |
여러 조건에 따른 대기/통지 |
ReentrantLock 과 Condition
|
- 락 범위 최소화: 임계 영역을 최대한 작게 유지
- 락 분할: 서로 다른 자원에 대해 별도의 락 사용
- 락 계층 구조화: 데드락 방지를 위해 일관된 락 획득 순서 유지
- 불필요한 동기화 제거: 읽기 전용 데이터는 동기화 불필요
- 적절한 락 선택: 요구사항에 맞는 적절한 락 메커니즘 선택
자바의 동시성 API는 다양한 락 메커니즘을 제공하여 서로 다른 사용 사례에 최적화된 솔루션을 구현할 수 있게 해준다. IllegalMonitorStateException
을 이해하고 방지하는 것과 적절한 락 메커니즘을 선택하는 것은 견고한 동시성 프로그래밍의 핵심이다. 특히 ReadWriteLock
은 읽기 작업이 많은 시나리오에서 동시성과 성능을 크게 향상시킬 수 있는 강력한 도구이다.