synchronized와 ReentrantLock, 그리고 JDK21 Virtual Thread

EP·2024년 1월 5일
6
post-thumbnail

Overview


2023년 12월에 IETF에서 초안으로 발표한 UUID Version 7는 Time-Based UUID를 생성하는 과정에서 정확히 같은 시간에 요청이 들어온 경우 고유성을 획득하기 위해 난수를 입력해야 합니다. 관련하여 미디엄에서 글을 작성하였는데요. 이 글을 작성하면서 UUID Version 7을 구현한 2개의 자바 라이브러리의 구현을 비교했습니다.

UUID Version 7 Java에서 구현하기 위해서는 동시에 들어온 요청에 고유성을 보장하기 위해 thread-safe 하도록 구현을 해야했습니다. 이 과정에서 Java 라이브러리인 uuid-creatorsynchronized 키워드를 메서드에 사용을 했고 java-uuid-generator(JUG)ReentrantLock를 사용해서 thread-safe한 로직을 구현했습니다.

이 둘의 차이를 확인해보았습니다.

uuid-creator - uuid version7 생성 로직 (2023.12.09 기준)

public synchronized UUID create() {
	  ...(생성 로직 생략)
}

java-uuid-generator(JUG) - uuid version7 생성 로직

public TimeBasedEpochGenerator(Random rnd, UUIDClock clock) {
    this._lastTimestamp = -1L;
    this._lastEntropy = new byte[10];
    this.lock = new ReentrantLock();
    if (rnd == null) {
        rnd = LazyRandom.sharedSecureRandom();
    }

    this._random = (Random)rnd;
    this._clock = clock;
}

...

public UUID construct(long rawTimestamp) {
    this.lock.lock();

    UUID var9;
    try {
			...(생성 로직 생략)
    } finally {
        this.lock.unlock();
    }

    return var9;
}

이 두 로직은 모두 thread-safe한 방식으로 구현된 로직입니다. uuid-creator는 synchronized를 통해 thread-safe를 구현했고 java-uuid-generator는 ReentrantLock로 thread-safe를 구현했습니다.

그런데 얼마 안지나 uuid-creator 라이브러리가 2023.12.23 리팩토링한 로직을 보면

protected final ReentrantLock lock = new ReentrantLock();

synchronized 로직을 ReentrantLock로 리팩토링한 내용이 있습니다. 무슨 이유일까요? 관련된 이슈 내용을 확인해봤습니다.

Replacing all synchronized methods with ReentrantLock

해당 이슈는 외부 개발자가 직접 jdk 버전 및 JVM 타입별(OpenJDK, GraalVM)로 synchronized, ReentrantLock, Wrapper를 사용한 ReentrantLock와의 성능 차이를 비교하며 ReentrantLock를 사용하는 것이 어떻겠냐는 제안이었습니다.

내용을 요약하면 외부 개발자가 wrapper ReentrantLock를 사용하면 synchronized보다 성능이 좋을거라 제시하였고 uuid-creator는 큰 성능상 상향이 없을거라 생각하여 synchronized를 유지하려고 한다는 판단이 있었습니다. 또한 ReentrantLocksynchronized와 같이 사용하면 효과가 없음을 확인했습니다.(2023년 10월 14일 - 15일)

2023년 12월 09일 이 이슈에 추가적인 코멘트가 달렸습니다. 최근 JDK 21(LTS)에서 릴리즈된 Virtual Thread가 synchronized를 사용하면 확장성이 저하된다는 문제를 제시했습니다. 여기서 말하는 확장성은 Virtual Thread를 사용하더라도 캐리어 스레드의 pinning을 유발하여 성능을 저하한다는 이야기입니다. 따라서 Virtual Thread를 사용하려면 지속적으로는 synchronized를 제거한다는 Oracle의 권장 내용을 참조했습니다. 즉, synchronized는 향후에 더 이상 사용되지 않을 수도 있다는 내용입니다.

이후 2023년 12월 23일 uuid-creator에서도 ReentrantLock 로 변경하는 리팩토링이 진행되었습니다.

이 글에서는 synchronizedReentrantLock의 사용성을 비교하고 JDK 21에 릴리즈된 Virtual Thread와 synchronized을 같이 사용하면 어떤 문제가 발생하는지에 대해서 설명하겠습니다.

synchronized vs ReentrantLock


자바에서는 멀티 스레딩 환경에서 스레드 안전성을 확보하기 위해 synchronized 혹은 ReentrantLock 를 사용하는 방법이 대표적입니다. synchronized 는 모니터 잠금 방식을 이용합니다. ReentrantLock 는 JDK 1.5 부터 등장한 java.util.concurrent 패키지에 포함되어있으며 명시적으로 락 객체를 생성하며 스레드의 재진입성을 지원합니다.

일반적으로 두 방식다 스레드 안전을 위해 사용되지만 일반적으로 synchronized 키워드 사용이 간편해서 특별한 이유가 있는 경우에만 ReentrantLock를 사용하곤 합니다.

synchronized 원리

synchronized 는 메서드 시그니처에 명시하거나 코드 블록으로 사용할 수 있습니다. synchronized 키워드가 들어간 메서드를 사용할 때 획득된 객체 잠금은 사용하는 클래스 객체입니다. synchronized 코드 블록인 경우 획득한 잠금 개체는 동기화 코드 블록안의 개체입니다.

public synchronized void method() {
    
}

public void method(){
    
    synchronized (object) {
        //do something
    }
}

일반적으로 synchronized 코드 블록을 우선적으로 사용하면 코드 블록을 실행한 후에만 block이 되기 때문에 효율적으로 모니터 락을 사용할 수 있습니다. 전체 메서드에 모니터 락이 확실한 경우에만 메서드에 synchronized를 붙여 사용하면 됩니다.

JVM은 잠금을 획득한 객체에 잠금 정보를 저장합니다. 각 개체는 대기열(entrySet, waitSet)이 각각 저장되어 잠금 경쟁에 실패한 차단된 스레드와 wait/sleep 메서드를 호출한 차단된 스레드를 저장합니다.


이미지 출처

EntrySet의 스레드는 잠금을 획득한 스레드가 종료된 후 잠금을 위해 경쟁하려고 깨어납니다. 경쟁에는 단 한 명의 승자가 있으며 패자는 EntrySet에 들어가 대기합니다. 잠금을 획득해서 작업을 하는 도중에 조건이 만족하지 않아 대기를 해야하는 경우는 waitSet에 들어갑니다. 그리고 active thread가 작업을 마치고 waitSet에 notify를 보낸경우 entrySet에게도 notify를 보냅니다. 이 과정에서 개체를 담는 컬렉션이 Set이므로 별도의 우선순위가 없습니다.

이 로직은 JDK 내에 내장되어 있으며 C++ 언어로 구현이 되어있습니다. 실제 오픈소스는 synchronizer.cpp에서 확인 할 수 있습니다.

JDK 1.5 버전까지는 synchronized 키워드는 모니터의 Heavyweight Lock 방식으로 구현되어있었습니다. 하지만 JDK 1.6 버전 이후로 Biased Lock, Lightweight Lock 등의 방식이 도입되어 성능이 개선되었습니다. 자세한 원리와 내용은 해당 글을 통해 깊게 학습하실 수 있습니다.

ReentrantLock 원리

synchronized 와 달리 ReentrantLock 는 Java로 구현이 되어있습니다. ReentrantLock 는 CAS(Comapre And Swap)으로 동시에 하나의 스레드만이 잠금을 획득할 수 있도록 상태값을 관리하여 스레드 안전을 보장합니다. 경쟁에 실패한 다른 스레드는 작업이 일시 중단되며 노드안에 캡슐화되어 FIFO 대기열에 저장이 됩니다.

public void method() {
      ReentrantLock lock = new ReentrantLock();
      try{
          lock.lock();
          //do something
      }finally {
          lock.unlock();
      }
  }
  
  public void methodTryLock() {
  	ReentrantLock lock = new ReentrantLock();
        try {
            if(lock.tryLock(10, TimeUnit.SECONDS)){
                try {
                    //do something
                } finally {
                    lock.unlock();
                }
            }
        } catch (InterruptedException e) {
            System.out.println("methodTryLock Interrupt----current thread is interrupted: " + Thread.currentThread().getName());
        }
  }

로직을 보면 알 수 있듯이 synchronized 키워드가 ReentrantLock 보다 사용하기가 편리합니다. 하지만 ReentrantLock 는 동기화의 기본 기능을 확장하여 사용할 수 있습니다.

ReentrantLock 은 쉽게말해 잠금의 고급 기능을 사용할 수 있습니다. 잠금 및 잠금 해제는 모두 수동으로 처리할 수 있습니다. 또한 잠금 시간 제한을 설정할 수 있어서 만료가 되면 해당 스레드를 건너뛰기 때문에 synchronized 에서 발생할 수 있는 교착 상태를 방지할 수 있습니다.

또한 공정 잠금, 불공정 잠금이 둘다 지원됩니다. synchronized 키워드는 불공정한 잠금으로 lock을 획득한 스레드가 먼저 실행이 됩니다. ReentrantLock 은 불공정 잠금의 방법을 선택할 때는 유사한 원리로 진행이 되지만 공정 잠금을 선택하면 대기열에 들어간 스레드 순으로 잠금 리소스에 접근할 수 있습니다.


이미지 출처

또한 특정 스레드가 장기간동한 active 상태임을 점유할 때 이를 외부 스레드에서 중단할 수 있습니다. 하지만 synchronized 키워드를 사용하면 이 잠금을 해제하는 방법을 쉽게 구현하기 어렵습니다.

ReentrantLock AbstractQueueSyncronized(AQS) 라는 방식을 통해 volitale과 CAS 기반으로 구현이 되어있습니다. 이 내용 또한 자세한 원리는 해당 글을 통해 확인할 수 있습니다.

synchronized vs ReentrantLock

앞서 설명했듯이 synchronized 는 JDK 1.6 이후에 다양한 Lock 개념을 도입해서 성능적으로 발전을이 되었습니다. 따라서 ReentrantLock 을 사용하므로써 무조건적인 성능 향상을 기대하기 어렵습니다. 결론적으로 ReentrantLock 특징인 Lock 의 고급 기능을 활용하기 위한 방법이 필요한 시나리오에서만 사용하도록 권장을 하는 트레이드오프 관계였습니다.

하지만 이 글의 초반부에 이야기가 나왔듯이 JDK 21의 Virtual Thread를 사용하면 이야기가 달라집니다.

Virtual Thread와 synchronized


Virtual Thread 개요

Virtual Thread는 Project Loom에서 고안한 기능으로 2023년 9월 LTS 버전인 JDK21에서 정식 Feature로 포함한 기능입니다. 기존의 Native Thread 개념으로 커널 스레드와 JVM 스레드가 1:1 매칭이 되는 시스템에서 Platform Thread와 Virtual Thread로 스레드의 단위를 쪼개 컨텍스트 스위칭 비용을 줄인 개념입니다.


이미지 출처

Virtual Thread는 기존의 Kotlin의 Coroutine, Reactive Programming 보다도 좋은 성능을 가지고 있음에도 프로덕션 코드의 수정을 최소화할 수 있다는 강점이 있었습니다.

Carrier Thread의 Pinning 문제

하지만 주의할 사항도 역시 많았는데요. Virtual Thread를 synchronized 블럭이나 메서드에서 사용하면 Pinning 현상이 나타난다고 합니다. Virtual Thread 는 park/unpark 과정을 통해 컨텍스트 스위칭 형태를 구현하였는데 synchronized 블럭에서는 Virtual Thread가 해당 스레드를 관장하는 Carrier Thread(Platform Thread)에 park를 할 수 없는 고착상태, 즉 pinned 상태가 되어버립니다. 이는 곧 성능 저하로 이어집니다.

Pinning carrier thread 이미지 출처

이 문제를 해결하기 위해 오라클에서는 java.util.concurrent.locks.ReentrantLock 를 권장합니다.

Pinning does not make an application incorrect, but it might hinder its scalability. Try avoiding frequent and long-lived pinning by revising synchronized blocks or methods that run frequently and guarding potentially long I/O operations with java.util.concurrent.locks.ReentrantLock.
고정을 해도 애플리케이션이 잘못되지는 않지만 확장성을 저해할 수 있습니다. 자주 실행되는 동기화된 블록이나 메서드를 수정하고 잠재적으로 긴 I/O 작업을 java.util.concurrent.locks.ReentrantLock으로 보호하여 빈번하고 오래 지속되는 고정을 피하세요.
https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html#GUID-704A716D-0662-4BC7-8C7F-66EE74B1EDAD

이에 신빙성을 더하듯 Virtual Thread를 적극적으로 도입할 의지가 있어 보이는 스프링의 공식 블로그에서도 이미 너무 많이 사용되고 있던 synchronized 키워드 사용을 개선하기 위한 고민을 확인할 수 있습니다.

On the path to becoming the best possible citizen in a Virtual Thread scenario, we will further revisit synchronized usage in the context of I/O or other blocking code to avoid Platform Thread pinning in hot code paths so that your application can get the most out of Project Loom.
가상 스레드 시나리오에서 최고의 시민이 되기 위한 과정에서, 애플리케이션이 프로젝트 룸을 최대한 활용할 수 있도록 핫 코드 경로에서 플랫폼 스레드가 고정되지 않도록 I/O 또는 기타 블로킹 코드의 맥락에서 동기화된 사용을 추가로 검토할 것입니다.
https://spring.io/blog/2022/10/11/embracing-virtual-threads/


Conclusion


이렇게 synchronized 키워드에서 Virtual Thread와 호환이 되지 않는 이유는 synchronized 는 JVM 내부적으로 구현이 되어있어 캐리어 스레드가 잠금 로직 관련 동작을 직접하도록 되어있어 Virtual Thread 활용에 대한 대응을 하지 못하기 때문입니다. 하지만 ReentrantLock은 Java 로 구현되어있고 락을 유연하게 사용할 수 있는 방식이기 때문에 Virtual Thread에 바로 대응할 수 있었습니다. 결론적으로는 현상황에서는 Virtual Thread를 사용하기 위해서는 ReentrantLock 를 사용하는 선택으로 기울어지고 있습니다.

Reference

Diagnose and Mitigate Pinning in Java's Virtual Thread Execution

virtual thread + synchronized = X

Java의 미래, Virtual Thread | 우아한형제들 기술블로그

Core Libraries

Java Monitor(Intrinsic Lock)와 Explicit Lock의 이해 – Biased/Lightweight/Heavyweight Lock vs ReentrantLock

Synchronized VS ReentrantLock, How to Choose?

Multithreading - Difference between lock and monitor in java

profile
Hello!

1개의 댓글

comment-user-thumbnail
2024년 1월 11일

향후 synchronized 쪽에도 변화가 생길지 모르겠지만, 당장은 ReentrantLock 를 채택할 수 밖에 없겠네요.
덕분에 항상 귀한 지식 쉽게 잘 얻어갑니다!

답글 달기