티스토리 뷰

자바는 두 가지 객체 소멸자를 제공합니다.

그 중 finalizer는 예측할 수 없고, 상황에 따라 위험할 수 있어 일반적으로 불필요합니다.

오동작, 낮은 성능, 이식성 문제의 원인이 되기도 합니다.

finalizer는 나름의 쓰임새가 몇 가지 있긴 하지만 기본적으로 쓰지 말아야 합니다.

그래서 자바 9에서는 finalizer를 사용 자제(deprecated) API로 지정하고 cleaner를 그 대안으로 소개합니다.

⇒ 하지만 자바 라이브러리에서도 finalizer를 여전히 사용합니다.

cleaner는 finalizer보다는 덜 위험하지만, 여전히 예측할 수 없고, 느리고, 일반적으로 불필요합니다.

자바에서는 접근할 수 없게 된 객체를 회수하는 역할을 가비지 컬렉터가 담당하고, 프로그래머에게는 아무런 작업도 요구하지 않습니다.

try-with-resources와 try-finally를 사용해 비메모리 자원을 회수하는 용도로 쓰입니다. (아이템 9)

finalizer와 cleaner는 즉시 수행된다는 보장이 없습니다.

객체에 접근할 수 없게 된 후 finalizer나 cleaner가 실행되기까지 얼마나 걸릴지 알 수 없습니다.

즉, finalizer와 cleaner로는 제때 실행되어야 하는 작업은 절대 할 수 없습니다.

예컨대 파일 닫기를 finalizer나 cleaner에 맡기면 중대한 오류를 일으킬 수 있습니다.

시스템이 동시에 열 수 있는 파일 개수에 한계가 있기 때문입니다.

시스템이 finalizer나 cleaner 실행을 게을리해서 파일을 계속 열어 둔다면 새로운 파일을 열지 못해 프로그램이 실패할 수 있습니다.

 

finalizer나 cleaner를 얼마나 신속히 수행할지는 전적으로 가비지 컬렉터 알고리즘에 달렸으며, 이는 가비지 컬렉션 구현마다 천차만별입니다.

finalizer나 cleaner 수행 시점에 의존하는 프로그램의 동작 또한 마찬가지입니다.

여러분이 테스트한 JVM에서는 완벽하게 동작하던 프로그램이 가장 중요한 고객의 시스템에서는 엄청난 재앙을 일으킬지도 모릅니다.

 

굼뜬 finalizer 처리는 현업에서도 실제로 문제를 일으킵니다.

클래스에 finalizer를 달아두면 그 인스턴스의 자원 회수가 제멋대로 지연될 수 있습니다.

자바 언어 명세는 finalizer나 cleaner의 수행 시점뿐 아니라 수행 여부조차 보장하지 않습니다.

접근할 수 없는 일부 객체에 딸린 종료 작업을 전혀 수행하지 못한 채 프로그램이 중단될 수도 있다는 이야기입니다.

따라서 프로그램 생애주기와 상관없는, 상태를 영구적으로 수정하는 작업에서는 절대 finalizer나 cleaner에 의존해서는 안 됩니다.

예를 들어 데이터베이스 같은 공유 자원의 영구 lock 해제를 finalizer나 cleaner에 맡겨 놓으면 분산 시스템 전체가 서서히 멈출 것입니다.

 

System.gc나 System.runFinalization 메서드에 현혹되지 맙시다.

finalizer나 cleaner가 실행될 가능성을 높여줄 수는 있으나, 보장해주진 않습니다.

사실 이를 보장해주겠다는 메서드가 2개 있었습니다.

바로 System.runFinalizersOnExit와 그 쌍둥이인 Runtime.runFinalizersOnExit입니다.

하지만 이 두 메서드는 심각한 결함때문에 수십 년간 지탄받아 왔습니다. [ThreadStop]

 

finalizer의 부작용은 여기서 끝이 아닙니다.

finalizer 동작 중 발생한 예외는 무시되며, 처리할 작업이 남았더라도 그 순간 종료됩니다.

잡지 못한 예외 때문에 해당 객체는 자칫 마무리가 덜 된 상태로 남을 수 있습니다.

그리고 다른 스레드가 이처럼 훼손된 객체를 사용하려 한다면 어떻게 동작할지 예측할 수 없습니다.

보통의 경우엔 잡지 못한 예외가 스레드를 중단시키고 스택 추적 내역을 출력하겠지만, 같은 일이 finalizer에서 일어난다면 경고조차 출력하지 않습니다.

그나마 cleaner를 사용하는 라이브러리는 자신의 스레드를 통제하기 때문에 이러한 문제가 발생하지 않습니다.

finalizer와 cleaner는 심각한 성능 문제도 동반합니다.

내 컴퓨터에서 간단한 AutoCloseable 객체를 생성하고 가비지 컬렉터가 수거하기까지 12ns가 걸린 반면(try-with-resources로 자신을 닫도록 했음), finalizer를 사용하면 550ns가 걸렸습니다.

다시 말하면 finalizer를 사용한 객체를 생성하고 파괴하니 50배나 느린 셈입니다.

finalizer가 가비지 컬렉터의 효율을 떨어뜨리기 때문입니다.

cleaner도 클래스의 모든 인스턴스를 수거하는 형태로 사용하면 성능은 finalizer와 비슷합니다.

⇒ 안전망 형태로만 사용하면 훨씬 빠르다고 합니다.

finalizer를 사용한 클래스는 finalizer 공격에 노출되어 심각한 보안 문제를 일으킬 수도 있습니다.

finalizer 공격 원리는 간단합니다.

생성자나 직렬화 과정에서 예외가 발생하면, 이 생성되다 만 객체에서 악의적인 하위 클래스의 finalizer가 수행될 수 있게 됩니다. 절대 있어서는 안 될 일입니다.

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

이렇게 일그러진 객체가 만들어지고 나면, 이 객체의 메서드를 호출해 애초에는 허용되지 않았을 작업을 수행하는 건 일도 아닙니다.

객체 생성을 막으려면 생성자에서 예외를 던지는 것만으로 충분하지만, finalizer가 있다면 그렇지도 않습니다.

이러한 공격은 끔찍한 결과를 초래할 수 있습니다.

final 클래스들은 그 누구도 하위 클래스를 만들 수 없으니 이 공격에서 안전합니다.

final이 아닌 클래스를 finalizer 공격으로부터 방어하려면 아무 일도 하지 않는 finalize 메서드를 만들고 final로 선언해야 합니다.

 

그렇다면 파일이나 스레드 등 종료해야 할 자원을 담고 있는 객체의 클래스에서 finalizer나 cleaner를 대신해줄 묘안을 무엇이 있을까요?

그저 AutoCloseable을 구현해주고, 클라이언트에서 인스턴스를 다 쓰고 나면 close 메서드를 호출하면 됩니다.

구체적인 구현법과 관련하여 알아두면 좋을 게 하나 있습니다.

각 인스턴스는 자신이 닫혔는지를 추적하는 것이 좋습니다.

다시 말해, close 메서드에서 이 객체는 더 이상 유효하지 않음을 필드에 기록하고, 다른 메서드는 이 필드를 검사해서 객체가 닫힌 후에 불렸다면 IllegalStateException을 던지는 것입니다.

 

finalizer와 cleaner의 적절한 쓰임새가 두 가지 있습니다.

[1]. 자원의 소유자가 close 메서드를 호출하지 않는 것에 대비한 안전망 역할입니다.

이런 안전망 역할의 finalizer를 작성할 때에는 그럴만한 값어치가 있는지 심사숙고해야 합니다.

대표적인 예로는 FileInputStream, FileOutputStream, ThreadPoolExecutor입니다.

[2]. 네이티브 피어와 연결된 객체에서 쓰입니다.

네이티브 피어란 일반 자바 객체가 네이티브 메서드를 통해 기능을 위임한 네이티브 객체를 말합니다.

네이티브 피어는 자바 객체가 아니니 가비지 컬렉터는 그 존재를 알지 못합니다.

cleaner나 finalizer가 나서서 처리하기에 적당한 작업입니다.

단, 성능 저하를 감당할 수 있고 네이티브 피어가 심각한 자원을 가지고 있지 않을 때에만 해당됩니다.

성능 저하를 감당할 수 없거나 네이티브 피어가 사용하는 자원을 즉시 회수해야 한다면 앞서 설명한 close 메서드를 사용해야 합니다.

 

cleaner는 사용하기에 조금 까다롭습니다.

다음의 Room 클래스로 이 기능을 설명해보겠습니다.

방(room) 자원을 수거하기 전엔 반드시 청소(clean)해야 한다고 가정해봅시다.

Room 클래스는 AutoCloseable을 구현합니다.

사실 자동 청소 안전망이 cleaner를 사용할지 말지는 내부 구현 방식에 관한 문제입니다.

즉, finalizer와 달리 cleaner는 클래스의 public API에 나타나지 않는다는 이야기입니다.

public class Room implements AutoCloseable {
    private statice final Cleaner cleaner = Cleaner.create();

    // 청소가 필요한 자원. 절대 Room을 참조해서는 안 된다!
    private static class State implements Runnable {
        int numJunkPiles; // 방(Room) 안의 쓰레기 수
        
        State(int numJunkPiles) {
            this.numJunkPiles = numJunkPiles;
        }

        // close 메서드나 cleaner가 호출한다.
        @Override public void run() {
            System.out.println("방 청소");
            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() {
        cleanable.clean();
    }
}

static으로 선언된 중첩 클래스인 State는 cleaner가 방을 청소할 때 수거할 자원들을 담고 있습니다.

이 예에서는 단순히 방 안의 쓰레기 수를 뜻하는 numJunkPiles 필드가 수거할 자원에 해당합니다.

더 현실적으로 만드려면 이 필드는 네이티브 피어를 가리키는 포인터를 담은 final long 변수여야 합니다.

State는 Runnable을 구현하고, 그 안의 run 메서드는 cleanable에 의해 딱 한 번만 호출될 것입니다.

이 cleanable 객체는 Room 생성자에서 cleaner에 Room과 State를 등록할 때 얻습니다.

run 메서드가 호출되는 상황은 둘 중 하나입니다.

보통은 Room의 close 메서드를 호출할 때입니다.

close 메서드에서 Cleanable의 clean을 호출하면 이 메서드 안에서 run을 호출합니다.

혹은 가비지 컬렉터가 Room을 회수할 때까지 클라이언트가 close를 호출하지 않는다면, cleaner가 State의 run 메서드를 호출해줄 것입니다.

 

State 인스턴스는 절대로 Room 인스턴스를 참조해서는 안 됩니다.

Room 인스턴스를 참조할 경우 순환참조가 생겨 가비지 컬렉터가 Room 인스턴스를 회수해갈(자동 청소될) 기회가 오지 않습니다.

State가 정적 충첩 클래스인 이유가 여기에 있습니다.

정적이 아닌 중첩 클래스는 자동으로 바깥 객체의 참조를 갖게 되기 때문입니다. (아이템 24)

이와 비슷하게 람다 역시 바깥 객체의 참조를 갖기 쉬우니 사용하지 않는 것이 좋습니다.

 

앞서 이야기한 대로 Room의 cleaner는 단지 안전망으로만 쓰였습니다.

클라이언트가 모든 Room 생성을 try-with-resoruces 블록으로 감쌌다면 자동 청소는 전혀 필요하지 않습니다.

다음은 잘 짜인 클라이언트 코드의 예입니다.

public class Adult {
    public static void main(String[] args) {
        try (Room myRoom = new Room(7)) {
            System.out.println("안녕~");
        }
    }
}

기대한 대로 Adult 프로그램은 "안녕~"을 출력한 후, 이어서 "방 청소"를 출력합니다.

이번엔 결코 방 청소를 하지 않는 다음 프로그램을 살펴봅시다.

public class Teenager {
    public static void main(String[] args) {
        new Room(99);
        System.out.println("아무렴");
    }
}

이 프로그램의 결과는 "아무렴"에 이어 "방 청소"가 출력되지 않을까요?

하지만 필자의 컴퓨터에서는 "방 청소"는 한 번도 출력되지 않았습니다.

앞서 '예측할 수 없다'고 한 상황입니다.

cleaner의 명세에는 이렇게 쓰여 있습니다.

💡
System.exit을 호출할 때의 cleaner 동작은 구현하기 나름이다. 청소가 이뤄질지는 보장하지 않는다.

 

명세에선 명시하지 않았지만 일반적인 프로그램 종료에서도 마찬가지입니다.

필자의 컴퓨터에서는 Teenager의 main 메서드에 System.gc()를 추가하는 것으로 종료 전에 "방 청소"를 출력할 수 있었지만, 우리의 컴퓨터에서도 그러리라는 보장은 없다고 합니다.

정리

  • cleaner(자바 8까지는 finalizer)는 안전망 역할이나 중요하지 않은 네이티브 자원 회수용으로만 사용하자.
  • 물론 이런 경우라도 불확실성과 성능 저하에 주의해야 한다.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
글 보관함