ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [이펙티브 자바] item 8 - finalizer와 cleaner 사용을 피하라
    개발서적읽기/Effective Java 3E 2020. 6. 25. 12:48


    ■자바가 제공하는 객체 소멸자 2가지, finalizer와 cleaner

    @Override
    public void finalize() {
    // ...
    }

    Object 클래스에 정의된 finalize 메서드를 Override하면 


    해당 객체가 GC 대상이 될 때 finalize 메소드가 호출된다.


    그런데 문득 궁금한 점이 생겼다. 


    메소드 이름은 finalize인데 책은 finalizer라고 표현한다. 


    무슨 차이가 있는걸까?


    출처 에 따르면, GC가 어떤 객체를 memory 해제하려고 하는데 그 객체가


    finalize 메소드를 재정의한 경우 즉각적으로 Collection(회수) 되지 않는다.


    대신 Finalization Queue에 들어간 후 Finalizer에 의해 정리가 된다.


    Finalizer는 객체의 finalize 메소드를 실행한 후 메모리 정리 작업을 수행한다.


    그런데 finalize 메소드를 재정의하는 것을 지양해야할 이유가 몇 가지 있다.


    1. 언제 호출될지 예측할 수 없다.


    2. 막상 호출됐을 때, 상황에 따라 위험할 수 있다.


    그래서 기본적으로 '쓰지 말아야' 한다.

    Java 9에서는 finalize 메소드를 사용 자제(deprecated) API로 지정하고 


    상대적으로 덜 위험한 clean 메소드를 그 대안으로 소개하기도 했다.


    하지만 clean 메소드도 역시 예측 불가능하고 일반적으로 불필요하다.


    먼저 cleaner 사용법을 알아본 후, finalize 메소드와 clean 메소드가 불필요한 이유들을 


    아래에서 하나씩 살펴보자




    ■cleaner 사용 방법

    public class CleaningExample implements AutoCloseable {
    // A cleaner, preferably one shared within a library
    private static final Cleaner cleaner = <cleaner>;

    static class State implements Runnable {

    State(...) {
    // initialize State needed for cleaning action
    }

    public void run() {
    // cleanup action accessing State, executed at most once
    }
    }

    private final State;
    private final Cleaner.Cleanable cleanable

    public CleaningExample() {
    this.state = new State(...);
    this.cleanable = cleaner.register(this, state);
    }

    public void close() {
    cleanable.clean();
    }
    }

    cleaner의 사용 방법은 finalizer보다 약간 복잡하다.


    그리고 finalizer와 달리 cleaner는 클래스의 public API에 나타나지 않는다.


    우선 CleaningExample 인스턴스가 close 메소드를 호출하게 되면


    cleaner가 실행된다. 그리고 cleaner는 State 스레드를 실행시키게 되고


    State 스레드 내부에 자원 해제에 관련된 내용을 채우면 된다.


    참고로 State 스레드는 딱 한번만 호출된다.


    만약 사용자가 CleaningExample 인스턴스의 close를 호출하지 않는다면


    GC가 CleaningExample 인스턴스를 회수할 때 cleanable.clean을 실행시켜줄 것이다. 


    중요한 것은 GC에 따라 실제로 실행될 수도 실행이 안 될 수도 있다는 것이다!


    한편 State 인스턴스는 절대로 CleaningExample 인스턴스를 


    참조해서는 안된다. 이러한 순환참조가 생겨버리면 GC가 


    CleaningExample 인스턴스를 회수해갈 수 없다. 


    State가 정적 중첩 클래스인 이유이기도 하다.


    정적이 아닌 중첩 클래스는 자동으로 바깥 객체의 참조를 갖게 된다(item 24)


    이와 비슷하게 람다 역시 바깥 객체의 참조를 갖기 쉬우니 사용하지 않는 것이 좋다.


    (finalizer도 순환참조 이슈가 아래에서 설명될 예정이다)




    ■예측 불가능한 finalizer와 cleaner의 수행 시점


    finalize 메소드는 언제 실행될지 알 수 없다.


    finalize 메소드는 GC가 실행될 때 바로 실행되지 않는다.


    그 이유는 이렇다.


    GC가 실행될때 객체들을 스캔하는데, 그 객체가 finalize 메소드를 


    재정의했을 경우 그 객체는 Finalization Queue에 들어가게 된다. 


    그리고 Finalizer에 의해 정리가 된다. 


    Fianlizer가 Queue에 들어간 객체들을 빠르게 처리하면 좋을텐데 


    아쉽게도 Fianlizer 스레드는 우선순위가 항상 가장 높지는 않다.


    Finalizer 스레드를 직접 컨트롤할 수 없어서 우선순위를 지정할 수 없기 때문이다.


    그래서 Finalizer 스레드가 다른 스레드들에 우선순위가 밀려 


    실행이 미뤄진다면 Finalization Queue에 객체가 쌓이게 된다. 


    그렇게 되면 OutOfMemory 오류가 발생할 수도 있다.


    혹은 finalize 메소드 내에 file에 관련된 객체의 자원을 회수하는 로직이 들어있다면,


    새로운 파일을 열지 못해 프로그램이 실패해버릴 수도 있다. 


    왜냐하면 시스템이 동시에 열 수 있는 파일 개수는 한계가 있기 때문이다.


    finalizer나 cleaner를 얼마나 신속히 수행할지는 전적으로 


    GC 알고리즘에 달렸으며 이는 GC 구현마다 천차만별이다.


    finalizer나 cleaner 수행 시점에 의존하는 프로그램의 동작 또한 마찬가지다.


    내 JVM에서는 잘 돌아가는데, 다른 JVM에서는 실패할 수도 있다.


    한편 cleaner는 자신을 수행할 스레드를 제어할 수 있다는 면에서 조금 낫다.


    하지만 여전히 백그라운드에서 수행되며 GC의 통제하에 있으니 


    즉각 수행되리라는 보장은 없다.




    ■보장되지 않는 finalizer와 cleaner의 수행 여부

    자바 언어 명세는 finalizer나 cleaner의 수행 시점뿐 아니라 

    수행 여부조차 보장하지 않는다

    접근할 수 없는 일부 객체에 딸린 종료 작업을 전혀 수행하지 못한 채

    프로그램이 중단될 수도 있다는 얘기다. 

    따라서 프로그램 생애주기와 상관없는, 상태를 영구적으로 수정하는

    작업에서는 절대 finalizer나 cleaner에 의존해서는 안된다.

    예를 들어 DB같은 공유 자원의 영구 락(lock) 해제를

    finalizer나 cleaner에 맡겨 놓으면 분산 시스템 전체가 서서히 멈출 것이다.

    혹여나 System.gc나 System.runFinalization 메서드에 현혹되지 말자.


    finalizer와 cleaner가 실행될 가능성을 높여줄 수는 있으나, 보장해주진 않는다.


    사실 이를 보장해주겠다는 메서드가 2개 있었다. 


    바로 System.runFinalizersOnExit와 그 쌍둥이인 Runtime.runFianlizersOnExit이다.


    하지만 이 두 메서드는 심각한 결함 때문에 수십년간 지탄받아 왔다. 


    (책에서 방금 위 문장 옆에 [ThreadStop] 이라고 적혀있는데, 


    아마 두 쌍둥이 메서드가 실행되면 GC의 Stop The World와 비슷한 상황이 벌어지나보다.)




    ■매우 치명적인 <finalize 메소드 실행 중 발생하는 예외>


    finalize 메소드 실행 중 발생하는 예외는 무시되며, 


    처리할 작업이 남았더라도 그 순간 종료된다. 


    잡지 못한 예외 때문에 해당 객체는 자칫 마무리가 덜 된 상태로 남을 수 있다. 


    그리고 다른 스레드가 이처럼 훼손된 객체를 사용하려 한다면


    어떻게 동작할지 예측할 수 없다. 


    보통의 경우엔 잡지 못한 예외가 스레드를 중단시키고 스택 추적 내역을 출력하겠지만, 


    같은 일이 finalizer에서 일어난다면 경고조차 출력하지 않는다.


    (그나마 cleaner를 사용하는 라이브러리는 자신의 스레드를 통제하기 때문에 


    이러한 문제가 발생하지 않는다.)




    ■심각한 성능 문제를 동반하는 finalizer와 cleaner


    자원을 해제하는 방법에는 finalizer와 cleaner 말고도 


    AutoCloseable 인터페이스의 close 메소드를 활용하는 방법도 있다. (item 9)


    결론적으로 자원 해제 속도 순서는 아래와 같다.


    AutoCloseable > finalizer = cleaner


    왜냐하면 finalizer와 cleaner는 GC의 효율을 떨어뜨리기 때문이다.




    ■심각한 보안 문제를 일으키는 finalizer와 cleaner


    finalizer가 공격당하는 원리는 이렇다.


    A 클래스와 이를 상속한 B 클래스가 있다.


    A 클래스엔 finalize 메소드를 재정의해놓지 않아서 안전하다.


    그런데 B 클래스는 finalize 메소드를 재정의해 놓았다.


    그리고 B 클래스의 생성자가 실행될 때 예외가 발생하여 finalize 메소드가 실행됐다.


    B 클래스가 재정의한 finalize 메소드에서는 B 클래스의 static 필드는 물론이고 


    A 클래스의 static 필드에까지 접근할 수도 있다. 


    그리곤 이 필드에 자신의 참조를 할당해버린다면 GC가 해당 객체를 수집하지 못한다.


    이렇게 일그러진 객체가 만들어지고 나면, 이 객체의 메서드를 호출해 


    애초에는 허용되지 않았을 작업을 수행해버릴 수도 있다. 


    객체 생성을 막으려면 생성자에서 예외를 던지는 것만으로 충분하지만


    finalizer가 있다면 그렇지도 않다.


    이 공격에 대한 대안이 2가지가 있다.


    1. class를 final로 만들기 (위 예에서는 A 클래스)


    2. 아무 일도 하지 않는 final finalize 메서드 만들기




    ■finalizer와 cleaner의 대안


    파일이나 스레드 등 종료해야 할 자원을 담고 있는 객체의 클래스에서


    finalizer나 cleaner를 대신해줄 묘안은 무엇일까?


    바로 AutoCloseable을 구현하는 것이다. 


    그리고 close 메소드를 호출하거나 자동으로 호출되는 try-with-resource 를 


    사용하는 것이다. (item 9) 위에서 잠깐 언급하기도 했다.


    AutoCloseable 사용 꿀팁이 있다.


    각 인스턴스는 자신이 닫혔는지를 추적하는 것이 좋다.


    아래는 FileInputStream의 close 메소드이다.

    public void close() throws IOException {
    synchronized (closeLock) {
    if (closed) {
    return;
    }
    closed = true;
    }
    if (channel != null) {
    channel.close();
    }

    fd.closeAll(new Closeable() {
    public void close() throws IOException {
    close0();
    }
    });
    }

    자세히 보면 closed라는 bool 변수가 있다.


    이 변수를 통해 close 메서드에서 이 객체는 더 이상 유효하지 않음을 기록하는 것이다.


    그래서 클래스 내의 다른 메서드는 이 필드를 검사해서 객체가 닫힌 후에 불렸다면


    IllegalStateException을 던지면 예측하지 못하는 예외를 하나라도 줄일 수 있다.




    ■finalizer와 cleaner의 용도


    그렇다면 이것들은 도대체 어디에 쓰면 좋은것일까.


    적절한 쓰임새가 두 가지 있다.


    자원의 소유자가 close 메서드를 미처 호출하지 않는 것에 대비한 안전망


    finalizer나 cleaner가 즉시(혹은 끝까지) 호출되리라는 보장은 없다.


    하지만 반드시 close 메소드가 호출되어야 하는 상황에서는 


    finalizer나 cleaner에 close 메서드 호출을 넣어서


    그나마 안전 장치를 하나 더 두는 것이 나을 것이다.


    FileInputStream, FileOutputStream, ThreadPoolExcecutor가 그 예이다.


    그리고 맨 위의 cleaner 예제에서도 안전망을 확인할 수 있다.


    (다시 강조하지만 안전망을 설치했어도 이 안전망 조차 실행이 안될 수도 있다!)


    네이티브 피어와 연결된 객체에 사용


    네이티브 피어(native peer)란 일반 자바 객체가 네이티브 메서드를 통해


    기능을 위임한 네이티브 객체를 말한다. 네이티브 피어는 자바 객체가 아니니


    GC는 그 존재를 모른다. 그 결과 자바 피어를 회수할 때 네이티브 객체까지는


    회수할 수 없다. finalizer나 cleaner가 등장하면 좋은 상황이다.


    단, 성능 저하를 감당할 수 있고 네이티브 피어가 심각한 자원을 가지고 있지


    않을 떄에만 해당된다. 성능 저하를 감당할 수 없거나 네이티브 피어가 사용하는


    자원을 즉시 회수해야 한다면, 앞서 설명한 close 메서드를 사용하자.




    ■결론


    cleaner(java 8까지는 finalizer)는 안전망 역할이나 


    중요하지 않은 네이티브 자원 회수용으로만 사용하자


    물론 이런 경우라도 불확실성과 성능 저하에 주의해야 한다.


    댓글

Designed by Tistory.