[교재 EffectiveJava] 아이템 8. finalizer와 cleaner 사용을 피하라.

반응형
728x90
반응형

finalizer, cleaner 사용을 피하라

자바는 finalizer, cleaner이라는 두가지 객체 소멸자를 제공한다. 결론을 미리 말하자면, finalizer, cleaner 사용은 피해야한다. 

두 객체 모두 즉시 수행된다는 보장이 없다. 객체에 접근할 수 없게 된 후 finalizer나 cleaner가 실행되기까지 얼마나 걸릴지는 알 수 없다. 때문에 제때 실행되어야 하는 작업은 절대 할 수 없다.

finalizer, cleaner를 얼마나 신속하게 수행할지는 전적으로 가비지 컬렉터 알고리즘에 달렸으며, 이는 가비지 컬렉터 구현마다 각기 다르다. 

 

 

finalizer 구현 예제

finalizer 구현예제
public class FinalizerIsBad {
    @Override
    protected void finalize() throws Throwable {
        System.out.print("");
    }
}

Object.finalize()를 오버라이딩하여 구현하면 된다.

public class Object {
    ...
    
    @Deprecated(since="9")
    protected void finalize() throws Throwable { }
}

@Deprecated 어노테이션으로 JDK9 버전 이후로는 사용하지 말라고 명시되어있다.

 

 

cleaner 구현 예제

cleaner 구현 예제
public class BigObject {

    private List<Object> resource; // 정리해줘야하는 객체

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

    // 정리를 실행 (별도의 Runnable Task로 만든다.)
    // inner class : 반드시 static
    // 위 BigObject를 참조하면 안된다.
    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.");
        }
    }
}

run() 메서드를 구현해야한다.

public class CleanerIsNotGood {

    /**
     * Phantom Reference와 비슷
     * @param args
     * @throws InterruptedException
     */
    public static void main(String[] args) throws InterruptedException {
        Cleaner cleaner = Cleaner.create();

        List<Object> resourceToCleanUp = new ArrayList<>();
        BigObject bigObject = new BigObject(resourceToCleanUp);
        // cleaner에 등록한다.
        // 어떤 오브젝트가 gc가 될때 아래 task를 사용해서 객체를 해제하라. 라는 명시
        cleaner.register(bigObject, new BigObject.ResourceCleaner(resourceToCleanUp));

        bigObject = null; // 참조 해제
        System.gc(); // gc 발생
        Thread.sleep(3000L);
    }

}

아래의 코드를 기억하자.

// 어떤 오브젝트가 gc가 될때 아래 task를 사용해서 객체를 해제하라. 라는 명시
cleaner.register(bigObject, new BigObject.ResourceCleaner(resourceToCleanUp));

 

 

finalizer, cleaner

finalizer

finalizer 스레드가 다른 애플리케이션 스레드보다 우선순위가 낮아서 실행될 기회를 제대로 얻지 못한다면 객체 수천개가 finalizer 대기열에 쌓이고 회수되기만을 계속 기다리게된다. 어떤 스레드가 finalizer를 수행할지 명시하지 않으니 이런 상황을 예방할 수 없다.

 

cleaner

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

 

이 얘기는 finalizer나 cleaner이 수행 여부 조차도 보장하지 못한다는 뜻이다. 

 

상태를 영구적으로 수정하는 작업에서는 절대 finalizer나 cleaner에 의존해서는 안된다. 예를 들어 데이터베이스 같은 공유 자원의 영구 락(lock) 해제를 finalizer나 cleaner에 맡겨 놓으면 분산 시스템 전체가 서서히 멈출 것이다.

 

 

finalizer의 부작용 

finalizer 동작 중 발생한 예외는 무시되며, 처리할 작업이 남아있더라도 그 순간 종료된다. 잡지 못한 예외 때문에 해당 객체는 마무리가 덜 된 상태로 남을 수 있다. 다른 스레드가 이처럼 훼손된 객체를 사용하려 한다면 어떻게 동작할지 예측할 수 없다. 정상적인 상황이라면 잡지 못한 예외가 스레드를 중단시키겠지만, finalizer에서 일어난다면 경고 조차 출력되지 않는다. 

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

 

 

성능 문제

finalizer와 cleaner는 심각한 성능 문제도 동반한다. 가비지 컬렉터가 객체를 수거하는 과정보다 finalizer를 사용한 객체를 수거하는 과정이 훨씬 더 오래걸린다. finalizer가 가비지 컬렉터의 효율을 떨어뜨리기 때문이다. 

 

 

finalizer 공격으로 인한 보안문제

생성자나 직렬화 과정(readObject와 readResolve 메서드)에서 예외가 발생하면, 이 생성되다 만 객체에서 악의적인 하위 클래스의 finalizer가 수행될 수 있게 한다.

 

부모 클래스 Account
public class Account {
    ...
    
    public Account(String accountId) {
        this.accountId = accountId;

        // 방지
        if (accountId.equals("푸틴")) {
            throw new IllegalArgumentException("푸틴은 계정을 막습니다.");
        }
    }
    
    ...
    
    public void transfer(BigDecimal amount, String to) {
        System.out.printf("transfer %f from %s to %s\n", amount, accountId, to);
    }
}

 

자식 클래스 BrokenAccount
public class BrokenAccount extends Account {

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

    @Override
    protected void finalize() throws Throwable {
        // 원하는 금액을 원하는 사람한테 보냄
        this.transfer(BigDecimal.valueOf(100), "test");
    }
}

 

1) 부모 클래스의 transfer() 메서드 호출

 this.transfer(BigDecimal.valueOf(100), "test");

 

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

 

위 코드를 호출하는 테스트코드
@Test
void 푸틴_계정() throws InterruptedException {
    Account account = null;
    try {
        account = new BrokenAccount("푸틴");
    } catch (Exception exception) {
        // 예외를 잡은 다음에 코드를 계속 진행함
        System.out.println("이러면???");
    }

    System.gc(); // gc 발생
    Thread.sleep(3000L);
}

BrokenAccount 생성자가 호출되고, 그의 부모클래스인 Account 생성자가 호출된다. Account 생성자 안에서 오류를 뱉어내지만, catch문으로 잡아버리면 이 오류가 무시되고, BrokenAccount 클래스 내 정의된 finalize()가 호출되면 공격 코드가 수행될 수 있다.

 

 

방어하는 방법

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

public class Account {

    ...

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

    /**
     * 해결방안) 아무것도 하지 않는 finalize()를 선언해라 (fianl을 붙혀라!)
     * @throws Throwable
     */
    @Override
    protected final void finalize() throws Throwable {
    }
}

 

이렇게 선언하면 하위 클래스에서 finalize()를 오버라이딩할 수 없다.

 

 

대체 방안

파일이나 스레드 등 종료해야할 자원을 담고있는 객체의 클래스에서 finalizer나 cleaner를 대신해줄 묘안이 있다. AutoCloseable을 구현해주고, 클라이언트에서 인스턴스를 다 쓰고나면 close 메서드를 호출하면 된다. (일반적으로 try~with~resource를 사용해야한다.)

 

AutoCloseable

public interface AutoCloseable {
    void close() throws Exception;
}

 

Closeable

public interface Closeable extends AutoCloseable {
    public void close() throws IOException;
}

AutoCloseable과의 차이는 Closeable 인터페이스의 close() 메서드는 IOException을 던진다. 그러므로 file, socket 등 io와 관련된 작업은 Closeable을 구현하는 것을 추천한다.

 

예제
public class AutoClosableIsGood implements Closeable {

    private BufferedReader reader;

    public AutoClosableIsGood(String path) {
        try {
            this.reader = new BufferedReader(new FileReader(path));
        } catch (FileNotFoundException e) {
            throw new IllegalArgumentException(path);
        }
    }

    ...

    /**
     * 아래 메서드를 구현해야한다.
     * close를 알아서 여기서 처리한다.
     *
     * 멱등성: 여러번 호출하더라도 같은 결과를 내보내야한다.
     */
    @Override
    public void close() {
        try {
            reader.close();
        } catch (IOException e) {
            // RuntimeException 계열로 변환함
            // 구체적인 RuntimeException 이면 더 좋음
            // 해당 스레드는 종료됨
            throw new RuntimeException(e);
        }
    }
}

 

 

도대체 어디서 사용하는걸까?

이쯤되면 의문이 든다. 위 finalizer(), cleaner()를 언제, 어디서 사용하는걸까?

 

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

즉시 호출 되리란 보장은 없어도, 클라이언트가 하지 않은 자원 회수를 늦게라도 해줘야한다. 아예 하지 않는것보단 낫다. 실제로 FileInputStream, FileOutputStream, ThreadPoolExecutor가 대표적이다.

 

2) native peer와 연결된 객체에서의 사용

네이티브 피어란, 일반 자바 객체가 네이티브 메서드를 통해 기능을 위임한 네이티브 객체를 말한다. 이는 자바 객체가 아니므로 가비지 컬렉터의 대상이 되지 못한다. 자바 피어를 회수할때 네이티브 객체까지 회수하지 못하므로, cleaner나 finalizer가 나서서 하기에 적당한 작업이다. 단, 성능 저하를 감당할 수 있고 네이티브 피어가 심각한 자원을 가지고있지 않을 때에만 해당된다. 이를 충족하지 못한다면 close 메서드를 사용해야한다.

 

 

Cleaner 사용 예제

Room 클래스를 보자. 방 자원을 수거하기 전에 반드시 청소(clean)해야 한다고 가정해보자. Room 클래스는 AutoCloseable을 구현한다. 

 

예제코드
public class Room implements AutoCloseable {
    private static final Cleaner cleaner = Cleaner.create();

    // Room 객체가 gc 대상이 되고 해제할때 수행됨
    // 청소가 필요한 자원. 절대 Room을 참조해서는 안 된다!
    private static class State implements Runnable {
        int numJunkPiles; // Number of junk piles in this room

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

        // close 메서드나 cleaner가 호출한다.
        @Override public void run() {
            System.out.println("Cleaning room");
            numJunkPiles = 0; // cleaning 작업이라고 생각하자.
        }
    }

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

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

    public Room(int numJunkPiles) {
        state = new State(numJunkPiles);
        // 클리너 등록해놓기
        // gc 대상이 되면 state 작업이 수행되도록 한다.
        cleanable = cleaner.register(this, state);
    }

    // try~with~resources 사용시 호출될 예정
    @Override public void close() {
        cleanable.clean();
    }
}

1) static으로 선언된 중첩 클래스 State

cleaner가 방을 청소할 때 수거할 자원들을 담고있다. 이 클래스 안에 선언된 numJunkPiles 필드가 수거할 자원에 해당한다. Runnable을 구현하고, 그 안의 run 메서드는 cleanable에 의해 딱 한번만 호출될 것이다. 

private static class State implements Runnable {

 

State 인스턴스는 절대로 Room 인스턴스를 참조하면 안된다. 

Room 인스턴스를 참조할 경우 순환참조가 생겨 가비지 컬렉터가 Room 인스턴스를 회수해갈 기회가 오지 않는다. 

 

▶ State 클래스가 정적 중첩 클래스인 이유

정적이 아닌 중첩 클래스는 자동으로 바깥 객체의 참조를 갖기 때문에, 정적으로 선언하여 바깥 객체의 참조를 갖지 않도록 해야한다.

 

2) cleanable 필드

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

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

 

Room 생성자

cleaner.register() 메서드를 사용해서 state 객체를 등록한다. 이는 어떤 오브젝트가 gc가 될때 state 오브젝트를 사용하라는 의미다.

public Room(int numJunkPiles) {
    state = new State(numJunkPiles);
    // 클리너 등록해놓기
    // gc 대상이 되면 state 작업이 수행되도록 한다.
    cleanable = cleaner.register(this, state);
}

 

3) run 메서드의 호출 상황

private static class State implements Runnable {
    int numJunkPiles; // Number of junk piles in this room

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

    // close 메서드나 cleaner가 호출한다.
    @Override public void run() {
        System.out.println("Cleaning room");
        numJunkPiles = 0; // cleaning 작업이라고 생각하자.
    }
}

Room이 close 메서드를 호출할때 수행된다. close 메서드에서 Cleanable의 clean을 호출하면 이 메서드 안에서 run을 호출한다. 

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

 

클라이언트 코드

모든 Room 생성을 try~with~resources 블록으로 감쌌다면 자동 청소는 필요하지 않다.

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

 

IntelliJ 내의 위 코드의 .class 파일을 보자.

public class Adult {
    public Adult() {
    }

    public static void main(String[] args) {
        Room myRoom = new Room(7);

        try {
            System.out.println("안녕~");
        } catch (Throwable var5) {
            try {
                myRoom.close();
            } catch (Throwable var4) {
                var5.addSuppressed(var4);
            }

            throw var5;
        }

        myRoom.close();
    }
}

1) close() 호출 로직이 있다.

myRoom.close();

Room 클래스의 아래 메서드가 호출되는 것이다.

// try~with~resources 사용시 호출될 예정
@Override public void close() {
    cleanable.clean();
}

 

 

반응형

Designed by JB FACTORY