개발 일지

Item 8. finalizer와 cleaner 사용을 피하라 본문

스터디/Effective Java

Item 8. finalizer와 cleaner 사용을 피하라

junjun_ 2022. 12. 14. 20:59

자바에서는 finalizer와 cleaner 두 가지 객체 소멸자를 제공합니다.

 

finalizer 

  • Object에 존재하는 finalize()를 의미합니다.
  • 클래스의 객체가 더 이상 사용되지 않으면 GC(Garbage Collector)가 자동으로 호출합니다
  • 예측할 수 없고, 상황에 따라 위험할 수 있어 일반적으로 불필요하다.

cleaner 

  • Java 9에서는 fianlizer가 deprecated 됐고 cleaner가 생겼습니다
  • finalizer보다 덜 위험하지만(별도의 스레드를 사용하기 떄문에), 여전히 예측할 수 없고, 느리고 일반적으로 불필요하다.

하지만 두 객체 소멸자 모두 일반적으로 불필요합니다. 왜 불필요 한지 알아보겠습니다.

 


단점 1. 수행 시점을 알 수 없다.

finalizer와 cleaner는 즉시 수행된다는 보장이 없다. 객체에 접근할 수 없게 된 후 finalizer나 cleaner가 실행되기까지 얼마나 걸릴지 알 수 없다. 즉 finalizer와 cleaner로는 제때 실행되어야 하는 작업은 절대 할 수 없다. 예컨데 파일 닫기를 finalizer나 cleaner에 맡기면 중대한 오류를 일으킬 수 있다. 시스템이 동시에 열 수 있는 파일 개수에 한계가 있기 때문이다. 시스템이 finalizer나 cleaner 실행을 게을리해서 파일을 계속 열어둔다면 새로운 파일을 열지 못해 프로그램이 실패할 수 있다. finalizer나 cleaner를 얼마나 신속히 수행할지는 전적으로 가비지 컬렉터 알고리즘에 달려있으며, 이는 가비지 컬렉터 구현 마자 천차만별이다.

 

아래 예제를 보겠습니다.

 

FinalizerExample.java

public class FinalizerExample {
    @Override
    protected void finalize() throws Throwable {
        System.out.println("Clean Up");
    }
    public void hello(){
        System.out.println("hello");
    }
}

ExampleRunner.java

public class ExampleRunner {
    public static void main(String[] args) {
        for (int i = 0; i < 1000000; i++) {
            ExampleRunner runner = new ExampleRunner();
            runner.run();
        }
    }

    private void run() {
        FinalizerExample finalizerExample = new FinalizerExample();
        finalizerExample.hello();
    }
}

 

다음과 같이 객체의 생성과 소멸을 반복하고 결과를 확인해 보겠습니다.

 

실행결과의 일부분

하나가 생성되고 scope가 끝나서 finalizer()이 실행될것을 기대하지만 결과가 일정하지 않은 것을 볼 수 있습니다.

즉 GC의 대상은 되지만 바로바로 GC가 되는것은 아니기라는 것을 알 수 있습니다.

 

 

단점 2. 인스턴스의 자원 반납이 지연

finalizer 스레드는 다른 애플리케이션 스레드보다 우선순위가 낮아 실행될 기회를 제대로 얻지 못할 수 있다.
cleaner는 자신을 수행할 스레드를 제어할 수는 있지만, 역시나 가비지 컬렉터에 의존하므로  즉각 수행되리라는 보장이 없기 때문에 사용하지 않는 게 좋습니다.

 

 

단점 3. 수행 여부를 보장할 수 없음

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

단점 1에서 생성한 Example.java를 다음과 같이 수정해 보겠습니다.

public class ExampleRunner {
    public static void main(String[] args) throws InterruptedException {
        ExampleRunner runner = new ExampleRunner();
        runner.run();
        Thread.sleep(1000); // 1초 기다림
    }

    private void run() {
        FinalizerExample finalizerExample = new FinalizerExample();
        finalizerExample.hello(); // finalize 호축됨 (FinalizerExample 에 finalize() override 되어있음)
    }
}

위 예제에서 FinalizerExample인스턴스인 finalizerExample은 run함수가 끝나는 동시에 scope가 종료되기 때문에  GC(Garbage Collector)가 finalize()을 자동으로 호출할 것을 기대할 수 있습니다. 하지만 막상 예제를 실행시켜보면 다음과 같은 결과가 나옵니다. 

 

 

실행결과

인스턴스의 scope가 끝났음에도 불구하고 finalize()가 실행되지않고 프로세스가 종료되었습니다. 즉 GC의 대상은 되지만 바로바로 GC가 되는 것도 아니고 심지어는 실행 여부조차 보장되지 않습니다.

 

System.gc();

를 사용할경우 finalize() 실행 가능성을 높여 줄 수는 있으나 보장해주지 않습니다. 

 

단점 4. 동작 중 발생한 예외 무시

finalizer는 동작 중 발생할 예외를 무시하며, 처리할 작업이 남았더라도 그 순간 종료된다.
잡지 못한 예외 때문에 해당 객체는 훼손될 수 있고, 다른 스레드가 이 훼손된 객체에 접근하게 될 수 있다.
cleaner는 자신의 스레드를 통제하기 때문에 위의 문제는 발생하지 않는다.

 

 

단점 5. 심각한 성능 문제 

finalizer와 cleaner를 사용한 객체를 생성하고 파괴할 때 AutoCloseable 객체를 생성하고 try-with-resources로 닫는 것보다 시간이 수십 배 더 걸릴 수 있다( try-with-resources: 12ns, finalizer와 cleaner: 약 550ns).  뒤에서 살펴볼 안전망 방식에서는 상대적으로 시간이 덜 소모되지만 try-with-resources를 이용하는 것보다 시간이 오래 소요된다.

 

 

단점 6. 보안 문제 (Finalizer 공격)

Finalizer 공격이라는 심각한 보안 이슈에도 이용될 수 있다

생성자나 직렬화 과정에서 예외가 발생하면 finalizer가 수행되는데, 이 finalizer를 오버 라이딩한 하위 클래스의 finalizer가 수행될 수 있다. 심지어, 이 finalizer를 static 필드에 할당하면 가비지 컬렉터에 의해 수거되지도 않는다.


해결) 상속 자체를 막을 수 있는 final 클래스로 만들거나, A 클래스 안에서 아무 일도 하지 않는 final로 선언된 finalize메서드를 만든다.

 

 

finalizer와 cleaner를 대신할 묘안?

자원 반납이 필요한 클래스 AutoCloseable 인터페이스를 구현하고 try-with-resource를 사용하거나, 클라이언트가 close 메소드를 명시적으로 호출하는 게 정석이다. 여기서 추가로 언급하자면, close 메서드는 현재 인스턴스의 상태가 이미 종료된 상태인지 확인하고, 이미 반납이 끝난 상태에서 close가 다시 호출됐다면 IllegalStateException을 던져야 한다.

 

그렇다면 언제 써야하는가..? 

딱 두가지 경우에만 사용할 수 있습니다.

  • 안전망 역할로 자원을 반납하고자 하는 경우.
  • 네이티브 리소스를 정리해야 하는 경우.

안전망 역할로 자원을 반납하고자 하는 경우.

AutoCloseable을 구현하지 않았을 경우를 대비한 "안전망" 역할.
클라이언트가 하지 않은 자원 회수를 늦게라도 해주는 것이 아예 안 하는 것보다는 좋습니다.

 

네이티브 리소스를 정리해야 하는 경우.

네이티브 피어는 일반 자바 객체가 네이티브 메서드를 통해 기능을 위임한 네이티브 객체를 말한다. 자바 객체가 아니므로 GC는 네이티브 객체의 존재조차 모른다. 이런 상황이라면 finalizer 또는 cleaner가 처리할 수 있다. 그러나 성능 저하를 감수할 수 있고 네이티브 피어가 그다지 중요하지 않은 자원을 가지고 있을 때에 해당한다. 그렇지 않다면 close 메서드를 활용해야 한다.

 

 

 

cleaner 예제

AutoCloseable사용

public class Room implements AutoCloseable {

    private static final Cleaner CLEANER = Cleaner.create();

    //clean은 별도의 스레드를 사용 하기때문에 Runnable을 구현한  클래스를 하나 만들어 준다
    //청소가 필여한 자원. 절때 Room을 참조해서는 안된다
    private static class State implements Runnable {
        int numJunkpiles;

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

        // close 메서드나 cleaner 가 호출된다
        // close 메서드에서 clean을 호출하면 이 close메서드 안에서 run을 호출한다
        @Override
        public void run() {
            System.out.println("방 청소");
            numJunkpiles = 0;
        }
    }
    //방의 상태. cleanable 과 공유한다
    private final State state;

     // cleanable 객체 이객체를 통해 clean 호출. 
    // 수거 대상이되면 방을 청소한다.
    private final Cleaner.Cleanable cleanable;

    //Room 생성자
    public Room(int numJunkpiles) {
        state = new State(numJunkpiles);
        cleanable = CLEANER.register(this, state);
    }

    //안전망 
    @Override
    public void close() throws Exception {
        cleanable.clean();
    }
}

 

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("아무렴");
    //  System.gc();
    }
}

 

 

청소가 이뤄질지는 보장하지 않는다 System.gc를 사용해도 역시 보장하지는 않는다.

 

 

정리

Cleanr 는 안전망 역할이나 중요하지 않는 네이티브 자원 회수용으로만 사용하자. 물론 이런 경우라고 불확실성과 성능 저하의 주의해야 한다.