[Effective Java] 아이템7 ~ 아이템10까지

두별·2023년 4월 15일
0

TIL

목록 보기
40/46
post-thumbnail

Effective Java 3/E 북스터디 기록
아이템7. 다 쓴 객체를 해제하라
아이템8. finalzer와 cleaner 사용을 피하라
아이템9. try-finally보다는 try-with-resoureces를 사용하라
아이템10. equals는 일반 규약을 지켜 재정의하라

아이템7. 다 쓴 객체를 해제하라

메모리 누수가 일어나는 위치는 어디인가?

public class Stack {
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public Stack() {
        elements = new Object[DEFAULT_INITIAL_CAPACITY];
    }

    public void push(Object e) {
        ensureCapacity();
        elements[size++] = e;
    }

    public Object pop() {
        if (size == 0) {
            throw new EmptyStackException();
        }
        return elements[--size];
    }

    private void ensureCapacity() {
        if (elements.length == size) {
            elements = Arrays.copyOf(elements, 2 * size + 1);
        }
    }
}

이 스택을 사용하는 프로그램을 오래 실행하다 보면 점차 가비지 컬렉션 활동과 메모리 사용량이 늘어나 결국 성능이 저하될 것이다.

상대적으로 드문 경우긴 하지만 심할 때는 디스크 페이징이나 OutOfMemoryError를 일으켜 프로그램이 예기치 않게 종료될 수도 있다.

이 코드에서는 프로그램 스택이 커졌다가 줄어들었을 때 스택에서 꺼내진 객체들을 가비지 컬렉터가 회수하지 않는다.

이 스택이 그 객체들의 다 쓴 참조(obsolete reference)를 여전히 가지고 있기 때문이다.

여기서 다 쓴 참조란 문자 그대로 앞으로 다시 쓰지 않을 참조를 뜻한다.

앞의 코드에서는 elements 배열의 '활성 영역'밖의 참조들이 모두 여기에 해당한다. 활성 영역은 인덱스가 size보다 작은 원소들로 구성된다.

가비지 컬렉션에서의 메모리 누수

객체 참조 하나를 살려두면 가비지 컬렉터는 그 객체뿐 아니라 그 객체가 참조하는 모든 객체(그리고 또 그 객체들이 참조하는 모든 객체)를 회수해가지 못한다.
그래서 단 몇 개의 객체가 매우 많은 객체를 회수되지 못하게 할 수 있고 잠재적으로 성능에 악영향을 줄 수 있다.
해법
해당 참조를 다 썼을 때 null 처리(참조 해제)하면 된다.

제대로 구현한 pop 메서드

public class Stack {

    // ...

    public Object pop() {
        if (size == 0) {
            throw new EmptyStackException();
        }
        Object result = elements[--size];
        elements[size] = null; // 다 쓴 참조 해제
        return result;
    }
}

null로 객체 참조를 해제

다 쓴 참조를 null 처리하면 다른 이점도 따라온다.
만약 null 처리한 참조를 실수로 사용하려 하면 프로그램은 즉시 NullPointerException을 던지며 종료된다. 프로그램 오류는 가능한 한 조기에 발견하는 게 좋다.
하지만, 객체 참조를 null 처리하는 일은 예외적인 경우여야 한다.
모든 객체를 다 쓰자마자 일일이 null 처리하는 것은 프로그램을 필요 이상으로 지저분하게 만들 뿐이다.
다 쓴 참조를 해제하는 가장 좋은 방법은 그 참조를 담은 변수를 유효 범위(scope) 밖으로 밀어내는 것이다. 변수의 범위를 최소가 되게 정의하자.

자기 메모리를 직접 관리하는 클래스의 메모리 주의 사항

이 스택은 (객체 자체가 아니라 객체 참조를 담는) elements 배열로 저장소 풀을 만들어 원소들을 관리한다.
배열의 활성 영역에 속한 원소들이 사용되고 비활성 영역은 쓰이지 않는다. 문제는, 가비지 컬렉터는 이 사실을 알 길이 없다는 것이다.
가비지 컬렉터가 보기에는 비활성 영역에서 참조하는 객체도 똑같이 유효한 객체다. 비활성 영역의 객체가 더 이상 쓸모없다는 건 프로그래머만 아는 사실이다.
따라서, 프로그래머는 비활성 영역이 되는 순간 null 처리해서 해당 객체를 더는 쓰지 않을 것임을 가비지 컬렉터에 알려야 한다.
일반적으로 자기 메모리를 직접 관리하는 클래스라면 항시 메모리 누수에 주의해야 한다. 원소를 다 사용한 즉시 그 원소가 참조한 객체들을 다 null 처리해줘야 한다.

캐시에서의 메모리 누수

캐시 역시 메모리 누수를 일으키는 주범이다.
캐시 외부에서 키(key)를 참조하는 동안만(값이 아니다) 엔트리가 살아 있는 캐시가 필요한 상황이라면 WeakHashMap을 사용해 캐시를 만들자.
다 쓴 엔트리는 그 즉시 자동으로 제거될 것이다. 단, WeakHashMap은 이러한 상황에서만 유용하다.

캐시에서의 메모리 누수를 방지하는 방식

캐시를 만들 때 보통은 캐시 엔트리의 유효 기간을 정확히 정의하기 어렵다. 따라서, 시간이 지날수록 엔트리의 가치를 떨어뜨리는 방식을 흔히 사용한다.
이런 방식에서는 쓰지 않는 엔트리를 이따금 청소해줘야 한다.
ScheduledThreadPoolExecutor 같은 백그라운드 스레드를 활용하거나 캐시에 새 엔트리를 추가할 때 부수 작업으로 수행하는 방법이 있다.
LinkedHashMap은 removeEldestEntry 메서드를 써서 후자의 방식으로 처리한다.
더 복잡한 캐시를 만들고 싶다면 java.lang.ref 패키지를 직접 활용해야 할 것이다.

리스너(listener) 혹은 콜백(callback)에서의 메모리 누수

클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면, 뭔가 조치해주지 않는 한 콜백은 계속 쌓여갈 것이다.
이럴 때 콜백을 약한 참조(weak reference)로 저장하면 가비지 컬렉터가 즉시 수거해간다. (ex. WeakHashMap에 키로 저장하면 된다.)

Weak Reference란?

  • 객체의 reachability가 strongly reachable 객체가 아닌 객체 중 Soft Reference가 없고 Weak Reference만 있는 상태

  • WeakReference ref = new WeakReference(new String("hello")); 와 같은 형태

  • WeakReference는 GC 동작마다 회수된다.

  • WeakReference 객체 내의 weakly reachable 객체에 대한 참조가 null로 설정되면, GC에 의해 메모리 회수

WeakReference<Sample> wr = new WeakReference<Sample>(new Sample());
Sample ex = wr.get(); // 참조
ex = null; // weakly reachable 객체 = null -> 메모리 회수

핵심 정리

메모리 누수는 겉으로 잘 드러나지 않아 시스템에 수년간 잠복하는 사례도 있다. 이런 누수는 철저한 코드 리뷰나 힙 프로파일러 같은 디버깅 도구를 동원해야만 발견되기도 한다. 그래서 이런 종류의 문제는 예방법을
익혀두는 것이 매우 중요하다.

아이템8. finalzer와 cleaner 사용을 피하라

자바는 두 가지 객체 소멸자를 제공한다.
그중 finalizer는 예측할 수 없고, 상황에 따라 위험할 수 있어 일반적으로 불필요하다.
finalizer는 나름의 쓰임새가 몇 가지 있긴 하지만 기본적으로 '쓰지 말아야' 한다.
그래서 자바 9에서는 finalizer를 사용 자제(deprecated) API로 지정하고 cleaner를 그 대안으로 소개했다. cleaner는 finalizer보다는 덜 위험하지만, 여전히 예측할 수 없고, 느리고, 일반적으로 불필요하다.
자바에서는 try-with-resources와 try-finally를 사용해 해결한다.

finalizer와 cleaner는 즉시 수행된다는 보장이 없다.

얼마나 신속히 수행할지는 전적으로 가비지 컬렉터 알고리즘에 달렸으며, 이는 가비지 컬렉터 구현마다 천차만별이다. 그래서 클래스에 finalizer를 달아두면 그 인스턴스의 자원 회수가 제멋대로 지연될 수 있다.
한편, cleaner는 자신을 수행할 스레드를 제어할 수 있다는 면에서 조금 낫다. 하지만 여전히 백그라운드에서 수행되며 가비지 컬렉터의 통제하에 있으니 즉각 수행되리라는 보장은 없다.

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

finalizer의 부작용

finalizer 동작 중 발생한 예외는 무시되며, 처리할 작업이 남았더라도 그 순간 종료된다.
잡지 못한 예외 때문에 해당 객체는 자칫 마무리가 덜 된 상태로 남을 수 있다.
보통의 경우엔 잡지 못한 예외가 스레드를 중단시키고 스택 추적 내역을 출력하겠지만, 같은 일이 finalizer에서 일어난다면 경고조차 출력하지 않는다.

AutoCloseable 객체를 생성하고 가비지 컬렉터가 수거하기까지 12ns가 걸린 반면 finalizer를 사용하면 550ns가 걸렸다. 다시 말해 finalizer를 사용한 객체를 생성하고 파괴하니 50배나 느렸다. finalizer가 가비지 컬렉터의 효율을 떨어뜨리기 때문이다.

finalizer를 사용한 클래스는 finalizer 공격에 노출되어 심각한 보안 문제를 일으킬 수도 있다. 생성자나 직렬화 과정에서 예외가 발생하면 이 생성되다 만 객체에서 악의적인 하위 클래스의 finalizer가 수행될 수 있게 된다. final이 아닌 클래스를 finalizer 공격으로부터 방어하려면 아무 일도 하지 않는 finalize 메서드를 만들고 final로 선언하자

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

그저 AutoCloseable을 구현해주고 클라이언트에서 인스턴스를 다 쓰고 나면 close 메서드를 호출하면 된다.
close 메서드에서 이 객체는 더 이상 유효하지 않음을 필드에 기록하고 다른 메서드는 이 필드를 검사해서 객체가 닫힌 후에 불렸다면 IlligalStateException을 던지는 것이다.

그렇다면 cleaner와 finalizer는 무슨 용도로 사용할 수 있을까?

하나는 자원의 소유자가 close 메서드를 호출하지 않는 것에 대비한 안전망 역할이다. cleaner나 finalizer가 즉시 (혹은 끝까지) 호출되리라는 보장은 없지만, 클라이언트가 하지 않은 자원 회수를 늦게라도 해주는 것이 아예 안 하는 것보다는 나으니 말이다?

cleaner와 finalizer를 적절히 활용하는 두 번째 예는 네이티브 피어와 연결된 객체에서다.
네이티브 피어란 일반 자바 객체가 네이티브 메서드를 통해 기능을 위임한 네이티브 객체를 말한다. 네이티브 피어는 자바 객체가 아니니 가비지 컬렉터는 그 존재를 알지 못한다.
성능 저하를 감당할 수 없거나 네이티브 피어가 사용하는 자원을 즉시 회수해야 한다면 앞서 설명한 close 메서드를 사용해야 한다.

만약 안전망으로 사용하고 싶지 않다면 아래와 같이 코드를 작성 하면 될것이다.

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

정리

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

아이템9. try-finally보다는 try-with-resoureces를 사용하라

자바 라이브러리에서는 자원 사용 후 close()로 닫아줘야 하는것들이 있다. 예를들면 InputStream, OutputStream, java.sql.connection 등..

전통적으로 이러한 자원들은 try-finally를 통해 자원을 닫아주었는데,

public static String inputString() throws IOException {
    BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
    try {
        return br.readLine();
    } finally {
        br.close();
    }
}

이 구문은 만약 기계의 물리적인 결함으로 인해 readline()이 실패할 경우 try에서 readLine()에 대한 실패가, finally에서 close()에 대한 실패가 발생한다. 이러한 경우는 시스템상에 close에 대한 예외처리만 잡기 때문에 물리적 결함에 대한 오류 파악이 매우 어렵다는 단점이 있다.

따라서 자바 7에서부터 제공하는 try-with-resource 구문을 사용하여 자원을 처리하는 것이 좋다. 이 구조를 사용하려면 자원이 AutoCloseable 인터페이스를 구현해야 한다. 이 인터페이스는 close 메소드만 정의한 인터페이스다.

// try-finally 구문
public class Copy {
    private static final int BUFFER_SIZE = 8 * 1024;

    // 코드 9-2 자원이 둘 이상이면 try-finally 방식은 너무 지저분하다! (47쪽)
    static void copy(String src, String dst) throws IOException {
        InputStream in = new FileInputStream(src);
        try {
            OutputStream out = new FileOutputStream(dst);
            try {
                byte[] buf = new byte[BUFFER_SIZE];
                int n;
                while ((n = in.read(buf)) >= 0)
                    out.write(buf, 0, n);
            } finally {
                out.close();
            }
        } finally {
            in.close();
        }
    }
}

하나의 자원을 처리하는 try-with-resources

    // 코드 9-3 try-with-resources - 자원을 회수하는 최선책! (48쪽)
    static String firstLineOfFile(String path) throws IOException {
        try (BufferedReader br = new BufferedReader(
                new FileReader(path))) { // try의 인자값으로 resource 전달
            return br.readLine();
        }
    }

복수의 자원을 처리하는 try-with-resources

    // 코드 9-4 복수의 자원을 처리하는 try-with-resources - 짧고 매혹적이다! (49쪽)
    static void copy(String src, String dst) throws IOException {
        try (InputStream   in = new FileInputStream(src);
             OutputStream out = new FileOutputStream(dst)) {
            byte[] buf = new byte[BUFFER_SIZE];
            int n;
            while ((n = in.read(buf)) >= 0)
                out.write(buf, 0, n);
        }
    }

try-with-resource with catch

    // 코드 9-5 try-with-resources를 catch 절과 함께 쓰는 모습 (49쪽)
    static String firstLineOfFile(String path, String defaultVal) {
        try (BufferedReader br = new BufferedReader(
                new FileReader(path))) {
            return br.readLine();
        } catch (IOException e) {
            return defaultVal;
        }
    }

핵심 정리

꼭 회수해야 하는 자원을 다룰 땐 try-finally말고 try-with-resources를 사용하자. 예외는 없다. 코드는 더 짧고 분명해지고, 만들어지는 예외 정보도 훨씬 유용하다. try-finally로 작성하면 실용적이지 못할 만큼 코드가 지저분해지는 경우라도, try-with-resources로는 정확하고 쉽게 자원을 회수할 수 있다.

아이템10. equals는 일반 규약을 지켜 재정의하라

equals 메소드는 재정의하기 쉬워 보이지만 잘못 수정하게 된다면 자칫하면 끔직한 결과를 초래할 수 있습니다.

재정의

다음에 열거한 상황 중 하나에 해당된다면 재정의하지 않는 것이 최선입니다.

  • 각 인스턴스가 본질적으로 고유하다
  • 인스턴스의 논리적 동치성 을 검사할 일이 없다
  • 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.
  • 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다

그럼, 재정의해야 할 때는 언제일까요?

객체 식별성이 아니라 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때입니다.
주로 값 클래스가 해당됩니다. Integer와 String처럼 값을 표현하는 클래스를 말합니다.
두 값 객체를 equals로 비교하는 프로그래머는 객체가 같은지가 아니라 값이 같은지를 알고 싶어하기 때문에, equals가 논리적 동치성을 확인하도록 재정의해야 합니다.

Object 명세

equals 메서드는 동치관계를 구현하며, 다음을 만족합니다.

  • 반사성
  • 대칭성
  • 추이성
  • 일관성
  • null-아님

equals 규약을 어기면 그 객체를 사용하는 다른 객체들이 어떻게 반응할지 알 수 없습니다.

주의사항

  • equals를 재정의할 땐 hashCode도 반드시 재정의하자
  • 너무 복잡하게 해결하려 들지 말자
    • 필드들의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있습니다.
  • Object 외의 타입을 매개변수로 받는 equals 메소드는 선언하지 말자

사용방법

사람이 직접 작성하는 것보다는 IDE 또는 제공해주는 라이브러리를 사용하는 것이 좋습니다. 직접 수정하다가 부주의할 실수를 저지르지는 않으니 말입니다.

  • IDE를 활용한 equals 생성
  • 롬북 어노테이션을 활용한 생성

핵심 정리

꼭 필요한 경우가 아니면 equals를 재정의 하지 말아야 합니다.
많은 경우에 Object의 equals가 비교를 정확히 수행해줍니다.
재정의해야 할 때는 그 클래스의 핵심 필드 모두를 빠짐없이, 다섯 가지 규약을 확실히 지켜가며 비교해야 합니다.
재정의 해야 한다면 IDE 또는 롬북 을 통하여 진행합니다.

리뷰

  • 아이템 8장
    • 이와 관련한 SIGTERM이란 것을 알아두면 좋다. https://2kindsofcs.tistory.com/53
    • finalizer와 cleaner는 스터디원 모두 처음 들어봤고 알아두면 좋긴 하겠지만 실무에 거의 쓰이진 않아서 다들 생소해 했다.
  • 아이템 9장
    • try-finally말고 try-with-resource를 쓰라고 책에서는 권장하고 있지만, 무조건 그렇게 해야하는 것이 아니라 상황을 봐가면서 써야한다. 실행이 끝났을 때 파일을 삭제하는 처리가 필요한 경우에는 finnaly에서 처리해줘야 하기 때문에
  • 아이템 10장
    • DTO 객체에 필드가 많은 경우 값을 비교하는 비용이 커질 수도 있기 때문에 PK가 같으면 같은 객체로 보는 팀내 규약을 정할 수도 있다.

0개의 댓글