자바에서는 finalizer와 cleaner 두 가지 객체 소멸자를 제공하고 있다.
나름의 쓰임이 몇 가지 있지만, 기본적으로 "쓰지 말아야" 한다고 책에서 강조하고 있다.
우선, finalizer와 cleaner가 대충 뭔지나 알아보자.
📌 What is finalizer & cleaner
GC는 더 이상 사용하지 않는 객체를 수집하여 메모리를 회수하는 역할을 한다.
finalizer() 메서드나 cleaner는 더 이상 사용되지 않는 객체의 비메모리 자원을 회수하는데 사용되며 메모리 누수를 방지하는 역할을 한다.
✒️ 메모리 자원과 비메모리 자원
메모리 자원은 주로 프로그램이 실행될 때 사용하는 자원으로, CPU가 바로 접근 가능한 고속의 임시 저장소를 말한다. 대표적으로 RAM(Random Access Memory)가 있는데, 프로그램이 실행될 때 필요한 데이터와 코드가 RAM에 저장되어 CPU가 빠르게 접근할 수 있다.
비메모리 자원은 메모리 이외의 자원으로, 하드 디스크, 파일, 네트워크 연결 등을 포함한다. 대표적으로는 파일, 소켓, DB 연결 등이 있다. 비메모리 자원은 프로그램이 실행되는 동안 계속해서 유지되기 때문에, 프로그램에서 사용하는 자원을 모두 해제하고 반환해주지 않으면 시스템 자원이 낭비되는 문제가 발생할 수 있다.
예를 들어, 객체나 변수를 선언하고 할당하는 것은 해당 프로그램 내부에서 동작하기 때문에 메모리 관리가 가능하다. 하지만 DB 연결을 예로 들었을 때, DB 서버의 주소, 사용자 이름, 암호 등을 지정하고, DB 접속을 위해 네트워크 연결을 설정해야 한다. 또한 DB 쿼리를 실행하고 결과를 가져오는 등의 작업도 수행해야 하는데, 이러한 일련의 작업은 외부 리소스와 상호작용하는 과정이므로, 해당 프로그램 내부에서의 동작이 아니라 외부에서 발생하기 때문에 비메모리 자원으로 취급한다.
외부 리소스를 사용하는 경우, 해당 리소스에 대한 자원을 할당하고 사용하는 것 외에도, 해당 리소스를 해제하고 정리하는 작업도 필요하다. 이는 메모리 이외의 자원을 사용하는 것과 관련이 있다.
finalizer는 객체가 소멸될 때 자동으로 호출되는 메서드로써 Object 클래스의 finalize() 메서드를 재정의하여 사용한다.
이렇게 하면 GC가 객체를 수집하기 전에 finalize 메서드가 호출되어, 객체를 사용한 리소스를 해제하는 마무리 작업을 해주면, GC가 메모리 자원을 회수한다.
자바 9에서 등장한 java.lang.ref.Cleaner는 객체의 비메모리 자원을 회수하는 클래스를 사용하였다.
finalize 메서드와 달리 명시적으로 호출되며, 일반적으로 'try-with-resources' 블록 내에서 사용된다.
해당 블록을 벗어날 때, cleaner가 자동으로 호출되어 객체의 비메모리 자원을 회수하는 방식이다.
기능만 들으면 두 가지 기능은 환상적이라고 볼 수 있지만, 단점이 극단적이라서 사용을 자제하는 것이 좋다.
한 줄 요약하자면 이렇다.
- finalizer: 실행 시점 예측 불가능, 위험하고 느리며, 일반적으로 불필요하다.
- cleaner: finalizer보다는 덜 위험하지만 여전히 예측할 수 없고, 느리며, 보통 불필요하다.
📌 단점
1️⃣ 즉시 수행된다는 보장이 없다.
finalizer와 cleaner는 실행 시점을 예측할 수 없다. 즉, 제때 실행되어야 하는 작업을 절대 수행할 수 없다.
이 둘의 수행 속도는 GC에 달려있으며, GC 구현마다 다르다. (테스트한 JVM에서는 완벽하게 동작하더라도, 클라이언트 시스템에서는 재앙을 불러일으킬 수 있다.)
문제는 제때 실행되지 않는 finalizer로 인해서 OutOfMemoryError가 발생할 수도 있다는 점이다.
finalizer 스레드는 다른 애플리케이션 스레드보다 우선순위가 낮아 대기열에서 회수되기만을 기다리고 있다가, 그러한 객체 수천 개가 finalizer 대기열에서 쌓이는 덕에 메모리 초과가 발생하는 경우다.
cleaner는 자신이 수행할 스레드를 제어할 수 있다는 점에서 낫긴 하지만, 여전히 백그라운드에서 수행되며 GC의 통제하에 있기 때문에 수행 여부를 예측할 수 없다.
2️⃣ 수행 여부조차 보장하지 않는다.
접근할 수 없는 객체에 대한 종료 작업을 전혀 수행하지 못한 채 프로그램이 중단될 수도 있다.
System.gc나 System.runFinalization 메서드는 실행 가능성을 높여줄 수는 있으나, 여전히 보장하지는 않는다.
이를 보장해주겠다는 메서드가 두 개나 더 있었는데, 다른 스레드가 소멸 대상의 객체에 접근하고 있는데도 실행해버린다는 꽁트를 보여주며 수십 년간 지탄을 받아왔다. (하지만 빨랐죠?)
3️⃣ 심각한 성능 문제도 동반될 수 있다.
(책에 나온 내용 그대로 적자면)AutoCloseable 객체를 생성해 GC가 수거하기까지 12ns가 걸린 반면, finalizer를 사용하면 550ns가 걸렸다.
finalizer를 사용한 객체를 생성하고 파괴하니 50배나 느렸는데, GC의 효율을 떨어뜨리기 때문이다.
cleaner 또한 500ns 정도 걸렸지만, 안정망 형태로 사용하면 66ns 정도로 5배 정도 느려진다.
4️⃣ 보안 이슈가 발생한다.
생성자나 직렬화 과정에서 예외가 발생하는 경우, 이 finalizer를 오버라이딩한 하위클래스의 finalizer가 수행될 수 있는 어처구니 없는 일이 발생한다. 심지어, 이 finalizer를 static field에 자신의 참조를 할당하여 GC가 수집하지 못 하도록 막을 수도 있다.
설명을 덧붙이자면, 인스턴스를 직렬화하고 다시 역직렬화를 할 때는 readResolve 메서드가 실행되며, 이 메서드는 새로운 인스턴스를 반환함을 앞에서 잠시 언급했었다.
A 클래스를 상속받아 finalize를 오버라이딩 한 B라는 클래스의 생성자에서 예외가 발생했다 가정해보자.
인스턴스 생성 도중 예외가 던져지면, 객체는 생성되지 않고 수거가 되어야 하는데, 객체가 죽으면서 finalize 메서드가 호출된다.
문제는 이 finalize 메서드 안에서 이 인스턴스가 가진 static field에 접근이 가능해서, 인스턴스 자체가 gc될 수 없도록 만들 수 있다. 즉, 제거되어야 하는데 제거되지 못 한채 남아있게 되는 것이다.
해결책은 상속을 막아버리거나, A 클래스에서 아무 일도 하지 않는 final로 선언된 finalize 메서드를 만들면 되지만, 가장 좋은 방법은 그냥 finalizer을 안 쓰면 된다.
5️⃣ 동작 중 발생한 예외를 무시한다.
finalizer는 동작 중 발생하는 예외를 모두 처리하지 않았는데 불구하고 그 순간 종료된다.
예외를 잡지 못했기 때문에 해당 객체는 훼손될 수 있고, 다른 스레드가 훼손된 객체에 접근할 수도 있다.
cleaner는 적어도 자신의 스레드를 통제할 수 있기 때문에 이 문제는 발생하지 않는다.
📌 AutoCloseable, 정상적인 자원 반납 방법
파일이나 스레드 등 종료해야 할 자원을 담고 있는 객체의 클래스에서 AutoCloseable을 구현해주고, 클라이언트에서 인스턴스를 다 쓰고 나면 close 메서드를 호출한다.
일반적으로 예외가 발생하면 제대로 종료되도록 try-with-resource를 사용한다.
각 인스턴스는 자신이 닫혔는지 추적하기 위해, close 메서드 호출 여부를 필드로 저장한다.
close 메서드에서 이 객체는 더 이상 유효하지 않음을 기록해두었다가, 다른 메서드는 이 필드를 검사해서 객체가 닫힌 후에 불렸다면 IllegalStateException을 던지도록 구현하면 깔끔하게 설계할 수 있다.
- try-finally : 명시적 자원 반납
public class Foo implements AutoCloseable {
@Override public void close() throws RuntimeException {
...
}
public void bar() {
...
}
}
public class Main {
public static void main(String[] args) {
try {
Foo resource = new Foo();
resource.bar(); // 리소스 사용
} finally {
resource.close(); // client가 close() 호출
}
}
}
- try-wit-resource : 암묵적 자원 반납 (가장 이상적)
public class Main {
public static void main(String[] args) {
try (Foo resource = new Foo()) {
resource.bar();
}
}
}
📌 finalizer와 cleaner 사용하는 경우
(1) AutoCloseable을 구현하지 않은 경우 "안전망" 역할(클라이언트가 하지 않은 자원 회수를 늦게라도 해주는 것이 낫다는 취지)과 (2)네이티브 피어와 연결된 객체인 경우 사용한다.
✒️ 네이티브 피어(Native Peer)
Java에서 네이티브 코드(다른 언어로 작성된 코드)를 호출하거나 Java 객체를 네이티브 코드에 전달할 수 있도록 하는 Java 네이티브 인터페이스(JNI)의 개념 중 하나다.
JNI는 Java 언어와 C, C++ 등의 네이티브 언어 간의 상호 운용성을 제공하는데, 이를 통하여 Java 애플리케이션에서 네이티브 라이브러리나 운영 체제의 기능 등을 사용할 수 있다.
문제는 (1)번의 경우, 안전망 역할이 정상적으로 동작할지 예측할 수가 없다.
cleaner의 명세에는 이렇게 적혀있다. "System.exit을 호출할 때의 cleaner 동작은 구현하기 나름이다. 청소가 이루어질지는 보장하지 않는다."
(2)번의 경우엔, 네이티브 피어가 자바 객체가 아니니 GC가 회수하지 못 하기 때문에 cleaner나 finalizer가 나서서 처리하기 좋다. 다만, 네이티브 피어가 심각한 자원을 가지고 있지 않거나, 성능 저하를 감당할 수 있는 경우만 사용하라.