"거래 시작" 로그는 동시에 여러 스레드가 접근해도 문제가 없기때문에, 이 부분을 제외했다.
로그를 봤을때 t2의 "거래 시작" 까지는 실행되는것을 볼 수 있다.
1.4. Synchronized의 단점
무한 대기: BLOCKED 상태의 스레드는 락이 풀릴 때 까지 무한 대기
특정 시간까지만 대기하는 타임아웃 불가
중간에 인터럽트 불가
공정성: 락이 돌아왔을 때 BLOCKED 상태의 여러 스레드 중에 어떤 스레드가 락을 획득할 지 알 수 없다. 최악의 경우 스레드가 너무 오랜기간 락을 획득하지 못할 수 있다.
조금 더 정교한 설정이 필요하면 java.util.concurrent 동시성 문제 해결을 위한 패키지를 사용한다.
2. concurrent.Lock
java.util.concurrent 라이브러리 패키지는 synchronized의 단점들을 보안하기 위해 만들어졌다.
2.1. LockSupport
LockSupport는 기본적인 스레드 동기화 메커니즘을 제공하는 클래스입니다. 이 클래스는 낮은 레벨의 잠금을 처리하며 스레드를 잠들게 하거나 깨우는 메서드를 제공합니다.
LockSupport의 주요 기능들입니다:
park(): 현재 스레드를 WAITING 상태로 만듭니다. 다른 스레드에 의해 깨워질 때까지 멈춰 있습니다.
unpark(Thread thread): 지정된 스레드를 WAITING 상태에서 RUNNABLE로 변경
parkNanos(long nanos): 주어진 시간(나노 초) 동안 스레드를 TIMED_WAITING상태로 변경합니다. 시간이 완료되면 자동으로 깨어납니다.
parkUntil(long deadline): 지정된 시점까지 스레드를 멈춥니다. 이 기능은 주로 타임스탬프 기반으로 스레드를 일시 중지하고 싶을 때 유용합니다.
package thread.sync.lock;
import java.util.concurrent.locks.LockSupport;
import static util.MyLogger.log;
import static util.ThreadUtils.sleep;
public class LockSupportMainV1 {
public static void main(String[] args) {
Thread thread1 = new Thread(new ParkTask(), "Thread-1");
thread1.start();
// 잠시 대기하여 Thread-1이 park 상태에 빠질 시간을 준다.
sleep(100);
log("Thread-1 state: " + thread1.getState());
log("main -> unpark(Thread-1)");
LockSupport.unpark(thread1); // 1. unpark 사용
//thread1.interrupt(); // 2. interrupt() 사용
}
static class ParkTask implements Runnable {
@Override
public void run() {
log("park 시작");
LockSupport.park();
log("park 종료, state: " + Thread.currentThread().getState());
log("인터럽트 상태: " + Thread.currentThread().isInterrupted());
}
}
}
// 실행 결과
14:29:32:062 [ Thread-1] park 시작
14:29:32:150 [ main] Thread-1 state: WAITING
14:29:32:151 [ main] main -> unpark(Thread-1)
14:29:32:151 [ Thread-1] park 종료, state: RUNNABLE
14:29:32:155 [ Thread-1] 인터럽트 상태: false
main 스레드가 Thread-1을 start() 하면 Thread-1은 RUNNABLE 상태가 된다.
Thread-1은 Thread-park()를 호출. Thread-1은 RUNNABLE -> WAITING 상태가 된다.
main 스레드가 Thread-1을 깨운다. Thread-1은 대기 상태에서 실행 가능 상태로 변한다. WAITING -> RUNNABLE
park()와 unpark()의 차이는, park는 해당 스레드가 실행하고, unpark는 다른 스레드에 의해서 실행되어야한다.
2.2. Interrupt 사용
14:32:31:506 [ Thread-1] park 시작
14:32:31:580 [ main] Thread-1 state: WAITING
14:32:31:580 [ main] main -> unpark(Thread-1)
14:32:31:581 [ Thread-1] park 종료, state: RUNNABLE
14:32:31:586 [ Thread-1] 인터럽트 상태: true
LockSupport.unpark(thread1) 대신, thread1.interrupt()를 사용하게되어도 깨울 수 있지만 인터럽트 상태가 true인걸 확인할 수 있다.
2.3. 시간 대기
parkNanos(nanos)를 사용한 경우.
package thread.sync.lock;
import java.util.concurrent.locks.LockSupport;
import static util.MyLogger.log;
import static util.ThreadUtils.sleep;
public class LockSupportMainV2 {
public static void main(String[] args) {
Thread thread1 = new Thread(new ParkTask(), "Thread-1");
thread1.start();
// 잠시 대기하여 thread1이 park 상태에 빠질 시간을 준다.
sleep(100);
log("Thread-1 state: " + thread1.getState());
}
static class ParkTask implements Runnable {
@Override
public void run() {
log("park 시작, 2초 대기");
LockSupport.parkNanos(2000_000000); // parkNanos 사용
log("park 종료, state: " + Thread.currentThread().getState());
log("인터럽트 상태: " + Thread.currentThread().isInterrupted());
}
}
}
14:34:49:069 [ Thread-1] park 시작, 2초 대기
14:34:49:149 [ main] Thread-1 state: TIMED_WAITING
14:34:51:084 [ Thread-1] park 종료, state: RUNNABLE
14:34:51:088 [ Thread-1] 인터럽트 상태: false
따로 unpark를 해주지 않아도, 2초후에 꺠어난다.
BLOCKED vs WAITING
WAITING 상태에 특정한 시간까지만 대기하는 기능이 포함된 것이 TIMED_WAITING. 둘을 묶어서 WAITING 상태라고 가정.
인터럽트
BLOCKED 상태는 인터럽트가 걸려도 대기 상태를 빠져나오지 못한다. 여전히 BLOCKED 상태
WAITING, TIMED_WAITING 상태는 인터럽트가 걸리면 대기 상태를 빠져 나온다. RUNNABLE 상태로 변경.
용도
BLOCKED 상태: 자바의 synchronized에서 락을 획득하기 위해 대기할 때 사용.
WAITING, TIMED_WAITING 상태: 스레드가 특정 조건이나 시간 동안 대기할 때 발생하는 상태
WAITING 상태: 다양한 상황에서 사용된다. 예를 들어, Thread.join(), LockSupport.park(), Object.wait()과 같은 메서드 호출시 WAITING 상태가 된다.
TIMED_WAITING 상태: Thread.sleep(ms), Object.wait(long timeout), Thread.join(long millis), LockSupport.parkNanos(ns) 등과 같은 시간 제한이 있는 대기 메서드 호출시 발생.
Thread.join() <--> Thread.join(millis)
Thread.park() <--> Thread.parkNanos(long millis)
Object.wait() <--> Object.wait(long timeout)
이러한 기능을 직접 구현하기는 어렵다. 여러 스레드가 대기하고 있는 상태에서 다음 스레드를 선정하는 것, 우선순위를 부여하는 것 등의 세밀한 작업은 LockSupport로 하기에는 synchronized 보다 더 저수준의 기능이다. 이를 해결하기 위해 Lock 인터페이스와 ReentrantLock이 존재한다.
3. Lock 인터페이스와 ReentrantLock
3.1. Lock 인터페이스
자바1.0 Synchronized와 BLOCKED 상태를 통한 임계 영역 관리의 한계를 극복하기 위해 자바1.5부터 Lock 인터페이스와 ReentrantLock 구현체가 등장했다.
Lock 인터페이스를 통해서 synchronized의 단점인 무한 대기 문제 해결
여기서의 Lock은 객체 내부에 있는 모니터 락이랑 다른 락이다. Lock 인터페이스와 ReentrantLock이 제공하는 기능이다.
락을 획득. 만약 다른 스레드가 이미 락을 획득 했다면, 락이 풀릴때까지 대기(WAITING) 한다. 인터럽트에 응답하지 않음
맛집에 줄을 서면 끝까지 기다린다. 친구가 다른 맛집을 찾았다고 중간에 연락해도 포기하지 않고 기다림.
WAITING 상태의 스레드에 인터럽트가 발생하면 WAITING 상태를 빠져나오는게 정상. 근데 lock()의 경우에는 다시 WAITING으로 변경시킨다. 결국 인터럽트를 무시
void lockInterruptibly()
락 획득을 시도하되, 다른 스레드가 인터럽트 할 수 있다. 락을 획득할 수 없다면 획득할때까지 대기한다. 대기중 인터럽트가 발생하면 InterruptException이 발생하며 락 획득을 포기
맛집에 줄을 서서 기다린다. 친구가 다른 맛집을 찾았다고 중간에 연락하면 포기한다.
boolean tryLock()
락 획득을 시도하고, 즉시 성공 여부 반환. 다른 스레드가 이미 락을 획득하면 false를 반환
true 반환 -> 락 획득 -> 임계 구역 코드 실행
false 반환 -> 포기
맛집에 대기 줄이 없으면 바로 들어가고, 대기 줄이 있으면 즉시 포기
boolean tryLock(long time, TimeUnit unit)
주어진 시간동안 락 획득 시도. 주어진 시간 안에 락을 획득하면 true 반환. 주어진 시간이 지나도 락을 획득하지 못한 경우 false를 반환.
인터럽트가 발생하면 InterruptedException이 발생하며 락 획득 포기
맛집에 줄을 서지만 특정 시간 만큼만 기다린다. 특정 시간이 지난 후에도 계속 줄을 서야 하면 포기. 친구가 다른 맛집을 찾았다고 중간에 연락해도 포기
Condition newCondition()
Condition 객체를 생성하여 반환. Condition 객체는 락과 결합되어 사용되며, 스레드가 특정 조건을 기다리거나 신호를 받을 수 있도록 한다. Object 클래스의 wait, notify, notifyAll 메서드와 유사함
3.2. ReentrantLock - 이론
공정성을 해결하기 위해 사용된다. Lock 인터페이스의 구현체 중 가장 대표적. 공정하게 스레드가 락을 얻을 수 있도록 한다.
// 사용 예시
package thread.sync.locks.reentrantLock;
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();
}
}
}
```
ReentrantLock은 공정성(fairness) 모드와 비공정성(non-fair) 모드로 설정 가능.
공정 모드(fair mode)
생성자에 true를 전달.
락을 요청한 순서대로 스레드가 락을 획득할 수 있게 한다. FIFO 방식으로 공정성을 보장하지만 성능 저하
비공정 모드(non-fair mode)
공정모드보다 락을 획득하는 속도가 빠르다
새로운 스레드가 기존 대기 스레드보다 먼저 락을 획득할 수 있다.
starvation이 발생할 수도 있다 (다만 내부적으로는 queue가 사용되어서, 정확히 락을 반환한 시점에 새로운 스레드가 오지 않는 이상 크게 문제는 없다고 함)
정리: 비공정 모드 vs 공정모드 - 성능 중시 vs 순서 보장
3.3. 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 balance) {
this.balance = balance;
}
@Override
public boolean withdraw(int amount) {
log("거래 시작: " + getClass().getSimpleName());
lock.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을 사용할 때는 반드시 unlock()을 호출해야 한다.
}
log("거래 종료");
return true;
}
@Override
public int getBalance() {
lock.lock();
try {
return balance;
} finally {
lock.unlock();
}
}
}