[Effective Java] finalizer와 cleaner 사용을 피하라

finalizer와 cleaner

finalizer는 예측할 수 없고, 상황에 따라 위험할 수 있어 일반적으로 불필요하다. 나름의 쓰임새가 몇 가지 있긴 하지만 기본적으로 쓰지 말아야 한다. 그래서 자바 9부터는 deprecated 되었다. 대신 그 대안으로 cleaner를 제공한다. cleaner는 finalizer보다는 덜 위험하지만 여전히 예측할 수 없고 느리고 일반적으로 불필요하다.

C++ 개발자의 입장에서 보는 destructor와 finalizer, cleaner는 다른 개념이다. destructor는 constructor의 대척점으로 특정 객체와 관련된 비 메모리 자원을 회수하는 방법이다. 하지만 자바는 resources와 try-finally를 사용해 해결한다.

finalizer와 cleaner는 즉시 수행된다는 보장이 없다. 그렇기 때문에 파일 닫기와 같은 작업을 finalizer와 cleaner로 수행하면 프로그램이 실패할 수 있다. finalizer와 cleaner를 수행하는 것은 전적으로 GC에 달려있다. finalizer 스레드는 다른 어플리케이션 스레드보다 우선순위가 낮아서 실행될 기회를 제대로 얻지 못할 수도 있다. 게다가 어떤 스레드가 finalizer를 수행할 지 명시하지 않으니 예방할 보편적인 해법도 없다. cleaner는 자신을 수행할 스레드를 제어할 수 있다는 면에서 조금 낫다. 하지만 여전히 백그라운드에서 GC의 통제하에 수행되니 즉각 수행된다는 보장은 없다.

finalizer와 cleaner는 수행 시점뿐 아니라 수행 여부조차 보장하지 않는다. 접근할 수 없는 일부 객체에 딸린 종료 작업을 전혀 수행하지 못한 채 프로그램이 중단될 수도 있다는 이야기다. 따라서 프로그램 생애주기와 상관없는 상태를 영구적으로 수정하는 작업에서는 절대 finalizer와 cleaner에 의존해서는 안된다. 예를 들어 데이터베이스의 lock이 그렇다.

System.gc나 System.runFinalization 메소드에 현혹되지 말자. 실행 가능성을 높혀줄 뿐이지 반드시 실행하지는 않는다. 바로 실행을 시켜주는 메소드가 있긴 하나 ThreadStop의 문제가 있다.

finalizer 동작 중 발생한 예외는 무시되며 처리할 작업이 남았더라도 종료된다. 그렇기 때문에 스택 추적도 힘들다.

성능 문제도 동반한다. finalizer는 GC의 호율을 떨어뜨린다.

해법은 AutoCloseable을 구현하고 인스턴스를 다 쓰면 close 메소드를 호출하면 된다. 각 인스턴스는 자신이 닫혔는지를 추적하는 것이 좋다. close 메소드에서 이 객체는 더 이상 유효하지 않음을 필드에 기록하고 다른 메소드는 이 필드를 검사해서 객체가 닫힌 후에 불렸다면 IllegalStateException을 던지는 것이다.

그럼 finalizer와 cleaner는 언제 쓸까? 첫째는 자원의 소유자가 close 메소드를 호출하지 않는 것에 대한 안전망 역할이다. 즉시 호출의 보장은 없지만 아예 자원을 회수하지 않는것보단 낫다. 두번째는 네이티브 피어와 연결된 객체에서다. 네이티브 피어는 외부 객체라 GC는 그 존재를 알지 못한다. 그렇기 때문에 자바 피어를 회수할 때 까지 네이티브 객체를 회수하지 못한다. 단 이 경우는 성능 저하를 감수하고 네이티브 피어가 심각한 자원을 가지고 있지 않을때이다. 그게 아니라면 close 메소드를 사용해야 한다.

cleaner를 안전망으로 사용하는 코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class Room implements AutoCloseable {
private static final Cleaner cleaner = Cleaner.create();

// 청소가 필요한 자원. 절대 Room을 참조해서는 안 된다!
private static class State implements Runnable {
int numJunkPiles; // 방(Room) 안의 쓰레기 수

State(int numJunkPiles) {
this.numJunkPiles = numJunkPiles;
}

// close 메서드나 cleaner가 호출된다.
@Override
public void run() {
System.out.println("방 청소");
numJunkPiles = 0;
}
}

// 방의 상태. cleannable과 공유한다.
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 메소드가 호출되는 상황은 둘 중 하나다. 보통은 close 메소드를 호출할 때다. close 메소드에서 Cleanble의 clean을 호출하면 이 메소드 안에서 run을 호출한다. 혹은 GC가 Room을 회수할 때 까지 클라이언트가 close를 호출하지 않는다면 cleaner가 State의 run 메소드를 호출 해준다.

static 중첩 클래스 인스턴스는 절대로 Room 인스턴스를 참조해서는 않된다. 순환 참조가 생겨 Room인스턴스를 회수해갈 기회가 오지 않는다.

Author: Song Hayoung
Link: https://songhayoung.github.io/2020/08/06/Languages/Effective%20JAVA/item8/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.