ReentrantLock - 이론
자바는 1.0부터 존재한 synchronized와 BLOCKED 상태를 통한 임계 영역 관리의 한계를 극복하기 위해 자바 1.5부터 Lock 인터페이스와 ReentrantLock 구현체를 제공한다.
synchronized 단점
- 무한 대기 : BLOCKED 상태의 스레드는 락이 풀릴 때까지 무한 대기한다.
- 특정 시간까지만 대기하는 타임아웃 X
- 중간에 인터럽트 X
- 공정성 : 락이 돌아왔을 때 ` BLOCKED ` 상태의 여러 스레드 중에 어떤 스레드가 락을 획득할지 알 수 없다. 최악의 경우 특정 스레드가 너무 오랜 기간 락을 획득하지 못할 수 있다.
package java.util.concurrent.locks;
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
Lock 인터페이스는 동시성 프로그래밍에서 쓰이는 안전한 임계 영역을 위한 락을 구현하는데 사용된다.
Lock 인터페이스는 다음과 같은 메서드를 제공한다. 대표적인 구현체로는 ReentrantLock 이 있다.
- void lock()
- 락을 획득한다. 만약 다른 스레드가 이미 락을 획득했다면, 락이 풀릴 때까지 현재 스레드는 대기(WAITTING) 한다. 이 메서드는 인터럽트에 응답하지 않는다.
- 예 ) 맛집에 한번 줄을 서면 끝까지 기다린다. 친구가 다른 맛집을 찾았다고 중간에 연락해도 포기하지 않고 기다린다.
주의!
여기서 사용하는 락은 객체 내부에 있는 모니터락이 아니다! Lock 인터페이스와 ReentreantLock 이 제공하는 기능이다!
모니터 락과 BLOCKED 상태는 synchronized 에서만 사용된다.
- void lockInterruptibly()
- 락 획득을 시도하되, 다른 스레드가 인터럽트 할 수 있도록 한다, 만약 다른 스레드가 이미 락을 획득했다면, 현재 스레드는 락을 획득할 때까지 대기한다. 대기 중에 인터럽트가 발생하면 InterruptedException 이 발생하며 락 획득을 포기한다.
- 예) 맛집에 한번 줄을 서서 기다린다. 다만 친구가 다른 맛집을 찾았다고 중간에 연락하면 포기한다
- boolean tryLock()
- 락 획득을 시도하고, 즉시 성공 여부를 반환한다. 만약 다른 스레드가 이미 락을 획득했다면 false를 반환하고, 그렇지 않으면 락을 획득하고 true를 반환한다.
- 예) 맛집에 대기 줄이 없으면 바로 들어가고, 대기 줄이 있으면 즉시 포기한다.
- boolean tryLock(long time, TimeUnit unit)
- 주어진 시간 동안 락 획득을 시도한다. 주어진 시간 안에 락을 획득하면 true 를 반환한다. 주어진 시간이 지나도 락을 획득하지 못한 경우 false 를 반환한다. 이 메서드는 대기 중에 인터럽트가 발생하면 InterruptedException 이 발생하며 락 획득을 포기한다.
- void unlock()
- 락을 해제한다. 락을 해제하면 락 획득을 대기 중인 스레드 중 하나가 락을 획득할 수 있게 된다.
- 락을 획득한 스레드가 호출해야 하며, 그렇지 않으면 IllegalMonitorStateException 이 발생할 수 있다.
- 예) 식당안에 있는 손님이 밥을 먹고 나간다. 식당에 자리가 하나 난다. 기다리는 손님께 이런 사실을 알려주어야 한다. 기다리던 손님 중 한 명이 식당에 들어간다.
- Condition newCondition()
- Condition 객체를 생성하여 반환한다. Condition 객체는 락과 결합되어 사용되며, 스레드가 특정 조건을 기다리거나 신호를 받을 수 있도록 한다. 이는 Object 클래스의 wait, notify, notifyAll 메서드와 유사한 역할을 한다.
이 메서드들을 사용하면 고수준의 동기화 기법을 구현할 수 있다. Lock 인터페이스는 synchronized 블록보다 더 많은 유연성을 제공하며, 특히 락을 특정 시간만큼만 시도하거나 인터럽트 가능한 락을 사용할 때 유용하다.
이 메서드들을 보면 알겠지만 다양한 메서드를 통해 synchronized 의 단점인 무한 대기문제도 깔끔하게 해결할 수 있다.
참고 : lock() 메서드는 인터럽트에 응하지 않는다고 되어있다. 이 메서드의 의도는 인터럽트가 발생해도 무시하고 락을 기다리는 것이다.
앞서 대기( WAITING ) 상태의 스레드에 인터럽트가 발생하면 대기 상태를 빠져나온다고 배웠다. 그런데 lock() 메서드의 설명을 보면 대기(WAITING) 상태인 테 인터럽트에 응하지 않는다고 되어있다. 어떻게 된 것일까?
lock()을 호출해서 락을 얻기 위해 대기 중인 스레드에 인터럽트가 발생하면 순간 대기 상태를 빠져나오는 것은 맞다.
그래서 아주 짧지만 WAITING -> RUNNABLE 이 된다. 그런데 lock() 메서드 안에서 해당 스레드를 다시 WAITING 상태로 강제로 변경해 버린다. 이런 원리로 인터럽트를 무시하는 것이다. 참고로 인터럽트가 필요하면 lockInterruptibly()를 사용하면 된다. 새로운 Lock 은 개발자에게 다양한 선택권을 제공한다.
공정성
Lock 인터페이스가 제공하는 다양한 기능 덕분에 synchronized의 단점인 무한대기 문제가 해결되었다.
그런데 공정성에 대한 문제가 남아있다.
synchronized 단점
- 공정성 : 락이 돌아왔을 때 BLOCKED 상태의 여러 스레드 중에 어떤 스레드가 락을 획득할지 알 수 없다. 최악의 경우 특정 스레드가 너무 오랜 기간 락을 획득하지 못할 수 있다.
Lock 인터페이스의 대표적인 구현체로 ReentrantLock 이 있는데, 이 클래스는 스레드가 공정하게 락을 얻을 수 있는 모드를 제공한다.
package thread.sync;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockEx {
// 비공적 모드 락
private final Lock nonFairLock = new ReentrantLock();
// 공정 모드 락
private final Lock fairLock = new ReentrantLock(true);
public void nonFairLockTest() {
nonFairLock.lock();
try {
// 임계 영역
} finally {
nonFairLock.unlock();
}
}
public void fairLockTest() {
fairLock.lock();
try {
// 임계 영역
} finally {
fairLock.unlock();
}
}
}
Reentrant 락은 공정성 (fairness) 모드와 비공정 (non-fair) 모드로 설정할 수 있으며, 이 두 모드는 락을 획득하는 방식에서 차이가 있다.
비공정 모드(Non-fair mode)
비공정 모드는 ReentrantLock의 기본 모드이다. 이 모드에서는 락을 먼저 요청한 스레드가 락을 먼저 획득한다는 보장이 없다. 락을 풀었을 때, 대기 중인 스레드 중 아무나 락을 획득할 수 있다. 이는 락을 빨리 획득할 수 있지만, 특정 스레드가 장기간 락을 획득하지 못할 가능성도 있다.
비공정 모드 특징
- 성능 우선 : 락을 획득하는 속도가 빠르다.
- 선점 가능 : 새로운 스레드가 기존 대기 스레드보다 먼저 락을 획득할 수 있다.
- 기아 현상 가능성 : 특정 스레드가 계속해서 락을 획득하지 못할 수 있다.
공정 모드 (Fair mode)
생성자에서 true를 전달하면 된다. 예) new ReentrantLock(true)
공정 모드는 락을 요청한 순서대로 스레드가 락을 획득할 수 있게 한다. 이는 먼저 대기한 스레드가 먼저 락을 획득하게 되어 스레드 간의 공정성을 보장한다. 그러나 이로 인해 성능이 저하될 수 있다.
공정 모드 특징
- 공정성 보장 : 대기 큐에서 먼저 대기한 스레드가 락을 먼저 획득한다.
- 기아 현상 방지 : 모든 스레드가 언젠가 락을 획득할 수 있게 보장된다.
- 성능 저하: 락을 획득하는 속도가 느려질 수 있다.
정리 ` Lock` 인터페이스와 ` ReentrantLock` 구현체를 사용하면 ` synchronized` 단점인 무한 대기와 공정성 문제를 모두 해결할 수 있다.
ReentrantLock - 활용
앞서 작성한 ` BankAccountV3` 예제를 ` synchronized` 대신에 ` Lock` , ` ReentrantLock` 을 사용하도록 변경해 보자.
package thread.sync;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import static util.MyLogger.log;
import static util.ThreadUtils.sleep;
public class BankAccountV4 implements BankAccount {
private int balance;
private final Lock lock = new ReentrantLock();
public BankAccountV4(int initialBalance) {
this.balance = initialBalance;
}
@Override
public boolean withdraw(int amount) {
log("거래 시작: " + getClass().getSimpleName());
lock.lock(); // ReentrantLock 이용하여 lock을 걸기
try {
log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
if (balance < amount) {
log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
return false;
}
// 잔고가 출금액 보다 많으면, 진행
log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
sleep(1000); // 출금에 걸리는 시간으로 가정
balance = balance - amount;
log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
} finally {
lock.unlock(); // ReentrantLock 이용하여 lock 해제
}
log("거래 종료");
return true;
}
@Override
public int getBalance() {
lock.lock(); // ReentrantLock 이용하여 lock을 걸기
try {
return balance;
} finally {
lock.unlock(); // ReentrantLock 이용하여 lock 해제
}
}
}
- private final Lock lock = new ReentrantLock()을 사용하도록 선언한다.
- synchronized(this) 대신에 lock.lock()을 사용해서 락을 건다
- lock() -> unlock() 까지는 안전한 임계영역이 된다.
- 임계 영역이 끝나면 반드시! 락을 반납해야 한다. 그렇지 않으면 대기하는 스레드가 락을 얻지 못한다.
- lock() -> unlock() 까지는 안전한 임계 영역이 된다.
- 임계 영역이 끝나면 반드시 락! 을 반납해야 한다. 그렇지 않으면 대기하는 스레드가 락을 얻지 못한다.
- 따라서 lock.unlock() 은 반드시 finally 블록에 작성해야 한다. 이렇게 하면 검증에 실패해서 중간에 return을 호출해도 또는 중간에 예상치 못한 예외가 발생해도 lock.unlock() 이 반드시 호출된다.
주의!
여기서 사용하는 락은 내부에 있는 모니터 락이 아니다. Lock 인터페이스와 ReentrantLock 이 제공하는 기능이다.
모니터 락과 BLOCKED 상태는 synchronized에서만 사용된다.
public class BankMain {
public static void main(String[] args) throws InterruptedException {
BankAccount account = new BankAccountV4(1000);
}
}
- BankMain에서 BankAccountV4를 실행하도록 코드를 변경하자.
실행 결과
20:11:38.778 [ t1] 거래 시작: BankAccountV4
20:11:38.778 [ t2] 거래 시작: BankAccountV4
20:11:38.786 [ t1] [검증 시작] 출금액: 800, 잔액: 1000
20:11:38.786 [ t1] [검증 완료] 출금액: 800, 잔액: 1000
20:11:39.265 [ main] t1 state: TIMED_WAITING
20:11:39.265 [ main] t2 state: WAITING
20:11:39.796 [ t1] [출금 완료] 출금액: 800, 잔액: 200
20:11:39.797 [ t1] 거래 종료
20:11:39.798 [ t2] [검증 시작] 출금액: 800, 잔액: 200
20:11:39.800 [ t2] [검증 실패] 출금액: 800, 잔액: 200
20:11:39.801 [ main] 최종 잔액: 200
실행결과 분석
- t1 , t2 가 출금을 시작한다. 여기서 t1이 약간 먼저 실행된다고 가정하겠다.
- ReenterantLock 내부에는 락과 락을 얻지 못해 대기하는 스레드를 관리하는 대기 큐가 존재한다.
- 여기서 이야기하는 락은 객체 내부에 있는 모니터 락이 아니다. ReentrantLock 이 제공하는 기능이다.
t1 : ReenterantLock에 있는 락을 획득한다.
락을 획득하는 경우 RUNNABLE 상태가 유지되고, 임계영역의 코드를 실행할 수 있다.
t1 : 임계 영역의 코드를 실행한다.
t2 : ReenterantLock 에 있는 락의 획득을 시도한다. 하지만 락이 없다.
- t2 : 락을 획득하지 못하면 WAITING 상태가 되고, 대기 큐에서 관리된다.
- LockSupport.park()가 내부에서 호출된다.
- 참고로 tryLock(long time, TimeUnit unit)와 같은 시간 대기 기능을 사용하면 TIMED_WAITING 이 되고, 대기 큐에서 관리된다.
t1 : 임계 영역의 수행을 완료했다. 이때 잔액은 balance = 200 이 된다.
t1 : 임계 영역을 수행하고 나면 lock.unlock()을 호출한다.
- 1. t1: 락을 반납한다.
- 2. t1 : 대기 큐에 스레드를 깨운다. LockSupport.unpark(thread)가 내부에서 호출된다.
- 3. t2 : RUNNABLE 상태가 되면서 깨어난 스레드는 락획득을 시도한다.
- 이때 락을 획득하면 lock.lock()을 빠져나오면서 대기큐에서도 제거된다.
- 이때 락을 획득하지 못하면 다시 대기상태가 되면서 대기 큐에 유지된다.
- 참고로 락 획득을 시도하는 잠깐 사이에 새로운 스레드가 락을 먼저 가져갈 수 있다.
- 공정모드의 경우 대기 큐에 먼저 대기한 스레드가 먼저 락을 가져간다.
- t2 : 락을 획득한 t2 스레드는 RUNNABLE 상태로 임계 영역을 수행한다.
t2 : 잔액[200] 이 출금액 [800] 보다 적으므로 검증 로직을 통과하지 못한다. 따라서 검증 실패이다. return false 가 호출된다.
이때 finally 구문이 있으므로 finally 구문으로 이동한다.
t2 : lock.unlock()을 호출해서 락을 반납하고, 대기 큐의 스레드를 하나 깨우려고 시도한다. 대기 큐에 스레드가 없으므로 이때는 깨우지 않는다.
완료 상태
참고 : volatile를 사용하지 않아도 Lock을 사용할 때 접근하는 변수의 메모리 가시성 문제는 해결된다.
ReentrantLock - 대기 중단
ReentrantLock을 사용하면 락을 무한 대기하지 않고, 중간에 빠져나오는 것이 가능하다. 심지어 락을 얻을 수 없다면 기다리지 않고 즉시 빠져나오는 것이 가능하다. 다음 기능들을 어떻게 활용하는지 알아보자.
boolean tryLock()
- 락 획득을 시도하고, 즉시 성공 여부를 반환한다. 만약 다른 스레드가 이미 락을 획득했다면 그렇지 않으면 락을 획득하고 true를 반환한다.
- 예) 맛집에 대기 줄이 없으면 바로 들어가고, 대기 줄이 있으면 즉시 포기한다.
boolean tryLock(long time, TimeUnit unit)
- 주어진 시간 동안 락 획득을 시도한다. 주어진 시간 안에 락을 획득하면 락을 획득하지 못한 경우 true를 반환한다.
- 주어진 시간이 지나도 락을 획득하지 못한 경우 false를 반환한다
- 이 메서드는 대기 중 인터럽트가 발생하면 InterruptedException 이 발생하며 락 획득을 포기한다.
- 예) 맛집에 줄을 서지만 특정 시간만큼만 기다린다. 특정 시간이 지나도 계속 줄을 서야 한다면 포기한다. 친구가 다른 맛집을 찾았다고 중간에 연락해도 포기한다.
tryLock() 예시
package thread.sync;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import static util.MyLogger.log;
import static util.ThreadUtils.sleep;
public class BankAccountV5 implements BankAccount {
private int balance;
private final Lock lock = new ReentrantLock();
public BankAccountV5(int initialBalance) {
this.balance = initialBalance;
}
@Override
public boolean withdraw(int amount) {
log("거래 시작: " + getClass().getSimpleName());
if (!lock.tryLock()) {
log("[진입 실패] 이미 처리중인 작업이 있습니다.");
return false;
}
try {
log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
if (balance < amount) {
log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
return false;
}
// 잔고가 출금액 보다 많으면, 진행
log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
sleep(1000); // 출금에 걸리는 시간으로 가정
balance = balance - amount;
log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
} finally {
lock.unlock(); // ReentrantLock 이용하여 lock 해제
}
log("거래 종료");
return true;
}
@Override
public int getBalance() {
lock.lock(); // ReentrantLock 이용하여 lock을 걸기
try {
return balance;
} finally {
lock.unlock(); // ReentrantLock 이용하여 lock 해제
}
}
}
- lock.tryLock()을 사용한다. 락을 획득할 수 없으면 바로 포기하고 대기하지 않는다.
- 락을 획득할 수 없다면 false를 반환한다.
22:25:59.476 [ t1] 거래 시작: BankAccountV5
22:25:59.476 [ t2] 거래 시작: BankAccountV5
22:25:59.479 [ t2] [진입 실패] 이미 처리중인 작업이 있습니다.
22:25:59.484 [ t1] [검증 시작] 출금액: 800, 잔액: 1000
22:25:59.484 [ t1] [검증 완료] 출금액: 800, 잔액: 1000
22:25:59.964 [ main] t1 state: TIMED_WAITING
22:25:59.964 [ main] t2 state: TERMINATED
22:26:00.493 [ t1] [출금 완료] 출금액: 800, 잔액: 200
22:26:00.494 [ t1] 거래 종료
22:26:00.494 [ main] 최종 잔액: 200
tryLock(시간) 예시
package thread.sync;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import static util.MyLogger.log;
import static util.ThreadUtils.sleep;
public class BankAccountV6 implements BankAccount {
private int balance;
private final Lock lock = new ReentrantLock();
public BankAccountV6(int initialBalance) {
this.balance = initialBalance;
}
@Override
public boolean withdraw(int amount) {
log("거래 시작: " + getClass().getSimpleName());
try {
if (!lock.tryLock(500, TimeUnit.MILLISECONDS)) {
log("[진입 실패] 이미 처리중인 작업이 있습니다.");
return false;
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
try {
log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
if (balance < amount) {
log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
return false;
}
// 잔고가 출금액 보다 많으면, 진행
log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
sleep(1000); // 출금에 걸리는 시간으로 가정
balance = balance - amount;
log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
} finally {
lock.unlock(); // ReentrantLock 이용하여 lock 해제
}
log("거래 종료");
return true;
}
@Override
public int getBalance() {
lock.lock(); // ReentrantLock 이용하여 lock을 걸기
try {
return balance;
} finally {
lock.unlock(); // ReentrantLock 이용하여 lock 해제
}
}
}
22:33:47.016 [ t1] 거래 시작: BankAccountV6
22:33:47.016 [ t2] 거래 시작: BankAccountV6
22:33:47.023 [ t1] [검증 시작] 출금액: 800, 잔액: 1000
22:33:47.023 [ t1] [검증 완료] 출금액: 800, 잔액: 1000
22:33:47.504 [ main] t1 state: TIMED_WAITING
22:33:47.504 [ main] t2 state: TIMED_WAITING
22:33:47.520 [ t2] [진입 실패] 이미 처리중인 작업이 있습니다.
22:33:48.039 [ t1] [출금 완료] 출금액: 800, 잔액: 200
22:33:48.039 [ t1] 거래 종료
22:33:48.040 [ main] 최종 잔액: 200
실행 결과 분석
t1 : 먼저 락을 획득하고 임계 영역을 수행한다.
t2 : lock.tryLock(0.5초)을 호출하고 락획득을 시도한다. 락이 없으므로 0.5 초간 대기한다.
이때 t2 은 TIME_WAITING 상태가 된다.
내부에서 LockSupport.parkNanos(시간) 이 호출된다.
t2 : 대기 시간인 0.5초간 락을 획득하지 못했다. lock.tryLock(시간)에서 즉시 빠져나온다. 이때 false 가 반환된다.
스레드는 TIMED_WAITING ->RUNNABLE 이 된다.
t2:
"[진입 실패] 이미 처리중인 작업이 있습니다."
출력하고 false를 반환하면서 메서드를 종료한다.
t1 : 임계 영역의 수행을 완료하고 거래를 종료한다. 마지막으로 락을 반납한다.
정리
자바 1.5에서 등장한 Lock 인터페이스와 ReentrantLock 덕분에 synchronized ` 의 단점인 무한 대기와 공정성문제를 극복하고, 또 더욱 유연하고 세밀한 스레드 제어가 가능하게 되었다
'JAVA' 카테고리의 다른 글
Runnable과 Callable (0) | 2025.01.12 |
---|---|
Java에서의 지연 초기화,지연 평가 Optional 활용: orElse vs orElseGet (0) | 2025.01.06 |
고급 동기화 - concurrent.Lock (1) | 2025.01.05 |
생산자 소비자 문제1 (1) | 2024.12.31 |
메모리 가시성 (0) | 2024.12.29 |