비트맵 및 메모리

비트맵 객체는 애플리케이션의 메모리 사용량에 가장 큰 영향을 미치는 경우가 많습니다. 앱 아이콘, 알림 이미지, 미디어 콘텐츠 등 비효율적인 비트맵 처리는 메모리 부족 (OOM) 오류와 시스템 전체 메모리 압력으로 빠르게 이어질 수 있습니다.

비트맵 구성 및 픽셀 데이터

비트맵이 사용하는 메모리 양은 주로 크기 (너비 × 높이)와 구성 (Bitmap.Config)에 따라 결정됩니다.

구성에서는 각 픽셀을 나타내는 데 사용되는 바이트 수를 정의합니다.

구성 바이트/픽셀 설명
ALPHA_8 1 알파 (투명도) 채널만 마스크에 유용합니다.
RGB_565 2 빨간색 (5비트), 녹색 (6비트), 파란색 (5비트) 알파 없음 높은 색상 충실도가 중요하지 않은 불투명 이미지에 적합합니다.
ARGB_8888 4 알파, 빨간색, 녹색, 파란색 (각각 8비트) 기본값이며 가장 일반적입니다.
RGBA_F16 8 절반 정밀도 부동 소수점입니다. 와이드 컬러 및 HDR 콘텐츠에 사용됩니다.
HARDWARE 해당 사항 없음 그래픽 메모리 (gralloc/DMABuf)에 저장됩니다. 하드웨어 비트맵을 참고하세요.

메모리 공식: Memory (Bytes) = Width × Height × Bytes Per Pixel

예를 들어 1080p 기기 (1920x1080)의 전체 화면 이미지는 ARGB_8888에서 1920 × 1080 × 4바이트 ≈ 8.3MB를 사용합니다.

힙 비트맵과 공유 비트맵 비교

힙 비트맵 (네이티브 힙)

최신 Android (8.0 이상)에서는 비트맵 픽셀 데이터가 네이티브 힙에 저장되고 작은 래퍼 객체만 Java 힙에 상주합니다.

앱이 이미지를 표시해야 하는 경우 일반적으로 압축된 이미지 파일에서 비트맵으로 디코딩되어 힙에 저장됩니다.

공유 비트맵 (ashmem/memfd)

비트맵이 프로세스 간에 전송될 때 (예: 알림을 위해 바인더를 통해 SystemUI로) Android는 공유 메모리 (ashmem 또는 memfd)를 사용하여 픽셀 데이터 복사를 방지합니다.

Bitmap 인스턴스는 Bitmap.asShared()를 호출하여 명시적으로 공유 메모리에 복사하거나 Bitmap가 Parcel 내부에 배치된 경우 (일반적으로 Bundle와 같은 Parcelable에 비트맵을 추가하여) 암시적으로 복사하여 바인더 IPC를 통해 전송할 수 있습니다.

공유 비트맵이 바인더 IPC를 통해 전송되면 픽셀 데이터 자체가 복사되는 것이 아니라 공유 메모리 영역을 참조하는 파일 설명자가 수신자 프로세스에 복제됩니다. 기본 메모리 영역은 여러 프로세스 간에 공유될 수 있으며 이를 참조하는 모든 파일 설명자가 닫힐 때까지 해제되지 않습니다.

변경 가능한 비트맵과 변경 불가능한 비트맵

  • 변경 가능한 비트맵: 생성 후 수정할 수 있습니다 (예: Canvas를 통해). 항상 자체 비공개 메모리 할당이 필요합니다. 변경 가능한 비트맵을 복사하는 경우 딥 카피 (모든 픽셀 데이터의 두 번째 복사본)를 만들어야 합니다.
  • 변경 불가능한 비트맵: 변경할 수 없습니다. 이를 통해 서로 다른 Bitmap 인스턴스 간에 동일한 기본 메모리 버퍼를 공유하는 등의 최적화가 가능합니다. APK 리소스 (BitmapFactory)에서 로드된 비트맵은 일반적으로 변경할 수 없습니다.

효율적인 비트맵 처리

비트맵 풀링 및 재사용

비트맵을 자주 할당하고 할당 해제하면 할당 변동이 발생하여 GC가 지속적으로 실행됩니다. 일반적인 이미지 로드 라이브러리는 비트맵 풀을 사용합니다.

Google에서는 Java 기반 애플리케이션의 경우 Glide를, Kotlin 기반 애플리케이션의 경우 (특히 Jetpack Compose 사용 시) Coil을 솔루션으로 권장합니다.

비트맵이 더 이상 필요하지 않으면 앱은 GC되도록 두는 대신 bitmap.recycle()를 호출하거나 풀로 반환합니다. 다음에 동일한 크기와 구성의 비트맵이 필요할 때 풀은 기존 버퍼를 제공하여 새 할당을 방지합니다.

하드웨어 비트맵

Bitmap.Config.HARDWARE를 사용하면 그래픽 메모리 (DMABuf)에 직접 픽셀 데이터를 저장할 수 있습니다.

  • 장점:
    • 메모리 절약: 애플리케이션 또는 네이티브 힙을 사용하지 않고 GPU 메모리를 사용합니다. 앱의 UI에 표시되는 비트맵은 어차피 GPU 메모리에 복사해야 하는 경우가 많으므로 이로 인해 복사 작업과 추가 메모리 비용이 절약됩니다.
    • 성능: 데이터가 이미 GPU에 있으므로 매우 빠르게 그릴 수 있습니다.
  • 단점:
    • 변경 불가: 하드웨어 비트맵은 수정할 수 없습니다.
    • 읽기 속도가 느림: CPU에서 픽셀에 액세스하는 것은 매우 비쌉니다 (예: getPixel()).
    • 기여도: AHAT와 같은 표준 도구에서 추적하기가 더 어렵습니다 (아래 참고).

일반적인 비트맵 메모리 문제

최신 비트맵 구성을 사용하는 경우에도 비트맵을 디코딩하고 예약하는 방식에 몇 가지 반복되는 패턴이 있으면 메모리가 크게 급증할 수 있습니다.

오버 스케일된 비트맵 디코딩

전체 해상도 4000×3000픽셀 사진은 ARGB_8888에서 48MB를 차지합니다. 전체 이미지를 디코딩하여 200×150픽셀 썸네일 내에 렌더링하면 할당된 픽셀 버퍼의 99% 이상이 낭비됩니다.

ImageDecoder 또는 BitmapFactory로 이미지를 직접 디코딩할 때는 디코딩 패스 중에 ImageDecoder.setTargetSize() 또는 BitmapFactory.Options.inSampleSize를 사용하여 타겟 뷰 측정기준과 일치하도록 다운샘플링합니다. Glide 및 Coil과 같은 이미지 로드 라이브러리는 제한된 타겟 뷰 크기를 제공하면 이 다운샘플링을 자동으로 실행합니다.

예를 들어 ImageDecoder로 비트맵을 디코딩할 때 출력 크기를 타겟 뷰 크기로 조정하는 OnHeaderDecodedListener를 전달합니다.

val source = ImageDecoder.createSource(resources, R.drawable.high_res_photo)
val bitmap = ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
    if (info.size.width > targetWidth || info.size.height > targetHeight) {
        decoder.setTargetSize(targetWidth, targetHeight)
    }
}

병렬 디코딩으로 인한 높은 동시 보관

개별 비트맵의 크기가 적절하고 수명이 짧은 경우에도 이미지를 병렬로 많이 디코딩하면 심각한 메모리 급증이 발생할 수 있습니다. 예를 들어 정리 도구 또는 갤러리 화면에서 동시에 아이콘이나 썸네일을 디코딩하기 위해 바운드되지 않은 스레드 풀에 30개의 작업을 디스패치하는 경우 압축 해제된 픽셀 버퍼와 디코더 스크래치 버퍼 30개가 모두 동시에 RAM을 차지합니다.

이 높은 동시 보관은 최대 네이티브 힙 메모리 사용량을 늘리고 일괄 처리가 완료되기 전에 lmkd 종료를 트리거할 수 있습니다. Dispatchers.IO.limitedParallelism(2)와 같은 제한된 스레드 풀, 세마포어 또는 코루틴 디스패처로 디코딩 동시성을 바인딩하여 한 번에 몇 개의 비트맵만 이동 중에 디코딩되도록 합니다.

프레임 처리 루프에서 재활용되지 않은 임시 비트맵

Android 8.0 이상에서 Java Bitmap 래퍼 객체는 Java 힙에서 약 56바이트만 차지하지만 픽셀 버퍼는 네이티브 힙에 있으며 수 메가바이트를 차지할 수 있습니다. Android 스튜디오 메모리 프로파일러 또는 AHAT에서 이 분할을 확인할 수 있습니다. 여기서 각 Bitmap 인스턴스는 멀티 메가바이트 네이티브 크기와 함께 약 56바이트의 얕은 Java 크기를 표시하고 Native Allocations(Bitmap (malloced)) 아래의 dumpsys meminfo에서도 확인할 수 있습니다.

카메라 프레임 분석, OCR 또는 ML 추론 루프와 같은 고빈도 파이프라인은 회전이나 자르기를 위해 ImageProxy.toBitmap() 및 Bitmap.createBitmap()를 호출하여 모든 프레임에서 새 비트맵을 할당하는 경우가 많습니다. 대체된 프레임에 대한 참조를 재활용하지 않고 삭제하면 네이티브 메모리가 급증할 수 있습니다. 작은 Java 래퍼는 Java 힙 점유율을 거의 늘리지 않으므로 NativeAllocationRegistry가 이를 회수하기 전에 수백 메가바이트의 네이티브 픽셀 버퍼가 누적되는 것을 방지할 만큼 가비지 컬렉션을 빠르게 트리거하지 않습니다.

타이트한 루프에서 프레임을 처리할 때는 가능한 경우 사전 할당된 버퍼를 재사용하거나 각 프레임 처리가 완료되는 즉시 임시 중간 비트맵에서 bitmap.recycle()를 명시적으로 호출하세요.

실습: 비트맵 탐색

BitmapLab 샘플 앱을 사용하여 이러한 개념을 살펴보겠습니다.

1. dumpsys meminfo로 측정하기

BitmapLab을 실행하고 ALLOCATE 10MB ARGB_8888을 탭합니다. 그런 후 다음을 실행합니다.

adb shell dumpsys meminfo -s com.android.bitmaplab

최신 Android 버전에서는 네이티브 할당 섹션을 찾습니다. 이러한 속성은 일반적인 앱 요약보다 비트맵에 훨씬 더 나은 기여 분석을 제공합니다.

 Native Allocations
                         Count                       Total(kB)
                        ------                         ------
   Bitmap (malloced):        1                          10240  # <--- 10MB Bitmap data!
Bitmap (nonmalloced):        0                              0
  • 비트맵 (malloced): 프로세스의 네이티브 힙에 할당된 비트맵입니다. 여기에 Android 8.0 이상의 대부분의 표준 비트맵이 있습니다.
  • 비트맵 (nonmalloced): 하드웨어 비트맵 또는 공유 비트맵 (ashmem 또는 memfd을 통해)과 같은 특수 메모리를 사용하는 비트맵입니다.

BitmapLab에서 공유 비트맵을 할당하면 Bitmap (nonmalloced)에 반영됩니다.

 Native Allocations
                         Count                       Total(kB)
                        ------                         ------
   Bitmap (malloced):        1                          10240
Bitmap (nonmalloced):        1                          10240  # <--- Shared Bitmap!

공유 비트맵 추적

일부 Android 버전과 커널 구성에서 dumpsys meminfo은 파일 설명자를 통해 프로세스의 주소 공간에 매핑된 비트맵의 고해상도 추적도 제공합니다.

기본적으로 공유 비트맵은 일반 이름 ('비트맵')을 사용합니다. 자세한 기여 분석과 고유한 비트맵 추적 (여러 프로세스에서 공유된 비트맵 식별)을 사용 설정하려면 다음 시스템 속성을 사용 설정해야 합니다.

adb shell setprop debug.hwui.bitmap_ashmem_long_name true

사용 설정하면 /proc/<pid>/smaps의 ashmem 리전 이름이 더 설명적으로 표시됩니다. meminfo는 이를 활용하며 결과는 다음과 같습니다.

 Shared Bitmaps
                         Count                       Size(KB)
                        ------                         ------
              Mapped:        1                          10240
              Unique:        1                          10240
  • 매핑됨: 모든 비트맵 관련 메모리 매핑의 총 크기입니다.
  • 고유: 고유 항목만 고려한 비트맵 크기입니다 (즉, 동일한 기본 공유 비트맵 픽셀 데이터의 매핑이 두 개 이상인 경우 한 번만 계산됨).

2. AHAT의 비트맵

AHAT는 비트맵에 대한 우수한 시각화를 제공합니다.

  1. BitmapLab에서 비트맵을 몇 개 할당합니다.
  2. -b 플래그를 사용하여 힙 덤프를 캡처합니다 (네이티브 비트맵 데이터 포함).

    adb shell am dumpheap -b png com.android.bitmaplab /data/local/tmp/bitmaps.hprof
    adb pull /data/local/tmp/bitmaps.hprof .
    ahat bitmaps.hprof
    
  3. localhost:7100를 열고 사이드바에서 Bitmaps 링크를 찾거나 Bitmap 클래스를 검색합니다.

  4. AHAT는 실제로 브라우저에서 비트맵을 렌더링하므로 메모리를 많이 사용하는 이미지를 쉽게 식별할 수 있습니다.

렌더링된 비트맵을 표시하는 AHAT

3. Perfetto의 비트맵 트랙

Perfetto는 시간이 지남에 따라 비트맵 할당과 개수를 추적할 수 있습니다. 이러한 카운터는 특정 애플리케이션에 gfx atrace 카테고리가 사용 설정된 경우 Android 프레임워크에서 내보냅니다.

  1. 트레이스를 시작합니다. gfx 카테고리를 포함하고 -a 플래그를 사용하여 특정 앱 패키지를 타겟팅해야 합니다.

    external/perfetto/tools/record_android_trace -o bitmaps.perfetto-trace \
        -t 15s -b 64mb view gfx dalvik am res memory -a com.android.bitmaplab
    
  2. BitmapLab에서 Allocate(할당) 및 Clear(지우기) 버튼을 반복해서 탭합니다.

  3. Parcel/Unparcel Bitmap도 탭합니다.

  4. ui.perfetto.dev에서 트레이스를 분석합니다.

com.android.bitmaplab의 프로세스 섹션에는 다음이 표시됩니다. * 비트맵 수: 활성 비트맵 수를 보여주는 카운터입니다. * 비트맵 메모리: 비트맵에서 사용한 총 바이트를 보여주는 카운터입니다.

상위 수준 슬라이스 (Perfetto SDK)

BitmapLab은 Perfetto SDK를 사용하여 비트맵 작업의 상위 수준 슬라이스를 내보내기도 합니다. 트레이스에서 BitmapLab_를 검색하여 다음을 찾습니다. * BitmapLab_parcelUnparcel: parceling 및 unparceling 로직을 다루는 슬라이스 * BitmapLab_postNotification: 알림 게시 흐름을 다루는 슬라이스입니다.

알림 흐름 추적

알림 게시를 탭하면 앱에서 현재 비트맵이 포함된 알림을 만들어 시스템에 전송합니다. 이를 담당하는 프레임워크 코드는 parceling(비트맵을 Binder IPC를 통해 전송될 Parcel에 작성)과 unparceling(수신 측의 Parcel에서 비트맵 읽기)을 연결하는 흐름 이벤트가 있는 Perfetto 슬라이스를 내보냅니다.

아래 스크린샷에서는 알림을 게시하기 위해 바인더 트랜잭션에 사용될 대형 비트맵을 앱이 패키징하고 system_server 프로세스에서 이에 상응하는 패키지 해제를 볼 수 있습니다.

알림을 통해 BitmapLab에서 system_server로의 흐름을 보여주는 Perfetto

Perfetto를 사용하면 스레드와 프로세스 전반에 걸쳐 추가로 전파되는 동일한 알림 비트맵을 추적할 수도 있습니다. 예를 들어 system_server (INotificationManager 바인더 서버를 구현함)의 바인더 스레드에서 com.android.systemui에 동일한 비트맵을 전달하여 알림 그늘에 표시할 수 있는 system_server 작업자 스레드로 전파됩니다.

시스템 앱 문제

SystemUI (알림) 및 Launcher와 같은 시스템 앱은 다음과 같은 고유한 문제에 직면합니다.

  1. 무한 콘텐츠: 알림과 위젯이 많을 수 있습니다. 각각 큰 비트맵을 보유하는 경우 시스템에서 메모리가 빠르게 부족해질 수 있습니다.
  2. 중복: 동일한 앱 아이콘이 런처의 캐시, SystemUI의 알림 영역, 설정 앱에 있을 수 있습니다.
  3. 하드웨어 버퍼를 통한 공유: 이를 완화하기 위해 시스템 구성요소는 프로세스 간에 HardwareBuffer 인스턴스를 공유하는 중앙 집중식 '이미지 오프로드' 서비스로 이동하고 있습니다.
  4. DMABuf 속성 지정: 하드웨어 비트맵은 힙 공간을 절약하지만 DMABuf 메모리를 사용하므로 표준 메모리 도구에서 특정 프로세스에 속성을 지정하기가 더 어렵습니다.

    adb shell dmabuf_dump를 사용하여 시스템 전체 DMABuf 할당을 확인합니다. 이 도구는 프로세스별 버퍼 분석을 제공합니다.

     droid.bitmaplab:19562
                      Name              Rss              Pss         nr_procs            Inode               Exporter
                 <unknown>          3840 kB          1280 kB                3             3397              virtio_gpu
                    system            12 kB             4 kB                3             3398                  system
                 <unknown>          3840 kB          1920 kB                2             3399              virtio_gpu
                    system            12 kB             6 kB                2             3400                  system
             PROCESS TOTAL         11556 kB          5136 kB
    
    • RSS: 프로세스에 매핑된 경우 버퍼의 총 크기입니다.
    • Pss: 비례 크기 (RSS를 버퍼를 공유하는 프로세스 수로 나눈 값)입니다. 회계에 가장 적합한 측정항목입니다.
    • nr_procs: 현재 이 버퍼에 대한 참조를 보유하는 프로세스 수입니다.
    • 내보내기: 버퍼를 할당한 드라이버 (예: Cuttlefish의 virtio_gpu 또는 하드웨어의 공급업체별 Ion/DMA-BUF 힙)

    adb shell dmabuf_dump -b를 사용하여 모든 버퍼와 전체 시스템 DMA-BUF 사용량의 요약을 확인할 수도 있습니다.


← Java | ↑ 위로 | 네이티브 →