비트맵 메모리: 네이티브 힙, 하드웨어 비트맵, 그리고 inBitmap 재사용
비트맵 메모리: 네이티브 힙, 하드웨어 비트맵, 그리고 inBitmap 재사용
Bitmap은 평범한 자바 객체처럼 보이지만, 실제 무게의 대부분은 가비지 컬렉터가 직접 들여다볼 수 없는 곳에 자리 잡고 있습니다. 화면을 가득 채우는 사진 한 장이 수십 메가바이트에 달하는 픽셀 데이터를 차지할 수 있는데, 이 데이터가 어디에 할당되고, 어떻게 집계되며, 언제 해제되는지는 안드로이드 버전을 거치며 달라져 왔습니다. Bitmap 뒤에 숨은 저장 모델을 이해하면 작은 파일을 디코딩하는데도 왜 OutOfMemoryError가 발생하는지, 하드웨어 비트맵의 픽셀을 왜 하나씩 읽을 수 없는지, 이미지 로딩 라이브러리가 메모리를 어떻게 통제하는지 알 수 있습니다. 이 레슨을 끝까지 읽고 나면 다음을 할 수 있게 됩니다.
Bitmap의 픽셀 데이터가 어디에 할당되는지, 그리고NativeAllocationRegistry가 그 할당을 가비지 컬렉션과 어떻게 연결하는지 설명할 수 있습니다.Bitmap.Config가 픽셀당 비용을 어떻게 결정하는지 설명하고, 비트맵의 바이트 수를 계산할 수 있습니다.- 하드웨어 비트맵이 무엇인지, 왜 불변(immutable)인지, 그리고 픽셀 접근이 언제 예외를 던지는지 설명할 수 있습니다.
BitmapFactory.Options.inBitmap이 기존 할당을 재사용하는 과정과 이를 지배하는 규칙을 따라가 볼 수 있습니다.- 다운샘플링과 config 선택을 적용하여 비트맵 메모리를 일정 범위 안으로 묶어 둘 수 있습니다.
픽셀이 사는 곳
Bitmap 인스턴스 자체는 작습니다. 이 클래스가 들고 있는 필드를 보면 다음과 같습니다.
public final class Bitmap implements Parcelable {
private final long mNativePtr;
private int mWidth;
private int mHeight;
private WeakReference<HardwareBuffer> mHardwareBuffer;
private boolean mRecycled;
...
}
mNativePtr은 픽셀 버퍼를 소유하는 네이티브 객체를 가리키는 포인터입니다. 최신 안드로이드(8.0, API 26 이상)에서는 픽셀 데이터가 자바 힙이 아니라 네이티브 힙에 할당됩니다. 그 이전인 안드로이드 7.1 이하에서는 픽셀이 ART 힙에 있었고, 이 때문에 비트맵이 일반 객체와 비교적 작은 힙을 두고 경쟁하면서 단편화(fragmentation)와 OutOfMemoryError를 자주 일으키는 원인이 되었습니다. 픽셀을 네이티브 힙으로 옮기면서 비트맵은 훨씬 넓은 프로세스 주소 공간을 쓸 수 있게 되었고, 자바 GC의 작업 집합(working set)에서도 빠지게 되었습니다.
네이티브 메모리도 Bitmap 객체에 더 이상 도달할 수 없게 되면 회수되어야 합니다. 프레임워크는 이를 NativeAllocationRegistry로 연결합니다. Bitmap 생성자를 보면 다음과 같습니다.
mNativePtr = nativeBitmap;
final int allocationByteCount = getAllocationByteCount();
getRegistry(fromMalloc, allocationByteCount).registerNativeAllocation(this, mNativePtr);
registerNativeAllocation은 네이티브 할당의 수명을 자바 Bitmap의 수명에 묶습니다. Bitmap이 수집되면 레지스트리가 mNativePtr에 대해 네이티브 해제 함수를 실행합니다. 여기서 allocationByteCount를 넘기는 것이 중요한데, 이 자바 객체가 살려 두고 있는 네이티브 메모리가 얼마나 되는지 런타임에 알려 주어, GC가 컬렉션 실행 여부를 판단할 때 그 압박을 함께 고려하게 하기 때문입니다. 48MB짜리 비트맵은 자바 객체 자체는 아주 작더라도 컬렉터에게는 48MB짜리 힌트가 됩니다.
픽셀 버퍼가 실제로 자리 잡을 수 있는 곳은 세 군데이며, Bitmap.cpp의 네이티브 코드에서 결정됩니다.
- 힙(Heap):
allocateHeapBitmap은 단순히calloc(size, 1)을 호출합니다. 이것이 기본값입니다. - Ashmem(공유 메모리):
allocateAshmemBitmap은ashmem_create_region을 호출한 뒤MAP_SHARED로mmap을 수행합니다. 비트맵이 프로세스 경계를 넘어야 할 때, 예를 들어Parcel을 통해 전달되거나asShared()로 노출될 때 사용됩니다. - 하드웨어(Hardware):
allocateHardwareBitmap은AHardwareBuffer를 할당하고 픽셀을 그래픽 메모리에 업로드합니다. 이에 대해서는 아래에서 더 다룹니다.
getRegistry(boolean malloc, ...) 호출은 malloc 기반인 경우(힙)와 그렇지 않은 경우(mmap 기반인 ashmem)를 구분하는데, 두 경우가 서로 다른 해제 함수를 필요로 하기 때문입니다.
recycle()을 호출하면 네이티브 메모리를 즉시 해제하도록 요청할 수 있습니다.
public void recycle() {
if (!mRecycled) {
nativeRecycle(mNativePtr);
mNinePatchChunk = null;
mRecycled = true;
mHardwareBuffer = null;
}
}
recycle() 이후 비트맵은 죽은 것으로 표시됩니다. getPixels()를 호출하거나 비트맵을 그리려고 하면 예외가 발생하거나 아무 일도 일어나지 않습니다. 소스 주석에서 짚어 두었듯이 recycle()은 픽셀 데이터를 동기적으로 해제하지 않습니다. 다른 네이티브 참조가 남아 있지 않게 되면 수집될 수 있도록 허용할 뿐입니다. 최신 안드로이드에서는 레지스트리가 정리를 처리하므로 recycle()이 필요한 경우는 드물지만, 다음 GC를 기다리지 않고 큰 할당을 곧바로 버리고 싶을 때는 여전히 쓸모가 있습니다.
바이트 세기: Config와 바이트 수
비트맵이 사용하는 메모리는 너비 × 높이 × 픽셀당 바이트 수입니다. 픽셀당 바이트 수는 Bitmap.Config에서 나옵니다. enum 선언에 있는 값들은 다음과 같습니다.
public enum Config {
ALPHA_8(1),
RGB_565(3),
ARGB_4444(4),
ARGB_8888(5),
RGBA_F16(6),
HARDWARE(7),
RGBA_1010102(8);
}
괄호 안의 숫자는 네이티브 식별자이지 바이트 크기가 아닙니다. 픽셀당 비용은 다음과 같습니다. ALPHA_8은 1바이트(알파만, 마스크에 사용), RGB_565는 2바이트(빨강 5비트, 초록 6비트, 파랑 5비트, 알파 없음), ARGB_8888은 4바이트(채널당 8비트, 기본값이며 대부분의 앱이 사용해야 하는 값), RGBA_F16은 8바이트(채널당 반정밀도(half precision) 부동소수점, 광색역(wide gamut)과 HDR 콘텐츠용), RGBA_1010102는 4바이트입니다. ARGB_4444는 폐기되었으며(deprecated), KitKat 이후로는 이를 요청해도 조용히 ARGB_8888로 처리됩니다.