자바에서 객체 소멸자는 finalizer와 cleaner가 있다. 둘 다 예측할 수 없고, 위험할 수 있으며, 일반적으로 불필요하다.
(cleaner가 비교적 덜 위험하다고는 함)
C++ 파괴자 vs 자바 finalizer와 cleaner
책에서는 두 개념이 같은 것이라고 오해하지 않도록 비교해준다. (가볍게 넘어가도 될 듯 함)
C++의 파괴자는 객체와 관련된 자원을 회수하는 용도와 비메모리 자원을 회수하는 용도로 사용된다. 반면 자바는 전자는 가비지 컬렉터를, 후자는 try-with-resources와 try-finally를 사용해 해결한다.(아이템 9)
*try-with-resources
try-with-resources는 자동으로 AutoCloseable 인터페이스를 구현한 리소스를 해제하는 기능이다. try 블록 안에서 사용한 리소스는 try 블록이 끝나면 자동으로 close() 메소드를 호출하여 해제된다.
따라서 try-with-resources를 사용하면 코드의 가독성과 유지보수성을 향상시킬 수 있으며, 자원 해제를 잊어버리는 실수를 방지할 수 있다.
try (리소스 선언) {
// 리소스를 사용하는 코드 블록
} catch (Exception e) {
// 예외 처리
}
*try-finally
try 블록 안에 예외가 발생하더라도(발생하지 않더라도) finally 블록 안의 코드를 실행하여 자원을 해제하거나 다른 정리 작업을 수행할 수 있다.
finally를 언제 사용하는 건지 의문이 있었는데 자원 해제를 위해 사용할 수 있었구나....
try {
// 예외가 발생할 가능성이 있는 코드 블록
} finally {
// 항상 수행되는 코드 블록 -> close() 메소드를 호출하여 자원을 해제
}
단점 1. 즉시 수행된다는 보장이 없다.
finalizer와 cleaner가 수행되는 시점은 가비지 컬렉터 구현에 따라 달라진다. 즉, 수행 시점을 알 수 없기 때문에 타이밍이 중요한 작업은 finalizer와 cleaner을 사용해서는 안된다.
단점 2. 수행되지 않을 수 있다.
심지어 아예 수행되지 않을 가능성도 있다. 다시 말해 종료 작업을 하지 않은 채 프로그램이 중단될 수 있다. 따라서 상태를 영구적으로 수정하는 작업에서는 절대 사용하면 안된다. 그 예로 데이터베이스에서 특정 레코드를 수정하거나, 파일 시스템에서 파일을 생성하거나 삭제하는 작업이 있다.
그렇다고 System.gc나 System.runFinalization를 사용하면 안된다. finalizer와 cleaner가 실행될 가능성은 높여주지만, 보장하진 않는다.
그래서 보장해주는 메소드인 System.runFinalizersOnExit, System.runFinalizersOnExit이 있다. 그런데 단점이 심각해 사용하지 않는다.
*System.gc, System.runFinalization, System.runFinalizersOnExit, Runtime.runFinalizersOnExit
위 메소드는 모두 JVM에게 가비지 컬렉션과 객체의 finalize() 메소드 실행을 요청하는 역할을 한다. 모두 finalize() 메소드 실행을 보장하지 않으므로 사용하지 않는 것이 좋다. (try-with-resources 구문을 사용하자..)
System.gc : JVM에게 가비지 컬렉션을 요청하는 메소드이다. 실행 여부와 시기가 확실하지 않으므로 사용하지 않는 것이 좋다.
System.runFinalization: JVM에게 가비지 컬렉션을 요청하는 메소드이다. 마찬가지로 실행 시기와 순서가 보장되지 않으므로 사용하지 않는 것이 좋다.
System.runFinalizersOnExit, Runtime.runFinalizersOnExit: JVM이 종료될 때, 모든 객체의 finalize() 메소드를 실행하도록 요청한다. 시기와 순서를 보장하지 않으며, JVM 종료 시간을 느리게 만들어 사용하지 않는 것이 좋다.
단점 3. finalizer 동작 중 발생한 예외는 무시되며 처리할 작업이 남았더라도 그 순간 종료된다.
반면 cleaner는 스스로 스레드를 통제하기 때문에 해당 문제가 발생하지 않는다.
단점 4. finalizer와 cleaner는 심각한 성능 문제가 있다.
AutoCloseable 객체를 생성하고, try-with-resource로 자원 반납을 하는데 걸리는 시간은 12ns이다.
반면 finalizer, cleaner를 사용한 경우 약 50배인 500ns가 걸린다.
이후에 살펴볼 안전망 방식에서는 약 5배인 66ns가 걸린다.
*AutoCloseable
AutoCloseable 인터페이스는 자원을 사용한 후에 자동으로 닫아주는 기능을 구현하기 위한 인터페이스이다. 이 인터페이스를 구현한 객체는 try-with-resources 구문을 사용하여 자동으로 리소스를 닫을 수 있다. close() 메소드를 하나 가지고 있음!
단점 5. finalizer를 사용한 클래스는 finalizer 공격에 노출되어 심각한 보안 문제를 일으킬 수 있다.
생성자 또는 직렬화 과정(readObject와 readResolve 메소드)에서 예외가 발생하면, 객체가 적절하게 초기화되지 못하고 생성되지 않을 수 있다. 이때 생성되지 않은 객체는 여전히 메모리에 존재할 수 있고, 이 객체에 대한 finalizer가 호출될 수 있다.
이 finalizer는 정적 필드에 자신의 참조를 할당하거나, 객체의 상태를 수정하여 가비지 컬렉터가 객체를 수집하지 못하게 막을 수 있다. 이렇게 되면 객체는 계속해서 메모리에 존재하고, 메모리 누수와 같은 문제를 일으킬 수 있다. 나아가 이런 객체는 악의적인 하위 클래스의 finalizer가 수행될 수 있게 되며, 보안에 문제가 생길 수 있다.
따라서, 객체 생성을 막으려면 생성자에서 예외를 던지는 것으로 해결되지 않는다. finalizer가 있는 경우, 예외가 발생하더라도 finalizer가 호출될 수 있으므로, finalizer에서도 객체의 상태를 올바르게 관리하여야 한다. 또한, 객체가 초기화되지 않았을 경우 finalizer에서는 객체의 메소드를 호출하지 않도록 주의해야 한다.
위의 내용이 와닿지 않는다면 아래 예시를 살펴보자.
public class MyClass {
private static MyClass instance;
public MyClass() {
if (instance != null) {
throw new IllegalStateException("Singleton already instantiated");
}
instance = this;
}
@Override
protected void finalize() throws Throwable {
System.out.println("Finalize method called");
instance = this;
}
}
위 코드는 finalizer 메소드에서 객체의 참조를 자신으로 설정한다. 이렇게 되면 GC가 객체를 수집하지 못하게 된다.
public class MyEvilSubclass extends MyClass {
@Override
protected void finalize() throws Throwable {
System.out.println("Finalize method called");
if (instance != null) {
instance.modifyState(); // 객체 상태 수정
MyClass.instance = this; // 정적 필드에 참조 할당
}
}
private void modifyState() {
// 객체 상태 수정
}
}
그런 상태에서 위 코드처럼 하위 클래스에서 finalizer 메소드를 오버라이딩하여 객체의 상태를 수정하거나 정적 필드에 자기 자신의 참조를 할당하면, MyClass 클래스가 GC에 수집되지 못한다.
그래서 이 부분에서 의문점이 있었다. 내가 이해한 바로는, 만약에 상위 클래스에서 객체의 참조를 자신으로 설정하지 않으면 하위 클래스에서 해당 객체를 참조할 수 없으므로, 악의적인 공격이 들어오지 않는다. 그러나 찾아보니 객체를 간접적으로 참조할 수 있는 방법이 있을 수 있기 때문에 여전히 보안 문제가 발생할 수 있다는 것이다.
이 문제를 해결하기 위한 방법으로 책에서는 final을 사용한다.
1. final 클래스로 지정한다. 즉, 하위 클래스를 만들 수 없으므로 공격에서 안전하다.
2. final이 아닌 클래스일 경우, 아무 일도 하지 않는 finalize 메소드를 만들어 final로 선언한다.
*readObject와 readResolve
java serialization은 객체를 직렬화하고 역직렬화하는 기능을 제공한다. 이를 위해 Serializable 인터페이스를 구현하고, 객체를 직렬화하고 역직렬화할 때 사용되는 readObject()와 writeObject() 메소드를 정의할 수 있다.
readObject는 객체를 역직렬화할 때 호출되어 객체의 상태를 복원하는 메소드이다. readResolve는 역직렬화된 객체를 대체할 객체를 반환하는 메소드이다.
위 메소드를 사용할 때에는 보안과 성능을 고려해서 구현해야 한다.
그래서 어떻게 반납하라고 ??
자원 반납이 필요한 클래스에 AutoCloseable 인터페이스를 구현하고 try-with-resources를 사용하거나, 클라이언트가 close 메소드를 명시적으로 호출하는 것이 정석 방법..
책에서는 close 메소드에서 해당 객체의 유효성을 알리는 필드를 두고, 다른 메소드에서 이미 객체가 닫힌 경우에는 illegalStateException을 던지라고 한다.
그러면 finalizer와 cleaner는 어디에 쓰이는 겨 ???????
1. 자원의 소유자가 close 메소드를 호출하지 않는 것에 대비한 안전망 역할
finalizer와 cleaner의 경우 호출될 것이라는 보장은 없지만, close를 호출하는 걸 까먹는 것보다 낫다. 소유자가 직접 close 메소드를 호출하는 것이 베스트지만, 만약을 위해 finalizer를 사용한다.
실제로 자바 라이브러리의 일부는 안전망 역할의 finalizer를 제공한다.
ex. FileInputStream, FileOutputStream, ThreadPoolExecutor
public class FileInputStream extends InputStream {
//...
protected void finalize() throws IOException {
if ((fd != null) && (fd != FileDescriptor.in)) {
close();
}
}
//...
}
2. 중요하지 않은 네이티브 자원 회수
네이티브 피어는 자바 객체가 아니므로 GC의 관리 대상이 아니다. 따라서 cleaner와 finalizer를 이용하는 것이 좋다.
그러나 불확실성과 성능 저하가 있기 때문에 중요한 리소스인 경우 close 메소드를 사용하자.
*네이티브 피어
책에서는 다음과 같이 설명하였다.
일반 자바 객체가 네이티브 메소드를 통해 기능을 위임한 네이티브 객체
?? 네이티브 메소드는 뭐고 네이티브 객체는 뭐임
알아보자..
네이티브 객체는 자바 코드에서 네이티브 코드(주로 C나 C++등으로 작성된 라이브러리 혹은 운영체제와의 상호작용을 위한 코드)를 호출하고, 그 결과로 반환된 네이티브 객체를 자바 코드에서 다루는 객체이다. 이러한 네이티브 객체는 JVM 밖에서 생성되고 관리된다.
쉽게 말하면, 네이티브 피어는 자바 어플리케이션 내에서 네이티브 코드로 작성된 객체이다.
자바에서는 네이티브 피어와 자바 객체를 연결하는 과정에서, 네이티브 메모리를 할당하거나, 네이티브 라이브러리를 로드하고 초기화하는 작업이 필요하다. 이때 네이티브 피어를 안전하게 사용하기 위해선 네이티브 메모리나 리소스를 사용한 후에 반드시 해제해줘야 한다. 이유는 뭐 메모리 누수나 자원 누출 등의 문제..
이때 네이티브 피어를 안전하게 사용하는 방법으로 finalizer 메소드를 이용해 네이티브 메모리나 리소스를 해제하는 작업을 수행한다.
cleaner를 안정망으로 활용하는 예시를 보자.
package item08;
import java.lang.ref.Cleaner;
public class Room implements AutoCloseable {
private static final Cleaner cleaner = Cleaner.create();
private static class State implements Runnable { // cleaner가 방을 청소할 때 수거할 자원
/*
반드시 Room 인스턴스를 참조하면 안 된다.
Room 인스턴스를 참조할 경우 순환참조가 생겨 GC가 Room 인스턴스를 회수해갈 기회가 오지 않는다.(즉, 청소 못함)
State 클래스가 정적인 이유도 마찬가지다.
Room 인스턴스를 참조하지 않아야 하기 때문에 정적 클래스로 정의해야 한다.
정적이 아닌 중첩 클래스는 자동으로 바깥 객체의 참조를 갖게 되기 때문이다.(아이템 24)
*/
int numJunkPiles;
public State(int numJunkPiles) {
this.numJunkPiles = numJunkPiles;
}
@Override
public void run() { // cleanable에 의헤 딱 한 번만 호출됨
/*
run 메소드가 호출되는 경우
1. Room의 close 메소드가 호출될 때(보통의 경우)
: close 메소드에서 Cleanable의 clean을 호출하면 이 메소드 안에서 run을 호출한다.
2. GC가 Room을 회수할 때까지 클라이언트가 close를 호출하지 않는다면, cleaner가 State의 run을 호출
*/
System.out.println("방 청소");
numJunkPiles = 0;
}
}
private final State state;
private final Cleaner.Cleanable cleanable;
public Room(State state, Cleaner.Cleanable cleanable) { // cleaner 객체는 Room 생성자에서 cleaner에 Room과 State를 등록할 때 얻음
this.state = state;
this.cleanable = cleanable;
}
@Override
public void close() throws Exception {
cleanable.clean();
}
}
Room 클래스는 AutoClosable 인터페이스를 구현하기 때문에, 클라이언트 코드에서는 try-with-resources 문을 사용해 Room 객체를 생성하고 사용한 후에 자동으로 자원을 회수할 수 있다. 자원을 안전하게 회수하기 위해, 클라이언트 코드에서 Room 객체를 생성할 때 Cleanable 객체를 얻어와서 close 메소드에서 clean 메소드를 호출하면, 이에 따라 State 객체의 run 메소드가 호출되어 자원이 안전하게 회수된다. 만약 클라이언트 코드에서 close 메소드를 호출하지 않는다면, GC가 Room 객체를 회수할 떄까지 cleaner 객체에서 State 객체의 run 메소드가 호출된다.
'Language > Java' 카테고리의 다른 글
[effective java] 아이템 10. equals는 일반 규약을 지켜 재정의하라. (0) | 2023.03.09 |
---|---|
[effective java] 아이템 9. try-finally보다는 try-with-resources를 사용하라. (0) | 2023.03.08 |
[effective java] 아이템 7. 다 쓴 객체 참조를 해제하라. (0) | 2023.03.04 |
[effective java] 아이템 5. 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라. (0) | 2023.03.02 |
[effective java] 아이템 4. 인스턴스화를 막으려거든 private 생성자를 사용하라. (0) | 2023.03.02 |