자바의 ReentrantLock 가볍게 알아보기!
자바에서는 스레드 간 동기화 작업을 지원하기 위해 synchronized
와 ReentrantLock
을 지원한다.
그 중 ReentrantLock
은 synchronized
와 비교해 조금 더 유연한 락 획득 및 관리 방식을 지원하고 있다.
기본적인 작동 방식을 익혀보자.
공정한 락과 비공정한 락
ReentrantLock
인스턴스를 생성할 때는 fair
라는 인자를 설정해 락 획득을 "공정"하게 처리할지 아닐지를 설정할 수 있다.
여기서 "공정"하다는 의미는 락 획득을 대기 중인 스레드들에 대하여 가장 오래 대기한 스레드에게 먼저 락을 점유할 수 있도록 우선권을 준다는 의미이다.
생성자에서 볼 수 있다시피, fair
인자에 따라 아예 다른 인스턴스를 생성해주는 것을 확인할 수 있다.
인자가 없는 기본 생성자도 제공하는데, 기본 생성자에선 fair
를 false
로 간주하고 NonFairSync()
인스턴스를 반환한다.
잠금 방식
ReentrantLock
은 잠금을 위해 크게 두 가지 메서드를 지원한다.
1. lock()
기본적인 잠금 방식이다.
만약 lock()
호출 시 다른 스레드에서 락을 소유 중이라면 현재 스레드는 잠시 블락되고 락을 획득할 수 있을 때까지 대기한다.
synchronized
를 사용했을 때와 유사한 형태라고 이해하면 될 것 같다.
사용법은 아래 코드를 참고할 수 있다.
public class ReentrantLockBookCountService {
private final ReentrantLock reentrantLock = new ReentrantLock();
private int count;
public void decreaseCount() {
try {
reentrantLock.lock();
this.count -=1;
} finally {
reentrantLock.unlock();
}
}
}
2. tryLock()
tryLock()
은 lock()
과 다르게 스레드가 락 획득을 위해 대기하는 시간을 타임아웃으로 지정할 수 있다.
이에 따라서 지정된 시간동안 락 획득에 성공했는지 여부를 리턴한다.
따라서 tryLock()
을 적절하게 사용하면 스레드가 락 획득을 지나치게 오래 기다리는 상황을 막을 수 있고, 락 획득 실패 시 다양한 처리 전략을 적용하는 것이 가능해진다.
tryLock()
을 사용하는 예시는 아래 코드와 같다.
public void decreaseCount() {
if (reentrantLock.tryLock(5, TimeUnit.SECONDS)) { // 5초만 동안 락 획득 시도
try {
this.count += 1;
} finally {
reentrantLock.unlock();
}
} else {
// 락 획득 실패 시 처리 작업
}
}
```
만약 타임아웃 값을 지정하지 않고 메서드를 호출하면 락 획득 시도 시 다른 스레드가 락을 점유 중이라면 곧바로 실패처리하고 false
를 리턴할 것이다.
따라서, 동일한 테스트 코드를 돌렸을 때 tryLock()
방식은 lock()
방식과 다르게 간헐적으로 테스트에 실패하는 것을 확인할 수 있다.
@Test
void decreaseTest() throws InterruptedException {
bookCountService.setCount(100);
Runnable decreaseCount = () -> bookCountService.decreaseCount();
concurrentTest(100, decreaseCount);
assertThat(bookCountService.getCount()).isEqualTo(0);
}
void concurrentTest(int executeCount, Runnable methodToTest) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch countDownLatch = new CountDownLatch(executeCount);
for (int i = 0; i < executeCount; i++) {
executorService.submit(() -> {
methodToTest.run();
countDownLatch.countDown();
});
}
countDownLatch.await();
}
tryLock() 사용 시 주의점
tryLock()
메서드에 적힌 자바독을 확인해보면 아래와 같은 문구를 확인할 수 있다.
* Even when this lock has been set to use a
* fair ordering policy, a call to {@code tryLock()} <em>will</em>
* immediately acquire the lock if it is available, whether or not
* other threads are currently waiting for the lock.
* This "barging" behavior can be useful in certain
* circumstances, even though it breaks fairness. If you want to honor
* the fairness setting for this lock, then use
* {@link #tryLock(long, TimeUnit) tryLock(0, TimeUnit.SECONDS)}
* which is almost equivalent (it also detects interruption).
간단하게 해석해보자면, tryLock()
은 락이 공정한 순서로 획득되도록(fair ordering policy) 설정되었더라도 이와 상관없이 락 획득이 가능하다면 다른 대기 중인 스레드들을 무시하고 락을 획득하게 할 수 있다.
이는 공정성을 어기는 작동방식이지만 특정 상황에 유용할 수 있다.
만약 tryLock()
을 사용하면서 공정성을 함께 유지하고자 한다면 타임아웃을 인자로 받는 생성자를 통해 인스턴스를 생성해야한다. (타임아웃은 0초로도 설정 가능하다!)
결론적으론, fair
파라미터를 true
로 하여 생성한 인스턴스에 대해 공정성을 유지하고 싶다면 tryLock()
사용 시 타임아웃 값을 인자로 받는 메서드를 통해 사용하여야한다!
예시에 사용한 코드는 깃헙에서 확인할 수 있다!