April 08, 2022
자바는 두 가지 객체 소멸자를 제공한다.
finalizer
cleaner
자바의 finalizer와 cleaner는 C++의 파괴자(destructor)와는 다른 개념이다.
finalizer와 cleaner는 즉시 수행된다는 보장이 없다.
finalizer나 cleaner를 얼마나 신속히 수행할지는 전적으로 가비지 컬렉터 알고리즘에 달렸으며, 이는 가비지 컬렉터 구현마다 천차만별이다. finalizer나 cleaner 수행 시점에 의존하는 프로그램의 동작 또한 마찬가지다.
굼뜬 finalizer는 현업에서도 실제로 문제를 일으킨다.
자바 언어 명세는 finalizer나 cleaner의 수행 시점뿐 아니라 수행 여부조차 보장하지 않는다. 접근할 수 없는 일부 객체에 딸린 종료 작업을 전혀 수행하지 못한 채 프로그램이 중단될 수도 있다는 얘기다. -> 프로그램 생애주기와 상관 없는, 상태를 영구적으로 수정하는 작업에서는 절대 finalizer나 cleaner에 의존해서는 안된다. 예) 데이터베이스 같은 공유 자원의 영구 락(lock) 해제
finalizer 동작 중 발생한 예외는 무시되며, 처리할 작업이 남았더라도 그 순간 종료된다. 잡지 못한 예외 때문에 해당 객체는 자칫 마무리가 덜 된 상태로 남을 수 있다. 그리고 다른 스레드가 이처럼 훼손된 객체를 사용하려 한다면 어떻게 동작할지 예측할 수 없다. 그나마 cleaner를 사용하는 라이브러리는 자신의 스레드를 통제하기 때문에 이러한 문제가 발생하지 않는다.
finalizer와 cleaner는 심각한 성능 문제도 동반한다.
finalizer를 사용한 클래스는 finalizer 공격에 노출되어 심각한 보안 문제를 일으킬 수도 있다.
finalizer 공격 원리
finalizer나 cleaner를 대신해줄 묘안
AutoCloseable을 구현해주고, 클라이언트에서 인스턴스를 다 쓰고 나면 close 메서드를 호출하면 된다. (일반적으로 예외가 발생해도 제대로 종료되도록 try-with-resources를 사용해야 한다.)
각 인스턴스는 자신이 닫혔는지를 추적하는 것이 좋다. close 메서드에서 이 객체는 더 이상 유효하지 않음을 필드에 기록하고, 다른 메서드는 이 필드를 검사해서 객체가 닫힌 후에 불렸다면 IllegalStateException을 던지는 것이다.
finalizer와 cleaner의 적절한 쓰임새가 두 가지 있다.
자원의 소유자가 close 메서드를 호출하지 않는 것에 대비한 안정망 역할
네이티브 피어(native peer)와 연결된 객체에서의 역할
네이티브 피어란 일반 자바 객체가 네이티브 메서드를 통해 기능을 위임한 네이티브 객체를 말한다.
cleaner를 사용하지 말지는 순전히 내부 구현 방식에 관한 문제다. 즉, finalizer와 달리 cleaner는 클래스의 public API에 나타나지 않는다.
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("방 청소");
numJunkPiles = 0;
}
}
// 방 상태
private final State state;
private final Cleaner.Cleanable cleanable;
public Room(int numJunkPiles) {
state = new State(numJunkPiles);
cleanable = cleaner.register(this, state);
}
@Override
public void close() {
cleanable.clean();
}
}
static으로 선언된 중첩 클래스인 State는 cleaner가 방을 청소할 때 수거할 자원들을 담고 있다.
State는 Runnable을 구현하고, 그 안의 run 메서드는 cleanable에 의해 딱 한 번만 호출될 것이다.
run 메서드가 호출되는 상황은 둘 중 하나다.
State 인스턴스는 ‘절대로’ Room 인스턴스를 참조해서는 안 된다. Room 인스턴스를 참조할 경우 순환참조가 생겨 가비지 컬렉터가 Room 인스턴스를 회수해갈 (따라서 자동 청소될) 기회가 오지 않는다. -> State가 정적 중첩 클래스인 이유 정적이 아닌 중첩 클래스는 자동으로 바깥 객체의 참조를 갖게 되기 때문이다. 이와 비슷하게 람다 역시 바깥 객체의 참조를 갖기 쉬우니 사용하지 않는 것이 좋다.
Room의 cleaner는 단지 안전망으로 쓰였다. 클라이언트가 모든 Room 생성을 try-with-resources 블록으로 감쌌다면 자동 청소는 전혀 필요하지 않다.
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("아무렴");
}
}
-> “방 청소”는 한 번도 출력되지 않는다. - 예측할 수 없다고 한 상황(청소가 이뤄질지는 보장X)
cleaner(자바 8까지는 finalizer)는 안전망 역할이나 중요하지 않은 네이티브 자원 회수용으로만 사용하자. 물론 이런 경우라도 불확실성과 성능 저하에 주의해야 한다.