자바의 두 가지 객체 소멸자
- finalizer: 예측할 수 없고 상황에 따라 위험할 수 있어 일반적으로 불필요하다. 오동작, 낮은 성능(finalize 메소드 실행하기에), 이식성(이식성의 문제는 deprecated 되어서?) 문제의 원인이 되기도 한다.
- cleaner: 자바 9로오며 finalizer가 deprecated API로 지정되고 그 대안으로 소개되었는데, 별도의 쓰레드를 사용해 finalizer보다는 덜 위험하지만 여전히 예측할 수 없고, 느리고, 일반적으로 불필요하다.
public class FinalizerExample {
// 어떤 클래스라도 오버라이딩해서 만들 수 있다. GC가 될때 이 메소드가 호출되는데, 언제 될지 예측할 수 없다.
// GC대상이 된다고 해서 바로 호출되는게 아니기 때문이다.
@Override
protected final void finalize() throws Throwable {
System.out.println("Clean up");
}
public void hello() {
System.out.println("hi");
}
}
위코드에 finalize를 오버라이딩하고 호출을 해보았다.
public class Sample {
public static void main(String[] args) throws InterruptedException {
Sample sample = new Sample();
sample.run();
// run 실행후 5초뒤 종료. GC의 대상은 됐지만 finalize는 호출되지 않았다.
Thread.sleep(5000L);
}
// 이 메소드가 끝나면 인스턴스의 유효성은 끝난다. GC의 대상이 된다.
private void run() {
FinalizerExample finalizerExample = new FinalizerExample();
finalizerExample.hello();
}
}
run이 종료되고 5초가 지나도 결국 finalize는 호출되지 않았다. GC의 대상은 됐지만 실행될 여건이 되지않았던 것 같다.
* 비메모리 자원을 회수하는 용도로 쓰이는 C++의 파괴자와는 다른 개념이다. 자바에서는 finalize와 cleaner가 아닌 try-with-resources나 try-finally가 그 역할을 한다.
단점 자세히 보기
(1) finalizer와 cleaner는 수행 시점을 보장하지 않는다.
객체에 접근할 수 없게된 후 실행되기까지 얼마나 걸릴지 알 수 없다. 즉, 제때 실행되어야 하는 작업은 절대 할 수 없다.
얼마나 신속히 수행할지는 전적으로 가비지 컬렉터 알고리즘에 달려있고 가비지 컬렉터 구현마다 천차만별이다.
ex) 파일 닫기를 finalize나 cleaner로? 시스템에 동시에 열 수 있는 파일 개수에 한계, 제때 수행되지 않으면 파일은 계속 열려있어 새 파일을 못 연다.
그리고 다른 애플리케이션 스레드보다 우선 순위가 낮아서 실행될 기회를 제대로 얻지 못한다. 어떤 스레드가 finalizer를 수행할지 명시하지 않으니 예방할 보편적 방법은 없다. cleaner는 자신을 수행할 스레드를 제어할 수 있따는 면에서 조금 낫지만(해당 스레드의 우선순위를 높혀준다거나), 여전히 백그라운드에서 수행되며 가비지 컬렉터의 통제하에 있으니 즉각 수행되리라는 보장은 없다.
(2) finalizer와 cleaner는 수행 여부를 보장하지 않는다.
접근할 수 없는 일부 객체에 딸린 종료 작업을 전혀 수행하지 못한 채 프로그램이 중단될 수도 있다. 아예 수행을 안한다는 것이다.
따라서 상태를 영구적으로 수정하는 작업에서는 절대 finalizer나 cleaner에 의존해서는 안된다.
ex) database같은 공유자원의 영구 락(lock) 해제를 finalizer나 cleaner에 맡겨놓으면 분산 시스템 전체가 서서히 멈출것이다.
System.gc나 System.runFinalization 메소드에 현혹되지 말자. 실행될 가능성을 높여줄 순 있지만, 보장하진 않는다.
보장 메소드로 System.runFinalizersOnExit, Runtime.runFinalizersOnExit는 심각한 결함으로 deprecated. [ThreadStop]
(3) finalizer 동작 중 발생한 예외는 무시되고, 처리할 작업이 남았어도 종료한다.
보통의 경우엔 잡지 못한 예외가 스레드를 중단시키고 스택 추적 내역을 출력하지만 같은 일이 finalizer에서 발생하면 경고조차 출력하지 않는다. 해당 객체는 자칫 마무리가 덜 된 상태로 남을 수 있고, 다른 스레드가 이 객체를 사용하려 하면, 어떻게 동작할지 예측할 수 없다.
하지만 cleaner를 사용하는 라이브러리는 자신의 스레드를 통제하여서 이러한 문제가 없다.
(4) finalizer와 cleaner의 심각한 성능 문제 동반
AutoCloseable 객체를 생성하고 GC가 수거하기까지 걸린 시간: 12ns
finalizer를 이용했을때 GC가 수거하기까지 걸린 시간: 550ns
cleaner를 이용했을때 GC가 수거하기까지 걸린시간: 500ns
안전망 방식(아래에서 설명): 66ns
즉, finalizer와 cleaner가 GC의 효율을 떨어뜨리고 성능이 저하됨을 알 수 있다.
(5) finalizer 공격에 노출되어 심각한 보안 문제를 동반
생성자나 직렬화 과정에서 예외가 발생하면 이 생성되다 만 객체에서 악의적인 하위 클래스의 finalizer가 수행될 수 있다.
객체 생성을 막으려면 생성자에서 예외를 던지는 것만으로 충분하지만, finalizer가 있다면 그렇지 않기 때문이다.
public class SuperAttack {
Integer value;
public SuperAttack(Integer value) {
if(value<0){
throw new IllegalArgumentException("양수를 입력하세요.");
}
this.value = value;
}
}
public class Attack extends SuperAttack{
static SuperAttack superAttack;
public Attack(Integer value) {
super(value);
}
public void finalize(){
superAttack = this;
}
/**
e = java.lang.IllegalArgumentException: 양수를 입력하세요.
superAttack = effective.item8.finalizeattack.Attack@555590
*/
public static void main(String[] args) {
try{
new Attack(-1);
}catch (Exception e){
System.out.println("e = " + e);
}
System.gc();
System.runFinalization();
System.out.println("superAttack = " + superAttack);
}
}
생성자 과정에서 value가 0보다 작으니 예외가 발생할거다. 따라서 try-catch에 잡혀 error를 출력하고
System.gc()와 System.runFinalization()을 이용해서 finalizer의 실행 가능성을 높여준다.
만약 finalize가 실행이 된다면 정적 필드인 superAttack에 Attack 자신이 업캐스팅되서 들어간다.
원래대로면 예외때문에 객체가 생성되지 않았겠지만 finalize가 수행되며 좀비 객체가 만들어지는 것이고,
사용을 못해야 맞는 인스턴스 메소드도 사용할 수 있게되는 끔찍한 결과를 초래한다.
더 자세한 내용은 아래 링크를 참조하자
https://yangbongsoo.tistory.com/8?category=919799
방어 수단으로 그 누구도 하위 클래스를 만들 수 없게 final 클래스로 만들거나,
final이 아닌 클래스라면 finalize 메소드를 만들고 참조할 수 없게 메소드를 final로 선언하자.
대안
AutoCloseable을 구현해주고 클라이언트에서 인스턴스를 다 쓰고 나면 close 메소드를 호출하면 된다. 일반적으로 예외가 발생해도 제대로 종료되도록 try-with-resources를 사용해야한다. 추가로 각 인스턴스는 자신이 닫혔는지 추적하는 것이 좋다.
close 메소드에서 이 객체가 더 이상 유효하지 않음을 필드에 기록하고 다른 메소드는 이 필드를 검사해 IllegalStateException을 던지자.
아니면 finalize() 메소드가 호출될 때 이 필드를 확인하고 예외를 던져도 좋다.
AutoCloseable
AutoCloseable에 대해서 소개하자면 java 7에서 나온 void close() 메소드 하나를 가지는 인터페이스다.
try-with-resources 문으로 관리되는 객체일 때만 close() 메서드가 자동으로 호출된다.
만약 이 인터페이스를 사용하지 않는다면 finally 부분에서 close()를 호출해줘야 한다.
public class AutoCloseableSample implements AutoCloseable{
public void doIt(){
System.out.println("called doIt()");
}
@Override
public void close() throws Exception {
System.out.println("called close()");
}
}
/**
* 결과:
* called doIt()
* called close()
* called doIt()
* called close()
*/
public class AutoSample {
public static void main(String[] args) throws Exception {
// try-with-resources
try(AutoCloseableSample autoCloseableSample = new AutoCloseableSample()){
autoCloseableSample.doIt();
}
// 자바 7 아래 try-finally
AutoSample autoSample = new AutoSample();
autoSample.doIt();
}
private void doIt() throws Exception {
AutoCloseableSample autoCloseableSample = null;
try {
autoCloseableSample = new AutoCloseableSample();
autoCloseableSample.doIt();
}finally {
if(autoCloseableSample!=null) {
// try-with-resources를 사용하지 않으면 닫아줘야한다.
autoCloseableSample.close();
}
}
}
}
참고: https://hyoj.github.io/blog/java/basic/java7-autocloseable.html#method-detail
cleaner와 finalizer의 적절한 쓰임새
(1) 자원의 소유자가 close 메소드를 호출하지 않는 것에 대비한 안전망 역할
finalizer와 cleaner가 즉시 호출되리란 보장은 없지만 클라이언트가 하지 않은 자원 회수를 늦게라도 해주는게 안하는 것 보단 낫다.
따라서 그만한 값어치가 있는지 고민하자. 대표적으로 FileInputStream, FileOutputStream, ThreadPoolExecutor가 대표적이다.
finalize() 메소드를 두어서 내부에서 close()를 호출하기 위함이다.
(2) 네이티브 피어와 연결된 객체에서
네이티브 피어: 일반 자바 객체가 네이티브 메소드를 통해 기능을 위임한 네이티브 객체. 자바 객체가 아니니 GC가 존재를 모른다.
따라서 자바 피어를 회수할 때 네이티브 객체까지 회수하지 못한다. cleaner나 finalizer로 성능 저하가 감당할 수 있고
네이티브 피어가 심각한 자원을 가지고 있지 않을때 사용하면된다. 그 외에 즉각 자원 회수가 필요하면 close() 메소드를 사용해야한다.
근데 이럴거면 얘는 처음부터 try-with-resources로 회수하는게 맞는거같다.
네이티브 피어는 아마 외부 플랫폼 언어로 프로그래밍 된것을 가져와서 사용할 때 쓰는 것 같다.
참고: https://www.infoworld.com/article/2077520/java-tip-23--write-native-methods.html
Cleaner 사용법
방(room) 자원을 수거하기 전에 반드시 청소(clean)해야 한다고 가정해보자.
자동 청소 안정망이 cleaner를 사용할지 말지는 순전히 내부 구현 방식에 관한 문제다.
즉 finalizer와 달리 cleaner는 클래스의 public API에 나타나지 않는다.
자세한 소스코드는 아래를 참고하자.
https://github.com/kkt219a/TIL/tree/main/effective-java-3rd/src/main/java/effective/item8/cleaner
state는 정리할 대상인 인스턴스(room)을 참조하면 안된다. 순환 참조가 생겨서 GC 대상이 되질 못한다. state를 만들 클래스는 반드시 static 클래스여야 한다. non-static 클래스(익명 클래스도 마찬가지)의 인스턴스는 그걸 감싸고 있는 클래스의 인스턴스를 참조하지 않는다.
정리
finalize는 deprecated되서 오버라이딩도 안되고, cleaner도 제한적으로 안전망이나 네이티브 자원회수에서 사용해야한다.
지금은 필요성을 못느끼겠으나 나중에 Cleaner부분을 쓴다면 자세히 알아봐야겠다.
* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.
'Reading > Effective Java' 카테고리의 다른 글
[Effective-Java] Item 10. equals는 일반 규약을 지켜 재정의하라 (0) | 2021.08.19 |
---|---|
[Effective-Java] Item 9. try-finally보다는 try-with-resources를 사용하라 (0) | 2021.08.18 |
[Effective-Java] Item 7. 다 쓴 객체 참조를 해제하라 (+Java Reference와 GC) (0) | 2021.08.16 |
[Effective-Java] Item 6. 불필요한 객체 생성을 피하라 (0) | 2021.08.15 |
[Effective-Java] Item 5. 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라 (0) | 2021.08.15 |