역사는 Java 9의 도입된 java.lang.ref.Cleaner와 함께 시작되며, 이는 예측 불가능한 실행 타이밍, 성능 저하 및 부활 공격을 포함한 보안 취약점을 가진 사용 중단 된 Object.finalize() 메소드를 대체했습니다. 핵심 문제는 **finalize()**가 재정의된 메소드가 살아있는 객체 그래프에 this에 대한 참조를 저장할 수 있게 하여 가비지 수집기가 이미 도달 불가능하다고 판단한 객체를 "부활시키는" 것을 허용했다는 것입니다. 이는 단일 생성 및 단일 파괴 불변성을 위반하며, 네이티브 리소스를 일관성 없는 상태로 남길 수 있습니다.
해결책은 Cleaner 구현 내에서 PhantomReference 의미를 활용하는 것입니다: 청소 작업은 참조 객체 자체가 아닌 Runnable 또는 청소 작업 객체만 수신하며, 참조 객체는 유령 도달 가능 상태에 있어야 합니다. 즉, 이미 정리되었고 부활할 수 없으므로, 청소 논리는 돌이킬 수 없는 도달 불가능한 객체에서 작동합니다.
public class NativeResource { private static final Cleaner cleaner = Cleaner.create(); private final long nativeHandle; public NativeResource() { nativeHandle = allocateNative(); cleaner.register(this, new CleanupAction(nativeHandle)); Reference.reachabilityFence(this); } private static class CleanupAction implements Runnable { private final long handle; CleanupAction(long handle) { this.handle = handle; } @Override public void run() { releaseNative(handle); } } private native long allocateNative(); private static native void releaseNative(long handle); }
우리 팀은 JNI를 통해 할당된 네이티브 OpenCV 버퍼를 래핑하는 ImageProcessor 객체를 관리하는 고처리량 이미지 처리 파이프라인을 운영했습니다. 초기에는 **finalize()**를 사용하여 cvReleaseImage()를 호출했지만, Java 힙 안정성이 있음에도 불구하고 가끔 네이티브 메모리 고갈 문제가 발생했고, 이는 네이티브 메모리에서 사용 후 해제 오류를 나타내는 간헐적인 세그멘테이션 결함과 함께 발생했습니다.
고려된 첫 번째 접근은 **finalize()**를 유지하되, "죽은" 객체를 추적하기 위해 정적 맵에서 동기화하여 부활 방지 장치를 추가하는 것이었습니다. 그러나 이는 예측할 수 없는 지연이 발생했습니다. 정리가 힙 압력이 시작된 후 몇 분이 지나야 발생할 수 있었고, 방지 로직 자체는 추적 맵에서 "죽은" 객체에 대한 강한 참조를 유지하여 메모리 누수를 발생시켰으며, 아이러니하게도 그들을 완전히 수집하는 것을 방해했습니다.
두 번째 접근은 인스턴스 생성자 내에서 람다로 네이티브 포인터를 캡처하여 java.lang.ref.Cleaner를 천진난만하게 사용하는 것이었습니다: cleaner.register(this, () -> free(pointer)). 이것은 정리 지연을 피할 수 있었지만, this가 생성 중 탈출할 경우 조기 수집 위험이 있었고, 치명적으로 람다가 실수로 ImageProcessor 인스턴스가 아닌 단순히 pointer 값에 닫히면, 이는 참조의 가비지 수집을 방해하는 강한 참조 순환을 생성하면서도 부활을 방지했습니다.
우리는 네이티브 포인터( long으로)만 보유하고 Runnable을 구현하는 정적 중첩 CleaningAction 클래스를 구현하는 세 번째 접근을 선택했습니다. 우리는 성공적인 네이티브 할당 직후 청소 작업을 등록하고 생성자 끝에서 **Reference.reachabilityFence(this)**를 명시적으로 호출하여 등록이 완료될 때까지 객체가 도달 가능하도록 보장했습니다. 이로 인해 부활 위험과 네이티브 누수를 제거했으며, 메모리 압력 사건을 매일 발생에서 6개월 동안 제로로 줄였습니다.
왜 Cleaner가 WeakReference나 SoftReference가 아닌 PhantomReference를 사용하는가? 이것이 청소 작업이 참조의 상태에 접근하는 것을 어떻게 방지하는가?
PhantomReference가 사용되는 이유는 가비지 수집기가 유령 도달 가능 상태에 진입한 후 프로그램 코드가 참조의 필드나 메소드를 접근하지 못하게 하기 위해서입니다. WeakReference와 달리, get()을 통해 참조에 대한 검색을 허용하는 WeakReference는, PhantomReference는 항상 get()에서 null을 반환하므로, 청소 작업이 logically destroyed와 가정하고 작동하게 하고, 부활 시도나 상태 검사를 방지합니다.
Object.finalize()의 맥락에서 "부활" 공격이란 무엇이며, 이것이 타입 시스템의 안전 보장 위반하는 이유는 무엇인가?
부활은 finalize() 메소드가 this 참조를 정적 필드나 살아있는 객체 그래프에 저장하고, 가비지 수집기가 회수 대상으로 표시한 후 객체를 다시 도달 가능하게 만들 때 발생합니다. 이는 객체의 생성자가 정확히 한 번 실행되고 최종화가 최대 한 번 실행된다는 불변성을 위반하며, 악의적이거나 버그가 있는 코드가 리소스가 해제된 상태에서 Java 필드가 여전히 접근 가능한 객체를 관찰하게 하여 사용 후 해제 취약점과 일관성 없는 객체 동작을 초래하게 됩니다.
Reference.reachabilityFence가 JVM의 즉시 컴파일 최적화와 어떻게 상호작용하며, Cleaner 등록에 사용할 때 언제 엄격히 필요하게 되는가?
Reference.reachabilityFence는 컴파일러 장벽 역할을 하여 JVM의 최적화가 특정 구문이 완료되기 전에 객체 참조를 재정렬하거나 제거하는 것을 방지합니다. 이는 특정 객체가 생성을 수행하는 동안 도달 불가능해지는 "조기 출판"을 방지합니다. 객체가 등록하는 동안 Cleaner와 등록할 때 엄격히 필요합니다. 그렇지 않으면, JVM은 네이티브 리소스 할당 이후 등록 호출 전에 this가 더 이상 필요하지 않다고 판단할 수 있어, 클리너가 실행되고 리소스를 해제하여 생성자가 계속해서 객체를 초기화하는 동안 리소스 손상을 초래할 수 있습니다.