FileInput/OutputStream과 Finalizer, 그리고 메모리 leak에 대하여

프로젝트를 진행하면서 서버의 메모리 누수 측정을 하면서 겪은 일에 대해 정리하고자한다.

문제의 시작은, 서버에서 프로세스 메모리를 측정했을 때 발견되는 지속적인 메모리 증가였다.
문제의 원인을 확인하기 위해 힙 덤프를 떠서 MAT으로
Leak Suspects를 확인할 때 마다 가장 넓은 파이의 메모리가 자꾸 늘어나는 것이었다.

(지금은 찔끔이지만 시간이 지나면서 지속적으로 올라갔다)

‘그럼 일단 Finalizer가 문제라는 거지’하고 Details를 클릭했더니…

이건 뭔가 이상하다.
내가 여러 기술 블로그에서 봐온 것들과는 다른 녀석이었다.

일단 파이 그래프 밑쪽에 있는 Accumulated Objects by Class in Dominator Tree에서 가장 문제가 되는 녀석을 자세히 보기로 했다.

여기서 자세히 살펴보니 queue에 FileInput/OutputStream이 계속 쌓여있는 것을 확인할 수 있었다.

작업 중인 서비스는 디스크의 파일을 통해 서비스를 제공하는 것이기 때문에
애플리케이션 로직에 파일 I/O에 대한 기능이 많이 있었고,
그래서 이 문제가 I/O 작업할 때 코드에서 자원 관리에 대해 지나친 부분이 있을거라 생각해 열심히 찾아보고,
의심가는 부분도 고쳐보고 했지만 결과는 똑같이 Finalizer의 메모리가 증가했었다.

그렇게 문제를 찾으려고 시간을 보내다 다시 덤프 분석 결과를 뜯어보니
이번엔 아래 박스친 부분에서 unfinalized가 눈에 들어왔다.

I should’ve focused on this earlier…
좀 더 일찍 이쪽에 더 관심을 줬었어야 했다…

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

지금 나의 상황이 딱 이런 것이었다.
기존에 있던 코드에서 파일 I/O 작업을 하는 로직에, finalize()가 있는 FileInputStream, FileOutputStream을 사용하는데, 기능의 메모리 누수를 측정하기 위해서 계속해서 부하를 주다보니 Finalizer queue에 객체가 쌓이고 처리가 되지 않다보니 Finalizer가 차지하는 메모리가 점점 증가하고 MAT에서도 이를 메모리 leak 원인으로 보여준 것이었다.

그럼 이 문제는 어떻게 해결할 수 있을까해서 찾아보니 이런 아티클-FileInputStream / FileOutputStream Considered Harmful과 젠킨스 PR-Avoid using new FileInputStream / new FileOutputStream을 확인할 수 있었다.
이를 통해 기존 FileInput/OutputStream의 사용을 아래와 같이 변경하였다.

1
2
Files.newInputStream(...);
Files.newOutputStream(...);

단순한 방법이지만 Finalizer 관련 문제를 해결하기에 좋은 방법이라 적용하였고,
변경한 뒤의 다시 메모리 누수를 측정했을 때 Filnalizer에 FileInputStreamFileOutputStream에 대한 내용은 찾을 수 없었다.

Thank god it works~


문제의 정확한 원인을 찾기까지가 시간이 좀 걸렸고, 엉뚱하게 삽질한 부분도 있고해서 부끄러운 경험이지만
(문제를 찾다찾다 집중이 떨어져 영어 해석이 잘 안되서 더 걸렸었다…)
FileInput/OutputStream에 이러한 문제점이 있던 것도 처음 알았고 Finalizer라는 것도 알 수 있게 되었다.
덕분에 더 배워갈 수 있었던 경험이었다. :)

참조:

Share