Effective Java : 아이템8. finalizer와 cleaner 사용을 피하라

    들어가기 전

    이 글은 인프런 백기선님의 강의를 복습하며 작성한 글입니다. 

     

    아이템8 핵심 정리

    • Finalizer, Cleaner는 GC가 일어날 때 수행된다.
      • GC가 언제 일어날지 모르기 때문에 Finalizer와 Cleaner는 즉시 수행된다는 보장이 없다.
      • GC가 일어나도, Finalizer와 Cleaner는 정상동작 하지 않을 수 있다.
    • finalizer 동작 중에 예외가 발생하면 정리 작업이 처리되지 않을 수도 있다. 
    • finalizer와 cleaner는 심각한 성능 문제가 있다.
    • finalizer는 보안 문제가 있다. 
      • Finalizer Attck이 가능하다.
    • 반납할 자원이 있는 클래스는 AutoClosable을 구현하고 클라이언트에서 close()를 호출하거나 try-with-resource를 사용해야 한다. 

    Finalizer는 public class가 아니기 때문에 개발자가 접근해서 사용할 수 없다. 반면 Cleaner는 public class이기 때문에 개발자가 접근해서 사용할 수 있다. 이렇게 서로 다른 접근성을 보이지만, Finalizer와 Cleaner는 실행 자체가 보장되지 않을 수 있고, 이런 이유 때문에 리소스가 전혀 반납 되지 않을 수 있다. 


    Finalizer

    Finalizer는 java.lang.ref.Finalizer에 선언된 package-private 클래스다. 그리고 Object 클래스는 finalize() 메서드를 구현하고 있다. FInalizer는 GC가 일어날 때, Finalizer의 Reference Queue에 적재된 객체들의 finalize() 메서드를 실행해주는 작업을 한다. 따라서 Finalizer를 이용해서 자원을 정리하고 싶다면 우리가 생성한 클래스에서 finalize() 메서드를 재정의해주면 된다. 

    아래 코드는 Finalizer를 사용하면 안되는 이유에 대해서 보여준다. 이유는 Finalizer의 Reference Queue를 처리하는 쓰레드는 GC 쓰레드의 우선순위보다 낮기 때문이다. 따라서 GC가 호출되더라도, Finalizer Reference Queue에는 정리되지 못한 finalize() 대상 객체들이 계속 존재하게 되어서 자원의 정리가 되지 않는다.

    아래 코드가 예시코드다. 아래 코드에서는 다음과 같이 동작한다.

    • FinalizerIsBad() 클래스를 생성한다. 이 클래스는 어디에도 할당되지 않기 때문에 Strong Reference가 없다. 따라서 GC 대상이 된다.
    • Finalizer 객체를 Reflect로 가져와서, 가지고 있는 ReferenceQueue에 객체가 얼마나 찼는지 살펴본다. 
    public class App{
    
        public static void main(String[] args) throws InterruptedException, ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
            int i = 0;
            while(true) {
                i++;
                new FinalizerIsBad();
    
                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);
                    if (queueLength > 0) {
                        System.out.format("There are %d references in the queue%n", queueLength);
                    }
    
                }
            }
        }
    }
    
    // finalize() 재정의 함. 여기서는 예외를 던짐.
    public class FinalizerIsBad {
    
        @Override
        protected void finalize() throws Throwable {
            throw new Exception();
        }
    }

    위 코드에서 해당 문제를 살펴볼 수 있다. 쓰레드가 바쁘지 않아서 GC와 Finalizer가 함께 돌아가고 있다면 항상 Reference Queue에는 0이 된다. 즉, 바로바로 처리 된다는 것을 의미한다. 그런데 이걸 가시화 하기 위해서 finalize() 메서드를 오버라이딩 한 다음 강제로 에러를 던져주면 Finalizer는 정상적으로 Reference Queue에 있는 것을 처리하는 동작을 하지 못한다. 따라서 Finalizer의 Reference Queue에는 finalize() 되지 못한 객체들이 쌓이게 된다. 

    위 실행 결과를 살펴보면 Finalizer 큐에 실행되지 않은 finalize() 메서드가 쌓인 것을 볼 수 있다. 만약 이것들이 모두 자원 정리 대상이라면, 큰 leak가 발생하게 될 것이다. 

    또 한 가지 주의해야 할 점은 finalize() 메서드 내부에서는 다른 객체를 참조하거나 만들지 않는 것이 중요하다. finalize()에서 다른 객체를 참조한다면, GC 대상이 아니기 때문에 객체가 다시 부활하게 될 수 있다. 반면 finalize()에서 새로운 객체를 생성하면 사실상 finalize() 할 때마다 계속 객체가 생성되기 때문에 문제가 발생한다. 

     

    정리

    • Finalizer는 각 클래스의 finalize() 메서드가 GC 될 때 실행된다.
    • Finalizer는 Reference Queue에 finalize()를 쌓아두는데, finalize()를 하는 도중에 에러가 발생하면 Finalizer는 동작하지 않는다. 자원이 정리되지 않고 쌓일 수 있다.
    • Finalizer를 처리해주는 쓰레드는 우선순위가 낮다. 따라서 GC가 일어나도 처리가 바로 되지 않을 수 있다. 

     


    Cleaner 사용 방법 (BigObject)

    GC가 발생하는 시점에 Cleaner는 본인에게 등록된 인스턴스들에 대해서 clean() 작업을 진행한다. 사용하는 방법은 다음과 같다. 

     

    Cleaner 사용 코드

    • Cleaner는 Static 메서드로 생성 메서드를 제공한다. 이 메서드를 이용해서 새로운 클리너를 생성하고, register() 메서드를 이용하면서 <처리한 객체, 클리너가 사용할 함수>를 전달한다.
    • 이렇게 코드를 작성해두면 Cleaner는 GC가 발생했을 때, 등록된 객체들에 대해서 Clean 작업을 진행한다. 
    public class CleanerIsNotGood {
    
        public static void main(String[] args) throws InterruptedException {
            Cleaner cleaner = Cleaner.create();
    
            ArrayList<Object> resourceToCleanUp = new ArrayList<>();
            BigObject bigObject = new BigObject(resourceToCleanUp);
    
            cleaner.register(bigObject, new BigObject.ResourceCleaner(resourceToCleanUp));
    
            bigObject = null;
            System.gc();
            Thread.sleep(3000L);
        }
    }

    위 코드를 실행하면 아래 결과가 나온다.

     

    BigObject 코드 구현 

    이 코드는 위에서 사용할 객체를 하나 구현했다. 구현할 때 주의해야 할 점을 살펴보면 다음과 같다.

    • ResourceCleaner는 Cleaner가 자원을 정리할 때 사용할 메서드다. 이 녀석은 Runnable 인터페이스를 구현해야한다.
    • Cleaner는 GC가 호출되면, clean 작업을 진행한다.
    • ResourceCleaner는 대상 클래스의 참조를 절대로 가져서는 안된다.
      • 참조를 가지게 되면, Strong Reference가 생기면서 GC 대상이 되지 않기 때문이다.
      • ResourceCleaner는 대상 클래스의 내부 클래스로 생성한다면 반드시 static 클래스로 구현해야한다. 그렇게 작성해야 대상 클래스의 참조를 가지지 않는다. (완벽 공략에서 확인 가능) 
      • ResourceCleaner는 정리해야 할 자원만 참조로 가진다. 

     

    public class BigObject {
    
        private List<Object> resource;
    
        public BigObject(List<Object> resource) {
            this.resource = resource;
        }
    
    	// Cleaner에서 사용할 Task
        // Inner 클래스로 사용할 경우, 반드시 static으로 생성.
        public static class ResourceCleaner implements Runnable {
    
    		// BigObject를 참조하면 안됨. 정리할 대상만 참조한다. 
            private List<Object> resourceToClean;
    
            public ResourceCleaner(List<Object> resourceToClean) {
                this.resourceToClean = resourceToClean;
            }
    
            @Override
            public void run() {
                resourceToClean = null;
                System.out.println("cleaned up.");
            }
        }
    }

    자원처리 권장방법 : AutoClosable, Closable 구현 + try-with-resource를 사용

    AutoCloseable, Closeable 인터페이스를 구현한 클래스는 try-with-resource를 이용해서 자원을 회수할 수 있다. 두 인터페이스의 차이점은 AutoCloseable은 Exception을 Throw, Closeable은 IOException을 Throw한다는 점이다. 이 방법이 코드 가독성도 가장 좋으며, try-with-resource가 가지는 장점도 있기 때문에 이 방법을 사용해서 자원을 정리하도록 하는 것이 권장된다. 

    public class AutoClosableIsGood implements AutoCloseable{
    
        private BufferedInputStream inputStream;
    
        @Override
        public void close()  {
            try {
                inputStream.close();
            } catch (IOException e) {
                throw new RuntimeException("failed to close " + inputStream);
            }
        }
    }

    위와 같이 AutoClosable 인터페이스를 구현한 클래스를 작성한다. AutoClosable 클래스는 close() 메서드만 오버라이딩하면 된다. 사용은 아래와 같이 하면 된다. 

    public class App {
        public static void main(String[] args) {
            try (AutoClosableIsGood good = new AutoClosableIsGood();) {
                // TODO 자원 반납 처리가 됨.
            }
        }
    }

    Cleaner는 언제 사용하는게 좋은가? 

    Cleaner는 언제 쓰는게 좋을까? 안정망을 제공하기 위해 사용하는 것이 좋다. 예를 들어 API 개발자는 AutoClosable 구현해두고 try-with-resource를 쓰기를 기대한다. 그렇지만 API를 사용하는 사람은 try-with-resource를 사용하지 않는 상황도 존재할 수 있다. 이런 경우에도 자원이 잘 정리될 수 있도록 Cleaner를 생성 및 등록해주는 것이 좋다. 

     

    Room Class : AutoCloseable 구현 및 Cleaner 안정망 추가

    아래는 Room Class를 구현한 코드다.

    • Room은 AutoCloseable을 구현했다. try-with-resources와 함께 사용되면, 이 절이 끝날 때 close() 메서드가 호출된다.
    • Room은 내부적으로 Cleaner를 가지고 있고, Cleaner가 처리해야 할 Task를 inner class로 가지고 있다. 
    • Room은 Cleaner에게 처리해야 할 Task를 등록했고, 등록 결과로 Cleanable을 가진다. 이 객체는 GC가 동작할 때 동작된다. 
    • close() 메서드에서 cleanable.clean()을 호출할 수 있다.

    위와 같은 방식으로 구현해두면 try-with-resources를 사용하는 유저 입장에도 정상적으로 동작하고, 혹시나 그렇게 사용하지 않는 유저가 있을지라도 등록된 Cleaner의 Task를 통해서 정상적으로 자원 회수를 할 수 있게 된다. 

    // 코드 8-1 cleaner를 안전망으로 활용하는 AutoCloseable 클래스 (44쪽)
    public class Room implements AutoCloseable{
    
        private static final Cleaner cleaner = Cleaner.create();
    
        // 청소가 필요한 자원. 절대 Room을 참조해서는 안 된다!
        private static class State implements Runnable {
    
            int numJunkPiles; // Number of junk piles in this room
    
            public State(int numJunkPiles) {
                this.numJunkPiles = numJunkPiles;
            }
    
            // Close 메서드나, cleaner가 호출한다.
            @Override
            public void run() {
                System.out.println("Cleaning Romm");
                this.numJunkPiles = 0;
            }
        }
    
        // 방의 상태. cleanble과 공유한다.
        private final State state;
        // cleanable 객체. 수거 대상이 되면 방을 청소한다.
        private final Cleaner.Cleanable cleanable;
    
        public Room(int numberOfJunkPiles) {
            this.state = new State(numberOfJunkPiles);
            this.cleanable = cleaner.register(this, state);
        }
    
        @Override
        public void close() {
            cleanable.clean();
        }
    }

     

    Adult vs Teenager

    아래에서는 try-with-resources를 사용하느냐로 close()가 호출되는지를 살펴보고자 한다. 

    • Adult는 try - with - resources를 이용해서 Room을 사용한다. 반면 Teenager는 아무것도 사용하지 않는다. 
    // cleaner 안전망을 갖춘 자원을 제대로 활용하는 클라이언트 (45쪽)
    public class Adult {
        public static void main(String[] args) {
            try (Room myRoom = new Room(7)) {
                System.out.println("안녕~");
            }
        }
    }
    
    
    // Cleaner 안전망을 갖춘 자원을 제대로 활용하지 못하는 클라이언트 (45쪽)
    public class Teenager {
        public static void main(String[] args) {
            new Room(99);
            System.out.println("Peace out");
    
            // 다음 줄의 주석을 해제한 후 동작을 다시 확인해보자.
            // 단, 가비지 컬렉터를 강제로 호출하는 이런 방식에 의존해서는 절대 안 된다!
            System.gc();
        }
    }

    먼저 Adult를 실행해보면 다음 결과가 나온다.

    Teenager에서 GC 호출 부분을 주석처리 하고 실행해보면 왼쪽처럼 자원 회수 메서드가 호출되지 않은 것을 볼 수 있다. 반면 GC 호출 부분을 활성화 한 후에 코드를 실행하면, 오른쪽처럼 자원회수 메서드 (cleaning Romm)이 정상적으로 호출된 것을 볼 수 있다. 


    정리

    • Finalizer, Cleaner 사용을 권장하지 않는다. 왜냐하면 GC가 언제 호출될지 모르기 때문에 자원의 정리 시점이 명확하지 않고, 실행도중 에러가 발생하면 정상동작하지 않기 때문이다.
    • AutoCloseable을 사용하는 것을 권장한다. try - with - resources를 사용하면 간단히 리소스를 정리할 수 있다.
    • Cleaner는 안정망으로 사용하도록만 한다. (자원 반납 필요할 때)
      • 이 때 구현한 Cleaner를 AutoCloseable의 close() 메서드가 호출하도록 구현할 수도 있다. 

    댓글

    Designed by JB FACTORY