ClassLoader 동기화의 역사는 원래 JVM 사양에서 시작되며, 이는 스레드 안전한 클래스 로딩을 명시했지만 초기에는 ClassLoader 인스턴스 모니터에서 거칠게 잠금을 제공했습니다. Java 7 이전에, **loadClass()**의 모든 호출은 this에 대해 동기화되어 있으며, 이는 애플리케이션 서버와 같은 멀티스레드 환경에서 동시 클래스 로딩이 흔하게 발생하는 경우 전역적 병목 현상을 초래합니다. Java 7은 registerAsParallelCapable() API를 도입하여 로더가 적은 경량 잠금 방식에 옵션을 제공해 처리량을 크게 향상시킵니다.
핵심 문제는 부모-위임의 재귀적 특성과 동기화된 메서드의 결합에서 발생합니다. 자식 ClassLoader가 **loadClass()**를 재정의하고 자기 인스턴스에서 동기화할 경우, 이는 **parent.loadClass()**를 호출하면서 부모 잠금을 획득하게 됩니다. 서로 순환 참조 요구 사항이 있는 OSGi 번들과 같은 복잡한 계층에서 이로 인해 스레드-A가 Child-A를 보유하고 부모를 기다리며, 스레드-B가 부모를 보유하고 Child-A를 기다리는 전형적인 잠금 순서 주기 가 발생합니다.
해결책은 동기화를 로더 인스턴스에서 로드하는 특정 클래스 이름으로 전환합니다. **registerAsParallelCapable()**가 ClassLoader의 정적 초기화기에서 호출될 때, JVM은 병렬 실행 가능 로더의 ConcurrentHashMap를 유지합니다. 여기서 클래스 이름의 interned string에 대해 잠금을 설정하며, 로더 객체 대신 이를 사용합니다. 이는 동일한 로더 내에서 서로 다른 스레드가 서로 다른 클래스를 동시에 로드할 수 있게 해줍니다. 그러나 이는 새로운 위험을 도입합니다: 만약 Loader-A가 클래스 이름 "X"에 잠금을 걸고 의존성을 위해 Loader-B에 위임하는 동안, Loader-B가 동시에 클래스 이름 "Y"에 잠금을 걸고 Loader-A로 "X"를 다시 위임하면, 스레드는 서로 다른 로더 네임스페이스에서 서로 다른 클래스 이름에 대해 순환 대기 상태에 빠지게 되어 표준 모니터 분석에서는 보이지 않는 교착 상태가 발생합니다.
고주파 거래 플랫폼이 모듈화 전략 엔진을 구현했으며, 각 알고리즘 jar가 사용자 정의 URLClassLoader 자식에 의해 로드된 시장 데이터 클래스를 위한 공유 부모를 참조했습니다. 시장 개방 중에 500개의 스레드가 동시에 전략을 활성화하여, 부모 로더의 모니터에서 대규모 경합을 촉발했고, 이는 거래 기회를 놓치는 결과를 초래했습니다.
해결책 1: 기본 동기화
초기 구현은 상속된 synchronized loadClass 메서드에 의존했습니다. 이는 하던-이전 일관성을 보장하지만, 이 접근 방식은 모든 클래스 로딩을 단일 모니터를 통해 직렬화했습니다. 성능 프로파일링 결과 95%의 스레드가 부모 ClassLoader 잠금을 기다리며 차단되어, 중요한 시작 창 동안 효과적인 처리량이 단일 스레드 수준으로 감소했습니다.
해결책 2: 비동기 사용자 정의 로딩
개발자들은 동기화를 완전히 제거하려고 시도했으며, 불변 jar 내용이 비가역적 로딩을 보장한다고 가정했습니다. 이로 인해 동일한 정의의 여러 개별 Class 객체가 동일한 로더에 존재하게 되어 LinkageError와 "전략을 전략으로 캐스팅할 수 없음"이라는 모호한 ClassCastException 메시지가 발생했습니다. 이는 경합 스레드에 의해 로드된 중복 클래스 정의로 인한 것입니다.
해결책 3: 병렬 가능 등록
팀은 사용자 정의 ClassLoader 서브클래스에서 **registerAsParallelCapable()**를 구현하고 병렬 잠금 메커니즘을 유지하기 위해 loadClass() 대신 **findClass()**를 엄격히 재정의했습니다. 이로 인해 부모 위임 체인을 유지하면서도 서로 다른 클래스 이름의 동시 해석이 가능해졌습니다. 이 해결책은 형제 로더 간의 순환 패키지 의존성을 제거하기 위해 플러그인 계층 구조를 재구성해야 했습니다. 결과적으로, 전체 로드 시 시작 지연이 120초에서 8초로 감소하고, 6개월 동안의 생산 거래 중에 ClassLoader 교착 상태가 발견되지 않았습니다.
**왜 **loadClass()**를 재정의하는 것이 **findClass()를 재정의하는 것보다 병렬 수행 가능 최적화를 조용히 비활성화하나요?
병렬 가능 메커니즘은 JDK에서 제공하는 loadClass(String name, boolean resolve) 템플릿 메서드 내에서 세부 잠금을 포함합니다. 서브클래스가 **loadClass(String)**를 재정의하면, 이는 ClassLoader의 내부 parallelLockMap을 통해 특정 클래스 이름에 대한 잠금을 획득하는 내부 로직을 우회합니다. 서브클래스는 의도치 않게 비동기 접근으로 돌아가 두 클래스 정의 경쟁을 초래하거나 this에 대한 수동 동기화가 필요하게 되며, 이는 전역 병목 현상을 다시 유발합니다. 올바른 패턴은 캐시 확인 및 부모 위임을 위해 **super.loadClass()**에 위임하고, 사용자 정의 바이트 배열을 클래스 변환 로직은 확실히 **findClass()**에 제한하여, 이미 설정된 이름별 잠금 맥락 내에서 실행되도록 합니다.
어떻게 ServiceLoader 패턴이 병렬 가능 ClassLoaders와 함께 사용되더라도 교착 상태를 유발할 수 있나요?
부모 ClassLoader에서 실행되는 ServiceLoader가 Child-A에 있는 서비스 구현을 인스턴스화하려고 할 때, 암묵적으로 **Child-A.loadClass()**를 호출합니다. 만약 해당 구현 클래스가 부모에서 유틸리티 클래스를 로드하는 정적 초기화를(trigger static initialization) 하고, 다른 스레드가 Child-A에서의 다른 서비스 구현을 로드하기 위해 부모 잠금을 대기하고 있다면, 순환 대기가 형성됩니다. 스레드-1이 "Logger"의 부모 클래스 이름 잠금을 보유하고 Child-A의 "ServiceImpl" 잠금을 기다리고, 스레드-2는 Child-A의 "ServiceImpl" 잠금을 보유하고(처음 ServiceLoader 호출로 인해) 부모의 "Logger" 잠금을 기다리고 있습니다. 초기화 중의 이러한 로더 간 클래스 로딩은 표준 스레드 덤프 분석기가 식별하기 어려운 교착 상태 사슬을 생성합니다. 이는 ClassLoader 인스턴스 모니터가 아니라 내부 이름 기반 잠금을 모니터링하기 때문입니다.
"defineClass window" 경합 조건은 무엇이며 병렬 기능이 이를 방지하지 않는 이유는 무엇인가요?
병렬 기능은 동일한 클래스 이름에 대한 loadClass 작업이 직렬화되도록 보장하지만, defineClass() 자체는 명확한 고유 리소스이므로 경합 조건에 취약합니다. 만약 사용자 정의 로더가 표준 findLoadedClass 검사를 넘어서는 외부 캐싱이나 바이트코드 변환을 구현하면—예를 들어 loadClass를 가로채는 Java 에이전트에서—두 스레드가 "로딩되지 않음" 검사를 동시에 통과하고 **defineClass(byte[], ...)**를 같은 이진 이름으로 호출할 수 있습니다. 두 번째 스레드는 LinkageError: attempted duplicate class definition을 수신합니다. 이는 SystemDictionary 검사가 원자적으로 수행되지만 정의 호출 전 사용자 정의 사전 검사의 간격이 병렬 가능 이름 잠금에 의해 보호되지 않기 때문에 발생합니다. 코드가 외부 부작용이나 추가 동기화 없이 템플릿 메서드 패턴을 엄격히 따르는 경우에만 해당 문제를 피할 수 있습니다.