본문 바로가기
Java/Java

Finalizer와 Cleaner가 아닌 AutoCloseable을 사용하자

by oneny 2023. 8. 10.
finalizer와 cleaner는 기본적으로 자원을 반납할 때 사용하는데 둘 모두 우리가 원하는 순간에 실행된다는 보장이 없다.

 

자원을 반납해야 하는 이유

OS마다 open할 수 있는 개수가 제한이 되어있다. 파일을 open할 때마다 파일 핸들러라는 것이 만들어지는데 쉽게 생각하면 id라고 보면 된다. 이렇게 파일을 너무 많이 open하면 리눅스 기준 "Too many open files" 에러가 발생하게 된다. 이러한 에러가 발생하는 이유 중 하나로 InputStream, OutputStream 같은 자원을 제대로 반납하지 않는 경우 즉, 열려있는 커넥션을 제대로 정리해주지 않기 때문에 발생한다.

따라서 자원을 잘반납하라고 만든 것이 finalizer와 cleaner이지만 의도와 다르게 제목처럼 사용을 피해야 한다. finalizer는 cleaner 보다 위험하고, finalizer와 cleaner 둘 다 언제 실행될지 보장할 수 없고 실행 자체가 안될 수 있는 문제가 있다. 이 말은 리소스가 반납이 안될 수 있다는 말이다. 그리고 finalizer는 우리가 접근할 수 있다. public 클래스가 아닌 package 레벨의 클래스로 기본적으로는 접근할 수 없어 우리가 참조해서 쓸 수 없다. public 클래스이기 때문에 cleaner는 우리가 접근할 수 있다. 

 

Finalizer

자바 문서에는 다음과 같이 써져있다.
어떤 객체가 더 이상 자신을 참조하지 않는다고 가비지 수집기가 판단하면 그 객체에 있는 finalize() 메서드를 호출한다. 서브클래스는 finalize() 메서드를 오버라이드해서 시스템 리소스를 처분하는 등 기타 정리 작업을 수행한다.

 

 

finalize() 메서드 살펴보기

public class FinalizerIsBad {

  @Override
  protected void finalize() throws Throwable {
    System.out.println("");
  }
}

Finalizer는 Object 클래스에 정의되어 있는 메서드로 Java 9버전부터 사용을 자제하라는 것을 확인할 수 있고, PhantomReference나 WeakReference 또는 Cleaner를 권장하도록 나와있는데 그것보다는 가장 적절한 해결책은 AutoCloseable을 사용하는 것이다.

 

종료화 대상으로 등록된 객체의 생명주기

종료화 큐 비우기

  1. 종료화가 가능한 객체는 큐로 이동한다.
  2. 애플리케이션 스레드 재시작 후, 별도의 종료화 스레드가 큐를 비우고 각 객체마다 finalize() 메서드를 실행한다.
  3. finalize()가 종료되면 객체는 다음 GC 사이클에 진짜 수집될 준비를 마친다.

가비지 수집 중 즉시 회수되지 않고 종료화 대상으로 등록된 객체는 위처럼 수명이 연장된다. 다시 말해 종료화할 객체는 모두 GC 마킹을 해서 도달 불가능한 객체로 인식시키고, 종료화한 다음에 반드시 GC를 재실행해서 데이터를 다시 수집해야 한다. 따라서 종료화 가능한 객체는 적어도 한 번의 GC 사이클이 더 보존된다.

 

종료화를 구현한 코드는 대부분 자바로 작성되어 있고, JVM은 대부분의 필요한 작업을 처리하는 애플리케이션 스레드와 함께 별도의 스레드를 만들어 종료화를 수행한다. 핵심 기능은 java.lang.ref.Finalizer에 구현돼 있고 위에서 볼 수 있듯이 static 필드인 ReferenceQueue가 있는 것을 확인할 수 있다.

 

또 종료화 구현체는 FinalReference 클래스에 크게 의존한다. 이전 블로그에서 작성했던 Soft/Weak Reference처럼 FinalRerefence 객체 역시 GC 서브시스템이 특별하게 처리한다. 이 말은 C++과 같은 언어에서는 프로그래머가 직접 객체 수명을 명시적으로 관리하여 리소스 획득/해제가 객체 수명과 직접적인 연관이 있어 객체가 삭제되면 바로 해제된다. 반면, 자바의 메모리 관리 시스템은 할당할 가용 메모리가 부족하면 그때그때 반사적으로 GC를 실행시키기 때문에 GC가 언제 일어날지는 아무도 몰라 객체가 수집될 때에만 실행되는 finalize() 메서드도 언제 실행될지 알 길이 없다.

따라서 가비지 수집은 딱 정해진 시간에 실행되는 법이 없으므로 종료화를 통해 자동으로 리소스 자원을 관리한다는 것은 어불성설이다. 리소스 해제와 객체 수명을 엮는 장치가 따로 없으니 항상 리소스가 고갈될 위험에 노출되어 있다고 생각해도 무방하다. 이러한

이러한 이유로 자바 9부터는 Object.finalize()는 deprecated되었다.

 

Finalizer 또다른 문제점

종료화 스레드 실행 도중 메서드에서 예외가 발생하면 어떻게 될까? 이때 유저 애플리케이션 코드 내부에는 아무런 컨텍스트도 없기 때문에 발생한 예외는 그냥 무시된다. 따라서 종료화 도중 발생한 오류는 개발자도 어쩔 도리가 없다.

또한, 위에서 설명했듯이 종료화에 블로킹 작업이 있을지 모르니 JVM이 스레드를 하나 더 만들어 finalize() 메서드를 실행해야 한다. 따라서 새 스레드를 생성/실행하는 오버헤드를 감수해야 한다

 

Finalizer 문제점 코드로 살펴보기

public class App {

  /**
   * 코드 참고 https://www.baeldung.com/java-finalize
   */
  public static void main(String[] args) throws 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);
        System.out.printf("There are %d references in the queue%n", queueLength);
      }
    }
  }
}

Object 클래스가 제공하는 finalize() 메서드를 재정의해서 new FinalizerIsBad();를 통해 생성자를 호출해서 객체를 계속 무한성 생성하도록 만들었다. 그리고 참조하는 변수를 없기 때문에 바로 GC의 대상이 된다.

그 뒤에 객체를 100만개를 만들때마다 Reflection을 사용해서 한 번씩 Finalizer 클래스를 참조해서 ReferenceQueue에 들어간 객체가 얼마나 있는지를 출력한다.

출력되는 결과를 보면 알 수 있듯이 계속해서 쌓이다 결국 OutOfMemoryError가 발생한 것을 확인할 수 있다. 왜 GC를 하지 못하는 것일까? 이는 Queue를 처리하는 해당 스레드의 우선순위가 낮아 객체를 만드느라 너무 바빠 ReferenceQueue를 비우지 못해 정리하지 못한 것이다. 이러한 이유도 Finalizer를 권장하지 않는 이유 중 하나이다.

그리고 finalizer() 메서드 안에서 다른 객체를 참조한다거나 본인 자신을 참조하는 등의 GC를 하기 위해 finalize() 메서드를 실행하려는데 오히려 객체가 늘어나는 이슈가 발생할 수도 있다.

 

Finalizer 공격

public class Account {

  private String accountId;

  public Account(String accountId) {
    this.accountId = accountId;
    
    if (accountId.equals("oneny")) {
      throw new IllegalArgumentException("oneny는 계정을 막습니다");
    }
  }

  public void transfer(BigDecimal amount, String to) {
    System.out.printf("transfer %f from %s to %s\n", amount, accountId, to);
  }
}

위 Account 클래스는 accountId를 필드로 가지는데 만약 oneny로 인자가 들어오면 IllegalArgumentException을 발생시키도록 작성했다.

 

public class Main {

  public static void main(String[] args) throws Throwable {
    Account onenyAccount = new Account("oneny");
    onenyAccount.transfer(BigDecimal.valueOf(100), "hello");
  }
}

따라서 위처럼 accountId를 oneny로 가지는 객체를 생성하려고 하면 예외가 발생하게 된다.

 

public class BrokenAccount extends Account {

  public BrokenAccount(String accountId) {
    super(accountId);
  }

  @Override
  protected void finalize() throws Throwable {
    this.transfer(BigDecimal.valueOf(100), "twony");
  }
}

하지만 Account를 상속받은 BrokenAccount 클래스를 finalize()를 재정의하면 accountId를 oneny로 가지는 객체도 twony에게 transfer를 실행시킬 수 있게 된다.

 

public class Main {

  public static void main(String[] args) throws Throwable {

    Account brokenAccount = null;

    try {
      brokenAccount = new BrokenAccount("oneny");
    } catch (IllegalArgumentException e) {
      System.out.println("이러면??");
    }

    System.gc();
    Thread.sleep(3000L);
  }
}

위 코드를 보면 BrokenAccount 생성자에 인자로 "oneny"를 넘겨줬고 당연히 IllegalArgumentException이 발생한다. 그 뒤에 GC가 발생하도록 작성했는데 GC가 일어나면 아까 재정의해두었던 finalize() 메서드가 호출되면서 아까 막아두었던 oneny가 twony로 transfer() 메서드가 호출되면서 돈을 보낼 수 있게 되는 문제가 발생한다.

 

방어하는 방법

final 클래스로 만들거나 finalize() 메서드를 오버라이딩한 다음 final을 붙여서 하위 클래스에서 오버라이딩할 수 없도록 막는다.

 

Cleaner

자바 9부터 권장하고 있는 방법 중 하나인 Cleaner는 다음처럼 사용할 수 있다.

public class BigObject {

  private List<Object> resource;

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

  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.");
    }
  }
}

필드인 resource는 정리가 되어야하는 용도로 생각하면 좋다. 그리고 정리하는 작업을 Runnable 인터페이스를 작업을 구현한다. finalizer() 메서드에서 하는 일은 Runnable 인터페이스 구현체에서 한다고 생각하면 된다.

이 때, 주의해야할 점은 inner 클래스로 만들 경우에 static 클래스로 만들어야 하고, 절대 BigObject 클래스 즉, 상위 클래스의 레퍼런스가 존재해서는 안 된다. 만약 중첩 클래스로 사용할 때 정적이 아닌 중첩 클래스면 자동으로 바깥 객체의 참조를 갖기 때문에 상위 클래스의 레퍼런스를 정리하지 못할 수 있다.

clean해주는 작업을 하는데 정리하려는 BigObject를 참조하게 되면 다시 객체가 부활할 수도 있는 이슈가 생길 수 있다. 따라서 inner 클래스에서 참조하는 값은 BigObject 인스턴스가 아닌 BigObject가 들고 있는 resource 필를 참조하도록 만들어야 한다. 

 

Cleaner 사용하기

내가 정리해야하는 객체와 정리하는 작업을 정의했으면 아래 코드처럼 Cleaner를 사용해보자. Cleaner 자체가 PhantomReference를 사용해서 만들어져 있기 때문에 Cleaner를 사용하는 것 자체 PhantomReference를 쓰는 것과 비슷하다.

 

public class CleanerIsNotGood {

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

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

    // 어떤 object가 gc될 때, 두 번째 인자처럼 Runnable 구현체를 사용해서 자원을 해제
    cleaner.register(bigObject, new BigObject.ResourceCleaner(resourceToCleanUp));

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

팩토리 메서드 패턴을 통해 cleaner 인스턴스를 생성하고, 사용하고 싶은 객체(bigObject, resourceToCleanup)을 만든다.  그리고 cleaner.register(bigObject, new BigObject.ResourceCleaner(resourceToCleanUp));로 cleaner에 등록한다. 그러면 bigObject가 GC가 될 때 new BigObject.ResourceCleaner(resourceToCleanUp)으로 작업을 해제한다.

 

AutoCloseable

가장 권장하는 방법인 AutoCloseable에 대해서 알아보자.

 

public interface AutoCloseable {

    void close() throws Exception;
}

인터페이스에 정의된 메서드에서 Exception 타입으로 예외를 던지지만 실제 구현체에서는 구체적인 예외를 던지는 것을 추천하며 구체적인 예외를 던지는 경우에는 사용하는 클라이언트 코드에서 예외를 처리해야하기 때문 가능하다면 예외를 던지지 않는 것도 권장한다. 이 인터페이스를 사용하면 장점은 뒤에서 살펴보겠지만 try-with-resources에 사용하여 finally 블럭을 사용하지 않고 자원을 반납할 수 있다는 것이다.

또한 close() 메서드는 idempotent하도록 권장하고 있다.

close()를 구현할 때 알면 좋은점이 있다. 즉, 여러 번 호출되더라도 같은 상태를 유지하도록 idempotent하다고 하는데 

 

AutoCloseable 인터페이스를 상속받은 Closeable 인터페이스의 특징
IOException을 던지며 반드시 idempotent해야 한다.

 

AutoCloseable 예시

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);
    }
  }
}

AutoCloseable 인터페이스를 구현하면 close() 메서드 하나만 재정의해야 한다. catch 블록에서 로깅 정도로만 할 수 있지만 위 방법처럼 RuntimeException으로 unchekcedException으로 변환하는 방법이 있다(구체적인 RuntimeException일수록 좋다). 이러한 방법을 사용하면 클라이언트 코드에서 굳히 예외를 잡지 않아도 되고, 예외가 발생한 스레드는 해당 예외를 던지면서 종료가 될 것이다.

 

AutoCloseable을 사용하는 클래스에서는 다음처럼 사용하면 된다.

public class App {

  public static void main(String[] args) {

    try (AutoCloseableIsGood goood = new AutoCloseableIsGood()) {
      // TODO 자원 반납 처리가 됨
    }
  }
}

보통은 try-catch-finally 구문에서 finally에서 자원을 정리하는 작업을 하는데 위 코드처럼 사용하는 방법을 권장한다. 이에 대한 자세한 내용은 try-with-resources에 대해 공부할 때 자세히 알아보자.

 

아무튼 이러한 방법을 사용하면 자원을 다 사용하고 나서 정리해준다. 아직 inputStream 객체가 참조하는 없는데 inputStream.close()가 메서드가 실행돼서 오류가 난 것을 통해 정리하는 작업을 한다는 것을 알 수 있다.

 

AutoCloseable, 근데 이제 Cleaner 곁들인..

그러면 Cleaner는 언제 사용하면 좋을까? 위처럼 AutoCloseable을 구현한 클래스를 만들어 놓고 try-with-resources를 쓰길 권장하지만 사용하는 입장에서는 try-with-resources를 사용하고 싶지 않을 수 있다. 따라서 사용하는 쪽에서 try-with-resources를 안사용하더라도 GC를 할 때 자원이 반납이 되는 기회를 가질 수 있는 안전망의 용도로 사용할 때 Cleaner를 사용할 수 있다.

 

Room

import java.lang.ref.Cleaner;

// 코드 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

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

    @Override
    public void run() {
      System.out.println("Cleaning room");
      numJunkPiles = 0;
    }
  }

  // 방의 상태. cleanable과 공유한다.
  private final State state;

  // cleanable 객체. 수거 대상이 되면 방을 청소한다.
  private final Cleaner.Cleanable cleanable;

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


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

 

 

Teenager

// cleaner 안전망을 갖춘 자원을 제대로 활용하지 못하는 클라이언트 (45쪽)
public class Teenager {

  public static void main(String[] args) {
    new Room(99);
    System.out.println("Peace out");

    // 다음 줄의 주석을 해제한 후 동작을 확인해보자.
    // 단, 가비지 컬렉터를 강제로 호출하는 이런 방식에 의존해서는 안 된다!
//    System.gc();
  }
}

Teenager 클래스는 그냥 new Room을 만들어서 끝낸다. 따라서 System.gc();를 통해 GC를 강제로 호출하지 않으면 자원 반납이 되지 않고 그냥 끝날 수도 있다. 이 경우처럼 혹시라도 

 

 

Adult

// cleaner 안전망을 갖춘 자원을 제대로 활용하는 클라이언트 (45쪽)
public class Adult {
  public static void main(String[] args) throws Exception {
    try (Room myRoom = new Room(7)) {
      System.out.println("안녕~");
    }
  }
}

Adult 클래스는 Teenager 클래스와 다르게 try-with-resources를 사용한다. 즉, 우리가 원하는 의도대로 사용하고 있다. 우리가 의도대로 사용하는 경우라면 자원이 잘 반납이 되는 것을 확인할 수 있다.

 

 

 

번외

 

중첩 클래스(Inner Class)와 람다(Lambda)

public class OuterClass {

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

  class InnerClass {

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

  public static void main(String[] args) {
    OuterClass outerClass = new OuterClass();
    InnerClass innerClass = outerClass.new InnerClass();
    System.out.println(innerClass);

    outerClass.printFields();

    innerClass.hello();
  }

  private void printFields() {
    Field[] declaredFields = InnerClass.class.getDeclaredFields();
    for (Field field : declaredFields) {
      System.out.println("field type: " + field.getType());
      System.out.println("field name: " + field.getName());
    }
  }
}

위에서 정적이 아닌 중첩 클래스는 자동으로 바깥 객체의참조를 갖는다고 설명했다. printFields() 메서드를 보면 InnerClass 인스턴스의 모든 필드를 출력하면 outerClass 타입의 this&0이라는 레퍼런스를 참조하고 있는 것을 확인할 수 있다. 따라서 InnerClass에서 OuterClass 클래스의 메서드를 호출할 수 있는 것이다. 

따라서 외부 레퍼런스가 생기기 때문에 Cleaner를 만들 때 별도의 클래스로 만드는 것은 상관없지만 중첩 클래스로 만드는 경우 외부 레퍼런스가 생겨 순환참조 문제가 발생하기 때문에 static로 정의하여 정적으로 만들어야 한다.

 

public class LambdaExample {

  private int value = 10;

  private final Runnable instanceLambda = () -> {
    System.out.println(value);
  };

  public static void main(String[] args) {
    LambdaExample example = new LambdaExample();
    Field[] declaredFields = example.instanceLambda.getClass().getDeclaredFields();
    for (Field field : declaredFields) {
      System.out.println("field type: " + field.getType());
      System.out.println("field name: " + field.getName());
    }
  }
}

람다에서도 역시 바깥 객체의 참조를 갖기 쉽다. 위 출력된 결과를 보면 상위 클래스 타입의 레퍼런스를 참조하고 있는 것을 확인할 수 있다. 이유는 람다식 내부에서 상위 클래스의 필드를 참조하고 있기 때문이다.

 

private final Runnable instanceLambda = () -> {
//    System.out.println(value);
};

 

 

 

 

위처럼 만약 상위 클래스의 필드를 참조하고 있지 않은 경우에는 상위 클래스의 레퍼런스를 참조하지 않아 아무것도 출력되지 않은 것을 확인할 수 있다.

 

핵심 정리

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

 

출처

자바 최적화 : 가장 빠른 성능을 구현하는 검증된 10가지 기법

이펙티브 자바 Effective Java 3/E - finalizer와 cleaner 사용을 피하라

이펙티브 자바 완벽 공략 1