본문 바로가기
JAVA/Effective Java

Item8. finalizer와 cleaner 사용을 피하라

by 민휘 2023. 5. 29.

이 문서가 다루는 것

  • finalizer와 cleaner 사용법과 문제점
  • 대신 Auto
  • 리소스 정리 작업의 필요성
  • 안전망 역할으로써의 cleaner
  • phanom reference

 

결론

  • 객체 소멸 전 리소스를 정리하는 작업을 할 때 finalizer와 cleaner를 사용하지 말아라.
  • 대신 AutoCloseable을 구현하고 try-with-resources를 사용해라.
  • finalizer는 아예 사용하지 말아라. (자바 9부터 deprecated됨)
  • cleaner는 리소스나 네이티브 피어 회수가 되지 않는 문제를 대비한 안전망 정도로만 사용해라. 이런 경우라도 불확실성과 성능 저하에 주의해야한다.

 

finalizer와 cleaner는 무엇인가?

finalizer와 cleaner는 객체 소멸 전 리소스를 정리하는 기능을 맡는다.

객체를 가비지 컬렉터가 소멸하기 전에 리소스를 정리해야한다. 그 이유는 주로 메모리, 외부 리소스, 상태 초기화와 관련되어있다. 프로그램에서 사용한 리소스를 정리하지 않고 객체가 소멸되면 이 리소스를 정리할 수 있는 방법이 없는데, 이렇게 정리되지 않은 리소스들이 쌓이면 프로그램이 정상적으로 동작하지 않는다.

 

리소스 정리 작업의 필요성

  • 메모리 leak : 사용하지 않는 객체가 메모리에 계속해서 쌓이는 상황으로, 결국 시스템 자원이 부족해지고 성능 저하를 초래한다. 사용한 메모리 공간을 반환하여 메모리 누수를 방지해야한다.
  • 외부 리소스 : 파일, 네트워크 연결, 데이터베이스 연결 등의 리소스를 해제해야한다. 외부 리소스는 한정된 자원이므로, 제때 해제하지 않으면 자원이 부족해져서 다른 프로세스나 스레드가 이 리소스를 사용할 수 없다. 예를 들어 파일 닫기 작업이 제때 이루어지지 않으면 시스템에 동시에 열 수 있는 파일 개수의 한계를 넘어가서 프로그램이 죽을 수 있다. 데이터베이스의 영구 락 해제 작업이 제때 이루어지지 않으면 분산 시스템 전체가 서서히 멈춘다.
  • 상태 초기화 : 객체가 다시 사용될 때 초기 상태로 돌리는 것이 필요한 경우가 있다. 예를 들어, 재사용 가능한 풀링(pooling) 객체의 경우, 이전에 사용된 객체를 반환할 때 객체 상태를 초기화해야 다음 사용자가 정상적으로 사용할 수 있다.

 

finalizer을 사용한 리소스 정리

finalizer는 Object 클래스에 정의된 finalize() 메서드를 재정의하여 사용하는 메커니즘이다. finalize() 메서드는 가비지 컬렉터가 해당 객체를 수거하기 직전에 자동으로 호출된다. finalize() 메서드를 재정의하여 객체 소멸 전에 필요한 정리 작업을 수행할 수 있다.

public class FinalizerIsBad {
    // Object에 정의된 finalize() 오버라이딩하여 리소스 정리 작업 정의
    // FinalizerIsBad가 gc에 의해 소거되기 직전 수행
    @Override
    protected void finalize() throws Throwable {
        System.out.println("");
    }
}

 

다음 코드는 Finalizer 구현 객체를 무한루프로 만들어 gc를 수행하게 한다. main 메소드 안에서는 객체를 백만개쯤 만들었을 때 finalizer을 실행하고, Finalizer 내부의 Queue에 얼마나 많은 오브젝트가 쌓였는지 체크한다.

 

참고로 Finalizer의 동작 매커니즘은 다음과 같다.

  • 큐에 쌓이는 오브젝트는 finalize()를 오버라이딩한 FinalizerIsBad
  • finalizer 스레드는 다른 애플리케이션 스레드보다 우선순위가 낮기 때문에 gc할 객체가 쌓인다
  • 큐에 등록된 객체들은 가비지 컬렉터가 소멸될 객체를 검사하고 그 다음 단계에서 해당 객체들의 finalize() 메서드를 호출한다
  • 호출된 finalize() 메서드에서는 해당 객체의 리소스 정리나 후처리 작업을 수행할 수 있다
  • finalize() 메서드가 호출되고 나면, 해당 객체는 가비지 컬렉터에 의해 메모리에서 해제된다
public class App {
    public static void main(String[] args) throws Exception {

        int i = 0;

        while (true) {
            // 무한루프 돌면서 gc할 객체 생성
            i++;
            new FinalizerIsBad();

            // 백만개쯤 만들었을 때 finalizer 실행한다.
            // Finalizer 내부의 Queue에 얼마나 많은 오브젝트가 쌓여있는지 확인
            if ((i % 1_000_000) == 0) {
                Class<?> finalizerClass = Class.forName("java.lang.ref.Finalizer");
                Field queueStaticField = finalizerClass.getDeclaredField("queue");
                queueStaticField.setAccessible(true);
                ReferenceQueue<Object> referenceQueue = (ReferenceQueue) queueStaticField.get(null);

                Field queueLengthField = ReferenceQueue.class.getDeclaredField("queueLength");
                queueLengthField.setAccessible(true);
                long queueLength = (long) queueLengthField.get(referenceQueue);
                System.out.format("There are %d references in the queue%n", queueLength);
            }

        }
    }
}

이 메소드를 실행해보면 gc가 안된 객체가 십만개+씩 쌓여있는 것을 볼 수 있는데, finalizer 스레드의 우선순위가 낮기 때문에 언제 gc를 할지 알 수 없다. 즉, 즉시 수행된다는 보장이 없다.

 

Cleaner을 사용한 리소스 정리

Cleaner도 Finalizer와 마찬가지로 큐를 두고 구현하는데, Cleaner는 정적 내부 클래스로 구현하고, Cleaner을 등록하여 사용한다.

public class BigObject {

    private List<Object> resources; // 정리가 필요한 리소스 ex. 이미지 파일

    public BigObject(List<Object> resources) { this.resources = resources; }

    // 내부 클래스는 반드시 static으로! static이 아니라면 참조가 살아있어서 메모리 누수 발생
    public static class ResourceCleaner implements Runnable {

        private List<Object> resourceToClean;

        public ResourceCleaner(List<Object> resourceToClean) {
            this.resourceToClean = resourceToClean;
        }

        // 리소스 정리
        @Override
        public void run() {
            resourceToClean = null;
            System.out.println("cleaned up.");
        }
    }

}

public class CleanerIsNotGoodApp {

    public static void main(String[] args) throws InterruptedException {
        Cleaner cleaner = Cleaner.create();

        List<Object> resourceToCleanUp = new ArrayList<>();
        BigObject bigObject = new BigObject(resourceToCleanUp);

        // 이 runnable을 사용해서 리소스 해제를 실행해라
        cleaner.register(bigObject, new BigObject.ResourceCleaner(resourceToCleanUp));

        bigObject = null;
        System.gc();
        Thread.sleep(3000L);

    }

}

 

 

리소스 정리 작업 시 주의 사항

JVM Garbage Collection 정리

 

JVM Garbage Collection 정리

Garbage Collection (JDK 11 기준) Application의 동적 메모리 관리를 자동으로 수행하는 JVM 프로그램을 의미한다. Garbage는 "실행중인 프로그램의 어느 포인터로도 접근할 수 없는 객체"를 지칭한다. GC는 기

inspirit941.tistory.com

 

절대!! 절대 리소스 정리 작업에서 소멸될 객체를 참조하면 안된다. GC는 프로그램에서 참조 중이지 않은 객체를 할당 해제하는 방식으로 작동한다. 해제할 객체가 참조되지 않아서 GC에 잡혔는데, 리소스 정리를 실행하기 위한 스레드를 만들고 여기에서 소멸할 객체를 참조해버리면 해당 객체는 해제되지 않고, 메모리 누수를 일으키는 주범이 된다. gc가 수행되는 와중 어떤 타이밍에 finalizer가 수행되느냐에 따라 다른데, 리소스 정리 작업에 참조가 있다면 오히려 객체가 부활할 수도 있다(!)

 

그래서 리소스 정리 작업은 절대 소멸할 객체를 참조하면 안된다. 만약 리소스 정리 작업을 내부 클래스에게 위임한다면 static 클래스로 선언해서 외부 클래스에 대한 참조가 없도록 만들자.

public class BigObject {

    private List<Object> resources; // 정리가 필요한 리소스 ex. 이미지 파일

    public BigObject(List<Object> resources) { this.resources = resources; }

    // 내부 클래스는 반드시 static으로! static이 아니라면 참조가 살아있어서 메모리 누수 발생
    public static class ResourceCleaner implements Runnable {

        private List<Object> resourceToClean;

        public ResourceCleaner(List<Object> resourceToClean) {
            this.resourceToClean = resourceToClean;
        }

        // 리소스 정리
        @Override
        public void run() {
            resourceToClean = null;
            System.out.println("cleaned up.");
        }
    }

}

 

 

finalizer와 cleaner 사용을 피하라! 왜?

  • finalizer와 cleaner는 즉시 수행된다는 보장이 없다.
  • finalizer와 cleaner는 실행되지 않을 수도 있다.
  • finalizer 동작 중에 예외가 발생하면 정리 작업이 처리되지 않을 수도 있다.
  • finalizer와 cleaner는 심각한 성능 문제가 있다.

 

😣 즉시 수행 및 실행 보장 안됨

앞에서 살펴보았듯이 finalizer와 cleaner는 다른 스레드에 비해서 우선순위가 낮기 때문에, 큐에 쌓인 gc될 객체들이 쌓여도 정리되지 않는다. 그래서 즉시 수행된다는 보장은 당연히 없으며, 심지어 실행되지 않고 프로그램이 죽을 수도 있다.

finalizer나 cleaner을 얼마나 신속히 수행할지는 전적으로 gc 알고리즘에 달렸으며, 이는 gc 구현마다 천차만별이다. 그래서 리소스 정리 작업은 f와 c로 절대 제때 실행될 수 없다. 예컨대 파일 닫기를 f나 c에 맡기면 중대한 오류를 일으킬 수 있다. 시스템이 동시에 열 수 있는 파일 개수에 한계가 있기 때문이다. 시스템이 f나 c 작업을 게을리 해서 파일을 계속 열어둔다면 새로운 파일을 열지 못해 프로그램이 실패할 수 있다.

 

자바는 f나 c의 수행 여부조차 보장하지 않는다. 접근할 수 없는 객체에 딸린 리소스를 정리하지 않아서 프로그램이 중단될 수 있다. 따라서 프로그램 생애주기와 상관없이 상태를 영구적으로 수행하는 작업은 절대 f나 c에 의존하면 안된다. 예를 들어 db 락 해제를 fc에 맡겨놓으면, 락 해제 작업이 이루어지지 않아 분산 시스템 전체가 서서히 멈춘다.

 

😣 동작 중에 예외가 발생하면 정리 작업이 처리되지 않을 수도 있다

finalizer 동작 중에 발생한 예외는 무시되며, 처리할 작업이 남았더라도 그 순간 종료된다. 잡지 못한 예외 때문에 해당 객체는 자칫 마무리가 덜 된 상태로 남을 수 있다. 그리고 다른 스레드가 이 훼손된 객체를 사용하려 한다면 어떻게 동작할지 예측할 수 없으므로 아주 위험하다. 보통의 경우 잡지 못한 예외가 있다면 스레드를 중단시키고 stack trace를 출력하지만, 같은 일이 finalizer에서 일어난다면 경고조차 출력하지 않는다.

 

finalizer는 자신의 스레드를 통제할 수 없기 때문에 동작 중 예외가 발생한다면 어떤 작업도 처리하지 못한 채 즉시 종료된다. 하지만 cleaner는 자신의 스레드를 통제할 수 있어 finalizer처럼 위험하게 객체가 훼손된 상태로 남지는 않는다. 그 이유는 Cleaner가 자바의 PhantomReference와 함께 사용되어 객체의 소멸 과정에서 명시적인 리소스 정리를 수행하기 때문이다.

 

😣 심각한 성능 문제

AutoCloseable는 gc가 수거하기까지 12ns가 걸렸지만, finalizer은 550ns, cleaner는 500ns가 걸린다. fc를 사용한 깨체를 생성하고 파괴하면 AutoCloseable보다 50배 느리다.

 

 

그렇다면 무엇을 사용하는가?

AutoCloseable을 구현하고, 클라이언트에서 인스턴스를 다 쓰고 나면 close 메서드를 호출해 리소스를 정리하면 된다.

public class AutoClosableIsGood implements AutoCloseable {

    private BufferedInputStream inputStream;

    @Override
    public void close() throws Exception {
        try {
            inputStream.close();
        } catch (IOException e) {
            throw new RuntimeException("failed to close " + inputStream);
        }
    }
}

public class AutoClosableIsGoodApp {
    public static void main(String[] args) throws Exception {
        try(AutoClosableIsGood good = new AutoClosableIsGood()) {
            // TODO 자원 반납 처리
        }   
    }
}

 

 

 

Finalizer와 Cleaner은 언제 사용할 수 있는가?

리소스와 네이티브 피어 정리의 안전망

모든 리소스 정리 작업은 AutoCloseable을 사용하되, 클라이언트가 close를 호출하지 않았을 때를 대비하는 안전망으로써 Finalizer이나 Cleaner을 사용할 수 있다. 이때 Finalizer와 Cleaner는 회수되지 않은 리소스에 대해 청소될 기회를 가질 수도 있다는 점에서 안전망 역할을 한다. 자바 라이브러리의 일부 클래스는 안전망 역할의 finalizer을 제공하는데, FileInputStream, FileOutputStream, ThreadPoolExecutor가 대표적이다.

 

네이티브 피어는 자바 이외의 언어로 작성된, 즉 플랫폼에 종속적인 라이브러리의 객체를 말한다. 자바 프로그램에서 네이티브 피어를 사용하려면 네이티브 메소드를 통해 기능을 위임하여 사용한다. 네이티브 피어와 연결된 객체를 gc할 때, gc는 네이티브 객체의 존재를 모르기 때문에 자바 피어를 회수할 때 네이티브 객체를 회수하지 못한다. 이때 Cleaner나 Finalizer을 사용하기에 적합하다.

 

그러나 앞에서 살펴보았듯이 Cleaner나 Finalizer가 가지는 성능 저하나 불확실성 문제를 고려해야한다. 그래서 처리되지 않으면 곤란한 리소스의 정리에서는 Cleaner나 Finalizer 사용을 자제해야 한다.

 

Cleaner을 안전망으로 사용하는 AutoCloseable 클래스

// An autocloseable class using a cleaner as a safety net (Page 32)
public class Room implements AutoCloseable {
    private static final Cleaner cleaner = Cleaner.create();

		// Cleaner로 정리되어야하는 작업 구현
    // Resource that requires cleaning. Must not refer to Room!
    private static class State implements Runnable {
        int numJunkPiles; // Number of junk piles in this room

        State(int numJunkPiles) { // 정리해야하는 쓰레기 개수 추적
            this.numJunkPiles = numJunkPiles;
        }

        // Invoked by close method or cleaner
        @Override public void run() {
            System.out.println("Cleaning room");
            numJunkPiles = 0; // 청소
        }
    }

    // The state of this room, shared with our cleanable
    private final State state;

		// 안전망으로써의 Cleaner
    // Our cleanable. Cleans the room when it’s eligible for gc
    private final Cleaner.Cleanable cleanable;

		// Cleaner와 AutoCloseable 연결
    public Room(int numJunkPiles) {
        state = new State(numJunkPiles);
				// Room이 수거될 때 state의 run이 호출되도록 등록
        cleanable = cleaner.register(this, state);
    }

		// 클라이언트가 명시적으로 호출해야하는 AutoCloseable의 메소드
    @Override public void close() {
        cleanable.clean(); // state의 run 호출
    }
}

// 나중에 사용한다면..
try (Room room = new Room(5)) {
    // 방 안의 작업을 수행
    // 자동으로 close() 메소드가 호출되어 리소스 정리가 이루어짐
}

 

 

Phantom Reference? feat. Cleaner

 

💡 Cleaner가 자바의 PhantomReference와 함께 사용되어 객체의 소멸 과정에서 명시적인 리소스 정리를 수행하기 때문이다.

 

앞에서 finalizer의 예외 발생시 위험사항을 다룰 때, cleaner는 팬텀 레퍼런스를 사용하므로 예외가 발생하더라도 리소스 정리를 하다 말아서 객체가 훼손되는 일은 없다고 했다. 이게 무슨 말인지 알아보자.

 

Java Phantom Reachable, Phantom Reference 란

 

Java Phantom Reachable, Phantom Reference 란

Java의 Phantom Reachable 특징과 Phantom Reference 사용 예시에 대해 알아봅니다.

luckydavekim.github.io

 

Phantom Reference는 올바르게 삭제하고 삭제 이후 작업을 조작하기 위한 참조 방법이다. PhantomReference는 gc가 객체를 수거하기 직전에 알림을 받을 수 있다. 그래서 Cleaner와 함께 사용하면 객체의 소멸 과정에서 명시적인 리소스 정리를 수행할 수 있다. 이 과정에서 예외가 발생하더라도 Cleaner는 예외를 catch하고 처리하여 리소스 정리 작업이 완료되므로, 예외가 발생하더라도 객체가 훼손되는 일은 없다.

 

register는 소멸될 객체와 정리 작업을 수행할 Cleanable을 등록해 PhantomReference를 등록한다. 객체가 가비지 컬렉터에 의해 수거될 때, PhantomReference가 알림을 받고 Cleaner에 등록된 Cleanable의 clean() 메소드가 호출된다. 이때 Cleanable의 clean() 메소드 내에서 객체의 리소스 정리 작업을 수행한다.

 

public final class Cleaner {

    /**
     * Registers an object and a cleaning action to run when the object
     * becomes phantom reachable.
     * Refer to the <a href="#compatible-cleaners">API Note</a> above for
     * cautions about the behavior of cleaning actions.
     *
     * @param obj   the object to monitor
     * @param action a {@code Runnable} to invoke when the object becomes phantom reachable
     * @return a {@code Cleanable} instance
     */
    public Cleanable register(Object obj, Runnable action) {
        Objects.requireNonNull(obj, "obj");
        Objects.requireNonNull(action, "action");
        return new CleanerImpl.PhantomCleanableRef(obj, this, action);
    }

		public interface Cleanable {
        /**
         * Unregisters the cleanable and invokes the cleaning action.
         * The cleanable's cleaning action is invoked at most once
         * regardless of the number of calls to {@code clean}.
         */
        void clean();
    }
}