[Android] Garbage Collection 의 기준 및 실행 구조에 대한 이야기. 컴부리 이야기

안녕하세요. 이상철입니다.

 

오늘은 안드로이드에서 Garbage Collection (이하 GC) 에 대한 이야기를 하려고 합니다.

먼저 누구나 JAVA 를 알고 있다고 한다면 한번은 들어봤고 대충 개념도 가지고 있는 것이 바로 GC 가 아닐까 생각됩니다.
저도 뭐 자바는 C나 C++ 처럼 명시적인 메모리 해제를 하지 않아도 되고 자바가 알아서 GC를 해준다.
GC 는 JAVA Virtual Machine (이하 VM) 에서 메모리가 부족하다고 하면 자동으로 사용하지 않는 메모리를 찾아서 해제해준다 이런 정도로 알고 있었던 것이죠.

그런데 Android 성능 개선을 위해서 도대체 이 GC 가 발생하는 기준이 뭘까 하고 생각하다가 안드로이드에서 GC 에 대한 것을 알아보게 되었습니다.

 

가장 간략히 정리하면,
안드로이드가 부팅하면서 Zygote process 를 생성 후 바로 Dalvik GC Deamon 을 생성하고, 이 데몬은 요청이 있을 때까지 가만히 있다가 GC 요청이 들어오면 GC 를 수행한다 이렇게 정리될 수 있을 것 같습니다.

 

좀 더 들어가 보면 (소스를 가지고 설명하겠습니다. 그렇다고 100% 소스로만 하는게 아니라요 ^^ 그냥 설명을 할 때 소스로 같이 설명하겠다는...),


폰 부팅하면서 Zygote process 실행 -> dalkiv vm 을 실행시킴 /android/dalvik/dalvikvm/Main.c 에서 main() 호출 -> /android/dalvik/vm/Jni.c JNI_CreateJavaVM() 호출 ->
/android/dalvik/vm/Init.c dvmStartup() 호출 -> dvmInitAfterZygote() 호출 -> /android/dalvik/vm/alloc/Alloc.c dvmGcStartupAfterZygote() 호출 ->
/android/dalvik/vm/alloc/Heap.c dvmHeapStartupAfterZygote() 호출 -> /android/dalvik/vm/alloc/HeapSource.c dvmHeapSourceStartupAfterZygote() 호출 ->
gcDaemonStartup() 호출 이 함수에서 gHs->hasGcThread = dvmCreateInternalThread(&gHs->gcThread, "GC", gcDaemonThread, NULL); 과 같이 Dalvik GC Daemon Thread 를 생성합니다.
이 함수를 계속 파고 들어가보면 결구 thread 이니 실제 실행되는 놈은 gcDaemonThread() 함수가 되고 이 함수를 보면 다음과 같이 되어 있습니다 (이 함수의 이해가 Dalvik GC 의 핵심으로 판단됩니다).

static void *gcDaemonThread(void* arg)
{
    dvmChangeStatus(NULL, THREAD_VMWAIT);
    dvmLockMutex(&gHs->gcThreadMutex);
    while (gHs->gcThreadShutdown != true) {  // <= Dalvik GC 데몬 스레드가 살아 있는 동안은 계속 무한 루프를 돌게 되어 있음.
        dvmWaitCond(&gHs->gcThreadCond, &gHs->gcThreadMutex);  // <= 이 dvmWaitCond() 안을 보면 결국 pthread_cond_wait() 함수를 호출하도록 되어 있음. 이는 결국 누군가 pthread_cond_signal() 을 보낼때까지 기다리게 만듬.
        dvmLockHeap();
        dvmChangeStatus(NULL, THREAD_RUNNING);
       
dvmCollectGarbageInternal(false, GC_CONCURRENT);  // <= 실제 GC 를 수행하는 곳.
        dvmChangeStatus(NULL, THREAD_VMWAIT);
        dvmUnlockHeap();
    }
    dvmChangeStatus(NULL, THREAD_RUNNING);
    return NULL;
}

즉 위에서 제가 주석을 달았듯이, 이 함수는 무작적 무한루프를 돌지 않습니다. 그렇게 되면 CPU 사용량이 100%가 되어서 아예 시스템이 멎어버리겠죠. ^^;
그래서 바로 pthread_cond_wait() 을 써서 (소스에서 dvmWaitCond() 함수 부분) 누군가 pthread_cond_signal() 을 보낼때까지는 동작을 하지 않고 멈추어 있습니다.
자 그럼 이제 제가 가장 궁금해하던 도대체 Android 에서 GC 는 언제 실행되고, 어떤 기준으로 실행될지 말지를 결정할까 하는 의문에 대한 해결에 근접하게 되었습니다.

 

같은 소스에서 pthread_cond_signal 을 호출하는 것을 보면 (소스에서는 dvmWaitCond() 함수를 사용) 바로 dvmHeapSourceAlloc() 함수 내에서 다음과 같이 호출을 합니다.
그런데 함수의 이름에서도 알겠다 싶이, 이놈은 바로 Heap Memory 할당이 필요할 때마다 불리는 함수입니다. 그러니 프로그램을 실행시키면 이놈이 엄청나게 실행됩니다.
또한 heap 은 각 어플리케이션마다 각각 가지고 있기 때문에 엄청나게 불리는 것입니다.

 

if (heap->bytesAllocated > heap->concurrentStartBytes) {  // 오 제가 의문으로 갖었던 바로 GC 를 실행하는 기준이 나와 있네요. ^^; (주석1)
        --
         * We have exceeded the allocation threshold.  Wake up the
         * garbage collector.
         --
        dvmSignalCond(&gHs->gcThreadCond);  // pthread_cond_signal 을 보냄. 즉, GC 하라고 명령을 던지게 됨.
(주석2)
}

 

오~ 바로 이 부분에서 제가 갖었던 의문이 모두 풀리게 되네요. 즉, 안드로이드는 주석1 에서처럼 현재 할당된 힙 메모리가 안드로이드가 정한 concurrentStartBytes 보다 크면
바로 GC 를 시작하라고 pthread_cond_signal 을 그 현재 실행중인 프로그램의 thread 에 날리게 됩니다 (주석2 부분).

 

그럼 이 concurrentStartBytes 는 어떻게 설정될까요?
dvmHeapSourceGrowForUtilization() 함수 내를 보면,

 

  -- The ideal size includes the old heaps; add overhead so that
     * it can be immediately subtracted again in setIdealFootprint().
     * If the target heap size would exceed the max, setIdealFootprint()
     * will clamp it to a legal value.
     --
    overhead = getSoftFootprint(false);
    oldIdealSize = hs->idealSize;
    setIdealFootprint(targetHeapSize + overhead);

    freeBytes = getAllocLimit(hs);  // 현재 어플리케이션에 할당할 수 있는 최대의 Heap Memory 값을 가지고 옴. 이것은 Android Dalvik system 에서 정함.
 
    if (freeBytes < CONCURRENT_MIN_FREE) {  // 만약 할당할 수 있는 최대 힙메모리 값이 256K 보다 작으면 (CONCURRENT_MIN_FREE 값은 256K 입니다)
        -- Not enough free memory to allow a concurrent GC. --
        heap->concurrentStartBytes = SIZE_MAX;  // SIZE_MAX (이 값은 -1 임) 을 할당.  
    } else {
        heap->concurrentStartBytes = freeBytes - CONCURRENT_START;  // 최대 힙메모리 값 - 128K (CONCURRENT_START 값이 128K) 된 값을 할당.
    }

 

위 부분을 보면 이제 GC 가 돌아가는 기준을 찾을 수 있게 됩니다.
예를 들어 설명하면, 5MB 게임 어플이 실행한다면, freeBytes 가 대충 5.2MB 로 잡히고 그렇게 되면 concurrentStartBytes 는 5.2MB - 128K 값이 할당이 되는 것입니다.
따라서 이 게임을 하다보면 위 주석1 부분에서 heap->bytesAllocated 값이 계속 증가하다가 5.2MB - 128K 만큼 보다 더 많이 메모리가 할당되면 바로 이 게임에 대한 GC 가 동작되는 것입니다 (주석2 부분을 호출됨).


소스까지 찾아가면서 설명을 해서 좀 복잡한 설명일 수 있는데, 간단히 정리하면 다음과 같습니다.

안드로이드에서 각 프로그램마다 자신의 고유한 Heap Memory 가 있고, 그 프로그램이 실행될 때 필요한 힙 메모리의 양은 Dalvik 이 정하는데, 이 프로그램이 Dalvik 이 정한 메모리 양에서 128K 를 뺀 양까지 힙 메모리 할당에 도달하면 그 프로그램에 대해서 GC 가 실행된다.

 

이렇게 정리할 수 있겠습니다. 그리고 저도 자세히 몰랐던 사실인데, 이번 소스 추적을 하면서 안 것은, GC 가 Android System 전체에 꼴랑 하나만 있어서 도는게 아니라 각 프로그램마다 각각 실행되며, 그 기준도 각각 다르나 (왜냐하면 각 프로그램마다 요구되는 메모리 총량 - 즉 Dalvik 이 계산한 이상적인 값 (ideal size) 이 다르기 때문에) Dalvik 이 프로그램 실행될 때 계산한 이상적인 값 - 128K 값에 도달하면 GC 가 실행된다는 것입니다.

 

이상입니다.


덧글

  • 바보종한 2011/09/26 11:54 # 삭제 답글

    결국 엉터리로 프로그램 짜드라도 대충 128K 이상의 메모리 릭은 발생하지 않는다는 거야 형?
  • Branden 2011/09/26 13:44 # 답글

    바보 종한이...공부 좀 해야겠어...
    GC 가 수행될 때 사용하지 않는 object 를 대상으로 찾는데, 메모리 릭 되게 짜버리면 그 object 는 GC 가 찾는 object 에서 찾기의 대상이 안되고, 그럼 메모리 leak 은 발생~ 이렇게 계속하면 무한으로 메모리 릭이 발생할 수도 있지만...그렇게는 안될듯...^^;
  • klkim 2015/10/15 08:43 # 삭제 답글

    이걸 어떻게 까보셨어요ㅕ?? 한번 까보고싶어서요 ㅎㅎ
  • Branden 2015/10/16 13:32 #

    Google Android 사이트에서 안드로이드 전체 소스코드를 받으면 내부파일까지(Native 부분) 모두 볼 수 있습니다.
    안드로이드 전체 소스코드 받아서 한번 추적해보세요^^
  • 마로맨 2020/07/28 19:22 # 삭제 답글

    좋은 정보 감사합니다. 많은도움이 되었습니다.
댓글 입력 영역