Java프로그래밍Senior Java Developer

플랫폼 스레드에서 가상 스레드로 마이그레이션할 때 모니터 경합 및 동기화 블록과 관련된 근본적인 위험은 무엇이며, 이는 왜 캐리어 스레드 고정을 초래하나요?

Hintsage AI 어시스턴트로 면접 통과

질문에 대한 답변

**프로젝트 룸(Project Loom)**의 가상 스레드는 ForkJoinPool에서 가져온 캐리어 스레드 위에 탑재된 계속성으로 작동합니다. 가상 스레드가 synchronized 블록에 접근하거나 네이티브 코드를 실행하면, 하위 캐리어 스레드가 고정되어 차단 I/O 작업 중에 스케줄러가 가상 스레드를 언마운트할 수 없게 됩니다. 이는 실질적으로 동시성의 정도를 캐리어 풀의 크기(일반적으로 CPU 코어 수에 해당)로 줄이며, 경합하는 가상 스레드가 고정된 캐리어 풀을 독점하게 되어 부하가 걸릴 때 처리량이 붕괴될 수 있습니다.

실생활 상황

한 금융 서비스 회사는 전통적인 Tomcat 스레드 당 요청 모델(500개의 플랫폼 스레드로 제한됨)에서 Jetty로의 마이그레이션을 통해 50,000개의 동시 WebSocket 연결을 처리할 수 있을 것으로 기대했습니다. 배포 직후, 가상 스레드를 도입했음에도 불구하고 지연 시간이 초 단위로 급증하고 처리량은 시장 개장 변동성이 있을 때 겨우 800 TPS로 정체되었습니다. 스레드 덤프를 살펴본 결과, 모든 24개의 캐리어 스레드가 synchronized 블록 내부에서 BLOCKED 상태로 고정되어 있었고, 수천 개의 가상 스레드는 I/O를 위해 대기하고 있었습니다.

첫 번째 해결책은 -Djdk.virtualThreadScheduler.parallelism을 사용하여 ForkJoinPool의 병렬성을 1000으로 증가시키는 것이었습니다. 이렇게 하면 고정된 작업을 흡수할 더 많은 캐리어 스레드가 제공되어 대규모 플랫폼 스레드 풀로 되돌리는 효과가 있었습니다. 그러나 이 접근 방식은 본질적으로 아키텍처 결함을 숨기고 지나치게 많은 OS 자원을 소비하여 가상 스레드 가상화에서 약속한 메모리 효율성 이점을 무효화했습니다.

두 번째 해결책은 공유 속도 제한 캐시를 보호하는 모든 synchronized 블록을 ReentrantLock을 사용하도록 리팩토링하는 것이었습니다. 내장 모니터와 달리 ReentrantLock은 가상 스레드 스케줄러와 통합되어 경합이나 차단 작업 중에 캐리어를 고정하지 않고 언마운트를 허용합니다. 이 접근 방식은 가상 스레드의 경량성을 유지하지만 코드베이스 감사 및 잠금 중단 시맨틱스를 신중히 처리해야 했습니다.

세 번째 해결책은 동시 해시 맵 캐시를 ConcurrentHashMap 계산 메서드 또는 낙관적인 읽기를 위한 StampedLock과 같은 순수 잠금 없는 데이터 구조로 교체하는 것이었습니다. 이 방법은 많은 읽기 경로에서 블로킹을 제거하지만, 상호 배제가 필요한 데이터베이스 연결 체크아웃 시퀀스와 같은 상태가 있는 외부 자원에 대한 독점 액세스를 요구하는 상황을 해결하지 못합니다.

팀은 프로파일링으로 핀 고정 핫스팟으로 확인된 50개의 주요 synchronized 섹션을 ReentrantLock으로 타겟 마이그레이션하는 두 번째 해결책을 선택했습니다. 이 선택은 경합 중에 가상 스레드를 언마운트할 수 있게 하여 근본 원인을 직접 해결했으며, 애플리케이션 비즈니스 논리를 변경하거나 메모리 사용량을 증가시키지 않았습니다.

리팩토링 및 재배포 후 시스템은 목표인 50,000개의 동시 연결을 안정적인 100ms 미만의 p99 지연으로 달성했습니다. 캐리어 스레드 풀은 기본 크기인 24로 유지되어(총 CPU 코어 수와 일치) 가상 스레드가 본질적인 동기화를 피해야만 진정한 확장성을 제공함을 나타냈습니다.

// 이전: 캐리어 스레드 고정 synchronized (rateLimiter) { // 여기에 블록되면 가상 스레드는 언마운트할 수 없음 externalApi.call(); } // 이후: 언마운트를 허용 rateLimiter.lock(); try { // 가상 스레드가 언마운트되어 캐리를 해제 externalApi.call(); } finally { rateLimiter.unlock(); }

후보자들이 놓치는 점

왜 synchronized 블록과 네이티브 메서드에서만 핀 고정이 발생하고, ReentrantLock은 언마운트를 허용하는가?

핀 고정은 JVM이 내장 모니터(synchronized)를 스레드 스택 기반 모니터 레코드와 C++ 수준의 VM 내부 구조를 사용하여 구현하기 때문에 발생합니다. 이는 물리적 OS 스레드의 실행 컨텍스트에 본질적으로 묶여 있습니다. 가상 스레드가 synchronized 블록에 들어갈 때 JVM은 모니터 상태를 손상시키거나 네이티브 수준에서 발생 보장(happens-before guarantees)을 위반하지 않고는 계속을 다른 캐리어로 안전하게 이동할 수 없습니다. 반면, ReentrantLockAbstractQueuedSynchronizer를 기반으로 Java에서 순수하게 구현되어 VarHandleLockSupport.park 원시를 사용하여 가상 스레드 스케줄러가 개입할 수 있게 하므로, 네이티브 스레드 상태에 의존하지 않고 안전하게 언마운트 및 재마운트를 허용합니다.

캐리어 스레드 핀 고정이 ForkJoinPool의 작업 도난과 어떻게 상호작용하여 잠재적인 기아 시나리오를 만드는가?

정상 작동 중에 ForkJoinPool은 작업이 CPU 바운드 또는 비차단적이라고 가정합니다. 작업 스레드가 차단될 때 추가 작업자 스레드를 생성하거나 활성화하여 병렬성 한도까지 보완합니다. 그러나 핀 고정된 가상 스레드는 캐리어를 차단하여 풀의 보상 메커니즘을 효과적으로 신호하지 않습니다. 결과적으로, 20개의 가상 스레드가 동시에 20개의 캐리어를 고정하면(예: synchronized 블록에 들어감), 스케줄러에 대기 중인 수천 개의 준비된 가상 스레드를 실행할 캐리어가 남지 않게 됩니다. 이는 사용 가능한 작업이 있음에도 불구하고 차단되지 않은 작업이 진행되지 못하게 하는 우선 순위 역전(priority inversion)을 발생시킵니다. 이로 인해 사용 가능한 풀 크기가 동적으로 축소되고 재앙적 상황이 발생합니다.

ThreadLocal 변수를 과도하게 사용하면 가상 스레드 환경에서 캐리어 스레드 핀 고정을 유발할 수 있습니까?

ThreadLocal 변수는 핀 고정을 유발하지 않습니다. 가상 스레드 구현은 마운트 및 언마운트 작업 중에 스레드-로컬 맵을 캐리어 간에 이동합니다. 하지만 후보자들은 종종 ThreadLocal이 메모리 관리 재앙을 초래하는 별개의 문제를 발생시킨다는 것을 간과합니다: 수백만 개의 단기간 가상 스레드가 스레드 로컬에 접근하게 되면 각 캐리어 스레드는 그것이 호스팅했던 모든 가상 스레드에 대한 ThreadLocalMap 항목을 축적합니다. 이 맵은 키(가상 스레드) 제거 또는 가비지 수집이 있을 때에만 청소되므로, 장기 실행 캐리어 스레드에서 무한한 메모리 증가를 초래할 수 있습니다. 이는 핀 고정과는 관련 없는 메모리 누수로, 대규모 가상 스레드 배포에 치명적이며 적절한 청소를 위해 ScopedValue(JEP 446)로의 마이그레이션을 요구합니다.