개발/BE

[Java] Heap Memory 누수 분석기4 : dump파일 분석(MAT)

kkap999 2024. 3. 11. 22:07
728x90

이전 글에서 이어집니다 : [Java] Heap Memory 누수 분석기3 : 누수 원인을 분석해보자 - jmap, jhat

그래서 메모리 누수 원인은 뭐였을까?
MAT를 통해 뜬 덤프 파일을 분석해보았다.
.hprof 파일을 열면 다음과 같이 확인해볼 수 있다.

딱 봐도 저 파란 부분이 메모리를 왕 잡아먹고 있는 것으로 보인다.
Leak Suspects라는 탭을 클릭하면 메모리 누수 원인을 MAT가 예측해준다.

역시나 Finalizer가 문제였던 것으로 보인다.
그래서 Finalizer는 뭐냐 ? 메모리 해제/GC를 담당하는 객체이다.
(참고링크 : https://ivvve.github.io/2019/06/05/java/ETC/fileiostream-finalizers/)

finalize() 메서드가 있는 객체는 GC 과정에서 메모리에서 즉시 사라지는 것이 아니라, Finalizer라는 우선순위가 낮은 쓰레드의 내부 queue에 해당 객체가 추가된다. 그리고 Finalizer가 queue에 있는 객체들의 finalize() 메서드를 호출하게 되면 GC가 되는 것이다. 하지만 만약 애플리케이션이 finalize()가 있는 객체를 많이 생성하게 된다면 Finalizer 쓰레드는 우선순위가 낮기 때문에, 객체들의 finalize() 메서드를 계속 실행시킬 수 없다. 그러다보면 메모리가 계속 쌓일 수가 있다.

Finalizer

  1. finalizer와 cleaner는객체에 접근을 할 수 없게 된 후, 이 것이 실행되기까지 얼마가 걸릴지 알 수 없다. 심지어 수행 시점뿐 아니라 수행 여부조차 보장하지 않는다.
    => 즉, 제때 실행되어야 하는 작업은 절대 할 수 없다. (ex. file close, db 공유자원의 영구 lock 해제 등의 인스턴스 자원 회수)
    왜냐면 finalizer와 cleaner은 GC에 의해 실행되고, GC 알고리즘마다 다르게 실행되기 때문이다.
  2. finalizer 동작 중 발생한 예외는 무시되고, 처리할 작업이 남았더라도 그 순간 종료된다
    => 만약 다른 스레드가 이처럼 훼손된 객체를 사용하려 한다면 어떻게 동작할지 예측할 수 없다.
    일반적으로는 잡지 못한 예외가 스레드를 중단시키고 스택 추적 내역을 출력하겠지만, finalizer에서는 경고조차 출력하지 않는다.

c.f.) cleaner는 자신의 스레드를 통제하기 때문에 이 문제에 대해서는 괜찮다.

Finalizer는 예외가 발생시 인스턴스를 손상된 상태로 방치한다

마지막 문제점은 자원 해제 동안에 예외를 다루는 것의 부재입니다. 만약 finalizer가 예외가 발생하면 자원 해제 수행은 중단되고 인스턴스가 아무런 알림없이 손상된 상태로 유지됩니다.

Finalizer를 사용하면 왜 OutOfMemoryError가 발생하는가?

finalizer를 가지고 있는 인스턴스 A를 생성할때 JVM은 java.lang.ref.Finalizer 타입의 인스턴스를 함께 생성합니다. 다 사용한 인스턴스 A가 가비지 컬렉션 대상이 되면 JVM은 인스턴스 A를 처리할 준비가 되었다고 표시하고 참조 큐에 넣습니다.
한편 Finalizer 스레드는 계속 실행되며 참조 큐에서 삭제할 인스턴스를 탐색합니다. 삭제할 인스턴스를 탐색하면 큐에서 삭제할 인스턴스를 삭제(dequeue)하고 해당 인스턴스의 finalizer를 호출합니다.
다음 가비지 컬렉션이 실행되는 동안 삭제될 인스턴스가 더이상 어떤 참조 변수로부터 참조되지 않으면 참조가 삭제될 것입니다.
만약 한 스레드가 고속으로 인스턴스 생성을 계속한다면(위 예제와 같은 경우) Finalizer 스레드는 따라가지 못할 것입니다. 결국 메모리가 모든 객체를 저장할 수 없게 되고 OutOfMemoryError가 발생합니다. 위와 같은 예제를 통하여 알 수 있는 점은 인스턴스가 고속의 속도로 생성되는 상황은 자주 발생하지는 않지만 finalizer가 수행되는데 메모리 자원을 많이 사용한다는 점을 증명한다는 것을 알 수 있습니다.

 

그래서 Finalizer때문이라는 것 까지 추정해보았으니, 실제 어떤 객체가 메모리를 잡아먹고 있는지 툴을 통해서 확인해보았다.

MAT 통해 확인해보니 PgConnection 이놈이 문제인 것으로 보임
Finalizer 객체는 꼬리에 꼬리를 물고 Retained Heap을 잡고있으며, 실제 먹고있는 본 객체는 위 객체가 반복해서 나옴

구글링을 해본 결과 Finalizer의 finalize()가 위에서 설명한 이유들로 인해 오버라이딩 하는것을 권장하지 않고 있지만, 현재 쓰고 있는 postgresql 42.3.8에는 오버라이딩 되어있는 것을 확인할 수 있었음
또한 위와 같은 이유들로 인해 Java9버전부터는 deprecated 상태라고 함
이거는 Java의 Finalizer와 postgresql 라이브러리의 버전 문제라고 파악하고 살펴보았음

postgresql 42.3.8 버전의 PgConnection 및 finalize() 메서드 오버라이딩 확인

protected void finalize() throws Throwable {
    try {
      if (openStackTrace != null) {
        LOGGER.log(Level.WARNING, GT.tr("Finalizing a Connection that was never closed:"), openStackTrace);
      }

      close(); //  QueryExecutor의 close
    } finally {
      super.finalize();
    }
  }

 

postgresql 42.6.0 버전은 Cleanable의 close 메서드로 대체

@Override
  public void close() throws SQLException {
    if (queryExecutor == null) {
      // This might happen in case constructor throws an exception (e.g. host being not available).
      // When that happens the connection is still registered in the finalizer queue, so it gets finalized
      return;
    }
    openStackTrace = null;
    try {
      cleanable.clean();
    } catch (IOException e) {
      throw new PSQLException(
          GT.tr("Unable to close connection properly"),
          PSQLState.UNKNOWN_STATE, e);
    }
  }

Closeable을 implement하는 객체의 LazyCleaner close로 대체되었음 : https://www.postgresql.org/about/news/postgresql-jdbc-4260-released-2613/

 

Java1.8 -> 11버전(postgresql 42.6.0 버전과의 호환을 위함)
postgresql 42.3.8 -> 42.6.0 버전으로 업그레이드해주니 해결되었다!

 

(부록 - 업데이트 이후 덤프 분석결과)

retained 영역의 크기가 확 줄었음을 확인할 수 있었다!


급 마무리!

참고 링크
- https://www.ibm.com/docs/ko/imdm/11.6?topic=issues-javanetconnectexception-connection-refused-error

- https://www.oracle.com/technical-resources/articles/javase/finalization.html