-
[Java] LockSupport와 ReentrantLocklegacy/Java 2024. 8. 5. 02:16
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) 메서드를 제공한다.