![[Java] LockSupport와 ReentrantLock](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FebR5D3%2FbtsIUsr8lCT%2FjClHTkfYLYrHk3T6cbTfP0%2Fimg.png)
1. LockSupport
1-1. LockSupport란?
synchronized의 경우 임계 영역에 접근하지 못하고 대기 중인 스레드는 BLOCKED 상태가 된다. BLOCKED 상태는 다른 스레드에 의해 interrupt 될 수 없기 때문에 lock을 얻지 못하면 영원히 대기 상태로 유지한다.
LockSupport는 무한 대기를 해결하기 위해서 대기 중인 스레드의 상태를 BLOCKED가 아닌 WAITING 이 되게 한다. WAITING 상태는 다른 스레드가 interrupt를 통해 깨우거나, 스스로가 시간을 정하여 깨어날 수 있다.
1-2. LockSupport의 대표적인 기능
- park() : 현재 스레드를 WAITING 상태로 바꾼다.
- parkNanos(nanos) : 나노초 동안 현재 스레드를 TIMED_WAITING 상태로 바꾼다. 나노초 이후엔 RUNNABLE 상태로 변경된다.
- LockSupport.unpark(thread) : 지정한 스레드를 WAITING 상태에서 RUNNABLE 상태로 변경한다. unpark()의 경우 외부 스레드에서 호출해야 한다. WAITING 상태의 스레드에서는 unpark()를 호출할 수 없기 때문이다.
1-3. 예시 코드
- park() 코드
public class LockSupportMainV1 {
public static void main(String[] args) {
Thread t1 = new Thread(new MyTask(), "thread");
t1.start();
sleep(100);
log("t1 스레드 상태 : " + t1.getState());
log("main 스레드에서 park()를 호출하여 t1 스레드가 깨어난다.");
// LockSupport.unpark(t1); // unpark()를 통해 스레드를 RUNNABLE 상태로 만든다.
t1.interrupt(); // t1 스레드를 인터럽트를 발생시켜 RUNNABLE 상태로 변경한다.
}
static class MyTask implements Runnable {
@Override
public void run() {
log("park 시작");
LockSupport.park();
log("park 종료, 스레드 상태 : " + Thread.currentThread().getState());
log("인터럽트 상태: " + Thread.currentThread().isInterrupted());
}
}
}
- park() 출력 결과
synchronized와 달리 임계 영역에 접근하지 못한 스레드는 WAITING 상태가 된다. WAITING 상태의 스레드는 unpark() 또는 interrupt()를 통해 RUNNABLE 상태로 변경할 수 있다.
// LockSupport.unpark(t1) 호출
/*
* 00:48:56.290 [ thread] park 시작
* 00:48:56.378 [ main] t1 스레드 상태 : WAITING
* 00:48:56.378 [ main] main 스레드에서 park()를 호출하여 t1 스레드가 깨어난다.
* 00:48:56.378 [ thread] park 종료, 스레드 상태 : RUNNABLE
* 00:50:39.697 [ thread] 인터럽트 상태: false
*/
// t1.interrupt() 호출
// BLOCKED 상태와 달리, WAITING 상태의 스레드에 interrupt를 발생시킬 수 있다.
/*
* 00:50:39.610 [ thread] park 시작
* 00:50:39.694 [ main] t1 스레드 상태 : WAITING
* 00:50:39.694 [ thread] park 종료, 스레드 상태 : RUNNABLE
* 00:50:39.697 [ thread] 인터럽트 상태: true
*/
- parkNanos(nanos) 코드
public class LockSupportMainV2 {
public static void main(String[] args) {
Thread t1 = new Thread(new MyTask(), "thread");
t1.start();
sleep(100);
log("t1 스레드 상태 : " + t1.getState());
log("main 스레드에서 park()를 호출하여 t1 스레드가 깨어난다.");
}
static class MyTask implements Runnable {
@Override
public void run() {
log("park 시작");
LockSupport.parkNanos(2000_000000); // 2초(20억 나노초) 후에 TIMED_WAITING -> RUNNABLE 상태가 된다.
log("park 종료, 스레드 상태 : " + Thread.currentThread().getState());
}
}
}
- parkNanos(nanos) 결과
park() 시에 대기 시간을 설정하여, 대기 시간 동안 임계 영역에 접근하지 못한 스레드를 RUNNABLE 상태로 변경한다.
// LockSupport의 parkNanos(nanos)는 지정한 시간동안 대기 후, lock을 얻지 못하면 대기를 종료한다.
/*
* 00:53:21.164 [ thread] park 시작
* 00:53:21.249 [ main] t1 스레드 상태 : TIMED_WAITING
* 00:53:21.250 [ main] main 스레드에서 park()를 호출하여 t1 스레드가 깨어난다.
* 00:53:23.172 [ thread] park 종료, 스레드 상태 : RUNNABLE
*/
2. ReentrantLock
2-1. ReentrantLock이란?
Lock 인터페이스 구현체 중 하나로, 멀티 스레딩 환경에서 임계 구역을 위한 lock을 구현하는 데 사용된다. ReentrantLock 객체에서 사용하는 락은 synchronized에서 사용되는 객체 내부의 모니터락이 아닌, ReentrantLock이 제공하는 lock이다.
2-2. Lock 인터페이스
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();
}
- void lock()
- 임계 영역에 접근하기 위한 lock(모니터락x, 모니터락은 synchronized에서만 사용)을 획득하는 메서드이다.
- 다른 스레드가 lock을 획득한 상태라면, 현재 스레드는 lock을 얻을 때까지 WAITING 상태가 된다. 주의할 점은 WAITING 상태임에도 다른 스레드에 의해 interrupt가 될 수 없다.
- 원래 WAITING 상태의 스레드에 interrupt를 발생시킬 수 있으나, 예외적으로 lock()의 경우 WAITING → RUNNABLE이 되었다가 강제로 RUNNABLE → WAITING 상태가 된다.
- lockInterruptibly()
- lock()과 동일하게 lock을 얻기 위해 사용하되, 다른 스레드에 의해 interrupt 될 수 있다. 인터럽트가 발생하면 InterruptedException 예외가 발생한다.
- tryLock()
- lock 획득을 시도하고, lock 획득 여부(boolean)를 반환한다. 다른 스레드가 lock을 가지고 있다면 false를 반환하고, 현재 스레드가 lock을 획득한 경우 true를 반환한다.
- tryLock(time, unit)
- lock을 획득하기 위해 사용하되, 현재 스레드가 lock을 갖지 못한 경우 time 동안 TIMED_WAITING 상태로 대기하고, time동안 lock을 얻지 못하면 false를 반환한다. 또한 time동안 대기하는 동안 다른 스레드에 의해 interrupt 될 수 있다. 이때, InterruptedException 예외가 발생한다.
- unlock()
- 현재 스레드가 가지고 있는 lock을 반환한다. 임계 영역에 접근하기 위해 대기 중인 스레드가 있다면 그중에 한 스레드가 lock을 획득할 수 있다. lock()을 통해 lock을 얻은 경우, 사용 완료 후 반드시 unlock()을 호출해야 한다. 호출하지 않으면 대기 중인 스레드가 lock을 얻지 못하고 대기 상태를 유지하게 된다.
2-3. 공정성와 비공정성
lock을 얻지 못하여 대기 중인 스레드가 여러 개 존재할 수 있는데, 어떤 스레드가 lock을 반환하였을 때 대기 중인 스레드 중에 어떤 스레드가 lock을 획득할지 보장할 수 없다. 여기서 공정성이란 lock을 얻을 기회가 생겼을 때, 대기 중인 스레드들이 공정하게 lock을 얻을 수 있도록 보장한다.
ReentrantLock은 공정성 모드와 비공정성 모드를 지원한다.
- 공정성 모드
- lock을 얻을 기회가 생겼을 때, 여러 스레드 중에 요청한 순서대로 lock을 얻을 수 있도록 한다.
- new ReentrantLock(true); → ReentrantLock 객체 생성 시에 인자로 true를 전달한다.
- 공정성 보장 : 대기 큐에서 먼저 대기한 스레드부터 lock을 얻을 수 있다.
- 기아 현상 방지 : 모든 스레드가 반드시 lock을 얻을 수 있다. 우선순위 존재 X
- 성능 저하 : lock 획득 속도가 느려진다.
- 비공정성 모드
- lock을 얻을 기회가 생겼을 때, 어떠한 순서대로 lock을 얻음을 보장하지 않는다. 즉, 대기 중인 스레드 중에 아무나 lock을 얻게 된다.
- 성능 우선 : lock 획득 속도가 빠르다.
- 선점 가능 : 뒤늦게 대기한 스레드가 먼저 lock을 얻을 수 있다.
- 기아 현상 발생 : lock 획득 순서가 없으므로, 특정 스레드가 lock을 획득하지 못할 수 있다.
2-4. 예시 코드
- main 코드
public class BankMain {
public static void main(String[] args) throws InterruptedException {
// BankAccount bankAccount = new BankAccountV1(1000); // 계좌 초기 잔액은 1000원이다.
// BankAccount bankAccount = new BankAccountV2(1000); // 계좌 초기 잔액은 1000원이다.
// BankAccount bankAccount = new BankAccountV3(1000); // 계좌 초기 잔액은 1000원이다.
// BankAccount bankAccount = new BankAccountV4(1000); // ReentrantLock을 사용하여 lock 처리
// BankAccount bankAccount = new BankAccountV5(1000); // ReentrantLock을 사용하여 lock 처리
BankAccount bankAccount = new BankAccountV6(1000); // ReentrantLock을 사용하여 lock 처리
Thread t1 = new Thread(new WithdrawTask(bankAccount, 800), "t1");
Thread t2 = new Thread(new WithdrawTask(bankAccount, 800), "t2");
t1.start();
t2.start();
sleep(500);
log("t1 state: " + t1.getState());
log("t2 state: " + t2.getState());
t1.join();
t2.join();
log("최종 잔액: " + bankAccount.getBalance());
}
}
- lock() 및 unlock()을 사용하여 임계 영역 다루기
- lock()의 경우 내부에서 LockSupport.park()가 호출된다.
- unlock()의 경우 내부에서 LockSupport.unpark()가 호출된다.
lock()을 사용한 경우 반드시 작업을 종료하고 unlock()을 호출해야 한다. lock을 반환하지 않으면 대기 중인 스레드가 lock을 얻을 수 없어 무한정 대기하게 된다.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class BankAccountV4 implements BankAccount {
private int balance; // 계좌 잔액, (공유자원)
private final Lock lock = new ReentrantLock(); // 비공정 모드(Non-fair) : 대기 중인 스레드 중에 아무나 락을 획득할 수 있다.
public BankAccountV4(int initialBalance) {
this.balance = initialBalance;
}
@Override
public boolean withdraw(int amount) {
log("거래 시작: " + getClass().getSimpleName());
lock.lock(); // ReentrantLock 이용하여 lock을 건다.
// 임계 영역이 끝나면 반드시 lock을 해제해야 한다.
// lock을 해지하지 않으면 대기 중인 스레드가 lock을 획득하지 못하고 계속 대기하게 된다.
try {
log("[검증 시작] 출금액 : " + amount + ", 현재 잔액 : " + balance);
if (balance < amount) { // 계좌 잔액 < 출금액
log("[검증 실패] 출금액 : " + amount + ", 현재 잔액 : " + balance);
return false;
}
log("[검증 완료] 출금액 : " + amount + ", 현재 잔액 : " + balance);
sleep(1000); // 계좌에서 출금하는데 1초가 걸린다고 가정
balance -= amount;
log("[출금 완료] 출금액 : " + amount + ", 현재 잔액 : " + balance);
} finally { // 반드시 unlock을 해야 한다.
lock.unlock(); // ReentrantLock 이용하여 lock을 해제한다.
}
log("거래 종료");
return true;
}
@Override
public int getBalance() {
lock.lock(); // ReentrantLock 이용하여 lock을 건다.
try {
return balance;
} finally {
lock.unlock(); // ReentrantLock 이용하여 lock을 해제한다.
}
}
}
- lock() 및 unlock() 출력 결과
lock을 획득하지 못한 t2 스레드는 WAITING 상태가 되며, t1 스레드가 작업을 종료하고 lock을 반납하면 t2 스레드가 획득하게 된다. 이때, t2 스레드는 WAITING 상태에서 RUNNABLE 상태가 된다.
/*
* 22:41:34.857 [ t1] 거래 시작: BankAccountV4
* 22:41:34.857 [ t2] 거래 시작: BankAccountV4
* 22:41:34.864 [ t1] [검증 시작] 출금액 : 800, 현재 잔액 : 1000
* 22:41:34.864 [ t1] [검증 완료] 출금액 : 800, 현재 잔액 : 1000
* 22:41:35.339 [ main] t1 state: TIMED_WAITING
* 22:41:35.339 [ main] t2 state: WAITING
* 22:41:35.869 [ t1] [출금 완료] 출금액 : 800, 현재 잔액 : 200
* 22:41:35.869 [ t1] 거래 종료
* 22:41:35.869 [ t2] [검증 시작] 출금액 : 800, 현재 잔액 : 200
* 22:41:35.869 [ t2] [검증 실패] 출금액 : 800, 현재 잔액 : 200
* 22:41:35.871 [ main] 최종 잔액: 200
*/
- tryLock()을 사용하여 임계 영역 다루기
- lock 획득을 시도하되, 획득한 경우에 true를 반환하고, 그렇지 않은 경우에 false를 반환한다.
- lock을 획득하지 못한다면 false를 반환하고, 대기큐에 저장되지 않고 바로 종료된다.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class BankAccountV5 implements BankAccount {
private int balance; // 계좌 잔액, (공유자원)
private final Lock lock = new ReentrantLock(); // 비공정 모드(Non-fair) : 대기 중인 스레드 중에 아무나 락을 획득할 수 있다.
public BankAccountV5(int initialBalance) {
this.balance = initialBalance;
}
@Override
public boolean withdraw(int amount) {
log("거래 시작: " + getClass().getSimpleName());
// lock을 획득할 수 없는 경우 tryLock()은 false를 반환한다.
// 반대로 lock을 획득할 수 있는 경우, lock을 획득하고 true를 반환한다.
if(!lock.tryLock()) {
log("[진입 실패] 이미 처리 중인 작업이 존재합니다.");
return false;
}
// 임계 영역이 끝나면 반드시 lock을 해제해야 한다.
// lock을 해지하지 않으면 대기 중인 스레드가 lock을 획득하지 못하고 계속 대기하게 된다.
try {
log("[검증 시작] 출금액 : " + amount + ", 현재 잔액 : " + balance);
if (balance < amount) { // 계좌 잔액 < 출금액
log("[검증 실패] 출금액 : " + amount + ", 현재 잔액 : " + balance);
return false;
}
log("[검증 완료] 출금액 : " + amount + ", 현재 잔액 : " + balance);
sleep(1000); // 계좌에서 출금하는데 1초가 걸린다고 가정
balance -= amount;
log("[출금 완료] 출금액 : " + amount + ", 현재 잔액 : " + balance);
} finally { // 반드시 unlock을 해야 한다.
lock.unlock(); // ReentrantLock 이용하여 lock을 해제한다.
}
log("거래 종료");
return true;
}
@Override
public int getBalance() {
lock.lock(); // ReentrantLock 이용하여 lock을 건다.
try {
return balance;
} finally {
lock.unlock(); // ReentrantLock 이용하여 lock을 해제한다.
}
}
}
- tryLock() 출력 결과
lock을 획득하지 못한 스레드는 바로 종료되기 때문에 TERMINATED 상태가 된다.
/*
* 22:50:37.540 [ t1] 거래 시작: BankAccountV5
* 22:50:37.540 [ t2] 거래 시작: BankAccountV5
* 22:50:37.543 [ t2] [진입 실패] 이미 처리 중인 작업이 존재합니다.
* 22:50:37.547 [ t1] [검증 시작] 출금액 : 800, 현재 잔액 : 1000
* 22:50:37.548 [ t1] [검증 완료] 출금액 : 800, 현재 잔액 : 1000
* 22:50:38.024 [ main] t1 state: TIMED_WAITING
* 22:50:38.025 [ main] t2 state: TERMINATED
* 22:50:38.554 [ t1] [출금 완료] 출금액 : 800, 현재 잔액 : 200
* 22:50:38.556 [ t1] 거래 종료
* 22:50:38.563 [ main] 최종 잔액: 200
*/
- tryLock(time, unit)을 통해 임계 구역 다루기
- 메서드 호출 당시에 lock을 획득할 수 있으면 바로 true를 반환하고, 획득하지 못했다면 time 동안 lock을 획득하기 위해 대기한다. 대기하는 스레드의 상태는 TIMED_WAITING 상태가 된다.
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class BankAccountV6 implements BankAccount {
private int balance; // 계좌 잔액, (공유자원)
private final Lock lock = new ReentrantLock(); // 비공정 모드(Non-fair) : 대기 중인 스레드 중에 아무나 락을 획득할 수 있다.
public BankAccountV6(int initialBalance) {
this.balance = initialBalance;
}
@Override
public boolean withdraw(int amount) {
log("거래 시작: " + getClass().getSimpleName());
try {
if (!lock.tryLock(500, TimeUnit.MILLISECONDS)) {
log("[진입 실패] 0.5초 간 기다렸으나 lock을 획득하지 못하여 작업을 중단합니다.");
return false;
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// 임계 영역이 끝나면 반드시 lock을 해제해야 한다.
// lock을 해지하지 않으면 대기 중인 스레드가 lock을 획득하지 못하고 계속 대기하게 된다.
try {
log("[검증 시작] 출금액 : " + amount + ", 현재 잔액 : " + balance);
if (balance < amount) { // 계좌 잔액 < 출금액
log("[검증 실패] 출금액 : " + amount + ", 현재 잔액 : " + balance);
return false;
}
log("[검증 완료] 출금액 : " + amount + ", 현재 잔액 : " + balance);
sleep(1000); // 계좌에서 출금하는데 1초가 걸린다고 가정
balance -= amount;
log("[출금 완료] 출금액 : " + amount + ", 현재 잔액 : " + balance);
} finally { // 반드시 unlock을 해야 한다.
lock.unlock(); // ReentrantLock 이용하여 lock을 해제한다.
}
log("거래 종료");
return true;
}
@Override
public int getBalance() {
lock.lock(); // ReentrantLock 이용하여 lock을 건다.
try {
return balance;
} finally {
lock.unlock(); // ReentrantLock 이용하여 lock을 해제한다.
}
}
}
- tryLock(time, unit) 출력 결과
tryLock()과 달리 lock을 얻지 못한 t2 스레드가 바로 종료되는 것이 아닌, time 동안 TIMED_WAITING 상태가 된다. time 동안 lock을 얻지 못하면 TERMINATED 상태가 된다.
/*
* 22:54:38.298 [ t1] 거래 시작: BankAccountV6
* 22:54:38.298 [ t2] 거래 시작: BankAccountV6
* 22:54:38.303 [ t1] [검증 시작] 출금액 : 800, 현재 잔액 : 1000
* 22:54:38.303 [ t1] [검증 완료] 출금액 : 800, 현재 잔액 : 1000
* 22:54:38.783 [ main] t1 state: TIMED_WAITING
* 22:54:38.784 [ main] t2 state: TIMED_WAITING
* 22:54:38.805 [ t2] [진입 실패] 0.5초 간 기다렸으나 lock을 획득하지 못하여 작업을 중단합니다.
* 22:54:39.304 [ t1] [출금 완료] 출금액 : 800, 현재 잔액 : 200
* 22:54:39.304 [ t1] 거래 종료
* 22:54:39.307 [ main] 최종 잔액: 200
*/
3. synchronized vs ReentrantLock
3-1. synchronized
- 모니터락을 획득하지 못한 스레드는 BLOCKED 상태가 된다. BLOCKED 상태인 스레드에 interrupt를 발생시킬 수 없기 때문에, lock을 획득할 때까지 무한정 대기하게 된다.
- 일정 시간동안 모니터락을 획득하지 못한 경우에 종료하도록 하는 기능을 제공하지 않는다.
3-2. ReentrantLock
- synchronized보다 lock을 다루는 기능을 더 제공한다.
- ReentrantLock이 제공하는 lock을 획득하지 못한 스레드는 WAITING 상태가 되어 대기큐에 저장된다.
- WAITING 상태인 스레드에 interrupt를 발생시키거나, unlock()을 통해 RUNNABLE 상태로 변경할 수 있다.
- 일정 시간동안 lock을 획득하지 못한 경우 RUNNABLE 상태가 되도록 하는 tryLock(time, unit) 메서드를 제공한다.