자바는 finalizer
와 cleaner
라는 두 가지 객체 소멸자를 제공한다. 그 중 finalizer
는 예측할 수 없고, 상황에 따라 위험할 수 있어 일반적으로 불필요하다. 오동작, 낮은 성능, 이식성 문제의 원인이 되기도 한다. 자바 9에서는 finalizer
는 deprecated
됐고 대안으로 cleaner
라는게 새로 생겼다. cleaner
는 finalizer
보다는 덜 위험하지만, 여전히 예측할 수 없고, 느리고, 일반적으로 불필요하다.
C++의 파괴자(destructor)와는 다른 개념이다. C++에서의 파괴자는 특정 객체와 관련된 자원을 회수하는 보편적인 방법이다. 하지만 자바에서는 접근 불가능 객체를 회수하는 역할을 가비지 컬렉터가 담당한다. C++의 파괴자는 비메모리 자원을 회수하는 용도로도 쓰인다. 하지만 자바에서는 try-with-resources
와 try-finally
를 사용한다.
finalizer
와 cleaner
는 즉시 수행된다는 보장이 없다. 객체에 접근할 수 없게 된 후 finalizer
와 cleaner
가 실행되기까지 얼마나걸릴지 알 수 없다. 즉, finalizer와 cleaner는 제때 실행되어야 하는 작업은 절대 할 수 없다. 예를 들어, 파일 닫기를 finalizer
와 clenar
에 맡기면 중대한 오류를 일으킬 수 있다. 시스템이 동시에 열 수 있는 파일 개수에도 제한이 있고, finalizer
와 cleaner
가 언제 실행될지 알 수 없기 때문이다. 만약 게으르게 작동한다면 새로운 파일을 열지 못하는 오류가 발생할 수 있다.
finalizer
는 인스턴스 자원 회수를 지연시킬 수도 있다. finalizer
스레드는 우선 순위가 낮아서 실행될 기회를 제대로 얻지 못할 수 있다. 자바 언어 명세는 어떤 스레드가 finalizer
를 수행할지 명시하지 않으니 이 문제를 예방할 보편적인 방법은 finalizer
를 사용하지 않는 것이다. 한편, cleaner
는 자신을 수행할 스레드를 제어할 수 있다는 면에서 조금 낫다. 하지만 여전히 백그라운드에서 수행되며 가비지 컬렉터의 통제하에 있으니 즉각 수행될 보장은 없다.
finalizer
와 cleaner
가 수행이 지연되는 것보다 아에 수행되지 않을 수도 있다는 것을 명심해야 한다. 접근할 수 없는 일부 객체에 딸린 종료 작업을 전혀 수행하지 못한 채 프로그램이 중단될 수도 있다는 얘기다. 따라서 프로그램 생애주기와 상관없는, 상태를 영구적으로 수정하는 작업에서는 절대 finalizer나 cleaner에 의존해서는 안된다. 예를 들어 데이터베이스 같은 공유 자원의 영구 락 해제를finalizer
나 cleaner
에 맡겨 놓으면 분산 시스템 전체가 서서히 멈출 것이다.
System.gc
나 System.runFinalization
에 속지 말라. 그걸 실행해도 finalizer
나 cleaner
를 바로 실행한다고 보장하진 못한다. 그걸 보장해주겠다고 만든 System.runFinalizersOnExit
와 그 쌍둥이 Runtime.runFinalizersOnExit
은 둘다 망했고 수십년간 deprecated
상태다.
finalizer
동작 중 발생한 예외는 무시되며, 처리할 작업이 남았더라도 그 순간 종료된다. [JLS, 12.6]
잡지 못한 예외 때문에 해당 객체는 자칫 마무리가 덜 된 상태로 남을 수 있다. 그리고 다른 스레드가 이처럼 훼손된 객체를 사용하려 한다면 어떻게 동작할지 예측할 수 없다. 보통의 경우엔 잡지 못한 예외가 스레드를 중단시키고 스택 추적 내역을 출력하겠지만, 같은 일이 finalizer
에서 일어난다면 경고조차 출력하지 않는다. 그나마 cleaner
를 사용하는 라이브러리는 자신의 스레드를 통제하기 때문에 이러한 문제가 발생하지 않는다.
finalizer
와 cleaner
는 심각한 성능 문제도 동반한다. AutoCloseable
객체를 생성하고 가바지 컬렉터가 수거하기까지 12ms가 걸린다. 반면, finalizer
를 사용하면 550ms가 걸린다. 약 50배가 더 걸린다. cleaner
의 경우에도 66ns가 걸리는데 5배가 더 걸렸다.
finalizer
를 사용한 클래스는 finalizer
공격에 노출되어 심각한 보안 문제를 일으킬 수 있다. finalizer
공격 원리는 간단하다. 한 클래스가 있고 그 클래스를 공격하려는 클래스가 해당 클래스를 상속 받는다. 그리고 그 악의적인 클래스의 인스턴스를 생성하는 도중에 예외가 발생하거나, 직렬화할 때 예외가 발생하면, 원래는 죽어야할 객체의 finalizer
가 실행될 수 있다. 그럼 그 그안에서 해당 인스턴스의 레퍼런스를 기록할 수도 있고, 가비지 컬렉터가 수집하지 못하게 막을 수 있다. 또한 그 객체의 메서드를 호출해 애초에는 허용되지 않았을 작업을 수행할 수도 있다.
객체 생성을 막으려면 생성자에서 예외를 던지는 것만으로 충분하지만, finalizer
가 있다면 그렇지도 않다. 원래는 생성자에서 예외가 발생해서 존재하질 않았어야 하는 인스턴스인데 finalizer
때문에 살아 남은 것이다.
final
클래스는 그 누구도 상속으로 하위 클래스를 만들 수 없다. 그러므로 finalizer
공격에서 안전하다. final
이 아닌 클래스에서 finalizer
공격으로부터 방어하려면 아무 일도하지 않는 finalizer
메서드를 만들고 final
선언을 해서 오버라이딩을 막을 수 있다.
그렇다면 파일이나 스레드 등 종료해야할 자원을 담고 있는 객체의 클래스에서 finalizer
와 cleaner
를 대신해줄 묘안은 무엇일까? 그냥 AutoCloseable
을 구현해주고, 클라이언트에서 인스턴스를 다 쓰고 나면 close()
메서드를 호출하면 된다. 구체적인 구현법과 관련하여 알아두면 좋은게 있다. 각 인스턴스는 자신이 닫혀는지를 추척하는 것이 좋다. 다시 말해, close()
메서드에서 이 객체는 더 이상 유효하지 않음을 필드에 기록하고, 다른 메서드는 이 필드를 검사해서 객체가 닫힌 후에 불렸다면, IllegalStateException
을 던지는 것이다.
이쯤이면 finalizer
와 cleaner
는 대체 어디에 쓰는 물건인지 궁금해진다. 적절한 쓰임새는 (아마도) 두 가지 있다.
하나는 자원의 소유자가 close()
메서드를 호출하지 않는 것에 대비한 안전망 역할이다. finalizer
와 cleaner
가 즉시(혹은 끝까지) 호출되리라는 보장은 없지만, 클라이언트가 하지 않은 자원 회수를 늦게라도 해주는 것이 아에 안 하는 것보다 나으니 말이다. 이런 안전망 역할의 finalizer
를 작성할 때는 그럴만한 값어치가 있는지 심사숙고해야 한다.
실제로 자바에서 제공하는 FileInputStream
, FileOutputStream
, ThreadPoolExecuter
는 안전망 역할의 finalizer
를 제공한다.
finalizer
와 cleaner
의 적절한 활용 두 번째 예는 네이티브 피어와 연결된 객체이다.
자바 클래스 -> 네이티브 메소드 호출 -> 네이티브 객체(native peer)
네이티브 피어는 자바 객체가 아니라서 가비지 컬렉터가 회수를 하지 못한다. 그 결과 자바 피어를 회수할 때 네이티브 객체까지 회수하지 못한다. finalizer
와 cleaner
로의 처리가 적절한 때이다. 단, 성능 저하를 감당할 수 있고 네이티브 피어가 중요한 자원을 가지고 있지 않을 때에만 해당된다. 성능 저하를 감당할 수 없거나 네이티브 피어가 사용하는 자원을 즉시 회수해야 한다면 앞서 설명한 close()
메서드를 사용해야 한다.
public class CleanerSample implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
private final CleanerRunner cleanerRunner;
private final Cleaner.Cleanable cleanable;
public CleanerSample() {
cleanerRunner = new CleanerRunner();
cleanable = CLEANER.register(this, cleanerRunner);
}
@Override
public void close() {
cleanable.clean();
}
public void doSomething() {
System.out.println("do it");
}
private static class CleanerRunner implements Runnable {
// TODO 여기에 정리할 리소스 전달
@Override
public void run() {
// 여기서 정리
System.out.printf("close");
}
}
}
static
으로 선언된 중첩 클래스인 CleanerRunner
는 cleaner
가 방을 청소할 때 수거할 자원들을 담고 있다. CleanerRunner
는 Runnable
을 구현하고, 그 안의 run()
메서드는 cleanable
에 의해 딱 한번 호출된다. cleanable
객체는 CleanerSample
생성자에서 cleaner
에 CleanerSample
과 CleanerRunner
를 등록할 때 얻는다. run
메소드가 호출되는 상황은 둘 중 하나다. 보통은 CleanerSample
의 close
메서드를 호출할 때다. close()
메서드에서 Cleanable
의 clean
을 호출하면 이 메서드 안에서 run
을 호출한다. 혹은 가바지 컬렉터가 CleanerSample
을 회수할 때까지 클라이언트가 close()
를 호출하지 않으면, cleaner
가 CleanerRunner
의 run
메서드를 호출해 줄 것이다.
Cleaner
스레드는(CleanerRunner
)는 정리할 대상인 인스턴스(cleanerSample
)을 참조하면 안된다. 순환 참조가 생겨서 가비지 컬렉터의 대상이 되질 못한다. Cleaner
스레드를 만들 클래스는 반드시 static
클래스이어야 한다. static
이 아닌 중첩 클래스는 자동으로 바깥 객체의 참조를 갖게 되기 때문이다. 이와 비슷하게 람다 역시 바깥 객체의 참조를 갖기 쉬우니 사용하지 않는 것이 좋다.
앞서 이야기한 대로 CleanerSample
의 cleaner
는 단지 안전망으로만 쓰였다. 클라이언트가 모든 CleanerSample
생성을 try-with-resoureces
블록으로 감쌌다면 자동 청소는 전혀 필요하지 않다. 예시 코드는 아래와 같다.
public class Main {
public static void main(String[] args) throws InterruptedException{
//try with
//이상적인 자원 반납 방법
try (CleanerSample cleanerSample=new CleanerSample()){
sampleResource.hello();
}
}
}