[Java] Lock 인터페이스와 ReentrantLock

MEUN·2024년 11월 5일
0

🔎 등장 배경

synchronized 사용 시 발생하는 아래 문제점을 극복하기 위해 자바 1.5부터 Lock 인터페이스와 기본 구현체인 ReentrantLock을 제공하게 되었다.

synchronized 한계

1) 무한 대기

  • 락 획득 시까지 BLOCKED 상태 유지
  • 이때 특정 시간동안 이후 발생하는 타임 아웃이나 인터럽트를 통한 대기 중단이 불가

2) 공정성

  • BLOCKED 상태의 스레드 중 어느 스레드가 락을 획득할지 알 수 없음

3) 유연성 저하

  • 모든 락의 획득과 해제가 블록 구조 방식으로 이루어지므로 여러 개의 락 획득 시 획득 순서와 반대로 해제 필요
  • 또한, 락의 해제는 락 획득 시 범위와 동일한 범위에서 해제 필요



🔒 Lock 인터페이스

Lock 인터페이스 구현 시 Lock의 획득과 해제에 대하여 synchronized 방식에 비해 유연성이 증가하게 된다.

락의 해제 시 획득 순서와 동일하지 않아도 되는 등 보다 유연성을 가지지만 구현 시 책임을 가지게 된다.

package java.util.concurrent.locks;

import java.util.concurrent.TimeUnit;

/**
 * (중략)
 * @see ReentrantLock
 * @see Condition
 * @see ReadWriteLock
 * @jls 17.4 Memory Model
 *
 * @since 1.5
 */
public interface Lock {

    void lock();
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    void unlock();
    Condition newCondition();
}

메소드

lock()

void lock();
  • 락 획득
  • 다른 스레드가 락을 획득한 경우 스레드는 대기
  • 인터럽트에 응답하지 않음

lockInterruptibly()

void lockInterruptibly() throws InterruptedException;
  • lock()과 달리 락 획득 대기 중 인터럽트 발생 시 InterruptedException 발생 후 락 획득 포기

tryLock()

boolean tryLock();
  • 락 획득 시도 후 획득 여부 반환
  • 획득 성공 여부를 즉시 반환하므로 인터럽트에 응답할 필요 없음

tryLock(long time, TimeUnit unit)

boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
  • 매개변수의 시간 동안 락 획득 시도
  • 대기 중 인터럽트 발생 시 InterruptedException 발생 후 락 획득 포기

unlock()

void unlock();
  • 락 해제
  • 락을 획득한 스레드가 호출해야 함

newCondition()

Condition newCondition();
  • 락과 결합하여 사용 가능한 Condition 객체 생성 후 반환
  • Condition 객체는 스레드가 특정 조건을 기다리거나 신호를 받을 수 있도록 함

사용 방법

보통 아래와 같은 관용구를 사용하게 된다.

 Lock l = ...;
 l.lock();
 try {
   // 임계 구역: 락에 의해 자원 접근 시 보호됨
 } finally {
   l.unlock();
 }
  • 락 획득/해제가 서로 다른 범위에서 발생하는 경우 락이 유지되는 동안 실행되는 모든 코드가 try~catch , try~finally로 보호되어 필요 시 잠금이 해제될 수 있도록 해야 한다.

유의 사항

세 가지 형태의 락 획득 방식(인터럽트 가능/불가능, 타이밍 적용)은 성능/순서 보장 등에서 차이가 존재할 수 있다.

구현체는 세 가지 형태의 락 획득 방식에 대해 동일하게 보장할 필요가 없으며, 진행 중인 락 획득의 인터럽트를 지원할 의무도 없다.

락 획득 시 인터럽트를 지원하는 경우, 인터럽트는 락 획득 전체 과정에서 지원되거나, 메서드 진입 시에만 지원될 수 있다.



🔐 Lock 구현체

인터페이스를 직접 구현하여 사용할 수도 있지만 자바에서는 다양한 구현체를 제공한다.

대표적인 구현체는 아래와 같으며, 구현체마다 세부 구현사항이 다를 수 있어 사용 시 확인 후 사용하는 것이 좋다.

1) ReentrantLock

synchronized 메서드 및 구문을 통해 접근하는 모니터 락과 동일한 동작을 하며 추가 기능을 제공하는 재진입 가능한 상호 배제 락이다.

여기서 말하는 재진입은 동일한 스레드가 동일한 락을 여러 번 걸 수 있다는 의미이다.

마지막으로 락을 획득했지만 아직 해제하지 않은 스레드가 락을 소유하게 된다.

동일한 스레드 내 최대 2,147,483,647번의 재귀적 락을 지원하며, 한도 초과 시 락 메서드에서 Error 예외를 발생시킨다.

공정성과 공정/비공정 모드

public class ReentrantLock implements Lock, java.io.Serializable {
    private final Sync sync;
    
    ... 중략...
    
	  public ReentrantLock() {
        sync = new NonfairSync(); // 비공정 모드
    }

    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync(); // 매개변수가 true일 경우 공정 모드
    }
    
    ... 중략...
}
  • 생성자에 공정성 여부를 true로 넘길 경우, 경쟁 상황에서 가장 오랫동안 대기한 스레드에 락을 부여하는 것을 우선시하여 기아 현상이 발생하지 않도록 보장한다.
  • 비공정 모드인 경우 특정 접근 순서를 보장하지 않는 대신 성능은 공정 모드에 비해 좋다.
  • 하지만 락의 공정성이 스레드 스케줄링의 공정성을 보장하지는 않는다.
  • tryLock() 메서드는 공정성을 따르지 않으며, 다른 스레드가 대기중이어도 락을 사용할 수 있으면 true 로 반환됨

메서드

isHeldByCurrentThread()

public boolean isHeldByCurrentThread() {
    return sync.isHeldExclusively();
}
  • 현재 스레드가 해당 락을 소유하고 있는지 확인
  • 소유하고 있을 경우 true, 아닐 경우 false 반환

getHoldCount()

public int getHoldCount() {
    return sync.getHoldCount();
}
  • 현재 스레드가 해당 락을 몇 번 획득하고 있는지 반환, 이는 재진입 횟수를 의미

사용 예시

 class X {
   private final ReentrantLock lock = new ReentrantLock();
   // ...

   public void m() {
     lock.lock();
     try {
       // 임계 영역
     } finally {
       lock.unlock();
     }
   }
 }

2) ReentrantReadWriteLock

ReentrantLock 과 유사하지만 읽기/쓰기 전용 락을 각각 가지고 있다는 점에서 차이가 있다.

또한, 특정 종류의 컬렉션을 사용할 때 동시성 개선에 사용될 수 있다.

단, 컬렉션이 크고, 쓰기보다 읽기 작업이 많고 동기화 오버헤드보다 더 큰 오버헤드를 가지는 작업인 경우에만 유용하다.

공정성과 공정/비공정 모드

 public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable {
    private final ReentrantReadWriteLock.ReadLock readerLock
    private final ReentrantReadWriteLock.WriteLock writerLock;
    final Sync sync;

    public ReentrantReadWriteLock() {
        this(false); // 비공정 모드
    }
    
    public ReentrantReadWriteLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync(); // true일 경우 공정 모드
        readerLock = new ReadLock(this);
        writerLock = new WriteLock(this);
    }

    public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
    public ReentrantReadWriteLock.ReadLock  readLock()  { return readerLock; }

    ... 중략 ...
}
  • ReentrantLock 과 동일하게 공정/비공정 모드 선택이 가능하다.

락의 강등

 class CachedData {
   Object data;
   boolean cacheValid;
   final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

   void processCachedData() {
     rwl.readLock().lock();
     if (!cacheValid) {
       rwl.readLock().unlock();
       rwl.writeLock().lock();
       try {
         if (!cacheValid) {
           data = ...;
           cacheValid = true;
         }
         rwl.readLock().lock();
       } finally {
         rwl.writeLock().unlock(); // 읽기 락 획득 후 쓰기락 해제를 통한 락 강등
       }
     }

     try {
       use(data);
     } finally {
       rwl.readLock().unlock();
     }
   }
 }
  • 쓰기 스레드는 읽기 락을 획득할 수 있지만, 반대의 경우는 불가능하다.
    (ex. 쓰기 락을 보유한 스레드가 읽기 락 획득 가능)
  • 이러한 특성으로 인해 쓰기 락을 획득한 상태에서 읽기 락을 획득한 경우 쓰기 락을 해제하여 락의 등급을 강등시킬 수 있으나, 읽기 락을 쓰기 락으로 업그레이드는 불가능하다.

3) StampedLock

읽기/쓰기 접근 제어를 위해 세 가지 모드를 제공하는 권한 기반의 락이며, 자바 1.8부터 제공되었다.

StampedLock의 상태는 버전과 모드로 구성되고, 락 획득 메서드는 락 상태에 따른 접근을 제어할 수 있고 락의 상태를 나타내는 식별자인 스탬프를 반환한다.

락 해제 및 변환 메서드는 스탬프를 매개변수로 받아 락 상태와 일치하지 않으면 실패한다.

다른 구현체와 달리 Lock 또는 ReadWriteLock 인터페이스를 직접 구현하지 않았다.

public class StampedLock implements java.io.Serializable {
    ... 중략 ...

    private transient volatile Node head;
    private transient volatile Node tail;

    transient ReadLockView readLockView;
    transient WriteLockView writeLockView;
    transient ReadWriteLockView readWriteLockView;

    private transient volatile long state;
    private transient int readerOverflow;

    public StampedLock() {
        state = ORIGIN;
    }

    ... 중략 ...
}

모드

스탬프를 어떤 방식으로 획득하였는지에 따라 아래와 같이 나뉜다.

  1. 쓰기 모드 : writeLock() 사용 시
  2. 읽기 모드 : readLock() 사용 시
  3. 낙관적 읽기 모드 : tryOptimisticRead() 사용 시

모드 업그레이드

tryConvertToWriteLock(long stamp) 메서드를 통해 아래 상황에서 쓰기 모드로의 업그레이드를 지원한다.

  • 이미 쓰기 모드인 경우
  • 읽기 모드이고 다른 읽기 스레드가 없는 경우
  • 낙관적 읽기 모드이고 락이 사용한 경우

주의사항

  • 재진입이 불가하여 락이 걸린 상태에서 락을 획득하려고 하면 안 된다.
  • 스케줄링 시 읽기 모드나 쓰기 모드와 같은 특정 모드를 우선시 하지 않음
  • try~ 메서드는 공정성을 보장하지 않을 수 있음



📚 참고 자료

0개의 댓글