이펙티브자바 - 아이템8) finalizer와 cleaner 사용을 피하라.

Date:    Updated:

카테고리:

개요

자바의 객체 소멸은 가비지 컬렉터가 담당한다. 그 외에도 finalizer(java 8)와 cleaner(java 9부터)라는 자원 반납을 위한 두개의 객체 소멸자를 따로 제공한다.

그러나 두가지 모두 예측할 수 없고, 느리고, 상황에따라 위험할 수 있기 때문에 기본적으로 ‘사용하지 말것’을 권장하고 있다.

왜 기본적으로 쓰지 말아야 하는지 알아보자.

수행 시점 및 수행 여부를 보장하지 않는다.

소멸 대상이 된 객체(객체에 접근 할 수 없게됨)를 대상으로 finalizercleaner가 실행되기까지 얼마나 걸릴지 알 수 없다.

즉, 제때 실행되어야 하는 작업은 절대 할 수 없다. (ex. 파일 리소스 반납 등)

finalizercleaner의 수행 속도는 전적으로 가비지 컬렉터 알고리즘에 달렸다.(가비지 컬렉터 구현마다 천차만별)

finalizercleaner가 수행되리라는 보장도 없다. 시스템이 종료될때까지 소멸대상 객체를 소멸 시키지 못할수도 있다는 의미다.

finalizer의 스레드 우선순위가 기본적으로 낮기때문에 다른 로직이 수행되면 실행 순서가 자연스레 뒤로 밀린다. cleaner는 별도의 스레드로 동작하여 우선순위를 높게할 수 있어서 finalizer 보다는 나은 상황이지만 백그라운드에서 실행된다는 점은 변함이 없기때문에 언제 처리 될지는 장담할 수 없다.

결국 최악의 경우 자원을 반납하지 못한 인스턴스가 계속 쌓이다가 OutOfMemoryException이 발생할 수 있다.

실행을 보장하는 System.runFinalizersOnExitRuntime.runFinalizersOnExit 메서드가 존재하지만 치명적 결함으로 인하여 deprecated 되었다.

System.runFinalizersOnExit( )는 deprecated 되었다. 가장 큰 이유는 rechable한 객체를 finalize를 하는게 말도 안된다는 것이고, 또다른 이유는 finalize의 순서가 보장되지 않기 떄문이다.

public class FinalizerExample {

    @Override
    protected void finalize() throws Throwable {
        System.out.println("clean up"); // 언제 수행될지 보장하지 않음. (=실행이 아예 안될수도 있음)
    }

    public void printHello(){
        System.out.println("hello");
    }
}
public class Main {

    public static void main(String[] args) throws InterruptedException {
        Main main = new Main();
        main.run();

        TimeUnit.SECONDS.sleep(10); // 일정시간 대기해도 finalizer가 수행되지 않는다.
    }

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


예외 발생 무시 (finalizer 한정)

finalizer 동작 중 발생한 예외는 무시되며 처리할 작업이 남아있어도 그 순간 바로 종료된다.

보통 예외가 발생하면 스레드를 중단 시키고 stack trace를 출력하지만, finalizer에서 예외 발생 시 경고 조차 출력하지 않는다.

잡지 못한 예외 때문에 훼손된 객체가 남아있어서 어떻게 동작할 지 예측할 수 없게 된다.

(단, cleaner는 사용하는 라이브러리가 자신의 스레드를 통제하기 때문에 이런 문제가 발생하지 않는다.)

성능 문제

AutoCloseable 객체를 생성하고 try-with-resource로 자원 반납까지 걸린시간 : 12ns

finalize()를 구현한 객체가 자원 반납까지 걸린시간 : 550ns

속도가 무려 50배 정도 차이난다. (cleaner도 클래스의 모든 인스턴스를 수거하는 형태이므로 finalizer와 비슷한 성능을 낸다.)

보안 문제 (finalizer 공격)

finalizer를 사용한 클래스는 심각한 보안 문제를 일으킬 수 있다. finalizer를 상속받은 클래스가 생성자나 직렬화 과정에서 예외가 발생하면 해당 객체의 finalizer 가 강제로 수행될 수 있다.

finalizer는 정적 필드에 자신의 참조를 할당하여 가비지 컬렉터가 수집하지 못하게 막을 수 있다.

public class Dashboard {

    int value;

    public Dashboard(int value) {
        if(value < 1)
            throw new IllegalStateException("1보다 작은 숫자는 허용하지 않습니다."); // 예외가 발생하여 객체 생성 실패 즉, gc의 대상이 됨.
        this.value = value;
    }

    public int getValue() {
        return value;
    }

    public void print() {
        System.out.println("Hi!");
    }
}
public class FinalizerAttack extends Dashboard {

    public static Dashboard dashboard;

    public FinalizerAttack(int value) {
        super(value);
    }

    @Override
    protected void finalize() throws Throwable {
        // Finalizer 로 인하여 Dashboard 객체를 주입받을 수 있게 된다.
        // static 변수에 주입된다 -> 객체가 다시 접근 가능하게 됨 -> Dashboard가 GC의 대상에서 벗어나게 되어서 소멸되지 못함.
        dashboard = this;
    }

    public static void main(String[] args) {
        try {
            // 일부러 생성자 예외를 발생시킨다.
            FinalizerAttack finalizerAttack = new FinalizerAttack(-1);
        } catch (Exception e) {
            System.out.println(e);
        }

        System.gc();        // 가비지 컬렉터 강제 수행
        System.runFinalization(); // finalizer 강제 수행

        if (dashboard != null) {
            // 소멸 되어야할 객체가 소멸 되지 않고 메서드를 수행하게 된다.
            System.out.println("dashboard = " + dashboard);
            System.out.println("dashboard.getValue() = " + dashboard.getValue());
            dashboard.print();
        }
    }
}


이 공격으로부터 방어를 하려면 아무 동작을 하지않는 finalize()final 키워드를 선언함으로써 하위 클래스가 오버라이딩 하려는것을 막으면 된다.

public class Dashboard {

    int value;

    public Dashboard(int value) {
        if(value < 1)
            throw new IllegalStateException("1보다 작은 숫자는 허용하지 않습니다.");
        this.value = value;
    }

    @Override
    protected final void finalize() throws Throwable {
        // 하위 클래스가 상속받더라도 finalize는 오버라이딩 하지 못한다.
    }

    public int getValue() {
        return value;
    }

    public void print() {
        System.out.println("Hi!");
    }
}

자원 반납을 위해 AutoCloseable을 사용하자 (권장)

앞서 설명한 finalizer, cleaner 대신 파일이나 스레드 등 자원 반납을 위해 AutoCloseable을 사용하자.

자원 반납이 필요한 클래스에 AutoCloseable 인터페이스를 구현하고 close()를 명시적으로 호출하면 된다.

public class Test implements AutoCloseable{

    @Override
    public void close() throws Exception {
        System.out.println("Test.close");
    }

    public void hi(){
        System.out.println("Test.hi");
    }
}
public class Main {
    public static void main(String[] args) throws Exception {
        Test test = null;
        try {
            test = new Test();
            test.hi();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if(test != null){
                test.close();
            }
        }
    }
}

try-with-resource를 사용하면 close()를 명시하지 않아도 try 블록이 끝날때 자동으로 호출해준다.

public class Main {
    public static void main(String[] args) throws Exception {
        try(Test test = new Test()){
            test.hi();
        } // 이후 test.close() 실행됨
    }
}

finalizer와 cleaner를 자원 반납의 안전망으로 활용

그렇다면 finalizercleaner은 어떤 상황에서 쓸 수 있을까?

사용자가 (try-with-resource를 사용하지 않았을 때) close()를 명시하지 않았을 때 finalize() 를 구현하여 close()를 강제로 수행하는 방법이다.

사용자가 미처 자원을 회수하지 못한 경우를 대비하여 finalize()가 대신 close()를 수행해 줌으로써 자원에 대한 안전망 역할을 하는것이다.

추가로, 안전망을 구현할 때 자원이 반납이 되었는지에대한 여부를 추적하여 예외처리를 하는것이 좋다.

public class Test implements AutoCloseable {

    // 자원이 반납되었는지 여부
    private boolean closed;

    @Override
    public void close() throws Exception {
        if(this.closed)
            throw new IllegalStateException("이미 자원이 반납되었습니다.");
            
        closed = true;
        System.out.println("Test.close");
    }

    @Override
    protected void finalize() throws Throwable {
        if(!this.closed) close();
    }

    public void hi(){
        System.out.println("Test.hi");
    }
}

cleanerfinalizer가 끝까지 호출되리라는 보장은 없지만 사용자가 하지않은 자원 회수를 늦게나마 해주는 편이 아예 회수를 안하는 것보다 낫다.

실제로 자바에서 제공하는 FileInputStream, FileOutputStream, ThroeadPoolExecutor, java.sql.Connection 에는 안전망 역할의 finalizer가 있다.

finalizercleanernative 객체를 정리하는데에 활용

native 객체는 일반적인 객체가 아니므로 가비지 컬렉터가 그 존재를 모른다. native 객체가 들고있는 리소스가 중요하지 않고 성능상 영향이 크지 않은 자원이라면 cleanerfinalizer를 사용해서 해당 자원을 반납할 수 있다.

만약 중요한 리소스인 경우에는 AutoCloseableclose()를 사용하는 것이 좋다.

cleaner 예제

cleaner를 직접 구현한 예시를 살펴보자.

public class CleanerExample implements AutoCloseable {

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

    private final CleanerRunner cleanerRunner; // clean 작업을 수행할 별도의 쓰레드가 필요함.

    private final Cleaner.Cleanable cleanable;

    public CleanerExample(int resources) {
        this.cleanerRunner = new CleanerRunner(resources);
        this.cleanable = CLEANER.register(this, cleanerRunner); // 인스턴스 clean 을 실제로 수행할 runner 를 등록함.
    }

    @Override
    public void close() throws Exception {
        cleanable.clean();
    }

    public void helloWorld(){
        System.out.println("CleanerExample.helloWorld");
    }

    private static class CleanerRunner implements Runnable {
        // 실제로 정리할 resource 가 여기 있어야함.
        // 단, 내부에 CleanerExample 타입의 인스턴스를 가져오면 순환 참조 오류가 발생하므로 유의할 것.
        int resources;

        public CleanerRunner(int resources) {
            this.resources = resources;
        }

        @Override
        public void run() {
            // 해당 인스턴스가 필요 없고 GC의 대상이 되었을 때 호출됨.
            System.out.println("Clean 작업 수행 (자원 반납)");
            resources = 0;
        }
    }
}

cleaner는 별도의 쓰레드가 필요하기 때문에 Runnable을 상속받아서 run()을 구현해야 한다.

실직적으로 자원을 반납하는 역할을 하는것은 CleanerRunner이며 내부 클래스 타입 인스턴스를 참조하면 순환 참조 오류가 발생하게 되므로 정적 중첩 클래스로 구현한다.

public class Main {
    public static void main(String[] args) throws Exception {
        Main main = new Main();
        main.run(10);
        System.gc(); // 가비지 컬렉터를 강제로 수행시켜도 cleaner가 수행 되리란 보장은 없다. (이번엔 수행되었음)
    }

    private void run(int resources) {
        CleanerExample cleanerExample = new CleanerExample(resources);
        cleanerExample.helloWorld();
    }
}|


결과에는 가비지 컬렉터를 강제로 수행시키면 cleaner가 동작하는것 처럼 보이나, 실제로는 수행을 보장하지 않으니 의존하지 말아야한다. (안전망 역할임을 유의하자.)

정리

  • cleaner(자바 8까지는 finalizer)는 안전망 역할이나 중요하지 않은 네이티브 자원 회수용으로만 사용하자.
  • 물론 이마저도 불확실성과 성능 저하에 유의해야한다.

📣 Reference

Effective Java 3/E - Joshua J. Bloch
WegraLee/effective-java-3e-source-code
[이팩티브 자바] #8 Finalizer와 Cleaner 쓰지 마세요
Finalizer attack
Effective Java - 객체의 생성과 소멸
[아이템 8] finalizer와 cleaner 사용을 피하라
[Effective Java] 아이템8 - finalizer와 cleaner 사용을 피하라
어떻게 이런 FINALIZE()를 쓰란 말이에요
Why is the runFinalizersOnExit method in class System deprecated?

댓글남기기