결론
finalizer와 cleaner은 자원 해제 시 사용하는 객체입니다.
두 객체 모두 불확실성, 성능 저하 등 많은 문제를 일으키므로 사용을 피하고, 대신 try-with-resources 로 자원으로 자원을 해제할 것을 권장합니다.
하지만, cleaner(자바 8까지는 finalizer)는 안전망 역할이나 중요하지 않은 네이티브 자원 회수용으로 사용할 수도 있습니다. 물론 이런 경우라도 불확실성과 성능 저하에 주의해야 합니다.
설명
자바는 두 가지 객체 소멸자를 제공합니다. finalizer와 cleaner입니다.
finalizer는 많은 문제의 원인이 되므로 자바 9에서 finalizer를 사용 자제(deprecated) API로 지정하고,
cleaner를 그 대안으로 소개했습니다. (하지만 자바 라이브러리에서도 finalizer를 여전히 사용합니다.)
cleaner는 finalizer보다는 덜 위험하지만, 여전히 많은 문제를 일으킵니다.
finalizer와 cleaner의 사용을 피해야 하는 이유에 대해 알아보겠습니다.
이유 1: 즉시 수행될 보장이 없다.
객체 사용을 끝낸 후, 객체를 소멸하기 위해 finalizer나 cleaner가 실행되기까지 얼마나 걸릴지 알 수 없습니다.
ex) 파일 닫기
시스템이 finalizer나 cleaner 실행을 게을리해서 파일을 계속 열어둔다면, 시스템이 동시에 열 수 있는 파일의 개수가 한계가 있기 때문에 새로운 파일을 열지 못해 프로그램이 실패할 수 있습니다.
ex) finalizer나 cleaner 수행 시점에 의존하는 프로그램
finalizer와 cleaner를 얼마나 신속히 수행할지는 전적으로 가비지 컬렉터 알고리즘에 달렸으며, 이는 가비지 컬렉터 구현마다 천차만별입니다.
따라서, 여러분이 테스트한 JVM에서는 완벽히 동작하던 프로그램이 가장 중요한 고객의 시스템에서는 엄청난 재앙을 일으킬지도 모릅니다.
ex) GUI 애플리케이션, OutOfMemoryError - 회수 지연
꿈뜬 finalizer 처리 덕분에 현업에서도 문제를 일으킵니다. 원인을 알 수 없는 OutOfMemoryError를 발생하며 죽는 GUI 애플리케이션을 디버깅해보니, 죽는 시점에 그래픽스 객체 수천 개가 finalizer 대기열에서 회수되기만을 기다리고 있었습니다. 불행히도 finalizer 스레드는 다른 애플리케이션 스레드보다 우선순위가 낮아서 실행될 기회를 제대로 얻지 못한 것입니다.
이 문제의 해결방법은 딱 하나, finalizer를 사용하지 않는 방법뿐입니다.
반면, cleaner는 자신을 수행할 스레드를 제어할 수 있다는 점에서 조금 낫지만, 여전히 백그라운드에서 수행되며 가비지 컬렉터의 통제하에 있으므로 즉각 수행되리라는 보장은 없습니다.
이유 2: 수행 여부조차 보장하지 않는다.
접근할 수 없는 일부 객체에 딸린 종료 작업을 전혀 수행하지 못한 채 프로그램이 중단될 수도 있습니다.
따라서, 프로그램 생애주기와 상관없이 명시적으로 종료되어야 하는 작업에서는 절대 finalizer와 cleaner에 의존해서는 안 됩니다.
ex) 데이터베이스와 같은 영구자원의 영구 락(lock) 해제
해당 해제를 finalizer나 cleaner에게 맡겨 놓으면 분산 시스템 전체가 서서히 멈출 것입니다.
이유 3: 심각한 성능 문제를 동반한다.
- 간단한 AutoCloseable 객체를 생성하고 가비지 컬렉터가 수거하기까지 걸린 시간 : 12ns
(try-with-resources로 자신을 닫도록 했다.) - finalizer 사용 : 550ns (50배 증가)
다시 말해 finalizer를 사용한 객체를 생성하고 파괴하니 50배나 성능이 느려졌습니다.
finalizer가 가비지 컬렉터의 효율을 떨어트리기 때문입니다.
- cleaner를 클래스의 모든 인스턴스를 수거하는 형태로 사용 : 500ns (finalizer와 비슷)
- cleaner를 안전망 형태로 사용 : 객체 하나를 생성, 정리, 파괴하는 데 약 66ns
(안전망을 설치하는 대가로 성능이 5배만 느려졌다.)
참고 AutoCloseable 클래스, try-with-resource
try-with-resources는 try(...) 문에서 선언된 객체들에 대해서 try가 종료될 때 자동으로 자원을 해제해주는 기능입니다. 주로 외부 자원인 파일 관련 객체와 socket Handler 객체와 같은 자원들은 try-catch-finally 문을 사용하여 마지막에 다 사용한 자원을 해제하는 코드를 많이 보았을 것입니다.
AutoCloseable은 try에 선언된 객체가 AutoCloseable을 구현했다면 Java는 try구문이 종료될 때, 해당 객체의 close() 메서드를 호출하여 할당된 자원을 해제합니다.
finalizer 부작용 1: 예외 무시
finalizer 동작 중 발생한 예외는 무시되며, 처리할 작업이 남았더라도 그 순간 종료됩니다.
잡지 못한 예외때문에, 해당 객체는 자칫 마무리가 덜 된 상태로 남을 수 있습니다. 그리고 다른 스레드가 이처럼 훼손된 객체를 사용하려 한다면 어떻게 동작할지 예측할 수 없습니다.
보통의 경우엔 잡지 못한 예외가 스레드를 중단시키고 스택 추적 내역을 출력하겠지만, 같은 일이 finalizer에서 일어난다면 경고조차 출력하지 않습니다.
그나마 cleaner를 사용하는 라이브러리는 자신의 스레드를 통제하기 때문에 이러한 문제가 발생하지 않습니다.
finalizer 부작용 2: finalizer 공격에 노출되어 심각한 보안 문제를 일으킬 수 있다.
finalizer 공격 원리는 간단합니다.
- 생성자나 직렬화 과정에서 예외가 발생한다면, 이 생성되다 만 객체에서 악의적으로 하위 클래스의 finalizer가 수행되도록 한다.
- 이 finalizer는 정적 필드에 자신의 참조를 할당하여 가비지 컬렉터가 수집하지 못하게 막을 수 있다.
- 이렇게 일그러진 객체가 만들어지고 나면, 이 객체의 메서드를 호출해 애초에는 허용되지 않았을 작업을 수행하는 건, 일도 아니게 된다.
객체 생성을 막으려면 생성자에서 예외를 던지는 것만으로 충분하지만, finalizer가 있다면 그렇지 않습니다. 이러한 공격은 끔찍한 결과를 초래할 수 있습니다.
final이 아닌 클래스를 finalizer 공격으로부터 방어하려면 아무 일도 하지 않는 finalize 메서드를 만들고 final로 선언해야 합니다.
참고: System.gc나 System.runFinalization 메서드에 현혹되지 말자.
두 메서드는 finalizer나 cleaner가 실행될 가능성을 높여줄 수는 있으나, 보장해주진 않습니다.
사실 이를 보장해주는 2개의 메서드, 즉 System.runFinalizerOnExit와 Runtime.runFinalizerOnExit 가 있으나, 이 두 메서드는 ThreadStop이라는 심각한 결합때문에 수십 년간 지탄받아 왔으므로 사용하면 안됩니다.
해결책: AutoCloseable 구현 , close 메서드 호출
파일이나 스레드 등 종료해야할 자원을 담고 있는 객체라면 AutoCloseable을 구현해주고, 인스턴스를 다 쓰고 나면 close 메서드를 호출함으로써 자원을 안전하게 해제할 수 있습니다.
일반적으로 예외가 발생해도 제대로 종료되도록 try-with-resources를 사용해야 합니다.
또한, 각 인스턴스는 자신이 닫혔는지를 추적하는 것이 좋습니다. 다시 말해, close 메서드에서 이 객체는 더 이상 유효하지 않음을 필드에 기록하고, 다른 메서드는 이 필드를 검사해서 객체가 닫힌 후에 불렸다면 IllegalStateException 을 던지는 것입니다.
finalizer와 cleaner 쓰임1: 안전망 역할
finalizer와 cleaner는 자원의 소유자가 close 메서드를 호출하지 않는 것에 대비한 안전망 역할로 쓰일 수 있습니다.
finalizer와 cleaner가 즉시(혹은 끝까지) 호출되리라는 보장은 없지만, 클라이언트가 회수 하지 않은 자원을 늦게라도 해주는 것이 아예 안 하는 것보다 낫기 때문입니다.
하지만, finalizer를 작성할 떄는 그럴만한 값어치가 있는지 심사숙고해야 합니다.
참고
자바 라이브러리의 일부 클래스는 안전망 역할의 finalizer를 제공합니다.
ex) FileInputStream, FileOutputStream, ThreadPoolExecutor가 대표적입니다.
예제 Room 클래스: cleaner의 안전망 역할
Room 클래스로 이 기능을 설명해보겠습니다.
방(room) 자원을 해제한다면, 그 전에 반드시 자원 하나를 청소(clean)해야 한다고 가정해봅시다.
방법 1. room 클래스의 자원을 해제하는 close()가 호출되면, 해당 자원의 clean()을 호출하여 청소한다.
방법 2. 만약 room 클래스의 close()가 호출되지 않을 경우, cleaner에게 clean()을 위임한다.
참고
사실 자동 청소 안전망을 cleaner로 사용할지 말지는 순전히 내부 구현 방식에 관한 문제입니다.
즉, finalizer와 달리 cleaner는 클래스의 public API에 나타나지 않습니다.
cleaner를 안전망으로 활용하는 AutoCloseable 클래스 (p. 44)
- Room 클래스는 AutoCloseable를 구현합니다.
- Room 클래스는 자원 해제를 담당할 cleaner 객체를 가지고 있습니다.
- Room 클래스는 자원 해제 하기 전, 청소를 진행해야 하는 정적 내부 클래스 State를 가지고 있습니다.
- State는 Room이 자원을 해제하기 전, 자원 해제해야 할 numJunkPiles를 담고 있습니다. 해당 자원은 run() 함수를 호출함으로써 "청소"합니다.
(더 현실적으로 만들려면, 이 필드는 네이티브 피어를 가리키는 포인터를 담은 final 변수여야 합니다.) - Room 객체가 생성될 때, State 객체도 함께 생성이 되고, Cleanble에 Room과 State를 등록하여 Cleaner의 Cleanable 객체를 얻습니다.
- Room 객체가 소멸되어 close() 함수가 호출되면, Cleanable의 Clean 메서드(자원 해제)를 호출합니다.
- Cleaner가 등록된 State의 자원 해제를 수행합니다.
- State의 run() 메서드가 실행되어 청소를 진행합니다.
State는 Runnable을 구현했으므로, 그 안의 run 메서드는 cleanable에 의해 딱 한 번만 호출됩니다.
public class Room implements AutoCloseable {
// Cleaner 생성
private static final Cleaner cleaner = Cleaner.create();
// room 객체가 소멸될 때, 청소가 필요한 자원
private static class State implements Runnable {
int numJunkPiles; // Number of junk piles in this room
State(int numJunkPiles) {
this.numJunkPiles = numJunkPiles;
}
// 청소 → close 메서드나 cleaner가 호출한다.
@Override public void run() {
System.out.println("Cleaning room");
numJunkPiles = 0;
}
}
// room이 소멸될 때 청소가 필요한 자원. cleanable과 공유한다.
private final State state;
// cleanable 객체. room객체 소멸되면, cleaner가 state 자원을 해제
private final Cleaner.Cleanable cleanable;
public Room(int numJunkPiles) {
state = new State(numJunkPiles);
cleanable = cleaner.register(this, state); // cleaner에게 해제해야 할 자원 등록
}
// room 객체가 소멸될 때, cleaner의 clean 메서드 (자원 해제) 호출
@Override public void close() {
cleanable.clean();
}
}
State의 run() 메서드(청소)가 호출되는 상황 2가지
- 보통은 Room의 close 메서드를 호출할 때입니다.
close 메서드에서 Cleanable의 clean을 호출하면, 이 메서드 안에서 run을 호출합니다. - 혹은 클라이언트가 close를 호출하지 않는다면, cleaner가 (바라건대) State의 run 메서드를 호출해줄 것입니다. - 안전망 역할
주의 순환 참조
State 인스턴스는 '절대로' Room 인스턴스를 참조해서는 안됩니다.
Room 인스턴스를 참조할 경우, 순환참조가 생겨 가비지 컬렉터가 Room 인스턴스를 회수할 (따라서 자동 해제할) 기회가 오지 않습니다.
State가 정적 중첩 클래스인 이유는 여기에 있습니다. 정적이 아닌 중첩 클래스는 자동으로 바깥 객체의 참조를 갖게 되기 때문입니다.
이와 비슷하게 람다 역시 바깥 객체의 참조를 갖기 쉬우니 사용하지 않는 것이 좋습니다.
여기서 Room의 cleaner는 단지 안전망으로만 쓰였습니다.
만약, 클라이언트가 Room 생성을 try-with-resources 블록으로 감쌌다면, Cleaner의 자동 청소는 전혀 필요하지 않습니다.
다음은 해당 코드입니다.
Adult (Try-with-resources): 방 청소 반드시 실행
public class Adult {
public static void main(String[] args) {
try(Room myRoom = new Room(7)) {
System.out.println("안녕~");
}
}
}
출력 결과
안녕~
Cleaning room
Teengager (Try-with-resource X): Cleaner에 의해 방 청소가 실행 될 수도, 안될 수도 있다.
public class Teenager {
public static void main(String[] args) {
new Room(99);
System.out.println("안녕~");
}
}
출력 결과
안녕~
이 예제에서도 Room 객체가 생성되고, 프로그램이 종료될 때, Cleaner에 의한 Cleaning room 을 출력하길 기대했지만, 한 번도 출력되지 않았습니다. 앞서 '예측할 수 없다'고 한 상황입니다.
cleaner의 명세에는 다음과 같이 쓰여져 있습니다.
System.exit을 호출 할 때의 cleaner 동작은 구현하기 나름이다. 청소가 이뤄질지는 보장하지 않는다.
명세에선 명시하지 않았지만, 일반적인 프로그램 종료에서도 마찬가지입니다.
참고 System.gc()
내 컴퓨터에서는 Teenager의 main 메서드에 System.gc()를 추가하는 것으로 종료 전에 "Cleaning room"을 출력할 수 있었지만, 여러 분의 컴퓨터에서도 그러리라는 보장은 없습니다.
쓰임2: 네어티브 피어(native peer)와 연결된 객체
네이티브 피어란. 일반 자바 객체가 네이티브 메서드를 통해 기능을 위임한 네이티브 객체를 의미합니다.
네이티브 객체는 자바 객체가 아니므로 가비지 컬렉터가 그 존재를 알지 못해 회수하지 않습니다.
즉, 자바 피어를 회수할때까지 네이티브 객체를 회수하지 못합니다.
finalizer와 cleaner가 처리하기에 적당한 작업입니다.
단, 성능 저하를 감당할 수 있고, 네이티브가 심각한 자원을 가지고 있지 않을 때에만 사용해야 합니다.
성능 저하를 감당할 수 없거나, 네이티브 피어가 사용하는 자원을 즉시 회수해야할 경우 앞서 설명한 close 메서드를 사용해야 합니다.
출처
https://www.yes24.com/Product/Goods/65551284
'책 > effective java' 카테고리의 다른 글
[effective java] item 10. equals는 일반 규약을 지켜 재정의하라 (0) | 2023.07.31 |
---|---|
[effective java] item 9. try-finally보다는 try-with-resources를 사용하라 (0) | 2023.07.30 |
[effective java] item 7. 다 쓴 객체 참조를 해제하라 (0) | 2023.07.25 |
[effective java] item 6. 불필요한 객체 생성을 피하라 (0) | 2023.07.25 |
[effective java] item 5. 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라 (0) | 2023.07.24 |