finalizer와 cleaner 사용을 피하라
자바는 finalizer
와 cleaner
두 가지로 객체 소멸자를 제공한다. 그 중 finalizer
는 예측할 수 없고, 상황에 따라 위험할 수 있어 일반적으로 불필요하다. cleaner
는 finalizer
보다 덜 위험하지만, 여전히 예측할 수 없고, 느리며 일반적으로 불필요하다. 따라서 Java 9
에서 부터 deprecated(사용 자제) 되었으며, 그 대안으로 cleaner
를 사용한다.
deprecated 관련 문서 : https://docs.oracle.com/javase/9/docs/api/java/lang/Object.html#finalize--
정리에 앞서 상태를 영구적으로 수정하는 작업에서는 절대로 두 가지의 객체 소멸자를 사용하면 안된다. 예를 들어 데이터베이스 같은 공유 자원의 영구 lock
해제를 두 가지 방식으로 사용하면 분산 시스템 전체가 멈출 수 있다. 또한 finalizer
는 예외 발생 시 처리할 작업이 남았더라도 그 순간 종료된다. (스레드를 관리하지 않기 때문에 스택 추적도 불가능, finailzer
공격도 발생)
즉, finalizer
는 사용하면 안되며, 공격에 대비하기 위해 아무 작업도 하지 않는 finalizer
를 상속받고, final
로 선언해주는 것이 안전하다.
두 객체 소멸자를 대신할 방법으론 AutoCloseable
을 구현하고 다 쓰고 나면 close
메서드를 호출하면 된다.
finalizer
finalizer
는 예측할 수 없다는 의미에 대해 알아보자. 본 예제는 deprecated 되기 전인 자바 8 버전에서 수행했다.
public class FinalizeTest {
public static void main(String[] args) {
for(int i = 0; i < 20; i++) {
Finalize test = new Finalize(i);
test = null; // 참조 해제
System.gc(); // GC 호출
}
}
}
class Finalize {
private int num;
public Finalize(int num) {
this.num = num;
}
@Override
protected void finalize() throws Throwable {
System.out.println(num+ "번 finalize 실행");
}
}
/*
출력 결과 - 비순차적이고, 실행이 보장되지 않는다.
4번 finalize 실행
0번 finalize 실행
13번 finalize 실행
16번 finalize 실행
17번 finalize 실행
18번 finalize 실행
19번 finalize 실행
15번 finalize 실행
14번 finalize 실행
11번 finalize 실행
12번 finalize 실행
10번 finalize 실행
*/
finalize()
를 상속 받은 후 실행 단에서 사용하는 객체를 참조 해제(null) 후,System.gc()
로 GC를 실행 시켰다 (객체 소멸자가 실행될 가능성만 높여준다). 결과를 보면 GC가 처리하는 순서는 무작위로 제거되며, 전부 삭제시키는 것이 아닌 메모리의 용량에 따라 소멸 여부를 결정하게 된다.finalizer
는 어떤 스레드가 수행할 지 명시하지 않기 때문에 수 많은finalizer
스레드 들이 대기열에서 회수되기를 기다리는 문제(OutOfMemeryError)
가 발생한다. 이 문제를 해결할 방법이 없어서 자바 9버전에서cleaner
를 통해 자신을 수행할 스레드를 제어할 수 있는 기능을 추가했다. 단, 수행 순서는 여전히 보장할 수 없다.
cleaner
자바 9에서 대안으로 나온 cleaner
는 어떨까? cleaner
를 사용할지는 내부 구현 방식에서 선택의 문제이다. 즉, finailizer
처럼 Override 하는 것이 아닌 구성을 통해 cleaner
를 사용해야 한다.
import java.lang.ref.Cleaner;
public class Room implements AutoCloseable {
private static final Cleaner cleaner = Cleaner.create();
// Room 인스턴스를 참조해서는 순환참조가 발생하므로 State는 static class 정의
private static class State implements Runnable {
int numJunkPiles;
State(int numJunkPiles) {
this.numJunkPiles = numJunkPiles;
}
@Override
public void run() {
System.out.println("방 청소");
numJunkPiles = 0;
}
}
// 방의 상태. cleanable과 공유한다.
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
는Room
인스턴스를 참조해서는 안된다. 그렇게 되면 순환 참조가 발생하여 GC가 회수해갈 기회를 주지 못한다. 따라서 반드시State
를 정적 중첩 클래스로 만들어야 한다.- 방 안의 쓰레기 수를 뜻하는
numJunkPiles
필드가 수거할 자원에 해당 된다. 이 필드는 더 현실적으로는 네이티브 피어를 가리키는 포인터를 담은final long
변수여야 한다. - 네이티브 피어란? C / C++ 등의 어셈블리 프로그램을 컴파일한 기계어 프로그램을 지칭한다. 자바는
JNI
(Java Native Interface) 라이브러리를 사용하여 자바 피어를 실행 시켜 준다. 자바 피어가 로딩될 때System.loadLibrary()
메서드를 호출해서 네이티브 피어를 로딩하고native
키워드를 사용하여 호출하는 방식으로 사용된다.JNI
는 직접 사용할일은 거의 없고 대부분maven central
에서 자바 라이브러리를 가져와 사용한다.
/*
안녕~ 출력 후 Room Clean 출력
*/
class Adult {
public static void main(String[] args) {
try (Room myRoom = new Room(7)) {
System.out.println("안녕~");
}
}
}
- 다음과 같이
try-with-resources
블록으로Room
생성을 감싸서 처리하면 자동 청소는 전혀 필요하지 않게 된다.
class Adult {
public static void main(String[] args) {
new Room(99);
System.out.println("아무렴");
}
}
- 하지만 다음의 경우에는 청소가 이루어지지 않는다.
cleaner
동작이System.exit()
을 호출 할때 실행될지는 보장하지 않는다.
finalizer
와cleaner
는 결국 자원의 소유자가close
메서드를 호출하지 않는 것에 대비한 안전망 역할이다. 하지만 즉시 호출되리라는 보장은 없다. 따라서 단순한 안전망 역할이나 중요하지 않은 네이티브 자원 회수용으로만 사용하자. 물론 해당 경우에도 불확실성과 성능 저하에 주의해야 한다.
REFERENCES