Skip to content

ReentrantLock vs ReentrantReadWriteLock

김동철 edited this page Mar 1, 2025 · 2 revisions

1. IllegalMonitorStateException 이해하기

IllegalMonitorStateException은 자바 동시성 프로그래밍에서 자주 만나게 되는 예외로, 주로 모니터(monitor) 상태가 잘못된 상황에서 발생한다. 이는 주로 락(lock)의 획득과 해제 과정에서 문제가 있을 때 발생한다.

발생 원인

ReentrantLock을 사용할 때 이 예외가 발생하는 주요 원인은 다음과 같다:

  1. 락을 획득하지 않고 해제 시도: 스레드가 unlock() 메서드를 호출했으나 이전에 lock()으로 락을 획득하지 않은 경우
  2. 다른 스레드의 락 해제 시도: 스레드 A가 획득한 락을 스레드 B가 해제하려고 시도하는 경우
  3. 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을 방지하기 위한 모범 사례:

  1. try-finally 블록 사용: 락 획득과 해제를 적절히 관리

    ReentrantLock lock = new ReentrantLock();
    
    public void safeMethod() {
        lock.lock();
        try {
            // 임계 영역 코드
        } finally {
            lock.unlock(); // 항상 락 해제 보장
        }
    }
  2. 락 소유권 검사: 필요한 경우 isHeldByCurrentThread() 메서드로 검사

    public void conditionalUnlock() {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
  3. 락 관리 추적: 복잡한 동시성 코드에서는 락 획득/해제 로깅

2. ReadWriteLock(RWLock)

ReadWriteLock은 읽기 작업과 쓰기 작업에 대해 다른 락을 제공하는 고급 락 메커니즘이다. 자바에서는 java.util.concurrent.locks 패키지의 ReadWriteLock 인터페이스를 통해 제공되며, 가장 일반적인 구현체는 ReentrantReadWriteLock이다.

주요 개념

  1. 읽기 락(Read Lock): 공유 락으로, 여러 스레드가 동시에 획득 가능
  2. 쓰기 락(Write Lock): 배타적 락으로, 한 번에 하나의 스레드만 획득 가능
  3. 읽기-쓰기 상호작용:
    • 읽기 락이 활성화된 상태에서는 다른 읽기 락은 획득 가능하지만 쓰기 락은 획득 불가
    • 쓰기 락이 활성화된 상태에서는 다른 읽기 락과 쓰기 락 모두 획득 불가

ReentrantReadWriteLock 특징

  1. 재진입 가능(Reentrant): 동일 스레드가 이미 획득한 락을 다시 획득 가능
  2. 락 다운그레이드: 쓰기 락을 보유한 상태에서 읽기 락을 획득한 후 쓰기 락을 해제하여 읽기 락으로 '다운그레이드' 가능
  3. 락 업그레이드: 기본적으로 읽기 락에서 쓰기 락으로 직접 '업그레이드'는 불가능 (데드락 위험)
  4. 공정성 정책: 공정(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();
        }
    }
}

3. ReadWriteLock 고급 패턴

3.1 스탬프드 락(StampedLock)

Java 8에서 도입된 StampedLockReadWriteLock의 개선된 버전으로, 낙관적 읽기(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);
    }
}

3.2 읽기-쓰기 락 고려사항

  1. 사용 적합성:

    • 읽기 작업이 쓰기 작업보다 훨씬 많을 때 효과적
    • 읽기와 쓰기 작업이 균등하게 분포되어 있다면 일반 ReentrantLock이 더 효율적일 수 있음
  2. 성능 고려사항:

    • 읽기 락은 비용이 낮지만, 락 관리에 따른 오버헤드 존재
    • 낙관적 읽기(StampedLock)는 경합이 낮은 경우 더 높은 성능 제공
  3. 데드락 방지:

    • 읽기-쓰기 락 사용 시 데드락에 주의해야 함
    • 특히 읽기 락에서 쓰기 락으로 업그레이드를 시도할 때 주의

3.3 공정성과 기아 현상

ReentrantReadWriteLock은 공정 모드와 비공정 모드를 지원한다:

// 공정 모드 락 생성
ReentrantReadWriteLock fairLock = new ReentrantReadWriteLock(true);

// 비공정 모드 락 생성 (기본값)
ReentrantReadWriteLock unfairLock = new ReentrantReadWriteLock(false);
  1. 비공정 모드:

    • 성능이 더 좋음
    • 읽기 스레드가 많으면 쓰기 스레드가 기아 상태에 빠질 수 있음
  2. 공정 모드:

    • 스레드들이 요청한 순서대로 락을 획득
    • 성능은 다소 떨어지지만 모든 스레드에 공정한 기회 제공

3.4 실제 적용 시나리오: 캐시 구현

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();
        }
    }
}

4. 결론: ReentrantLock과 ReadWriteLock 활용 전략

4.1 IllegalMonitorStateException 방지 전략

  1. 락 획득-해제 패턴 유지: 항상 같은 스레드에서 락 획득과 해제
  2. try-finally 블록 사용: 예외 발생 시에도 락 해제 보장
  3. 락 상태 검증: 불확실한 경우 isHeldByCurrentThread() 메서드로 확인
  4. 명확한 책임 범위: 락을 획득한 메서드가 해제할 책임을 가짐

4.2 락 선택 가이드

시나리오 권장 락 유형
간단한 상호 배제 synchronized 또는 ReentrantLock
복잡한 락 제어 필요 ReentrantLock
타임아웃 또는 인터럽트 필요 ReentrantLock
읽기 작업이 대부분인 경우 ReadWriteLock
고성능 낙관적 동시성 필요 StampedLock
여러 조건에 따른 대기/통지 ReentrantLockCondition

4.3 성능 최적화 팁

  1. 락 범위 최소화: 임계 영역을 최대한 작게 유지
  2. 락 분할: 서로 다른 자원에 대해 별도의 락 사용
  3. 락 계층 구조화: 데드락 방지를 위해 일관된 락 획득 순서 유지
  4. 불필요한 동기화 제거: 읽기 전용 데이터는 동기화 불필요
  5. 적절한 락 선택: 요구사항에 맞는 적절한 락 메커니즘 선택

자바의 동시성 API는 다양한 락 메커니즘을 제공하여 서로 다른 사용 사례에 최적화된 솔루션을 구현할 수 있게 해준다. IllegalMonitorStateException을 이해하고 방지하는 것과 적절한 락 메커니즘을 선택하는 것은 견고한 동시성 프로그래밍의 핵심이다. 특히 ReadWriteLock은 읽기 작업이 많은 시나리오에서 동시성과 성능을 크게 향상시킬 수 있는 강력한 도구이다.