Memory Leak에 관한 이야기를 해보려 한다. 저번에 작성한 궁금했던 것들 2편 - 바인딩 클래스와 생명 주기에서 '프래그먼트에서 바인딩 클래스 인스턴스를 정리해줘야 하는 이유'에 대해서 공부했다. 문서를 보며 개발을 진행하지만, 해당 이유를 알기 전까진 인스턴스를 정리해주지 않으면 Memory Leak이 발생한다는 것을 모르고 있었다. 따라서 JVM의 GC를 맹목적으로 믿는 것이 아니라, 메모리 누수로 인한 성능 저하의 가능성은 생각보다 가까이 있다는 것을 인지하고 있는 것이 중요하다고 생각했다. 이러한 문제를 미연에 방지하기 위해선 Memory Leak이 발생할 수 있는 조건이나 상황에 대한 공부가 필요하다고 생각됐다. 일단 Memory Leak에 대해 구체적으로 알아 보자.
Memory Leak
컴퓨터 과학에서 메모리 누수(memory leak) 현상은 컴퓨터 프로그램이 필요하지 않은 메모리를 계속 점유하고 있는 현상이다. 할당된 메모리를 사용한 다음 반환하지 않는 것이 누적되면 메모리가 낭비된다. 즉, 더 이상 불필요한 메모리가 해제되지 않으면서 메모리 할당을 잘못 관리할 때 발생한다. 일부 서적에서 메모리 손실이라는 용어로 뜻을 옮기기도 하지만 leak라는 표현은 단순히 잃는 것 이상의 개념이므로 누수라는 표현이 더 정확하다.
안드로이드 앱은 JVM 상에서 실행된다. 그리고 JVM의 GC(Garbage Collector)는 불필요한 메모리를 알아서 정리해주는 역할을 수행한다. 그렇다면 GC가 작동되지 않는 상황이나 조건이 따로 있는 걸까. JVM Garbage Collector의 동작 방식에 대해 먼저 알아보고 싶어졌다.
Reachability를 직역하면 도달 가능성이다. GC는 불필요한 메모리를 정리한다. 그렇다면, 가장 첫 번째로 정의해야 할 문제는 '해당 객체가 불필요한가?'에 대한 기준을 정의하는 것이다. 해당 기준이 없다면 필요한 메모리도 정리해버리면서 대참사가 일어날 것이다. 그렇다면 이 Reachable은 어떤 기준으로 판별하는가? 바로 참조다. 어떤 객체가 유효한 참조를 가지고 있다면 도달 가능성이 있다고 판단하는 것이다.
불필요성에 대한 판별을 하기 위해, 도달 가능성이라는 기준이 정의됐다. 그렇다면 GC의 첫 번째 역할은 정해졌다. 위의 사진처럼 GC는 루트 객체(활동 상태이며 프로세스가 사용중인 객체)로부터 출발하여 해당 객체(인스턴스)가 유효한 참조가 있는지 찾아다닌다.
앞에서 GC는 Reachablity라는 기준으로 객체들을 판별하기 위해 메모리 상의 객체들을 찾아다닌다고 했다. 하지만 여기서 새로운 이슈가 발생한다. 모든 객체들을 탐색하면 탐색 시간이 길어질 수도 있지 않겠냐는 것이다. 여기서 'Generation'이라는 개념이 등장한다. 해당 개념을 알아보기 전에 객체에 대한 전제를 하나 알아보자.
객체는 대부분 일회성되며, 메모리 상에 오랫동안 남아있는 경우는 드물다.
위의 말 그대로이다. 프로세스 메모리 상에 생성되는 객체들의 수명은 대체로 짧다는 것이다. 여기서 generation이라는 기준이 등장한다. 객체들의 수명은 대체로 짧기 때문에, 항상 모든 객체에 대한 GC를 수행하기보단, 생성된지 얼마 안 지난 객체들에 대해서 집중적으로 GC를 수행하면 효율이 증가하지 않겠느냐이다. 따라서 JVMd의 Heap 영역은 처음 설계될 때부터 해당 전제를 반영하여 Heap영역을 각 Generation 구간으로 나누었다. 현재 JVM의 Heap 구간은 크게 두 Generation으로 나누어 져있다. 바로 Young Generation, Old Generation이다. 벌써부터 각 구간에 대해 감이 온다.
Young Generation
Old Generation
Stop The World는 가비지 컬렉션을 실행하기 위해 JVM이 애플리케이션의 실행을 멈추는 작업이다. GC가 실행될 때는 GC를 실행하는 쓰레드를 제외한 모든 쓰레드들의 작업이 중단되고, GC가 완료되면 작업이 재개된다. 당연히 모든 쓰레드들의 작업이 중단되면 애플리케이션이 멈추기 때문에, GC의 성능 개선을 위해 튜닝을 한다고 하면 보통 stop-the-world의 시간을 줄이는 작업을 하는 것이다. 또한 JVM에서도 이러한 문제를 해결하기 위해 다양한 실행 옵션을 제공하고 있다.
GC가 불필요한 객체인지를 판별하기 위해 각 객체의 Reachability를 판별한다고 했다. 이는 Mark and Sweep 이라는 알고리즘에 적용된다. 무엇을 마킹하고, 무엇을 청소한다는 것일까? 당연히 reachable한 객체는 마킹하고, unreachable한 객체는 청소한다. GC의 핵심 업무는 해당 알고리즘을 통해 수행된다. 알고리즘을 간단하게 살펴 보자.
앞에서 JVM Heap - Young Generation - Eden 영역이 꽉 차게 되면 Minor GC를 수행한다고 했다. Minor GC의 프로세스는 다음과 같다.
시간이 지나면서 객체들이 계속 Old Generation으로 Promotion되면, Old 영역도 꽉 차는 순간이 발생한다. 이 때 Major GC가 발생하게 된다. Young 영역은 일반적으로 Old 영역보다 크키가 작기 때문에 GC가 보통 0.5초에서 1초 사이에 끝난다. 그렇기 때문에 Minor GC는 애플리케이션에 크게 영향을 주지 않는다. 하지만 Old 영역은 Young 영역보다 크며 Young 영역을 참조할 수도 있다. 그렇기 때문에 Major GC는 일반적으로 Minor GC보다 시간이 오래걸리며, 10배 이상의 시간을 사용한다.