오늘의하루

[스레드 안전성을 위한 선택] synchronized, ReentrantLock, ReentrantReadWriteLock 비교 본문

JAVA

[스레드 안전성을 위한 선택] synchronized, ReentrantLock, ReentrantReadWriteLock 비교

오늘의하루_master 2024. 9. 24. 17:05

문제 발견

레거시 프로젝트의 유지 보수를 맡게 되면서 모든 화면에서 로딩 시 약 170개의 쿼리가 실행되며 그중 약 60~70개의 변하지 않는 데이터를 반환하는 쿼리가 실행되고 있는 것을 확인했습니다.

이로 인해 화면 로딩 시간이 길어지는 문제을 발견해서 처음에는 Redis를 도입하여 성능을 개선하려고 했으나 서버 확장이 불가능하다는 결론이 내려졌습니다.

그래서 AOP를 이용한 인메모리 캐싱을 도입하기로 했고 메서드명과 매개변수를 조합하여 키를 생성하고 그에 따른 결과를 캐싱하는 방식으로 구현할 예정입니다.

이 과정에서 동시성 문제를 해결해야 했고 synchronized, ReentrantLock, ReentrantReadWriteLock과 같은 다양한 동기화 방법에 대해 깊이 알아보았습니다.

1. Synchronized

synchronized는 Java에서 제공하는 가장 기본적인 동기화 방법으로 블록 또는 메서드에 스레드 접근을 제한할 수 있으며 가장 사용하기 쉬운 방법입니다.

synchronized모니터 락(Monitor Lock)과 함께 사용되어 해당 블록이나 메서드에 진입하는 스레드에 대한 접근을 제어합니다.

자동으로 락을 걸고 해제하기 때문에 명시적인 락 해제가 필요 없으며 스레드가 synchronized 된 블록에 진입하면 다른 스레드는 동일한 객체에 대해 다른 synchronized 블록이나 메서드에 접근할 수 없기 때문에 데이터의 일관성과 무결성을 유지하는 데 도움을 줍니다.

1.1. 메서드에 적용된 Synchronized

public synchronized void test () {
  ...
}

이 방법은 this 즉 자기 자신에 대한 락을 거는 것인데 만약 어떠한 스레드가 test()에 진입하면 이 순간 다른 스레드들은 동기화되지 않은 메서드에는 진입이 가능하지만 해당 객체 내에 있는 동기화된 메서드들에는 진입할 수 없다는 것을 의미합니다.

1.2. 블록에 적용된 Synchronized

// case 1
public void test() {
  ...
  synchronized (this) {
    ...
  }
  ...
}

// case 2
public void test() {
  ...
  synchronized (obj) {
    ...
  }
  ...
}

블록에 적용하는 경우, this 혹은 특정 객체에 락을 거는 경우에 따라 약간의 차이가 있습니다.

this를 건다면 메서드에 적용하는 것과 같은 효과를 기대할 수 있지만 특정 객체에 대한 락을 걸면 해당 객체에 대한 접근을 막게 됩니다.

이 말은 특정 객체에 대해 락을 걸게 되면 다른 동기화된 블록 혹은 메서드에는 접근이 가능하다는 것입니다.

특정 객체에 대해 락을 걸면 해당 객체에 접근하기 위해서는 락을 얻어야 하므로 데이터 무결성을 유지할 수 있습니다.

1.3. wait(), notify(), notifyAll()

wait(), notify(), notifyAll()은 Object에 정의된 메서드이며 모니터 락과 관련되어 있습니다.

만약 모니터 락이 없는 즉, synchronized 되어 있지 않은 곳에서 호출 시 IllegalMonitorStateException이 발생할 수 있습니다.

  • wait() : 현재 동기화된 메서드 혹은 블록에 진입한 스레드를 중단하고 대기 상태로 변경합니다.
  • notify() : 현재 wait()로 인해 대기 상태로 변경된 스레드 중 랜덤 하게 하나를 깨운 후 대기 중인 스레드들과 락 경쟁 후 스레드 하나가 진입할 수 있습니다.
  • notifyAll() : 현재 wait()로 인해 대기 상태로 변경된 스레드 모두를 깨워 대기 중인 스레드들과 락 경쟁 후 스레드 하나가 진입할 수 있습니다.

1.3.1. 모니터(Monitor)와 wait(), notify(), notifyAll() 관계

모니터(JVM 내부에 구현되어 있음)는 객체에 대한 동기화를 관리하는 메커니즘으로 각 객체는 자신만의 모니터를 가지게 되는데 이 모니터는 해당 객체에 대한 접근을 제어하고 스레드 간의 동기화를 지원합니다.

  • 각 객체는 각자의 모니터가 있다.
    • synchronzied를 사용하여 메서드 혹은 블록을 정의하고 진입할 경우 모니터 락을 획득하게 됩니다.
  • wait()의 동작 원리
    • 스레드는 해당 객체의 모니터 락을 해제하고 모니터에 존재하는 Waiting Pool에 대기 중인 상태로 들어갑니다.
    • 이때 스레드는 CPU를 차지하지 않습니다.
  • notify(), notifyAll()
    • 모니터에 존재하는 Wating Pool에 있는 스레드 중 하나 혹은 전체를 깨울 수 있습니다.
    • notify()의 경우 어떤 스레드를 깨울지는 JVM이 결정하기 때문에 예측할 수 없습니다.

1.4. 단점

  • 블로킹 : synchronized는 하나의 스레드만 접근이 가능하므로 다른 스레드는 락이 해제될 때까지 대기하게 되어 성능 저하로 이어질 수 있습니다.
  • 대기 시간 증가 : 블로킹이 발생할 경우 대기 시간이 길어지면 CPU 자원을 비효율적으로 사용하게 되며 사용자 경험에 나쁜 영향을 줄 수 있습니다.
  • 교착 상태 : 두 개 이상의 스레드가 서로의 락을 기다리면서 시스템 전체의 작업을 멈출 수 있는 상황을 초래할 수 있습니다. 예를 들어, 스레드 A가 객체 X에 대한 락을 획득한 후 객체 Y에 대한 락을 기다리고, 스레드 B가 객체 Y에 대한 락을 획득한 후 객체 X에 대한 락을 기다리는 경우입니다.
  • notify와 notifyAll의 문제 : 이러한 메서드를 사용하면 어떤 스레드가 진입할지를 명확히 지정할 수 없습니다.

1.5. 교착 상태 예제

이 예제에서는 스레드 1과 스레드 2가 서로의 락을 기다리며 교착 상태에 빠지는 것을 보여줍니다.

@Slf4j
public class Test {
  private static final Object lock1 = new Object();
  private static final Object lock2 = new Object();

  @Test
  public void testDeadlock() throws InterruptedException {
    Thread thread1 = new Thread(() -> {
      synchronized (lock1) {
        log.info("Thread 1: lock1 객체 락 획득");
        try { Thread.sleep(100); } catch (InterruptedException e) {}
        log.info("Thread 1: lock2 객체 락 획득을 위해 대기중 ...");
        synchronized (lock2) {
          log.info("Thread 1: lock2 객체 락 획득");
        }
      }
    });

    Thread thread2 = new Thread(() -> {
      synchronized (lock2) {
        log.info("Thread 2: lock2 객체 락 획득");
        try { Thread.sleep(100); } catch (InterruptedException e) {}
        log.info("Thread 2: lock1 객체 락 획득을 위해 대기중 ...");
        synchronized (lock1) {
          log.info("Thread 2: lock1 객체 락 획득");
        }
      }
    });

    thread1.start();
    thread2.start();

    thread1.join();
    thread2.join();
  }
}

Thread 2: lock2 객체 락 획득
Thread 1: lock1 객체 락 획득
Thread 2: lock1 객체 락 획득을 위해 대기중 ...
Thread 1: lock2 객체 락 획득을 위해 대기중 ...

2. ReentrantLock

ReentrantLock은 Java에서 제공하는 동기화 메커니즘으로 synchronized에 비해 다양한 기능을 제공합니다.

이를 통해 스레드 간의 경쟁을 제어하고 여러 동기화 패턴을 지원하며 유연한 락 관리가 가능합니다.

2.1. 사용 예시

public class test {
  private final ReentrantLock lock = new ReentrantLock();
  
  public void lockTest() {
    ...
    lock.lock();
    try {
      ...
    } finally {
      lock.unlock();
    }
  }
}

ReentrantLock은 락을 차지하는 스레드만 lock()과 unlock() 사이에 접근할 수 있도록 하며 락이 해제되기 전까지는 다른 스레드의 접근이 가능하지 않습니다.

public class test {
  private final ReentrantLock lock = new ReentrantLock();
  
  public void lockTest1() {
    ...
    lock.lock();
    try {
      ...
    } finally {
      lock.unlock();
    }
  }
  
  public void lockTest2() {
    ...
    lock.lock();
    try {
      ...
    } finally {
      lock.unlock();
    }
  }
}

위와 같이 lockTest1()에 진입한 후 락을 획득했다면 lockTest2()의 lock() 호출은 대기 상태가 됩니다.

이는 동일한 ReentrantLock 객체를 사용하기 때문이며 한 스레드가 락을 보유하고 있는 동안 다른 스레드는 해당 락에 접근할 수 없습니다.

public class test {
  private final ReentrantLock lock1 = new ReentrantLock();
  private final ReentrantLock lock2 = new ReentrantLock();  
  
  public void lockTest1() {
    ...
    lock1.lock();
    try {
      ...
    } finally {
      lock1.unlock();
    }
  }
  
  public void lockTest2() {
    ...
    lock2.lock();
    try {
      ...
    } finally {
      lock2.unlock();
    }
  }
}

위의 코드에서는 ReentrantLock 객체를 2개 생성하여 각각의 메서드에서 서로 다른 락을 설정하고 있습니다.

이 경우 한 메서드에서 락을 소유하고 있어도 다른 메서드에서의 락 접근에는 영향을 미치지 않으므로 동시에 접근할 수 있습니다.

 

2.1.1. 응용 예시

Map <String, ReentrantLock>을 활용하여 특정 키값에 대한 사용 가능한 락 객체를 동적으로 생성하고 관리할 수 있습니다. 이를 통해 다수의 스레드가 서로 다른 리소스를 안전하게 관리할 수 있습니다.

public class test {
  private final Map<String, ReentrantLock> lockMap = new HashMap<>();

  public ReentrantLock getLock(String key) {
    return lockMap.computeIfAbsent(key, k -> new ReentrantLock());
  }

  public void lockTest(String key) {
    ReentrantLock lock = getLock(key);
    lock.lock();
    try {
      ...
    } finally {
      lock.unlock();
    }
  }
}
  • 동작 설명
    • 락 객체 관리 : getLock(key)은 computeIfAbsent를 사용하여 주어진 key에 대해 ReentrantLock 객체를 반환한다.
      • 만약 key에 대한 ReentrantLock이 없다면 new ReentrantLock()을 통해 새로운 객체를 반환한다.
    • 리소스 잠금 : lockTest(key)는 특정 key에 대한 리소스를 안전하게 잠급니다.
    • 동기화 : 여러 스레드가 서로 다른 key에 대해 lockTest(key)를 호출하면 각 스레드는 해당 key에 대한 락을 안전하게 획득하고 해제할 수 있으며 동일한 key를 사용하는 스레드는 락을 획득할 때까지 대기하게 되므로 리소스 접근이 안전하게 이루어집니다.

2.2 주요 기능

1. 공정성( fairness )

  • ReentrantLock은 대기 중인 스레드가 락을 얻을 순서를 보장하는 공정성 모드를 지원합니다.
  • 기본적으로는 비공정 락(우선순위 없음)을 사용하지만 공정 락을 원할 경우 생성자에 true를 전달하여 설정할 수 있습니다.
ReentrantLock fairness = new ReentrantLock(true); // 공정성 락
ReentrantLock unfair = new ReentrantLock();     // 비 공정성 락

 

  • 공정성 락의 구현은 아래와 같이 이루어집니다:
public ReentrantLock(boolean fair) {
  sync = fair ? new FairSync() : new NonfairSync();
}

 

1.1. 공정성 락의 동작

  • initialTryLock()과 tryAcquire(int acquires)은 대기 중인 스레드가 있을 경우 락을 획득하지 못하도록 막는 기능이 구현되어 있습니다.
final boolean initialTryLock() {
  Thread current = Thread.currentThread();
  int c = getState();
  if (c == 0) {
    if (!hasQueuedThreads() && compareAndSetState(0, 1)) {
      setExclusiveOwnerThread(current);
      return true;
    }
  } else if (getExclusiveOwnerThread() == current) {
    if (++c < 0) // overflow
      throw new Error("Maximum lock count exceeded");
    setState(c);
    return true;
  }
  return false;
}

protected final boolean tryAcquire(int acquires) {
  if (getState() == 0 && !hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
    setExclusiveOwnerThread(Thread.currentThread());
    return true;
  }
  return false;
}

이 코드는 공정성 락에서 큐에 대기 중인 스레드가 있다면 락을 획득하지 못하게 막는 기능입니다.

  • 락 획득 상태 확인 : getState()는 현재 락의 상태를 확인하며 0이면 락이 해제된 상태를 의미합니다.
  • 큐에 다른 스레드가 있는지 확인 : hasQueuedThreads()는 대기 큐에 다른 스레드가 있는지를 확인하고 대기 중인 스레드가 있다면 true를 반환합니다.
  • 락 소유자 설정 : compareAndSetState(0, 1)으로 락 획득 상태를 0에서 1로 변경하고 setExclusiveOwnerThread()를 통해 현재 스레드를 소유자로 설정합니다.
  • 재 진입 가능한 락 처리 : 이미 락을 획득한 상태라면 현재 스레드가 락을 획득한 스레드인지 확인하고 재 진입한 경우에는 기존 락 획득 상태 값을 증가시킵니다.
    • 물론 synchronized도 동일한 재 진입이 가능합니다.
  • 락 획득 성공 여부 : 락 획득에 성공하면 true, 실패하면 false를 반환합니다.
  • 큐에 담긴 첫 번째 스레드인지 확인 : hasQueuedPredecessors()는 현재 스레드가 대기 큐의 첫 번째 스레드인지 확인합니다.
public final boolean release(int arg) {
  if (tryRelease(arg)) {
    signalNext(head);
    return true;
  }
  return false;
}
  • release(int arg)는 락 해제 가능성을 확인하고 락이 해제되면 대기 중인 스레드를 깨우는 기능을 합니다.
private static void signalNext(Node h) {
  Node s;
  if (h != null && (s = h.next) != null && s.status != 0) {
    s.getAndUnsetStatus(WAITING);
    LockSupport.unpark(s.waiter);
  }
}
  • signalNext(Node h)는 큐에서 대기 중인 첫 번째 스레드를 확인하고 대기 상태인 경우 해당 스레드를 깨웁니다.
  • getAndUnsetStatus(WAITING)으로 노드의 상태를 변경합니다.
public static void unpark(Thread thread) {
  if (thread != null)
    U.unpark(thread);
}
  • unpart(Thread thread)는 대기 중인 스레드가 다시 실행될 수 있도록 만듭니다.

2. tryLock()

  • tryLock()은 락을 획득하기 위해 시도하여 성공하면 락을 획득하고 true, 실패하면 false를 반환하기 때문에 대기하지 않고 다른 작업을 수행할 수 있도록 도와줍니다.
public class test {
  Reentrant lock = new Reentrant();
  
  public void lockTest() {
    if(lock.tryLock()) {
      try {
        ... // 락 획득 후 처리
      } finally {
        lock.unlock();
      }
    } else {
      ... // 락 획득 못했을때 처리
    }
  }
  
}

3. lockInterruptibly()

  • lockInterruptibly()는 스레드가 락을 기다리는 동안 다른 스레드가 해당 스레드를 인터럽트 할 수 있는 기능을 제공합니다.
  • 락을 이미 획득한 스레드 : 이미 락을 획득한 스레드는 블로킹 상태가 아니므로 인터럽트의 영향을 받지 않습니다.
  • 대기 중인 스레드 : 대기 중인 스레드는 블로킹 상태이기 때문에 인터럽트가 발생하면 InterruptedException을 던집니다.
public class test {
  Reentrant lock = new Reentrant();
  
  public void lockTest() {
    try {
      lock.lockInterruptibly();
      try {
        ... // 락 획득 후 처리
      } finally {
        lock.unlock();
      }
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
  
}

4. 재진입

  • ReentrantLock은 재진입 가능한 락이기 때문에 같은 스레드가 이미 획득한 락을 다시 획득할 수 있는데 이때 락을 여러 번 획득한 경우 그 횟수만큼 unlock()을 호출해야 완전히 해제됩니다.
  • 재진입을 사용하는 이유는 락을 획득한 후 여러 메서드를 호출하면서도 락을 지속적으로 유지하여, 안전하게 작업을 수행할 수 있기 때문입니다.
public class test {
  ReentrantLock lock = new RenntrantLock();
  
  public void oneLockTest() {
    lock.lock();
    try {
      ...
      twoLockTest();
    } finally {
      lock.unlock();
    }
  }
  
  public void twoLockTest() {
    lock.lock();
    try {
      ...
    } finally {
      lock.unlock();
    }
  }
  
}

재진입이 가능한 이유

  • 재진입이 가능한 이유는 ReentrantLock 내부에서 현재 락을 보유한 스레드가 다시 락을 요청하는지 확인하기 때문입니다.
  • initialTryLock() 내부에서 락을 보유한 스레드와 현재 락을 요청한 스레드가 동일한지 확인하는 로직이 포함되어 있습니다.
final boolean initialTryLock() {
  ...
   // 재진입 로직
  } else if (getExclusiveOwnerThread() == current) {
    if (++c < 0) // overflow
      throw new Error("Maximum lock count exceeded");
    setState(c);
    return true;
  }
  return false;
}

5. Condition 사용

  • ReentrantLock은 Condition객체를 생성하여 스레드 간의 통신을 관리할 수 있습니다.
  • 이는 synchronized의 wait(), notify(), notifyAll()와 유사한  방식입니다.
  • await() : 현재 스레드를 대기 상태로 만든 후 다른 스레드가 signal(), signalAll()을 호출할 때까지 대기합니다.
    • ReentrantLock 객체는 여러 개의 Condition객체를 생성할 수 있으며 각 Condition객체는 자신만의 대기 리스트(노드)를 가지고 있는데 await() 호출 시 Condition객체의 대리 리스트에 쌓인다.
  • signal() : Condition 객체 내부의 대기 중인 스레드 중 첫 번째 스레드를 깨웁니다.
    • 기본적으로 ConditionNode로 인해 순서를 보장합니다.
// signal()이 어떻게 대기중인 첫번째 스레드를 깨우는지 보여주는 코드

private void doSignal(ConditionNode first, boolean all) {
  while (first != null) {
    ConditionNode next = first.nextWaiter;
    if ((firstWaiter = next) == null)
      lastWaiter = null;
    if ((first.getAndUnsetStatus(COND) & COND) != 0) {
      enqueue(first);
      if (!all)
        break;
    }
      first = next;
  }
}
        
public final void signal() {
  ConditionNode first = firstWaiter;
  if (!isHeldExclusively())
    throw new IllegalMonitorStateException();
  if (first != null)
    doSignal(first, false);
}
  • signalAll() : Condition 객체 내부의 대기 중인 모든 스레드를 깨운다.
@Slf4j
public class TEST {
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();
    private boolean ready = false;

    @Test
    public void lockTest() throws InterruptedException {

        Thread thread1 = new Thread(() -> {
            lock.lock();
            try {
                log.info("Thread 1: 진입 ~");
                while (!ready) {
                    condition.await();
                }
                log.info("Thread 1: 다시 진입 ~");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                lock.unlock();
            }
        });
        Thread thread3 = new Thread(() -> {
            lock.lock();
            try {
                log.info("Thread 3: 진입 ~");
                while (!ready) {
                    condition.await();
                }
                log.info("Thread 3: 다시 진입 ~");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                lock.unlock();
            }
        });
        Thread thread4 = new Thread(() -> {
            lock.lock();
            try {
                log.info("Thread 4: 진입 ~");
                while (!ready) {
                    condition.await();
                }
                log.info("Thread 4: 다시 진입 ~");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                lock.unlock();
            }
        });

        Thread thread2 = new Thread(() -> {
            lock.lock();
            try {
                Thread.sleep(2000);
                ready = true;
                log.info("Thread 2: signal() 시작!");
                condition.signal();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                lock.unlock();
            }
        });

        thread1.start();
        thread3.start();
        thread4.start();
        thread2.start();

        thread1.join();
        thread3.join();
        thread4.join();
        thread2.join();
    }

}

Thread 1: 진입 ~
Thread 3: 진입 ~
Thread 4: 진입 ~
Thread 2: signal() 시작!
Thread 1: 다시 진입 ~
  • 결과를 통해 await() 호출한 스레드는 Condition 객체의 대기 리스트에 추가되어 대기 상태가 되며 이후 해당 스레드는 락을 다시 획득할 때 await() 호출 시점부터 코드 실행이 재개되는 것을 확인할 수 있습니다.

6. Lock 상태 확인

  • ReentrantLock 상태를 확인할 수 있는 메서드를 제공합니다.
    • isLocked() : 누군가 락을 소유하고 있는지 확인
    • isHeldByCurrentThread() : 현재 스레드가 락을 소유하고 있는지 확인
    • getHoldCount() : 현재 스레드가 획득한 락의 횟수 (상태 값)을 반환
public void test () {
  if(lock.isLocked()) { log.info("누군가 락을 소유하고 있습니다."); }
  
  if(lock.isHeldByCurrentThread()) { log.info("현재 스레드가 락을 소유하고 있습니다."); }
  
  log.info("락의 횟수 ( 상태값 ) 은 {}", lock.getHoldCount());
}

2.3. 주의 사항

ReentrantLock은 synchronized보다 복잡하고 유연한 기능을 제공하지만 그만큼 관리해야 할 요소가 많습니다.

특히 unlock()을 호출하여 락을 해제하는 것을 잊으면 즉시 교착 상태에 빠질 수 있으므로 주의해야 합니다.

3. ReentrantReadWriteLock

ReentrantReadWriteLock은 Java에서 제공하는 동기화 방법이며, 읽기와 쓰기를 위한 별도의 락을 제공하여 다중 스레드 환경에서 성능을 향상시키는 데 도움을 줍니다. 이 락은 읽기 작업은 여러 스레드가 동시에 진행할 수 있도록 허용하면서 쓰기 작업은 하나의 스레드가 독점적으로 수행되도록 설계되어 있습니다.

3.1. 락의 종류

  • 읽기 락
    • 여러 스레드가 동시에 락을 획득하여 작업을 수행할 수 있습니다.
    • 다른 스레드가 쓰기 락을 획득하면 읽기 락을 획득할 수 없습니다.
    • 읽기 락을 가진 상태에서도 쓰기 작업을 시도할 수 있지만 데이터 일관성을 보장하지 않습니다.
  • 쓰기 락
    • 하나의 스레드만 락을 획득하여 읽기 및 쓰기 작업을 수행할 수 있습니다.
    • 다른 스레드는 쓰기 락이 해제될 때까지 대기해야 하며 쓰기 락이 걸려 있는 동안에는 읽기 락과 쓰기 락을 모두 획득할 수 없습니다.
주의 사항 : ReentrantReadWriteLock의 읽기 락과 쓰기 락은 락을 거는 방식의 차이일 뿐 특정 작업(읽기, 쓰기)만 해야한다는 보장은 없습니다.
// 특정 작업을 보장하지 않는 코드
public class Test {
  private final ReentrantReadWriteLock lock = new ReentrantReadWrtieLock();
  private Integer testNum = 1;
  
  @Test
  public void test () {
    Thread thread = new Thread(() -> {
      lock.readLock().lock();
      try {
        testNum = 2; // 쓰기 작업
      } finally {
        lock.readLock().unlock();
      }
    });
    
    thread.start();
    thread.join();
  }
  
}

3.2. 기본 사용법

public class Test {
  private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
  private int testNum = 1;
  
  public void read() {
    lock.readLock().lock();
    try {
      log.info("읽기 작업 = {}", testNum);
    } finally {
      lock.readLock().unlock();
    }
  }
  
  public void write() {
    lock.writeLock().lock();
    try {
      log.info("수정 작업 시작");
      testNum++;
      log.info("수정 작업 후 = {}, testNum");
    } finally {
      lock.writeLock().unlock();
    }
  }
  
}
  • synchronized 및 ReentrantLock은 읽기 작업과 쓰기 작업을 나누어 락을 획득할 수 없기 때문에, 읽기 작업이 많고 쓰기 작업이 상대적으로 적은 경우 성능상의 문제가 발생할 수 있습니다.
  • ReentrantReadWriteLock을 사용하면 락을 나눠서 관리하기 때문에 병목 현상이 줄어들어 성능이 개선됩니다.
  • ReentrantReadWriteLock은 ReentrantLock와 똑같이 공정성 락을 만들 수 있습니다.
ReentrantReadWriteLock = unfair = new ReentrantReadWriteLock();      // 비 공정
ReentrantReadWriteLock = fairnes = new ReentrantReadWriteLock(true); // 공정
  • ReentrantReadWriteLock은 ReentrantLock 처럼 Lock을 구현한 객체이기 때문에 Condition 객체를 사용하여 스레드 간의 통신을 활용할 수 있습니다.

3.3. 응용 예시

public class Test {
  private static Map<String, ReentrantReadWriteLock> lockMap = new HashMap<>();
  
  public ReentrantReadWriteLock getLock(String key) {
    return lockMap.computeIfAbsent(key, k -> new ReentrantReadWriteLock());
  }

  public void read(String key) throws InterruptedException {
    ReentrantReadWriteLock lock = getLock(key);
    lock.readLock().lock();
    try {
      ...
    } finally {
      lock.readLock().unlock();
    }
  }

  public void write(String key) throws InterruptedException {
    ReentrantReadWriteLock lock = getLock(key);
    lock.writeLock().lock();
    try {
      ...
    } finally {
      lock.writeLock().unlock();
    }
  }
}
Comments