스터디에서 발표했던 안드로이드 메모리 누수와 관련된 글을 노션에 작성했는데, 블로그에 옮겨놔야할거 같아서 옮겨 놓는다.
Random Access Memory(RAM)
zRAM
Storage
메모리 관리 프로세스는 안드로이드 런타임(ART)와 그 이전 버전인 달빅 가상 머신으로 수행이 된다.
그럼 이제 메모리 릭이 왜 나는지를 알아야 하는데, 그전에 그렇다면 JVM은 운영체제에게서 받은 메모리를 어떻게 구분해서 사용하는지를 다시 기억을 되새김해봅시다.
JVM Runtime Data Area
기본적으로 큰 영역은 3가지입니다.
Method Area(메서드 영역)
Heap (힙)
Thread
PC Register
Native Method Stack
JVM Stack
그림 1
이렇듯 Heap영역에서 만들어진 객체를 회수하기 위해서 Android에서는 GC(Garbage Collector)를 사용합니다.
Heap영역을 추적해 더이상 사용하지 않는 객체를 힙영역에서부터 회수함으로써 메모리 영역을 남기는 것이다.
그렇다면 생각을 한번 해보면 우리가 사용하지 않는 힙영역의 객체를 계속해서 유지하고 있다면 GC는 이 객체가 계속해서 사용중이라고 생각할 거고 쓰레기라고 생각하지 않을 것이다.
이는 즉 객체를 메모리에서 해제하지 못하게 되는 것이고 이때 메모리 릭이 발생한다.
이러한 데이터들을 하나 , 둘씩 제거하지 못한다면 어드샌가 UI는 굳고 뭐...앱이 망가는 그런 상태가 발생하는거죠 OutOfMemory가 발생하게 되는 것입니다.
자 그럼 지금은 자바의 예로 들었으니까 이를 Android에 대입해서 생각을 한번 해봅시다
MainActivity 1개만 존재하는 App이 있습니다
class MainActivity : AppCompatActivity(){
override onCreate(savedStateInstance : Bundle?) {
// 그냥 생각나는 대로 적은 겁니다 일단 있다고 합시다.
}
} // (1)
이 상황에서 어떠한 다른 Thread를 생성하지 않았기 때문에 Main Thread 즉 UI Thread만 존재합니다.
그럼 MainThread 의 JVMStack 영역에는 MainActivity class에 대한 값이 있어야 하는데
그 값이 실 객체 값을 가지는 Call by Value 형태가 아닌 Heap 영역에 MainActivity에 대한 구현체는 있고 JvmStack에는 MainActivity 클래스의 주소인 주소 값만을 가지고 있는 형태가 될 것입니다.
뭐 기타..여러가지 Context라던가 이러한 Activity를 구성하기위한 여러가지 상위 객체들 또한 Stack에 올라가있는 형태가 되겠지요(MainThread Stack에)
여기서
class SingletonExample(context : Context) {
private var mContext = context
companion object{
var instance : SingletonExample? = null
fun getSingleton(context : Context) : SingletonExample {
if(instance == null) {
instance = SingletonExample(context)
}
return instance as SingletonExample
}
}
}
이와 같은 SingletonExample이라는 클래스를 만들어 Context를 Singleton 형태로 받을 수 있도록 해봅시다. ( 예시일 뿐입니다 결코 좋은 형태가 아닙니다. )
class MainActivity : AppCompatActivity(){
override onCreate(savedStateInstance : Bundle?) {
val singleton = SingletonExample.getSingleton(this)
}
} // (2)
여기서 singleton이라는 객체는 kotlin이라서 안적혀 있지만 new라는 것을 통해서 동적으로 생성된 객체입니다.
SingletonExample이 MainActivity에서 호출된 시점부터 SingletonExample은 MainActivity의 Context를 가지게 된 것이고 MainActivity가 죽게 되더라도, 즉 JVMStack영역에서 참조되는 것이 사라져 GC에서 제거 대상으로 포함해 제거를 하더라도, SingletonExample에서 계속 참조 중이기에 인스턴스를 아직 사용하고 있다고 간주하고 회수를 할 수 없게 됩니다. 이러한 상황이 메모리 누수를 일으킬 수 있게 됩니다.
Activity는 여러번 만들어지고 제거될 수 있는 객체이다.
하지만 정적 참조로 Activity를 참조한다면 앱이 메모리에 있는 동안 계속 유지되기 때문에 생명주기에 의해 제거되어도 GC가 수거할 수 없다.
따라서 Activity에서 정적 변수를 사용할 때 매우 주의 해야한다. 만약 정적 변수가 Activity를 직접 혹은 간접적으로 참조할 가능성이 있는 경우 onDestroy()
에서 참조를 끊어줘야한다.
세부적인 case
- static 변수에서 Activity를 참조하는 경우
- static View 에서 Activity를 참조하는 경우.
- 싱글턴 객체에 Activity 참조를 넘기는 경우.
inner class는 outer class 변수에 접근 가능하다는 특징이 있다. 이 경우 inner class는 outer class의 참조를 유지해야한다는 특성이 있기 때문에, 내부/익명 클래스가 outer class 보다 오랫동안 유지 될 경우 누수가 발생한다.
따라서 누수 위험을 피하기 위해서는 내부/익명 클래스는 정적 클래스를 사용한다.
주로 중요하게 작용하는 지점은 다음과 같다
Activity 내부에서 Thread, handler, AsyncTask 를 사용하는경우
스레드의 경우 Activity보다 오래 작업할 수 있다. 따라서 Thread의 작업을 onDestroy()
에서 종료해야한다.
메모리 누수가 발생하면, 기존에 차지한 메모리가 반환되지 않는다. 이 상황에서 다시 메모리를 요청하며 결과적으로 더 많은 메모리를 차지하게 된다.
하지만 메모리에는 한계가 있기 때문에, 더 이상 메모리에 할당할 수 없다면 앱은 메모리 부족으로 인해 강제 종료 된다.
⇒ 메모리 부족으로 앱이 종료되는 경우 사용자 입장에서 강제 종료의 경험을 겪는다.
메모리 누수가 발생하면 결과적으로 Android 시스템은 GC를 빈번하게 일으키게 된다. GC가 발생하면 UI 랜더링 및 이벤트 처리가 중단된다.
안드로이드는 대략 화면을 16ms로 그린다고 한다.
만약 GC가 오래걸리면 안드로이드는 프레임이 떨어진다.
⇒ 결과적으로 사용자 입장에서 앱이 느리다는 것을 인지하게 된다. 또한 심각한 경우 ANR 조건으로 ANR이 발생할 수 있다.
사용자는 일반적으로 100ms~200ms 이상 걸리는 작업에서 앱이 느리다고 인지하게 된다.
안드로이드 시스템이 메모리 할당을 거부할 때를 재현하기에는 어려움이 있다.
더불어 크래시 리포트로 추론하기도 어려움이 있다.
결국 프로그래머가 GC가 어떻게 작동하는지 잘 이해해야 한다.
그리고 메모리 누수를 찾기 위해서 코드 작성과 리뷰에 노력을 해야한다.
안드로이드에서 일부 코드가 의심스러울 경우 누수를 예측할 수 있는 방법을 소개하면 다음과 같다.
안드로이드 스튜디오 메모리 프로파일러
이 경우, 개발자가 결국 GC를 잘 이해하고 어디에서 누수가 날지 인지해야한다. 누수를 찾는 방법은 아래와 같은 순서를 거친다.
Square - Leak Canary
앱 액티비티들에 약한 참조를 만들어 GC 이후에 참조가 지워지는지 확인한다.
그렇지 않은 경우 .hprof 를 이용하여(힙 덤프) 분석하고 누수 발생을 확인한다.
만약 누수가 있는경우 알림이 표시되고 별도 앱을 통해 누수가 발생된 위치를 트리형태로 표시한다.
주의) 개발/테스트 빌드시에만 Leak Canary를 설치하는 것이 좋다. 사용자 빌드 전에 개발자와 QA가 미리 메모리 누수를 찾기 위함.
결과적으로 프로그래머 자신이 GC의 작동과 어떤 경우에 누수의 위험이 있는지 잘 인지하고 이를 신경쓰고, 주의하여 코드를 작성해야한다.