Effective Java Item 08

Dong yeong Kim·2022년 7월 10일
0

EffectiveJava

목록 보기
8/14

8. finalizer와 clenaer 사용을 피하라

안녕하세요, 이번 포스팅은 두가지의 소멸자에 대해 포스팅하려고 합니다.

늘 하는 여담으로, 한 달동안 제 감정곡선은 현재 나스닥(?!)처럼 요동치고 있는데요, 이제 저점 다지기를 하는 나스닥처럼... 제 기분도 오르락내리락 하는 것 같습니다.

[흔한 스캠 코인 차트]

가파르게 내린 만큼, 제 기분이 다시 천천히 잘 올라왔으면 좋겠습니다.
그만큼 신중하고, 어떻게보면 예민해지는 나날이기도 하지만, 행복한 것 같습니다.


각설하고, 소멸자에는 제목처럼 finalizer와 cleaner가 있는데요.

두 개 모두 예측이 힘들고, 위험합니다.
finalizer는 자바 9에서 derecated 되었으며, cleaner를 대안으로 소개했지만, 아무래도 찝찝한 부분이 있습니다.

(동작 안하는 것이 아니라, 동작하는 법을 모르는 것 뿐입니다!)

C++의 detructor는 특정 객체와 관련된 자원을 회수하는 보편적인 방식입니다.
하지만 자바에서는, 객체를 회수하는 역할을 가비지 컬렉터가 담당합니다.
C++의 파괴자는 비메모리 자원을 회수하는 용도로도 쓰입니다만, 여기서 JAVA는 try-with-resources, try-finally를 사용하여 해결합니다. (item. 9)

finalizer와 cleaner는 즉시 수행된다는 보장이 없습니다. 실행되기 전까지 얼마나 수행 되었는지 가늠도 어렵습니다.

즉, 이것은 제때 실행되어야 하는 작업은 절대 할 수 없습니다.

책의 예시를 들어, 파일 닫기를 finalizer나 cleaner에 맡기면, 중대한 오류를 일으킬 수 있습니다. 시스템이 동시에 열 수 있는 파일 개수가 한계가 있기 때문입니다.

이것들이 얼마나 수행하는지는 전적으로 가비지 컬렉터에 달렸으며, 이는 가비지 컬렉터의 구현마다 천차만별입니다.

개발에서는 분명 완벽하게 작동한 JVM에서, 시스템에서는 엄청난 재앙을 일으킬 수 있습니다.

대개 finalizer는 다른 애플리케이션 스레드보다 우선 순위가 낮습니다.
그리하여 제때 동작함을 의도한 개발자와의 의도와는 달리, 실행될 기회를 제대로 얻지 못하여 긴 시간동안 대기 - 점유하는 문제가 발생할 수 있습니다.

한편, cleaner는 자신을 수행할 스레드를 제어할 수 있다는 면에서 조금 낫지만, 여전히 백그라운드에서 수행되고, 가비지 컬렉터의 통제하에 있으니 즉각 수행되리라는 보장은 없습니다.

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

예를 들어, DBMS와 같은 공유 자원의 영구 락(lock)해제를 이 둘에게 맡겨 놓으면, 시스템 전체가 서서히 멈출 것입니다.


finalizer의 부작용은 여기서 끝이 아닙니다. 여기서 발생한 예외는 모두 무시되며, 처리할 작업이 남았더라도 그 순간 종료됩니다. 이처럼 훼손된 객체를 사용하려 한다면 어떻게 동작할지 예측할 수 없고, 경고 조차 출력하지 않습니다.

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

이 둘은, 심각한 성능 문제도 동반합니다.
아마도 제 생각에는 임의적으로 메모리를 조작하는 행위기 때문에, 가비지 컽렉터에 영향이 갈 것이라고(효율을 떨어뜨린다고) 생각합니다.

하지만, 잠시 후 예제 코드인 안전망 방식을 사용한다면 훨씬 빨라질 수 있습니다.


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

생성자나 직렬화 과정(item. 12)에서 예외가 발생하면, 생성되다 만 객체에서 악의적인 하위 클래스의 finalizer가 수행될 수 있게 됩니다.

앞에서 설명한 것 처럼, finalizer는 정적 필드에 자신의 참조를 할당하여 가비지 컬렉터가 수집하지 못하게 막을 수 있습니다.

이렇게 객체가 만들어지고 나면, 이 객체의 메소드를 호출해 악의적으로 이용하는 것은 일도 아닙니다.

객체 생성을 막으려면, 생성자에서 예외를 던지는 것만으로 충분하지만, finalizer가 있다면 무용지물입니다.

final 클래스들은 그 누구도 하위 클래스를 만들 수 없으니, 이 공격에서 안전합니다.
하지만 final이 아닌 클래스는 finalizer 공격으로부터 방어하려면, 아무 일도 하지 않는 finalize 메서드를 만들고, final로 선언하면 됩니다.


그래서 자원을 회수할 때 뭘 사용해야 하는지 슬슬 의문을 가질 수 있습니다.

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

그저 AutoCloseable을 구현해주고, 클라이언트에서 인스턴스를 다 쓰고 나면 close 메서드를 호출하면 됩니다. (일반적으로, 예외가 발생해도 제대로 종료되도록 try-with-resources를 사용해야 합니다.)

곧 나타날 예시코드의 방식을 알게되면, 각 인스턴스의 closed 여부를 추적할 수 있습니다. 다시 말해, close 메서드에서 이 객체는 더 이상 유효하지 않음을 필드에 기록하고, 다른 메서드는 이 필드를 검사해 객체가 닫힌 후에 불렸다면 IllegalStateException을 던지면 됩니다.

슬슬... finalizer와 cleaner을 사용하지 말라는 소리로 들리는데, 적절한 쓰임새가 두 가지 있다고 합니다.

첫 번째로, 자원의 소유자가 close 메서드를 호출하지 않는 것에 대한 안전망 역할입니다. cleaner나 finalizer가 호출되리라는 보장은 없지만, 클라이언트가 하지 않는 자원 회수를 늦게나마 회수 하는 것이 좋기 때문입니다.

이런 안전망 역할의 finalizer를 작성할 때는, 자원 관리 면에서나, 여러가지 방면에서 그만한 가치가 있는지 고려해야 합니다.

자바 라이브러리의 일부 클래스는 안전망 역할의 finalizer을 구현한 클래스가 있는데, 다들 한 번 쯤은 사용해본 FileInputStream, FileOutputStream, ThreadPoolExecutor가 있습니다.

    public FileInputStream(File file) throws FileNotFoundException {
        String name = (file != null ? file.getPath() : null);
        SecurityManager security = System.getSecurityManager();
        if (security != null) {
            security.checkRead(name);
        }
        if (name == null) {
            throw new NullPointerException();
        }
        if (file.isInvalid()) {
            throw new FileNotFoundException("Invalid file path");
        }
        fd = new FileDescriptor();
        fd.attach(this);
        path = name;
        open(name);
        altFinalizer = getFinalizer(this);
        if (altFinalizer == null) {
            FileCleanable.register(fd);       // open set the fd, register the cleanup
        }
    }

(FileInputStream의 예시 코드)

두 번째로, 네이티브 피어와 연결된 객체에서 사용됩니다.
네이티브 피어란, 일반 자바 객체가 네이티브 메서드(item. 66)를 통해 기능을 위힘한 네이티브 객체를 의미합니다.

네이티브 피어는 자바 객체가 아니니, 가비지 컬렉터는 그 존재를 알지 못합니다.
결국 자바 피어를 회수할 때 네이티브 객체까지 회수하지 못합니다.
여기서 바로 clenaer나 finalizer가 나서서 처리하기에 적당합니다.
(해당 내용은 추후 item 66에서 자세히 포스팅 하도록 하겠습니다.)

다음은 아까 언급한 cleaner를 안전망으로 활용하는 AutoCloseable 클래스의 예시입니다.

Room 클래스로, 방(room) 자원을 수거하지 전에 반드시 청소(clena)해야 한다고 가정하겠습니다. Room 클래스는 AutoCloseable을 구현합니다.

사실 clenaer를 사용할지 말지는 순전히 내부 구현 방식에 관한 문제입니다.
즉, fianlizer와 달리 cleaner는 클래스의 public API에 나타나지 않습니다.

package item8;

import java.lang.ref.Cleaner;

public class Room implements AutoCloseable{

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

    //청소가 필요한 자원, 절대 Room을 참조하면 안됩니다.
    private static class State implements Runnable {
        int numJunkPiles; // 쓰레기 수

        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) {
        this.state = new State(numJunkPiles);
        this.cleanable = cleaner.register(this, state);
    }

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

(역대 포스팅 하면서 가장 복잡하다고 느껴지는...)

static으로 선언된 State는, 단순히 방 안의 쓰레기 수를 뜻하는 nunJunkPiles 필드가 있습니다.
(더 현실적으로 만드려면 네이티브 피어를 가리키는 포인터를 담은 final long 변수여야 한다고 합니다.)

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

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

이 코드에서 run 메서드가 호출되는 상황은 둘 중 하나입니다.
가장 아래의 close 메서드를 호출할때, close 메서드에서 Clenable의 clean을 호출하면, 이 메서드 안에서 run을 호출합니다.
혹은 가비지 컬렉터가 Room을 회수할 때까지 클라이언트가 close를 호출하지 않는다면(이전의 언급한 것 처럼), cleaner가 state의 run 메서드를 호출 할 것입니다.

State 인스턴스는, 절대로 Room 인스턴스를 참조해서는 안 됩니다.
Room 인스턴스를 참조할 경우, 순환참조가 생겨 가비지 컬렉터가 Room 인스턴스를 회수할(여기서는 자동 청소할) 기회가 오지 않습니다.
State가 정적 중첩 클래스인 이유입니다.

정적이 아닌 중첩 클래스는, 자동으로 바깥 객체의 참조를 갖게 되기 때문입니다.(item. 24) 이와 비슷하게, 람다 역시 바깥 객체의 참조를 갖기 쉬우니 사용하지 않는 것이 좋다고 합니다.


앞서 말한 것 처럼, Room의 clenaer은 단지 안전망으로만 쓰였습니다.
클라이언트가 모든 Room 생성을 try-with-resources 블록으로 감쌌다면, 자동 청소는 전혀 필요하지 않습니다.

아래 예시로 예를 들어보겠습니다.

package item8;

public class Adult {
    public static void main(String[] args) throws Exception {
        try (Room myRoom = new Room(7)) {
            System.out.println("안녕하세요 ~");
        }
    }
}
안녕하세요 ~
방 청소

Process finished with exit code 0

기대한 대로, Adult 프로그램은 "안녕하세요~"를 출력 후, "방 청소"를 출력합니다.

이번에는, 결코 방 청소를 하지 않는 예시입니다.

package item8;

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

Process finished with exit code 0

"아무렴"에 이어 "방 청소"가 출력되리라 예상했으나, 출력되지 않습니다.

앞서 설명한 cleaner가 "예측할 수 없다"고 한 상황입니다.

cleaner 명세에는
System.exit을 호출할 때의 cleaner 동작은 구현하기 나름이다. 청소가 이뤄질지는 보장하지 않는다."
라고 기술되어 있습니다.

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

굳이 방 청소를 출력하고 싶다면(쓰레기를 치우고 싶다면)main 메서드의 System.gc()를 추가하는 것으로 종료 전에 "방 청소"를 출력할 수 있었지만(다시 한 번 말하는데 '있었지만') 그러리라는 보장은 없습니다.

정리하자면, cleaner(자바 8까지는 finalizer)는 안전망 역할이나 중요하지 않는 네이티브 자원 회수용으로만 사용해야 합니다. 물론 이런 경우라도 불확실성과 선응 저하에 주의해야합니다.


회고

이번 포스팅은 좀 어려운 내용을 담고 있었습니다.
아마 메모리를 직접 관리해야 하는 좀 더 논리적으로 깊숙한 것들의 내용이다 보니 그런 것 같습니다.

하지만, 다음 아이템과(try-with-resources) 깊은 연관이 있다는 것을 암시하는 아이템입니다.

이것들을 적절히 활용하여 완벽하게 이해하도록 노력하겠습니다.

profile
날 것의 기술 '불'로그

0개의 댓글