- Java에는 두 가지 객체 소멸자 finalizer & cleaner 가 있다.
- 그러나 이 소멸자는 사용을 피해야하는 여러 이유가 있다.
- 제때 실행되어야 하는 작업을 절대 할 수 없다.
- 이 둘이 얼마나 신속히 수행될지는 전적으로 가비지 컬렉터 알고리즘에 따라 다르다. (GC마다 천차만별)
- 특히, 클래스에 finalizer를 달면, 그 인스턴스의 자원 회수가 제멋대로 지연될 수 있다.
- ex. finalizer 스레드가 다른 애플리케이션 스레드보다 우선 순위가 낮아서 계속 기다리는 기아 현상
- 이에 비해 cleaner는 자신을 수행할 스레드를 제어할 수 있다.
- 그러나 백그라운드에서 수행한다는 점, GC 통제 하에 있다는 점이 여전히 즉각 수행된다는 보장이 없다.
- 접근할 수 없는 일부 객체에 딸린 종료 작업을 전혀 수행하지 못하고 프로그램이 중단될 수도 있다.
- 이를 풀어서 설명하면,
- 어떤 자원을 열어 사용하는 객체가 있다고 하자. 이 객체에 finalize 또는 cleaner 를 등록하여 자원 해제를 명시했다.
- 그러나, 객체가 더 이상 참조되지 않아 GC 대상이 되면, finalizer, cleaner가 실행되지 못한 채 Heap 메모리에서 삭제될 수 있다.
- 결과적으로, 열어놓은 자원 해제가 이루어지지 않고 프로그램이 종료되는 위험한 상황이 발생한다.
- 따라서, 프로그램 생애 주기와 상관 없는(외부 상태나 공유 자원 등) 상태를 영구적으로 수정하는 작업에서는 절대 이 두 가지에 의존하면 안된다.
AutoCloseable을 통해 GC가 수거하는 시간과 finalizer를 사용하는 시간을 비교하면 차이가 크다.- finalizer가 GC의 효율을 떨어뜨리기 때문이다.
- cleaner도 클래스의 모든 인스턴스를 수거하는 형태로 사용한다면 finalizer와 성능이 유사하다. (안좋음)
- 만약, 안전망 방식을 사용하면 속도가 많이 개선되나, 역시
AutoCloseable을 사용했을 때보다 성능이 약 5배 정도 느려진다.
이 성능 문제를 파고들려면, finalizer와 cleaner의 동작 매커니즘에 대해 알아야한다.
| 소멸자 | 역할 |
|---|---|
finalizer |
객체가 GC 대상이 될 때 JVM이 finalize() 메서드를 호출하는 메커니즘 (오버라이딩 가능) |
cleaner |
Java 9+에서 제공. 별도 스레드로 객체 수거 시 "클린업 작업" 실행하는 더 개선된 방식 |
-
finalizer가 GC에 방해되는 이유
- GC는 참조가 끊긴 객체를 바로 회수하는게 이상적이나, finalizer 때문에 GC가 객체를 바로 지우지 못한다.
- 따로,
Finalizer Queue에 넣고,finalize()가 호출될 때까지 대기해야 한다. - 즉, 바로 GC로 제거되지 않아 GC 사이클을 늘린다.
- 또한, 대기하는 동안 객체는 GC가 삭제하지 못하고 계속 살아 있게 된다.
- 이 상태가 반복되면, GC의 판단 하에 "오래 살아남는 객체"로 판단되어 → Old Generation으로 승격되기도 한다.
- Old 영역에 가면 GC가 덜 일어나고, 수거 비용도 훨씬 비싸기 때문에 GC의 리듬을 깨고, 성능과 메모리 압박만 가중되는 것이다.
-
cleaner의 별도 스레드 할당으로 인한 성능 저하
- 내부적으로
PhantomReference와ReferenceQueue를 사용하여 객체가 완전히 수거될 시점에, 등록된 cleanup 작업을 실행한다. - 이 작업은 별도의 CleanerThread가 처리하기 때문에, 병렬 처리를 위한 CPU 리소스를 더 사용한다.
- 또한, 많은 객체가 cleaner에 등록되면 별도 스레드의 병렬 처리가 많아져, 역시 성능 저하가 발생한다.
- 내부적으로
- finalizer 공격
- 어떤 클래스가 생성자 중간에 예외를 던져서 생성이 되다가 말아도, GC가 그 객체를 수거하면서
finalize()를 호출할 수 있다. - 그때 악의적인 하위 클래스가
finalize()를 override해서this를 저장하거나 다시 resurrect(부활)시킬 수 있다. - 결과적으로, 불완전하거나 유효하지 않은 객체가 외부로 노출된다.
- 어떤 클래스가 생성자 중간에 예외를 던져서 생성이 되다가 말아도, GC가 그 객체를 수거하면서
final이 아닌 클래스를 finalizer 공격으로 방어하려면, 빈 finalize 메서드를 만들고final로 선언하면 된다.
AutoCloseable을 구현하고, 클라이언트에서 인스턴스를 다 쓰고 나면close메서드를 호출하는 방법이다.- Item-09에서 언급된
try-with-resource를 사용하는 것이 좋다.
- 위에서 보았던 수많은 단점들로 인해, 이 두가지에 소멸을 의존하는 기능의 코드를 짜선 안된다.
- 우리는 이를 최후 안전망으로 사용할 수 있다. (자원 소유자가
close를 제대로 안했을 때에 대비한 안전망) - 또한, 이런 안전망이 항상 필요한 것은 아니므로 사용할만한 값어치가 있는지 고민해야 한다.
- Native Peer란, 일반 자바 객체가 네이티브 메서드를 통해 기능을 위임한 네이티브 객체를 말한다.
- Native Peer는 자바 객체가 아니기 때문에 GC가 존재를 모르고, 관리 대상도 아니다.
- 따라서, 이런 네이티브 자원을 회수할 때 이 두 가지를 활용할 수 있다.
- 단, 성능 저하를 감당할 수 있고 Native Peer가 심각한 자원을 가지고 있지 않을 때에만 해당한다. (바로 회수할 필요 없을 때)
- 즉시 회수해야 하는 자원은
close를 사용해야 한다.
- cleaner(Java 8까지는 finalizer)는 안전망 역할이나 중요하지 않은 네이티브 자원 회수용으로만 사용하자.
- 물론, 위에서 언급한 불확실성과 성능 저하에 주의해야한다.