Bitmaps und Arbeitsspeicher

Bitmap-Objekte sind oft die größten einzelnen Faktoren für den Speicherbedarf einer Anwendung. Ob App-Symbole, Benachrichtigungsbilder oder Medieninhalte – eine ineffiziente Bitmap-Verarbeitung kann schnell zu Out-Of-Memory-Fehlern (OOM) und systemweitem Speicherdruck führen.

Bitmap-Konfigurationen und Pixeldaten

Die Menge an Arbeitsspeicher, die ein Bitmap belegt, wird hauptsächlich durch seine Abmessungen (Breite × Höhe) und seine Konfiguration (Bitmap.Config) bestimmt.

Die Konfiguration definiert, wie viele Byte zur Darstellung jedes Pixels verwendet werden:

Konfiguration Bytes pro Pixel Beschreibung
ALPHA_8 1 Nur Alphakanal (Transparenz). Nützlich für Masken.
RGB_565 2 Rot (5 Bit), Grün (6 Bit), Blau (5 Bit). Kein Alpha. Gut für blickdichte Bilder, bei denen eine hohe Farbtreue nicht entscheidend ist.
ARGB_8888 4 Alpha, Rot, Grün, Blau (jeweils 8 Bit). Standardeinstellung und am häufigsten verwendet.
RGBA_F16 8 Gleitkommazahl mit halber Genauigkeit. Wird für Inhalte mit großem Farbraum und HDR-Inhalte verwendet.
HARDWARE – Im Grafikspeicher gespeichert (gralloc/DMABuf). Weitere Informationen finden Sie unter Hardware-Bitmaps.

Formel für den Arbeitsspeicher:Memory (Bytes) = Width × Height × Bytes Per Pixel

Ein Vollbild auf einem 1080p-Gerät (1920 × 1080) in ARGB_8888 benötigt beispielsweise: 1920 × 1080 × 4 Bytes ≈ 8, 3 MB.

Heap-Bitmaps im Vergleich zu freigegebenen Bitmaps

Heap-Bitmaps (nativer Heap)

In modernen Android-Versionen (8.0 und höher) werden Bitmap-Pixeldaten im nativen Heap gespeichert, während sich im Java-Heap nur ein kleines Wrapper-Objekt befindet.

Wenn eine App ein Bild präsentieren muss, wird es normalerweise aus einer komprimierten Bilddatei in ein Bitmap decodiert und im Heap gespeichert.

Gemeinsam genutzte Bitmaps (ashmem/memfd)

Wenn ein Bitmap zwischen Prozessen übertragen wird (z.B. über Binder an SystemUI für eine Benachrichtigung), vermeidet Android das Kopieren der Pixeldaten durch die Verwendung von gemeinsamem Arbeitsspeicher (ashmem oder memfd).

Eine Bitmap-Instanz kann explizit in den gemeinsam genutzten Speicher kopiert werden, indem Bitmap.asShared() aufgerufen wird. Sie kann auch implizit kopiert werden, wenn eine Bitmap in ein Parcel eingefügt wird (in der Regel durch Hinzufügen des Bitmaps zu einem Parcelable wie einem Bundle) und über Binder IPC gesendet wird.

Wenn eine gemeinsam genutzte Bitmap über Binder IPC gesendet wird, werden die Pixeldaten selbst nicht kopiert, sondern ein Dateideskriptor, der auf einen gemeinsam genutzten Speicherbereich verweist, wird für den Empfängerprozess dupliziert. Der zugrunde liegende Speicherbereich kann von mehreren Prozessen gemeinsam genutzt werden und wird erst freigegeben, wenn alle darauf verweisenden Dateideskriptoren geschlossen wurden.

Veränderliche und unveränderliche Bitmaps

  • Veränderliche Bitmaps: Können nach der Erstellung geändert werden, z.B. über ein Canvas. Sie erfordern immer eine eigene private Arbeitsspeicherzuweisung. Wenn eine veränderliche Bitmap kopiert wird, muss eine vollständige Kopie (zweite Kopie aller Pixeldaten) erstellt werden.
  • Unveränderliche Bitmaps: Können nicht geändert werden. Dies ermöglicht Optimierungen wie die gemeinsame Nutzung desselben zugrunde liegenden Speicherpuffers zwischen verschiedenen Bitmap-Instanzen. Bitmaps, die aus APK-Ressourcen (BitmapFactory) geladen werden, sind in der Regel unveränderlich.

Effiziente Bitmap-Verarbeitung

Bitmap-Pooling und ‑Wiederverwendung

Das häufige Zuweisen und Freigeben von Bitmaps führt zu Zuweisungs-Churn, wodurch die Garbage Collection ständig ausgeführt wird. Gängige Bibliotheken zum Laden von Bildern verwenden einen Bitmap-Pool.

Google empfiehlt Glide für Java-basierte Anwendungen und Coil für Kotlin-basierte Anwendungen (insbesondere bei Verwendung von Jetpack Compose).

Wenn eine Bitmap nicht mehr benötigt wird, ruft die App bitmap.recycle() auf oder gibt sie an einen Pool zurück, anstatt sie vom Garbage Collector löschen zu lassen. Wenn das nächste Mal ein Bitmap mit denselben Abmessungen und derselben Konfiguration benötigt wird, stellt der Pool den vorhandenen Puffer bereit, sodass keine neue Zuweisung erforderlich ist.

Hardware-Bitmaps

Mit Bitmap.Config.HARDWARE können Sie Pixeldaten direkt im Grafikspeicher (DMABuf) speichern.

  • Vorteile:
    • Speichereinsparungen: Es wird kein Anwendungs- oder nativer Heap verwendet, sondern GPU-Arbeitsspeicher. Oft müssen Bitmaps, die in der Benutzeroberfläche einer App angezeigt werden, ohnehin in den GPU-Speicher kopiert werden. Dadurch wird dieser Kopiervorgang und der zusätzliche Speicherbedarf vermieden.
    • Leistung: Extrem schnell, da die Daten bereits auf der GPU sind.
  • Nachteile:
    • Unveränderlich: Hardware-Bitmaps können nicht geändert werden.
    • Rücklesen ist langsam: Der Zugriff auf Pixel über die CPU (z.B. getPixel()) ist sehr aufwendig.
    • Attribution: In Standardtools wie AHAT (siehe unten) schwieriger nachzuvollziehen.

Häufige Fallstricke bei der Bitmap-Arbeitsspeichernutzung

Auch wenn Sie moderne Bitmap-Konfigurationen verwenden, können mehrere wiederkehrende Muster beim Decodieren und Planen von Bitmaps zu großen Speicherspitzen führen.

Dekodieren von überdimensionierten Bitmaps

Ein Foto mit voller Auflösung (4.000 × 3.000 Pixel) belegt in ARGB_8888 48 MB. Das vollständige Bild zu dekodieren, um es in einer Miniaturansicht mit 200 × 150 Pixeln zu rendern, verschwendet über 99% des zugewiesenen Pixelpuffers.

Wenn Sie Bilder direkt mit ImageDecoder oder BitmapFactory decodieren, reduzieren Sie die Auflösung während des Decodierungsvorgangs mit ImageDecoder.setTargetSize() oder BitmapFactory.Options.inSampleSize, damit sie den Abmessungen der Zielansicht entspricht. Bibliotheken zum Laden von Bildern wie Glide und Coil führen dieses Downsampling automatisch durch, wenn Sie eine begrenzte Zielansichtsgröße angeben.

Wenn Sie beispielsweise ein Bitmap mit ImageDecoder decodieren, übergeben Sie ein OnHeaderDecodedListener, das die Ausgabedimensionen auf die Größe der Zielansicht skaliert:

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)
    }
}

Hohe gleichzeitige Zuschauerbindung durch parallele Dekodierung

Auch wenn einzelne Bitmaps die richtige Größe haben und nur kurzlebig sind, kann das parallele Decodieren vieler Bilder zu starken Speicherspitzen führen. Wenn beispielsweise auf einem Bildschirm für Organisatoren oder Galerien 30 Aufgaben in einem unbegrenzten Thread-Pool gleichzeitig zum Decodieren von Symbolen oder Thumbnails gesendet werden, belegen alle 30 unkomprimierten Pixelpuffer und Decoder-Scratch-Puffer gleichzeitig RAM.

Diese hohe gleichzeitige Aufbewahrung erhöht den maximalen nativen Heap-Speicherbedarf und kann lmkd-Vorgänge auslösen, bevor der Batch abgeschlossen ist. Begrenzen Sie die Dekodierungskonkurrenz mit einem eingeschränkten Thread-Pool, einem Semaphor oder einem Coroutine-Dispatcher wie Dispatchers.IO.limitedParallelism(2), damit nur wenige Bitmaps gleichzeitig dekodiert werden.

Nicht wiederverwendete temporäre Bitmaps in Frame-Verarbeitungsschleifen

Unter Android 8.0 und höher belegt das Java-Wrapper-Objekt Bitmap nur etwa 56 Byte im Java-Heap, während sich der Pixelpuffer im nativen Heap befindet und mehrere Megabyte belegen kann. Sie können diese Aufteilung im Android Studio-Speicher-Profiler oder in AHAT überprüfen. Dort hat jede Bitmap-Instanz eine flache Java-Größe von etwa 56 Byte neben ihrer nativen Größe von mehreren Megabyte. In dumpsys meminfo finden Sie die Aufteilung unter Native Allocations (Bitmap (malloced)).

Bei Pipelines mit hoher Frequenz wie der Analyse von Kamerabildern, OCR oder ML-Inferenzschleifen wird häufig für jedes Bild eine neue Bitmap zugewiesen, indem ImageProxy.toBitmap() und Bitmap.createBitmap() für die Drehung oder das Zuschneiden aufgerufen werden. Wenn Sie Verweise auf abgelöste Frames entfernen, ohne sie wiederzuverwenden, kann der native Arbeitsspeicher stark ansteigen. Die winzigen Java-Wrapper erhöhen die Java-Heap-Belegung kaum. Daher wird die automatische Speicherbereinigung nicht schnell genug ausgelöst, um zu verhindern, dass sich Hunderte von Megabyte an nativen Pixelpuffern ansammeln, bevor NativeAllocationRegistry sie zurückfordert.

Wenn Sie Frames in einer engen Schleife verarbeiten, verwenden Sie nach Möglichkeit vorab zugewiesene Puffer wieder oder rufen Sie bitmap.recycle() für temporäre Zwischen-Bitmaps auf, sobald die Verarbeitung jedes Frames abgeschlossen ist.

Praktische Übung: Bitmap-Untersuchung

Wir verwenden die Beispiel-App BitmapLab, um diese Konzepte zu veranschaulichen.

1. Messen mit dumpsys meminfo

Starte BitmapLab und tippe auf ALLOCATE 10MB ARGB_8888 (10 MB ARGB_8888 zuweisen). Führen Sie dann diesen Befehl aus:

adb shell dumpsys meminfo -s com.android.bitmaplab

Suchen Sie in modernen Android-Versionen nach dem Abschnitt Native Allocations (Native Zuweisungen). Diese bieten eine viel bessere Zuordnung für Bitmaps als die allgemeine App-Zusammenfassung:

 Native Allocations
                         Count                       Total(kB)
                        ------                         ------
   Bitmap (malloced):        1                          10240  # <--- 10MB Bitmap data!
Bitmap (nonmalloced):        0                              0
  • Bitmap (malloced): Bitmaps, die im nativen Heap des Prozesses zugewiesen wurden. Hier befinden sich die meisten Standard-Bitmaps in Android 8.0 und höher.
  • Bitmap (nonmalloced): Bitmaps, die speziellen Speicher wie Hardware-Bitmaps oder Shared Bitmaps (über ashmem oder memfd) verwenden.

Wenn Sie in BitmapLab eine gemeinsame Bitmap zuweisen, wird sie in Bitmap (nonmalloced) angezeigt:

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

Tracking von gemeinsam genutzten Bitmaps

In einigen Android-Versionen und Kernelkonfigurationen bietet dumpsys meminfo auch eine hochauflösende Erfassung für Bitmaps, die über Dateideskriptoren in den Adressraum des Prozesses gemappt werden.

Standardmäßig wird für freigegebene Bitmaps ein generischer Name („bitmap“) verwendet. Wenn Sie eine detaillierte Zuordnung und das Tracking eindeutiger Bitmaps (Identifizieren freigegebener Bitmaps in verschiedenen Prozessen) aktivieren möchten, müssen Sie das folgende Systemattribut aktivieren:

adb shell setprop debug.hwui.bitmap_ashmem_long_name true

Wenn diese Option aktiviert ist, haben die ashmem-Regionen in /proc/<pid>/smaps beschreibendere Namen. meminfo nutzt das und die Ergebnisse sehen so aus:

 Shared Bitmaps
                         Count                       Size(KB)
                        ------                         ------
              Mapped:        1                          10240
              Unique:        1                          10240
  • Zugeordnet: Die Gesamtgröße aller Bitmap-bezogenen Speicherzuordnungen.
  • Eindeutig: Die Größe von Bitmaps, bei denen nur eindeutige Werte berücksichtigt werden. Das bedeutet, dass zwei oder mehr Zuordnungen derselben zugrunde liegenden gemeinsamen Bitmap-Pixeldaten nur einmal gezählt werden.

2. Bitmaps in AHAT

AHAT bietet eine hervorragende Visualisierung für Bitmaps.

  1. Weisen Sie in BitmapLab einige Bitmaps zu.
  2. Erstellen Sie einen Heap-Dump mit dem Flag -b (um native Bitmap-Daten einzuschließen):

    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. Öffnen Sie localhost:7100 und suchen Sie in der Seitenleiste nach dem Link Bitmaps oder suchen Sie nach der Klasse Bitmap.

  4. AHAT rendert die Bitmaps im Browser, sodass Sie ganz einfach erkennen können, welche Bilder viel Speicherplatz belegen.

AHAT mit gerenderten Bitmaps

3. Bitmap-Tracks in Perfetto

Mit Perfetto können Bitmap-Zuweisungen und ‑Anzahlen im Zeitverlauf verfolgt werden. Diese Zähler werden vom Android-Framework ausgegeben, wenn die atrace-Kategorie gfx für eine bestimmte Anwendung aktiviert ist.

  1. Starten Sie einen Trace. Sie müssen die Kategorie gfx angeben und mit dem Flag -a auf das jeweilige App-Paket ausrichten:

    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. Tippen Sie in BitmapLab wiederholt auf die Schaltflächen Zuweisen und Löschen.

  3. Tippen Sie auch auf Bitmap für Paket/Entpacken.

  4. Analysieren Sie den Trace in ui.perfetto.dev.

Im Prozessabschnitt für com.android.bitmaplab sehen Sie Folgendes: * Bitmap Count (Bitmap-Anzahl): Ein Zähler, der die Anzahl der aktiven Bitmaps angibt. * Bitmap-Arbeitsspeicher: Ein Zähler, der die insgesamt von Bitmaps verwendeten Byte angibt.

Slices auf hoher Ebene (Perfetto SDK)

BitmapLab verwendet auch das Perfetto SDK, um übergeordnete Slices für Bitmap-Vorgänge auszugeben. Suchen Sie im Trace nach BitmapLab_, um Folgendes zu finden: * BitmapLab_parcelUnparcel: Slices, die die Logik für das Aufteilen und Zusammenführen von Paketen abdecken. * BitmapLab_postNotification: Segmente für den Ablauf zum Posten von Benachrichtigungen.

Benachrichtigungsabläufe verfolgen

Wenn Sie auf Benachrichtigung posten tippen, erstellt die App eine Benachrichtigung mit der aktuellen Bitmap und sendet sie an das System. Der Framework-Code, der dafür verantwortlich ist, gibt Perfetto-Slices mit Flow-Ereignissen aus, die das Parcelling (Bitmap in ein Parcel schreiben, das über Binder IPC gesendet werden soll) und das Unparcelling (Bitmap am Empfangsende aus einem Parcel lesen) verbinden.

Im Screenshot unten sehen Sie, wie die App das große Bitmap für die Verwendung in einer Binder-Transaktion zum Posten der Benachrichtigung parzelliert und wie es im system_server-Prozess entparzelliert wird.

Perfetto-Screenshot, der einen Fluss von BitmapLab über die Benachrichtigung zum system_server zeigt

Mit Perfetto können Sie sogar derselben Benachrichtigungs-Bitmap folgen, wenn sie sich über Threads und Prozesse hinweg weiter ausbreitet, z. B. von einem Binder-Thread in system_server (das den INotificationManager-Binderserver implementiert) zu system_server-Worker-Threads, die dieselbe Bitmap dann möglicherweise an com.android.systemui weiterleiten, damit sie in der Benachrichtigungsleiste angezeigt wird.

Herausforderungen bei System-Apps

System-Apps wie SystemUI (Benachrichtigungen) und Launcher stehen vor besonderen Herausforderungen:

  1. Unbegrenzte Inhalte: Benachrichtigungen und Widgets können zahlreich sein. Wenn jede eine große Bitmap enthält, kann das System schnell nicht mehr genügend Arbeitsspeicher haben.
  2. Duplizierung: Das gleiche App-Symbol kann im Cache des Launchers, im Benachrichtigungsbereich von SystemUI und in den Einstellungen gespeichert sein.
  3. Freigabe über Hardwarepuffer: Um dieses Problem zu beheben, werden Systemkomponenten auf einen zentralen „Image Offload“-Dienst umgestellt, der HardwareBuffer-Instanzen prozessübergreifend freigibt.
  4. DMABuf-Attribution: Hardware-Bitmaps sparen Heapspeicher, verwenden aber DMABuf-Speicher, der mit Standard-Speichertools schwieriger einem bestimmten Prozess zuzuordnen ist.

    Verwenden Sie adb shell dmabuf_dump, um systemweite DMABuf-Zuweisungen zu sehen. Dieses Tool bietet eine Aufschlüsselung der Puffer nach Prozess:

     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: Die Gesamtgröße des Puffers, wenn er im Prozess zugeordnet ist.
    • Pss: Die proportionale Größe (RSS geteilt durch die Anzahl der Prozesse, die den Puffer gemeinsam nutzen). Das ist der beste Messwert für die Buchhaltung.
    • nr_procs: Die Anzahl der Prozesse, die derzeit eine Referenz auf diesen Puffer haben.
    • Exporter: Der Treiber, der den Puffer zugewiesen hat (z.B. virtio_gpu auf Cuttlefish oder ein anbieterspezifischer Ion-/DMA-BUF-Heap auf der Hardware).

    Mit adb shell dmabuf_dump -b erhalten Sie eine Zusammenfassung aller Puffer und der gesamten systemweiten DMA-BUF-Nutzung.


← Java | ↑ Nach oben | Nativ →