자바에서 제공하는 두가지 객체 소멸자인 Finalizer, Cleaner을 왜 지양해야 하는지 알아보자. 참고로 Finalizer
는 자바 9 에서는 Deprecated
되었고, Cleaner
가 나왔지만 역시 예측 불가능하고 느리며 불필요하다.
@Deprecated(since="9")
protected void finalize() throws Throwable { }
그렇다면, 왜 사용을 피해야 하는지 다양한 관점에서 알아보자.
finalizer
와 cleaner
는 즉시 수행된다는 보장이 없기 때문에, 제때 실행되어야 하는 작업은 절대 할 수 없다.finalizer
스레드가 다른 어플리케이션 스레드보다 우선 순위가 낮아서 실행될 기회를 제때 얻지 못해 OutOfMemoryError
을 내며 죽을 수도 있기 때문이다.
예를 들어, 파일 닫기처럼 동시에 열 수 있는 파일 개수에 한계가 있는 경우 사용을 피해야 한다.
위처럼 수행 시점뿐만 아니라, 수행 여부조차 보장하지 않는다.
따라서 프로그램 생애주기와 상관없는, 상태를 영구적으로 수정하는 작업에서는 finalizer
나 cleaner
에 의존해서는 안된다. 예를 들어, 데이터베이스 같은 공유 자원의 영구 lock
해제를 맡겨 놓으면 분산 시스템 전체가 서서히 멈추게 될 것이다.
try-with-resources
로 자신을 닫도록 하여 가비지 컬렉터가 수거하는 것보다, finalizer와 cleaner을 사용한 객체를 생성하고 파괴하는것이 훨씬 더 비효율적이다.
finalizer
를 사용한 클래스는 finalizer
공격에 노출되어, 심각한 보안 문제를 일으킬 수 있다.
🔖 finalizer 공격 문제
생성자나 직렬화 과정(아이템 12)에서 예외가 발생하면, 생성되다 만 객체에서 악의적인 하위 클래스의 finalizer가 수행될 수 있게 된다. 예를 들어, finalizer가 정적 필드에 자신의 참조를 할당하여 가비지 컬렉터가 수집하지 못하게 막을 수 있다.
final인 클래스는 하위 클래스 생성이 불가능하니 공격으로부터 안전한 반면, final이 아닌 클래스를 finalizer 공격으로부터 방어하려면 아무 일도 하지 않는 finalize 메서드를 만들고 final로 선언해야 한다.
finalizer
와 cleaner
대신, AutoCloseable
를 구현하고 클라이언트에서 인스턴스를 다 쓰고 나면 close
메서드를 호출할 수 있다. try-with-resources
를 사용하면 자동으로 안전하게 close
메서드가 호출된다.
또한 각 인스턴스는 자신이 제대로 닫혔는지 추적하는 것이 좋다.
즉, close
메서드에서 객체가 유효하지 않음을 필드에 기록하고, 다른 메서드가 이 필드를 검사해서 객체가 닫힌 후에 불렸다면 IllegalStateException
을 던지는 것이다.
자원의 소유자가 close 메서드를 호출하지 않는 것에 대비하는 안전망 역할을 할 수 있다. 아예 안하는 것보다는 호출이 늦게라도 되는 것이 낫기 때문이다.
대표적으로 FileInputStream
, FileOutputStream
, ThreadPoolExcutor
에서는 안전망 역할의 finalizer를 제공하고 있다.
public class ThreadPoolExecutor extends AbstractExecutorService {
...
@Deprecated(since="9")
protected void finalize() {}
}
네이티브 피어란, 일반 자바 객체가 네이티브 메서드를 통해 기능을 위임한 네이티브 객체를 말한다. 자바 객체가 아니니, 가비지 컬렉터는 네이티브 객체를 회수 할 수 없다. 이때 cleaner
와 finalizer
가 이 역할을 담당 할 수 있다.
단, 성능 저하를 감당 가능하고 네이티브 피어가 심각한 자원을 가지고 있지않을 경우에만 사용해야 한다.
cleaner
를 안전망으로 활용하는 Autocloseable
클래스의 예시이다.
public class Room implements AutoCloseable {
private static final Cleaner cleaner = Cleaner.create();
// 청소가 필요한 자원. 절대 Room을 참조해서는 안 된다!
private static class State implements Runnable {
int numJunkPiles; // 방 안의 쓰레기 수
State(int numJunkPiles) {
this.numJunkPiles = numJunkPiles;
}
// close 메서드나 cleaner가 호출한다.
@Override public void run() {
System.out.println("Cleaning room");
numJunkPiles = 0;
}
}
// 방의 상태. cleanable과 공유한다.
private final State state;
// cleanable 객체. 수거 대상이 되면 방을 청소한다.
private final Cleaner.Cleanable cleanable;
public Room(int numJunkPiles) {
state = new State(numJunkPiles);
cleanable = cleaner.register(this, state);
}
@Override public void close() {
cleanable.clean();
}
}
run()
메서드는 다음의 두가지 상황에서 호출되게 된다.
Room
에서 close
메서드를 호출한 경우Room
을 회수할때까지 클라이언트가 close
를 호출하지 않아 cleaner
가 직접 run
을 호출한 경우주의할 점은, State 인스턴스는 Room 인스턴스를 절대로 참조하면 안된다는 것이다.
만약 위 예시처럼 정적 클래스가 아닌 그냥 중첩 클래스였다면 내부 클래스는 바깥 객체의 참조를 가지게 된다. 이러한 경우, 바깥 클래스가 사용되지 않는데도 참조 객체라고 판단해 가비지 컬렉터가 Room
인스턴스를 회수하지 못하기 때문에 문제가 된다.
람다 역시 바깥 객체의 참조를 갖기 쉬우니 사용하지 않는 것이 좋다.
public class Adult {
public static void main(String[] args) {
try (Room myRoom = new Room(7)) {
System.out.println("안녕~");
}
}
}
해당 코드는 안녕
출력 이후, 이어서 방 청소
가 출력된다.
public class Teenager {
public static void main(String[] args) {
new Room(99);
System.out.println("Peace out");
// 다음 줄의 주석을 해제한 후 동작을 다시 확인해보자.
// 단, 가비지 컬렉러를 강제로 호출하는 이런 방식에 의존해서는 절대 안 된다!
// System.gc();
}
}
해당 코드는 방 청소
가 한번도 출력되지 못한다. System.exit
을 호출하거나 일반적인 프로그램 종료에서의 cleaner 동작은 구현하기 나름이며, 따라서 청소가 이뤄질지는 보장하지 못하기 않고 있기 때문이다.
🔖 핵심 정리
cleaner(자바 8까지는 finalizer)는 안전망 역할이나 중요하지 않은 네이티브 자원 회수용으로만 사용하자. 물론 이런 경우라도, 불확실성과 성능 저하에 주의해야 한다.