본문으로 건너뛰기
개발 머꼬
개발 노트Java
hohyeon.dev16

가상 스레드를 켰는데 처리량이 그대로였던 이유 — synchronized 핀닝

  • #Concurrency
  • #Engineering Note
  • #Java
  • #Upgrade

문제 발생

JDK 21로 올리고 요청 처리를 가상 스레드로 바꿨는데 처리량이 거의 달라지지 않았습니다. 오히려 부하가 높을 때 응답이 더 불규칙해졌습니다. 문제가 된 코드는 이런 모양이었습니다.

private final Object lock = new Object();

public Result handle(Request req) {
    synchronized (lock) {
        return externalApi.call(req);   // 네트워크 대기
    }
}

원인 분석

가상 스레드의 이점은 블로킹될 때 캐리어(플랫폼) 스레드를 놓아주는 것입니다. I/O를 기다리는 동안 OS 스레드를 점유하지 않으므로 적은 스레드로 많은 요청을 처리합니다.

JDK 21~23에서는 여기에 예외가 있었습니다. synchronized 메서드나 블록 안에서 블로킹되거나, synchronized에 진입하려고 대기하거나, Object.wait()로 기다리는 경우 가상 스레드가 언마운트되지 못하고 캐리어를 붙잡았습니다(pinning). 모니터 소유권이 가상 스레드가 아니라 캐리어에 묶여 있었기 때문입니다.

결과적으로 위 코드는 가상 스레드를 써도 동시에 처리 가능한 요청 수가 캐리어 풀 크기(기본적으로 CPU 코어 수 근처)로 제한됩니다. 가상 스레드를 켠 효과가 사라지고, 최악의 경우 캐리어가 모두 붙잡혀 데드락처럼 멈추는 상황까지 갔습니다. 라이브러리 깊숙한 곳의 synchronized 하나로도 벌어질 수 있어서, 가상 스레드 도입의 가장 큰 걸림돌이었습니다.

JEP 491(JDK 24) 이 이를 해결했습니다. 모니터 소유권을 캐리어가 아니라 가상 스레드 자신에게 기록하도록 바꿔, synchronized 안에서도 언마운트할 수 있게 됐습니다. 모니터는 가상 스레드를 따라가므로 JVM이 캐리어를 회수할 수 있습니다.

해결 방안

  1. 가장 확실한 해결은 JDK 24 이상으로 올리는 것입니다. 코드 변경 없이 synchronized 핀닝이 사라집니다. 가상 스레드 도입을 미뤄 왔다면 이 시점이 기준선입니다.
  2. 21~23에 머물러야 한다면 ReentrantLock으로 바꿉니다. java.util.concurrent의 락은 가상 스레드를 인지하므로 핀닝이 일어나지 않습니다.
private final ReentrantLock lock = new ReentrantLock();

public Result handle(Request req) {
    lock.lock();
    try {
        return externalApi.call(req);
    } finally {
        lock.unlock();
    }
}
  1. 핀닝을 측정합니다. 추측하지 말고 JFR의 jdk.VirtualThreadPinned 이벤트로 실제로 어디서 붙잡히는지 확인합니다. 우리 코드가 아니라 드라이버나 로깅 라이브러리인 경우가 많습니다.
  2. 락 범위를 좁힙니다. 버전과 무관하게 유효한 원칙입니다 — 네트워크 호출이나 파일 I/O를 락 안에 두지 않으면 애초에 문제가 작아집니다.
  3. JDK 24 이후에도 남는 핀닝이 있습니다. 네이티브 프레임(JNI)이나 클래스 초기화 중 블로킹은 여전히 캐리어를 붙잡습니다. synchronized가 해결됐다고 모든 핀닝이 사라진 것은 아닙니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.