Java프로그래밍수석 자바 개발자

명시적인 리소스 해제가 네이티브 메모리를 관리하는 JDK 클래스에서 자동 정리와 경쟁할 때 발생하는 동기화 위험은 무엇이며, **Inflater** 구현으로 예를 들 수 있습니까?

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

질문에 대한 답변

역사: Java 9 이전에 InflaterDeflater와 같은 클래스의 네이티브 리소스 관리는 **Object.finalize()**에 의존했습니다. 이 메커니즘은 예측할 수 없는 결과와 심각한 성능 오버헤드, 객체 부활의 위험이 있어 더 이상 사용되지 않게 되었습니다. Java 9는 Cleaner API를 도입하여 객체의 생명 주기와 정리 논리를 분리하고, 정리 중 객체가 도달할 수 없도록 보장하기 위해 PhantomReferenceReferenceQueue를 활용합니다.

문제: Inflater 구현에서는 기본 네이티브 z_stream 구조가 명시적으로 end() 메서드를 통해 해제되어야 네이티브 메모리 유출을 방지할 수 있습니다. 애플리케이션 스레드가 end()를 명시적으로 호출하는 동안 Cleaner 스레드가 등록된 정리 작업을 동시에 실행하려고 하면 경쟁 조건이 발생합니다. 적절한 동기화 없이는 두 스레드가 같은 네이티브 포인터를 해제하려고 시도하여 이중 해제 오류가 발생하거나 한 스레드가 다른 스레드가 해제한 후 리소스에 접근하게 되어 JVM 충돌(SIGSEGV)이 발생할 수 있습니다.

해결책: 이 솔루션은 AtomicBoolean 상태 플래그를 사용하여 어떤 스레드가 이를 시작하든 네이티브 정리가 한 번만 실행되도록 보장합니다. 명시적인 end() 메서드와 Cleaner의 정리 작업 모두 이 플래그에 대해 비교 및 설정(CAS) 작업을 수행합니다. 플래그의 상태를 false에서 true로 성공적으로 전환한 스레드만 네이티브 해제 루틴을 호출할 수 있습니다. 이 잠금 없는 접근 방식은 높은 압축 작업 성능을 유지하면서 스레드 안전성을 보장합니다.

실제 상황

고속 로그 압축 서비스는 매일 수백만 개의 로그 항목을 처리하며, 할당 오버헤드를 최소화하기 위해 풀링된 Deflater 인스턴스를 사용합니다. 리소스 사용을 최적화하기 위해 개발자들은 예외가 처리 파이프라인에서 발생했을 때 발생하는 인스턴스를 회수하는 것과 함께 Deflater 인스턴스를 풀로 다시 반환하기 전에 end()를 명시적으로 호출하는 패턴을 구현했습니다.

시스템은 피크 부하에서 간헐적이지만 치명적인 JVM 충돌(SIGSEGV)을 경험했으며, 코어 덤프는 네이티브 zlib 라이브러리 내의 메모리 손상을 나타냈습니다. 조사가 진행된 결과, Deflater 인스턴스가 풀로 반환될 때 애플리케이션 스레드가 end()를 호출했으나, 같위가 가비지 수집이 동시에 발생할 경우 Cleaner 스레드도 같은 네이티브 z_stream 핸들을 정리하려고 시도하게 되었습니다. 이러한 동기화되지 않은 네이티브 리소스 접근이 프로세스를 예측할 수 없게 충돌하게 만들었습니다.

첫 번째로 고려된 솔루션은 모든 Deflater 인스턴스 접근을 synchronized 블록이나 메서드를 사용하여 동기화하는 것이었습니다. 이 접근 방법은 상호 배제를 보장하여 경쟁 조건을 효과적으로 방지했습니다. 그러나 이는 높은 빈도의 압축 파이프라인에서 상당한 경합 오버헤드를 발생시켰고, 여러 스레드가 동시에 객체에 잘못 접근할 경우 데드락의 위험이 있었습니다.

두 번째 접근 방법은 AtomicBoolean를 사용하여 정리 상태를 추적하는 것이었습니다. 명시적인 end() 메서드와 Cleaner 작업 모두 네이티브 리소스를 건드리기 전에 이 플래그를 원자적으로 확인하고 설정했습니다. 이는 최소한의 성능 하락으로 잠금 없는 안전성을 제공했지만, 원자적 검사가 끝난 후 네이티브 호출 능력을 보장하기 위한 신중한 구현이 필요했습니다.

세 번째 옵션은 명시적인 end() 호출을 완전히 제거하고 Cleaner만을 리소스 관리에 의존하는 것이었습니다. 이렇게 하면 경쟁 조건을 완전히 제거할 수 있었지만, 가비지 수집 지연으로 인한 심각한 메모리 압력을 초래할 수 있는 네이티브 메모리 해제 타이밍의 예측 불가능성이 발생했습니다.

팀은 AtomicBoolean 접근법(솔루션 2)을 선택했습니다. 이는 가능할 때 즉각적인 정리가 이뤄지도록(명시적 호출) 하면서도 클리너가 나중에 실행했을 경우의 안전성을 보장했습니다. 이들은 래퍼 클래스를 수정하여 AutoCloseable을 구현하고, 원자적 상태 확인이 네이티브 해제를 보호하도록 했습니다. 이렇게 하여 충돌 문제를 완전히 해결하면서 요구되는 처리량을 유지하여 생산 환경에서 네이티브 메모리 관련 충돌을 제거했습니다.

후보자들이 종종 놓치는 점

**Cleaner API는 **Object.finalize()에 내재된 객체 부활 문제를 어떻게 방지합니까?

**Object.finalize()**에서는 finalize() 메서드가 실행될 때 객체가 여전히 접근 가능하기 때문에 this 참조가 유효합니다. 이로 인해 객체는 정적 필드에 자신에 대한 참조를 저장함으로써 스스로 부활할 수 있습니다. 이러한 부활로 인해 객체가 반복적으로 부활할 경우 가비지 수집이 무기한 지연됩니다. Cleaner API는 이를 방지하기 위해 PhantomReference를 사용합니다. Cleaner의 정리 작업이 실행될 때, 참조 대상(정리되는 객체)은 이미 팬텀 도달 가능 상태에 있으므로, 그 주위에 강한, 소프트, 또는 약한 참조가 존재하지 않아 부활할 수 없습니다. 정리 작업은 개체 자체의 메서드가 아니라 별도의 Runnable이므로, 정리 과정 전체 동안 객체가 도달할 수 없도록 보장됩니다.

**Thread.interrupt()는 JVM 종료 중 Cleaner 스레드를 중단하기에 비효율적인 이유는 무엇이며 그에 따른 의미는 무엇입니까?

Cleaner 스레드는 **ReferenceQueue.remove()**에서 지속적으로 차단되는 데몬 스레드로, 팬텀 참조가 사용 가능해지기를 기다립니다. **ReferenceQueue.remove()**는 인터럽트에 응답하여 InterruptedException을 발생시키지만, Cleaner 구현은 이 예외를 잡아 무한 루프를 계속 실행하여 실질적으로 인터럽트를 무시합니다. 이러한 설계는 종료 시퀀스 중에도 중요한 리소스 정리가 완료되는 것을 보장합니다. 그러나 등록된 정리 작업이 무기한 정지되면(예: 네트워크 시간 초과를 기다리거나 무한 루프에 빠지면), Cleaner 스레드는 절대 종료되지 않습니다. 이는 JVM이 정상적으로 종료되지 못하게 할 수 있으며, 다른 비 데몬 스레드가 클리너가 해제해야 하는 리소스를 기다릴 경우 심각한 문제가 발생할 수 있습니다.

Cleaner의 정리 작업이 정리되는 객체에 대한 강한 참조를 캡처하는 경우 발생하는 재앙적인 메모리 누수는 무엇입니까?**

**Cleaner.register()**에 전달된 Runnable이 객체에 대한 강한 참조를 캡처하는 경우(예: this::cleanupMethod 또는 this를 참조하는 람다를 통해) 치명적인 참조 사이클이 생성됩니다. Cleaner는 내부 Cleanable 객체 세트를 유지하며, 각 객체가 정리 Runnable에 대한 참조를 가지고 있습니다. 만약 그 Runnable이 원래 객체를 참조한다면, 객체는 Cleaner 스레드 자체에서 여전히 강하게 접근할 수 있습니다. 따라서 객체는 팬텀 도달 가능 상태가 되지 않고 PhantomReference는 큐에 들어가지 않으며, 정리 작업은 실행되지 않습니다. 이와 동시에 객체는 가비지 수집될 수 없으며, 이는 Cleaner에 등록된 각 객체와 함께 무한히 커지는 심각한 메모리 누수를 초래하게 되어, 결국 OutOfMemoryError를 발생시킬 수 있습니다.