-
Notifications
You must be signed in to change notification settings - Fork 1
Mutex 구현: ReentrantLock vs Synchronized
자바에서 멀티스레드 환경의 공유 자원 접근을 제어하는 상호 배제(Mutual Exclusion, Mutex) 메커니즘은 크게 두 가지 방식으로 구현할 수 있다: synchronized
키워드와 ReentrantLock
클래스. 이 두 방식은 모두 동일한 기본 목적(임계 영역 보호)을 서비스하지만, 구현 방식과 제공하는 기능에 있어 중요한 차이점이 있다.
synchronized
는 Java 언어 자체에 내장된 기본적인 락 메커니즘이다.
// 메서드 전체에 락 적용
public synchronized void synchronizedMethod() {
// 임계 영역 코드
}
// 블록에 락 적용
public void blockSynchronized() {
synchronized (this) {
// 임계 영역 코드
}
}
- 편리성: 문법이 간단하고 사용하기 쉬움
- 암시적 락 해제: 블록을 벗어나거나 예외가 발생해도 자동으로 락 해제
- 재진입성(Reentrancy): 이미 락을 획득한 스레드가 다시 동일한 락을 획득하는 것이 가능
- 가시성 보장: 락 획득/해제 시 메모리 배리어 효과로 변수의 가시성 보장
- 세밀한 제어 부족: 락 획득 시도를 타임아웃하거나 인터럽트할 수 없음
- 락 확인 불가: 특정 락이 현재 보유 중인지 프로그래밍 방식으로 확인 불가
- 읽기/쓰기 락 구분 없음: 읽기 작업과 쓰기 작업에 대한 구분된 락을 제공하지 않음
- 공정성 제어 불가: 대기 중인 스레드 간의 우선순위 또는 공정성 조절 불가
ReentrantLock
은 Java 5에서 도입된 java.util.concurrent.locks
패키지의 일부로, 고급 락 기능을 제공한다.
import java.util.concurrent.locks.ReentrantLock;
public class Example {
private final ReentrantLock lock = new ReentrantLock();
public void criticalMethod() {
lock.lock(); // 락 획득
try {
// 임계 영역 코드
} finally {
lock.unlock(); // 락 해제
}
}
}
- 명시적 락 제어: 락 획득과 해제를 명시적으로 제어
-
타임아웃 지원: 락 획득 시도에 시간 제한 설정 가능
if (lock.tryLock(1, TimeUnit.SECONDS)) { try { // 임계 영역 코드 } finally { lock.unlock(); } } else { // 락 획득 실패 처리 }
-
인터럽트 지원: 락 획득 대기 중 인터럽트 처리 가능
try { lock.lockInterruptibly(); try { // 임계 영역 코드 } finally { lock.unlock(); } } catch (InterruptedException e) { // 인터럽트 처리 }
-
공정성 설정: 가장 오래 대기한 스레드에게 우선권 부여 가능
// 공정성 모드로 락 생성 private final ReentrantLock fairLock = new ReentrantLock(true);
-
락 정보 조회: 현재 락 상태에 대한 정보 접근 가능
int waitingThreads = lock.getQueueLength(); boolean isLocked = lock.isLocked(); boolean isHeldByCurrentThread = lock.isHeldByCurrentThread();
-
조건 변수(Condition): 다수의 대기/통지 조건 생성 가능
private final Condition notFull = lock.newCondition(); private final Condition notEmpty = lock.newCondition(); // 생산자 코드 lock.lock(); try { while (isFull()) { notFull.await(); } // 아이템 추가 notEmpty.signal(); } finally { lock.unlock(); }
특성 | synchronized | ReentrantLock |
---|---|---|
구현 수준 | 언어 자체 내장 | 라이브러리 클래스 |
락 획득/해제 | 암시적(블록 단위) | 명시적(lock/unlock 호출) |
타임아웃 지원 | 불가능 | 가능(tryLock 메서드) |
인터럽트 지원 | 불가능 | 가능(lockInterruptibly 메서드) |
락 공정성 | 비공정 전용 | 공정/비공정 모드 선택 가능 |
조건 변수 | 객체당 하나(wait/notify) | 다수 생성 가능(Condition) |
사용 편의성 | 간단함 | 복잡함(try-finally 필요) |
성능(JDK 6 이후) | 최적화됨 | 유사하거나 약간 우수 |
안전성 | 락 해제 보장 | 명시적 unlock 필요(finally 블록 권장) |
확장성 | 제한적 | 다양한 API를 통해 높은 확장성 |
JDK 1.6 이전에는 ReentrantLock
이 synchronized
보다 성능이 뛰어났으나, JDK 1.6부터 synchronized
에 대한 성능 최적화(락 경량화, 적응적 스피닝, 락 비대화 등)가 도입되어 성능 차이가 크게 줄었다.
현대 JVM에서의 성능 특성:
- 비경합 상황: 대부분의 경우 두 구현 모두 유사한 성능
-
높은 경합 상황:
ReentrantLock
이 공정 모드에서 더 나은 성능을 보일 수 있음 -
특수 시나리오: 고급 기능이 필요한 경우
ReentrantLock
이 유리
- 간단한 락 요구사항이 있을 때
- 락 획득/해제 로직의 실수를 피하고 싶을 때
- 기존 코드 베이스와의 호환성이 중요할 때
- 특별한 락 기능이 필요하지 않을 때
public class SimpleCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
- 락 획득 시도에 시간 제한이 필요할 때
- 락 획득 대기 중 인터럽트 처리가 필요할 때
- 공정한 락 정책이 필요할 때
- 락 상태에 대한 정보가 필요할 때
- 동일한 락에 다수의 조건 변수가 필요할 때
public class AdvancedBuffer<T> {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private final T[] items;
private int count, putIndex, takeIndex;
public AdvancedBuffer(int capacity) {
items = (T[]) new Object[capacity];
}
public void put(T item) throws InterruptedException {
lock.lock();
try {
while (count == items.length) {
notFull.await();
}
items[putIndex] = item;
putIndex = (putIndex + 1) % items.length;
count++;
notEmpty.signal();
} finally {
lock.unlock();
}
}
public T take() throws InterruptedException {
lock.lock();
try {
while (count == 0) {
notEmpty.await();
}
T item = items[takeIndex];
items[takeIndex] = null;
takeIndex = (takeIndex + 1) % items.length;
count--;
notFull.signal();
return item;
} finally {
lock.unlock();
}
}
}
public class SynchronizedQueue<T> {
private final Queue<T> queue = new LinkedList<>();
private final int capacity;
public SynchronizedQueue(int capacity) {
this.capacity = capacity;
}
public synchronized void put(T item) throws InterruptedException {
while (queue.size() >= capacity) {
wait(); // 큐가 가득 찼으면 대기
}
queue.add(item);
notifyAll(); // 대기 중인 소비자에게 알림
}
public synchronized T take() throws InterruptedException {
while (queue.isEmpty()) {
wait(); // 큐가 비었으면 대기
}
T item = queue.remove();
notifyAll(); // 대기 중인 생산자에게 알림
return item;
}
}
public class ConcurrentCache<K, V> {
private final Map<K, V> map = new HashMap<>();
private final ReentrantLock lock = new ReentrantLock();
public V get(K key, long timeout, TimeUnit unit) throws InterruptedException {
// 제한 시간 내에 락 획득 시도
if (lock.tryLock(timeout, unit)) {
try {
return map.get(key);
} finally {
lock.unlock();
}
}
throw new TimeoutException("락 획득 시간 초과");
}
public void put(K key, V value) {
lock.lock();
try {
map.put(key, value);
} finally {
lock.unlock();
}
}
public boolean remove(K key, long timeout, TimeUnit unit) throws InterruptedException {
if (lock.tryLock(timeout, unit)) {
try {
return map.remove(key) != null;
} finally {
lock.unlock();
}
}
return false;
}
}
-
기본 원칙: 특별한 요구사항이 없다면
synchronized
를 우선 사용 - 락 범위 최소화: 락이 필요한 코드 부분만 보호하여 성능 최적화
- 락 분리: 서로 다른 데이터에 대해 분리된 락을 사용하여 경합 감소
-
데드락 방지:
- 일관된 락 획득 순서 유지
-
tryLock
을 활용한 데드락 회피 기법 구현
-
락 해제 보장:
ReentrantLock
사용 시 항상finally
블록에서unlock
호출 - 과도한 동기화 피하기: 불필요한 동기화는 성능 저하의 원인
-
최신 동시성 도구 활용:
java.util.concurrent
패키지의 고수준 도구 활용 고려
synchronized
와 ReentrantLock
모두 자바에서 상호 배제를 구현하는 유효한 방법이지만, 각각의 장단점이 있다. 단순한 상호 배제 요구사항에는 synchronized
가 간결하고 안전한 선택이지만, 타임아웃, 인터럽트 처리, 공정성, 다중 조건 변수 등의 고급 기능이 필요한 경우에는 ReentrantLock
이 더 적합하다.
현대 JVM에서는 두 방식 모두 성능 최적화가 잘 되어 있으므로, 기능적 요구사항과 코드 명확성을 기준으로 선택하는 것이 바람직하다. 특히 보다 복잡한 동시성 시나리오에서는 Lock
인터페이스와 이를 구현한 클래스들이 제공하는 유연성이 큰 이점이 될 수 있다.