안드로이드 개발자라면 Dalvik, ART에 대해서 많이 들어봤을 것입니다. Dalvik은 초기 안드로이드에서 사용된 가상 머신으로, 메모리와 배터리 제약을 극복하기 위해 스택 기반 대신 레지스터 기반 바이트코드를 사용하여 효율적으로 동작하도록 설계되었습니다. 이후 Android 5.0부터는 ART(Android Runtime)
가 기본 런타임 환경으로 전환되었는데, ART는 설치 시 AOT(Ahead-Of-Time)
를 통해 앱을 네이티브 코드로 변환하고, 필요에 따라 JIT(Just-In-Time)
를 추가로 적용하여 실행 성능을 크게 향상시킵니다.
마침 회사 백엔드 개발자분들께서도 JVM에 관심이 생겨 사내에서 함께 공부해 보자는 제안을 해주셨고, 평소 더 파보고 싶었던 JVM의 GC
, 메모리 관리 기법
등의 개념을 공부하면 Android 런타임 환경의 내부 동작 방식을 이해하는 데에 도움이 될 수 있을 것이라고 생각하여 설레는 마음으로 스터디에 합류하게 되었습니다.
스터디 교재로는 『JVM 밑바닥까지 파헤치기』라는 책을 선택했습니다. 책의 분량이 많았기 때문에 챕터별로 담당자를 지정해서 읽어오고, 이를 사내 컨플루언스에 문서화하고 발표하는 방식의 스터디를 진행하였습니다. 발표를 하면서도 짧은 질문과 의견들이 오고갔고, 주로 이 부분에서 자신이 이해한 것이 맞는지?
를 중심으로 근거를 찾아가면서 스터디를 진행하였습니다.
여러 챕터에 걸쳐서 안드로이드 개발자에게 좋은 내용들이 많았지만, 개인적으로는 가비지 컬렉터의 내용이 가장 좋았기 때문에 '3장 가비지 컬렉터와 메모리 할당 전략'의 핵심 내용들을 요약해서 정리해보도록 하겠습니다.
가비지 컬렉션?
가비지 컬렉션(GC)은 자바 언어에서 처음 소개한 기술이 아니며, MIT에서 개발된 리스프라는 언어가 시초라고 합니다. 이러한 가비지 컬렉션과 메모리 할당의 내부 동작을 이해하면 다양한 메모리 오버플로우, 메모리 누수 문제를 해결해야하는 상황에 도움이 될 수 있습니다.
자바 힙과 메서드 영역은 불확실
구현한 클래스마다 요구하는 메모리 크기가 다를 수 있습니다. 프로그램이 어떤 객체를 생성할지, 얼마나 많이 만들지는 오직 런타임에만 알 수 있습니다. 그래서 이 메모리 영역들의 회수는 동적으로 이루어지고, 가비지 컬렉터는 이런 영역을 관리하는 데 집중합니다.
가비지 컬렉터가 힙을 청소하려면 어떤 객체가 살아 있고 죽었는지를 판단해야 하는데, 여기서 죽었다는 말은 어떤 식으로도 프로그램 코드에서 사용될 수 없다는 뜻입니다.
여기서 객체가 살아있는지 판단하는 알고리즘으로 크게 두 가지로 나눌 수 있습니다.
- 참조 카운팅 알고리즘
- 도달 가능성 분석 알고리즘
참조 카운팅 알고리즘
- 객체를 가리키는 참조 카운터를 추가. 참조하는 곳이 하나 늘어날 때마다 카운터 값을 1씩 증가
- 참조하는 곳이 하나 사라질 때마다 카운터 값을 1씩 감소
- 카운터 값이 0이 된 객체는 더는 사용될 수 없음
마이크로소프트 COM, 파이썬, 러스트 등이 메모리 관리에 참조 카운팅 알고리즘 사용한다고 합니다.
그런데 자바, 적어도 자바 가상 머신에서는 참조 카운팅을 사용하지 않는데, 그 이유는 바로 순환 참조 문제
를 풀기 어렵기 때문입니다.
public class ReferenceCountingGC {
public Object instance = null;
// ...
public static void testGC() {
// 두 객체 생성
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
// 내부 필드로 서로를 참조
objA.instance = objB;
objB.instance = objA;
// 참조 해제
objA = null;
objB = null;
// 이 라인에서 GC가 수행된다면 objA와 objB가 회수될까?
System.gc();
}
public static void main(String[] args) {
testGC();
}
}
참조를 해제한 시점부터 두 객체에 접근할 길이 사라지지만, 아직도 서로를 참조하고 있기 때문에 참조 카운터는 아직 0이 아닙니다. 따라서 참조 카운팅 알고리즘으로는 둘을 회수하지 못하게 됩니다.
그러나 실행 결과 자바 가상머신에서 objA
와 objB
가 메모리에서 회수되었음을 확인할 수 있고, 이는 자바 가상 머신은 객체 생사 판단에 참조 카운팅 알고리즘
을 사용하지 않는다는 것을 알 수 있습니다.
도달 가능성 분석 알고리즘
자바, C# 등의 주류 언어들은 객체 생사 판단에 도달 가능성 분석(reachability analysis) 알고리즘을 사용합니다. 이 알고리즘의 핵심은 GC 루트
입니다. 이 루트 객체들을 시작 노드 집합으로 사용하며, 시작 노드들에서 출발하여 참조하는 다른 객체들로 탐색하여 들어가는 흐름으로 진행됩니다. 이때 탐색 과정에서 만들어지는 경로를 참조 체인(reference chain)
이라고 부릅니다.
단, 자바에서 GC 루트로 이용할 수 있는 객체는 정해져 있는데, 그 종류는 다음과 같습니다.
가상 머신 스택(스택 프레임의 지역 변수 테이블)에서 참조하는 객체
: 현재 실행 중인 메서드에서 쓰는 매개 변수, 지역 변수, 임시 변수 등메서드 영역에서 클래스가 정적 필드로 참조하는 객체
: 자바 클래스의 참조 타입 정적 변수메서드 영역에서 상수로 참조되는 객체
: 문자열 테이블 안의 참조네이티브 메서드 스택에서 JNI가 참조하는 객체
자바 가상 머신 내부에서 쓰이는 참조
: 기본 데이터 타입에 해당하는 Class 객체, 시스템 클래스 로더 등동기화 락(synchronized 키워드)으로 잠겨 있는 모든 객체
자바 가상 머신 내부 상황을 반영하는 JMXBean
: JVMTI에 등록된 콜백, 로컬 코드 캐시 등
이외에도 가비지 컬렉터의 종류나 현재 회수 중인 메모리 영역에 따라 다른 객체들도 임시로
추가가 가능합니다.
세대 단위 컬렉션 이론
현재 상용 가상 머신들이 채택한 가비지 컬렉터들은 대부분 세대 단위 컬렉션 이론에 기초해 설계되었습니다.
1. 약한 세대 가설 : 대다수 객체는 일찍 죽는다.
2. 강한 세대 가설 : 가비지 컬렉션 과정에서 살아남은 횟수가 늘어날수록 더 오래 살 가능성이 커진다.
💬 “늙었다는 것은 살아남았다는 것”
이 두 가지 가정이 합쳐져 가비지 컬렉터들에 일관된 설계 원칙을 제공합니다.
여기서 나이 = 가비지 컬렉션에서 살아남은 횟수
이고, 대부분의 객체가 곧바로 죽을 운명이라면? 살아남는 소수의 객체를 유지하는 방법에 집중하는 편이 유리합니다.
한 번 살아남은 객체는 통계적으로 잘 죽지 않으니 다른 영역에 따로 모아 두고, 가상 머신이 그 영역을 회수하는 빈도를 줄이는 방식입니다.
이러한 세대 단위 컬렉션 이론을 가상 머신에 적용한 설계자들은 자바 힙을 최소 두개의 영역으로 나누게 되었습니다.
- 신세대 : 가비지 컬렉션 때마다 다수의 객체가 죽고 살아남은 소수만 구세대로 승격
- 구세대
그러나 복잡해 보이는 상황이 하나가 있습니다. 🤔
- 객체들은 단독으로 존재 X
- 다른 세대에 존재하는 객체들을 참조하는 상황이 있음
또한, 신세대에서만 가비지 컬렉션을 하고 싶어도 구세대에서 참조 중인 객체도 분명 있을 것입니다. 살아남을 객체를 찾으려면 고정된 GC 루트
들뿐 아니라 구세대 객체까지
모두 탐색해야 하며, 이 경우 성능 면에서 확실히 부담이 큽니다. 이 문제를 풀기 위해 세대 단위 컬렉션 이론
에 3번째 가정이 추가됩니다.
3. 세대 간 참조 가설 : 세대 간 참조의 개수는 같은 세대 안에서의 참조보다 훨씬 적다.
상호 참조 관계의 두 객체는 삶과 죽음을 함께하는 경향
이 있습니다. 신세대 객체가 세대 간 참조를 가지고 있을 때 구세대 객체는 잘 죽지 않기 때문에 가비지 컬렉션을 거쳐도 신세대 객체는 세대 간 참조 덕에 구세대로 승격됩니다. 이렇게 같은 세대가 되었으므로 세대 간 참조는 자연스럽게 사라게 됩니다.
따라서 이 가설에 따르면 세대 간 참조의 수는 아주 적기 때문에 구세대 전체를 훑는 건 시간 낭비라고 판단합니다.
마크-스윕 알고리즘
가장 기본적인 가비지 컬렉션 알고리즘입니다. 먼저 회수할 객체들에 모두 표시(mark)한 다음, 표시된 객체들을 쓸어 담는(sweep) 방식입니다. 반대로, 살릴 객체에 표시(mark)하고, 표시되지 않은 객체를 회수(sweep) 하기도 합니다.
단점은?
- 실행 효율이 일정하지 않음 : 자바 힙이 다량의 객체로 가득 차 있고 대부분이 회수 대상이라면? 표시하는 일 & 회수하는 일 모두 커짐 → 객체가 많아질수록 표시하고 쓸어 담는 작업의 효율이 떨어짐
- 메모리 파편화가 심함 : 가비지 컬렉터가 쓸고 간 자리에는 불연속적인 메모리 파편이 만들어짐. → 파편화가 너무 심하면 프로그램이 큰 객체를 만들려 할 때 충분한 크기의 연속된 메모리를 찾기가 점점 어려워짐 → 또 다른 가비지 컬렉션 유발
마크-카피 알고리즘
회수할 객체가 많아질수록 효율이 떨어지는 마크-스윕 알고리즘의 문제를 해결하기 위해 생겨나게 되었습니다. 가용 메모리를 똑같은 크기의 두 블록으로 나눠서 한 번에 한 블록만 사용하고, 한쪽 블록이 꽉 차면 살아남은 객체들만 다른 블록에 복사하고 기존 블록 목록을 한 번에 청소하는 방식입니다.
만약 대다수 객체가 살아남으면? 메모리 복사에 상당한 시간 허비를 하게 됩니다. 대다수 객체가 회수된다면? 생존한 소수의 객체만 복사하면 됨 & 복사 과정에서 객체들이 메모리 한쪽 끝에서 차곡차곡 쌓이기 때문에 메모리 파편화 문제로부터 해방됩니다.
단점은?
가용 메모리를 절반으로 줄여 낭비가 제법 심하다는 점
오늘날 상용 자바 가상 머신은 대부분 신세대에 이 알고리즘을 적용합니다.
IBM의 연구에 따르면 신세대 객체 중 98
%가 첫 번째 가비지 컬렉션에 살아남지 못했고, 이는 신세대용 메모리 영역을 1:1로 나눌 필요가 없다는 결론이 나게 됩니다.
신세대를 하나의 큰 에덴 공간(80%)과 두 개의 작은 생존자 공간(10%)으로 나누는데, 핫스팟 가상 머신에서 에덴과 생존자 공간의 비율은 기본적으로 8:1 (에덴 80% + 생존자 공간 중 하나 10%) 입니다. (낭비하는 공간은 단 10%뿐)
-
98%의 객체가 회수된다는 데이터 = ‘일반적인 상황'에서 측정된 결과
😮 10% 넘는 객체가 살아남는 특이 케이스는 어쩌고?
이런 케이스에 대처하기 위한 설계로 메모리 할당 보증 메커니즘
개념이 등장합니다.
메모리 할당 보증 메커니즘
마이너 GC(신세대 GC)에서 살아남은 객체를 생존자 공간이 다 수용하지 못할 경우 다른 메모리 영역(구세대)를 활용해 메모리 할당을 보증하는 것으로, 이러한 할당 보증 메커니즘을 통해 가비지 컬렉션에서 살아남은 객체를 구세대에 바로 추가합니다.
마크-컴팩트 알고리즘
마크-카피 알고리즘
은 객체 생존율이 높을수록 복사할 게 많아져서 효율이 나빠집니다. 공간을 50%나 낭비하기 싫다면 할당 보증용 공간을 따로 마련하여 대다수 객체가 살아남는 극단적 상황에 대처해야 하기 때문에 구세대에는 적합하지 않습니다. (객체 생존율이 높기 때문)
이는 마크-컴팩트 알고리즘인데, 표시 단계는
마크-스윕
과 같습니다. 회수 대상 객체들을 모두 쓸어 담는 대신 생존한 모든 객체를 메모리 영역의 한쪽 끝으로 모은 다음, 나머지 공간을 한꺼번에 비웁니다.
마크-스윕 알고리즘과의 차이는?
💬 메모리 이동이 일어난다는 점
그런데? 가비지 컬렉션 후 살아남은 객체를 이동할지는 양날의 검과 같은 결정입니다. 구세대에서는 회수 때마다 살아남는 객체가 상당히 많을텐데, 생존한 객체들을 이동시킨 후, 이동된 객체들을 가리키던 기존 참조를 모두 갱신하는 것은 매우 부담되고, 그렇다고 마크-스윕 알고리즘
처럼 살아있는 객체를 전혀 이동시키지 않는다면 힙이 파편화되는 문제가 발생합니다. 그러나 이러한 문제는
파편화 없는 할당 연결 리스트로 해결 가능합니다.
하드디스크나 SSD에는 물리적으로 연속된 공간이 없더라도 큰 파일을 저장할 수 있는 이유가 파일을 조각으로 나눠 물리적으로 떨어진 파티션에 저장한 다음 이를 파티션 테이블로 관리하기 때문입니다.
대부분의 경우 메모리 파편화를 감내하면서 마크-스윕
을 사용하다가, 객체 할당에 영향을 줄 만큼 파편화가 심해지면 마크-컴팩트
를 돌려 연속된 공간을 확보하는 해법도 있습니다.
회고 및 마무리
책의 내용이 방대하였기에, 회사 동료분들과 함께 각 챕터별로 담당자를 맡아서 핵심 개념을 요약하여 공유하는 방식을 통해 효율적으로 스터디할 수 있었던 것 같습니다. 또한 JVM
을 공부하면 공부할수록 이 분야는 논문을 준비하는 대학원 석사 연구생들이 연구해봐도 좋을 것 같다라고 느낄정도로 심오하고도 깊은 내용이 많았습니다. 안드로이드 개발을 하다보면, 필요에 따라 빌드 구성 시 JVM
옵션을 설정할 때도 있는데, 이와 관련해서도 그동안 몰랐던 옵션들도 새롭게 알게 되었습니다. 업무를 하면서 JVM, GC
와 관련하여 더 공부해보고 싶을 때, 이 책을 자주 찾을 것 같습니다.