언어/JAVA

자바의 ReentrantLock 가볍게 알아보기!

SeongOnion 2024. 2. 28. 21:42
728x90

자바에서는 스레드 간 동기화 작업을 지원하기 위해 synchronizedReentrantLock을 지원한다.

 

그 중 ReentrantLocksynchronized와 비교해 조금 더 유연한 락 획득 및 관리 방식을 지원하고 있다.

 

기본적인 작동 방식을 익혀보자.

 

공정한 락과 비공정한 락

ReentrantLock 인스턴스를 생성할 때는 fair 라는 인자를 설정해 락 획득을 "공정"하게 처리할지 아닐지를 설정할 수 있다.

 

여기서 "공정"하다는 의미는 락 획득을 대기 중인 스레드들에 대하여 가장 오래 대기한 스레드에게 먼저 락을 점유할 수 있도록 우선권을 준다는 의미이다.

생성자에서 볼 수 있다시피, fair 인자에 따라 아예 다른 인스턴스를 생성해주는 것을 확인할 수 있다.

 

인자가 없는 기본 생성자도 제공하는데, 기본 생성자에선 fairfalse로 간주하고 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 &quot;barging&quot; 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() 사용 시 타임아웃 값을 인자로 받는 메서드를 통해 사용하여야한다!

 

예시에 사용한 코드는 깃헙에서 확인할 수 있다!