La liberazione di un oggetto nel codice della tua app o la chiusura di uno stream temporaneo non restituisce sempre la RAM fisica al sistema operativo. Gli allocatori nativi personalizzati, i runtime con garbage collection e le tabelle hash con indirizzamento aperto spesso conservano arene di memoria virtuale che rimangono supportate da pagine fisiche molto tempo dopo la scomparsa dei dati logici al loro interno. Analogamente, la lettura o la mappatura della memoria di archivi di asset di grandi dimensioni compila la cache della pagina del kernel, che rimane addebitata al gruppo di controllo della memoria (memcg) dell'app.
Questa pagina spiega come utilizzare le chiamate di sistema madvise e posix_fadvise per restituire al sistema operativo le pagine anonime e con backup su file non utilizzate, mantenendo intatte le prenotazioni degli indirizzi virtuali e i descrittori dei file, e come verificare l'impatto in adb shell e Perfetto.
Perché la deallocazione logica mantiene la RAM fisica
Il kernel Linux monitora la memoria fisica in base alla granularità delle pagine hardware,
che sono di 4 kB o 16 kB sui dispositivi Android. Quando un processo prenota una regione dello spazio di indirizzi virtuali con mmap, il kernel crea un'area di memoria virtuale (VMA) senza allocare RAM fisica. Il kernel
alloca e addebita una pagina fisica solo quando il processo tocca per la prima volta quella
pagina. Per la memoria anonima, una prima lettura mappa una pagina zero condivisa, quindi solo una scrittura alloca la RAM.
Una volta che una pagina viene inserita nella RAM, il kernel non sa se i byte all'interno di quella pagina rappresentano ancora oggetti attivi nel runtime della tua app. Due pattern comuni mantengono le pagine fisiche residenti dopo che l'app ha finito di utilizzarle:
- Arena e pool anonimi: gli allocatori nativi personalizzati (come gli allocatori slab, pool o frame-arena), i runtime gestiti con spazi heap non mobili e le tabelle hash di grandi dimensioni riservano intervalli di indirizzi virtuali contigui con
mmaputilizzandoMAP_PRIVATE | MAP_ANONYMOUS. Quando l'app reimposta un'arena, libera un blocco in un pool personalizzato o cancella una tabella hash azzerando i relativi metadati, le pagine sottostanti rimangono dirty. Senza un suggerimento esplicito al kernel, queste pagine continuano a essere conteggiate nel Resident Set Size (RSS) e nel Proportional Set Size (PSS) anonimi del processo e nell'addebito del cgroupmemory.current. In caso di pressione della memoria, il kernel le comprime in ZRAM, dove occupano ancora spazio di swap addebitato amemory.swap.current. - Cache di pagina basata su file dallo streaming degli asset: quando l'app legge asset bundle di grandi dimensioni, archivi di livelli, container multimediali o modelli di machine learning utilizzando
read,preadommapbasato su file, il kernel memorizza nella cache ogni pagina letta nella cache di pagina e addebita queste pagine al contatorefilenel filememory.statdel cgroup del processo. Se l'app decomprime o copia l'asset in un buffer di runtime anonimo o lo carica nella GPU, la conservazione delle pagine dei file non elaborati nella cache delle pagine duplica l'asset nella RAM.
Pubblica pagine anonime
La chiamata di sistema madvise fornisce al kernel suggerimenti su come il processo prevede di utilizzare un intervallo di indirizzi virtuali. Per le mappature anonime private (MAP_PRIVATE |
MAP_ANONYMOUS), il passaggio di MADV_DONTNEED indica al kernel di eliminare immediatamente le pagine fisiche e tutte le voci ZRAM scambiate che supportano l'intervallo allineato alla pagina specificato.
Dopo madvise con MADV_DONTNEED restituisce:
- L'intervallo di indirizzi virtuali rimane riservato e valido nello spazio degli indirizzi del processo (
VmSizenon cambia), quindi i puntatori e i limiti dell'arena rimangono invariati senza chiamaremunmapommap. - La mappatura delle pagine fisiche viene immediatamente annullata e le pagine vengono liberate, riducendo
RssAnon,Pss, il contatoreanoninmemory.statememory.swap.currentper tutte le pagine che si trovavano in ZRAM. - Qualsiasi lettura o scrittura successiva in un indirizzo in quell'intervallo genera un errore in una nuova pagina con zeri su richiesta.
| Flag di avviso | Mappatura del target | Comportamento del kernel su Android |
|---|---|---|
MADV_DONTNEED |
Anonimo privato (MAP_PRIVATE | MAP_ANONYMOUS) |
Elimina immediatamente le pagine fisiche e gli slot di scambio ZRAM. Gli accessi successivi generano errori nelle pagine con valori pari a zero. Calo deterministico di RssAnon e memory.current. |
MADV_DONTNEED |
Supportato da file (MAP_PRIVATE o MAP_SHARED) |
Annulla la mappatura delle pagine dalle tabelle delle pagine di processo (drop RssFile). Le pagine private copy-on-write in una mappatura MAP_PRIVATE vengono ignorate, quindi le modifiche vengono perse e l'accesso successivo rilegge il file. Le pagine MAP_SHARED dirty vengono riscritte nello spazio di archiviazione in un secondo momento. Le pagine pulite rimangono nella cache delle pagine finché non vengono rimosse. |
MADV_FREE |
Anonimo privato (MAP_PRIVATE | MAP_ANONYMOUS) |
Contrassegna le pagine come riutilizzabili in modo lazy. Il kernel conserva le pagine in RssAnon se non esiste una pressione sulla memoria e le recupera solo quando viene eseguito il recupero globale o memcg. |
MADV_WILLNEED |
Anonima con swap o con backup su file | Avvia la lettura anticipata asincrona per le mappature basate su file o legge le pagine anonime scambiate da ZRAM nella cache di scambio prima del tempo. |
Allinea gli intervalli interni alle dimensioni della pagina di runtime
madvise opera su pagine intere. Richiede un indirizzo di inizio allineato alla pagina e arrotonda la lunghezza al limite della pagina successiva. Se rilasci uno span all'interno di un'arena condivisa in cui i byte adiacenti contengono ancora oggetti attivi, l'arrotondamento dell'intervallo verso l'esterno può azzerare le allocazioni attive che condividono le pagine di confine.
Esegui sempre una query sulla dimensione della pagina di runtime con sysconf(_SC_PAGESIZE) per supportare i dispositivi con pagine da 4 kB e 16 kB e arrotonda l'indirizzo di inizio interno al limite della pagina successiva e l'indirizzo di fine interno al limite della pagina precedente. Per ulteriori indicazioni sugli allocatori indipendenti dalle dimensioni della pagina, consulta
Ottimizza gli allocatori di memoria personalizzati.
#include <sys/mman.h>
#include <unistd.h>
#include <cstdint>
// Reclaims physical pages strictly inside [region_start, region_start + length)
// without disturbing live objects on partially occupied boundary pages.
void ReleaseUnusedArenaSpan(void* region_start, size_t length) {
const uintptr_t page_size = static_cast<uintptr_t>(sysconf(_SC_PAGESIZE));
const uintptr_t page_mask = ~(page_size - 1);
const uintptr_t raw_start = reinterpret_cast<uintptr_t>(region_start);
const uintptr_t raw_end = raw_start + length;
// Round start UP and end DOWN so only fully unused pages are discarded.
const uintptr_t aligned_start = (raw_start + page_size - 1) & page_mask;
const uintptr_t aligned_end = raw_end & page_mask;
if (aligned_end > aligned_start) {
madvise(reinterpret_cast<void*>(aligned_start),
aligned_end - aligned_start,
MADV_DONTNEED);
}
}
Quando crei mappature anonime personalizzate con mmap, etichetta la VMA con prctl utilizzando PR_SET_VMA_ANON_NAME in modo da poter monitorare la sua residenza per nome in showmap e /proc/<pid>/smaps. dumpsys
meminfo conteggia le regioni anonime con nome personalizzato in Unknown.
Alcuni kernel meno recenti mantengono un puntatore al nome anziché copiarlo, quindi passa una stringa che rimanga valida per la durata della mappatura, ad esempio un valore letterale stringa:
#include <sys/mman.h>
#include <sys/prctl.h>
#ifndef PR_SET_VMA
#define PR_SET_VMA 0x53564d41
#define PR_SET_VMA_ANON_NAME 0
#endif
void* ReserveNamedArena(size_t capacity_bytes, const char* arena_name) {
void* ptr = mmap(nullptr, capacity_bytes, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (ptr != MAP_FAILED) {
prctl(PR_SET_VMA, PR_SET_VMA_ANON_NAME,
reinterpret_cast<unsigned long>(ptr), capacity_bytes,
reinterpret_cast<unsigned long>(arena_name));
}
return ptr;
}
Evict file cache pages (Elimina pagine della cache dei file)
Quando l'app legge i dati da un descrittore del file, il kernel memorizza nella cache le pagine del file nella cache delle pagine in previsione di letture future. Sebbene ciò velocizzi gli accessi ripetuti a piccoli file di configurazione o librerie condivise, è controproducente quando si esegue lo streaming di archivi di asset di grandi dimensioni, pacchetti di texture o blocchi audio e video che vengono analizzati una volta e archiviati in forma decodificata altrove.
La chiamata di sistema posix_fadvise consente alla tua app di informare il kernel sul pattern di accesso ai file previsto su un descrittore del file aperto:
POSIX_FADV_DONTNEED: indica al kernel che l'intervallo di byte specificato non sarà accessibile nel prossimo futuro. Il kernel rimuove immediatamente le pagine memorizzate nella cache pulite e non mappate in quell'intervallo allineato alla pagina dalla cache della pagina, riducendo il contatorefileinmemory.state l'addebitomemory.currentdel tuo cgroup.POSIX_FADV_SEQUENTIAL: raddoppia la finestra di readahead del kernel per il descrittore del file, migliorando il throughput durante lo streaming di un archivio di grandi dimensioni dall'inizio alla fine. Abbina questa opzione aPOSIX_FADV_DONTNEEDsui blocchi finali man mano che li utilizzi, in modo che l'impatto sulla cache della pagina rimanga limitato a una piccola finestra scorrevole.POSIX_FADV_RANDOM: disattiva la lettura anticipata speculativa del kernel sul descrittore del file. Utilizza questa opzione quando esegui ricerche sparse e non contigue per offset di byte in un file di archivio o di database di grandi dimensioni, in modo che ogni piccola lettura non inserisca pagine inutilizzate adiacenti nella cache delle pagine. Per i file mappati in memoria, chiamamadviseconMADV_RANDOMsulla mappatura.
L'esempio seguente trasmette in streaming un archivio di asset di grandi dimensioni in blocchi, attivando il readahead sequenziale in anticipo ed eliminando ogni intervallo allineato alla pagina elaborato dalla cache della pagina:
#include <fcntl.h>
#include <unistd.h>
#include <cstdint>
#include <vector>
void StreamAssetArchive(int fd, size_t total_size) {
const size_t page_size = static_cast<size_t>(sysconf(_SC_PAGESIZE));
constexpr size_t kChunkSize = 4 * 1024 * 1024; // 4 MB streaming window
std::vector<uint8_t> buffer(kChunkSize);
// Hint sequential access so the kernel prefetches upcoming chunks.
posix_fadvise(fd, 0, total_size, POSIX_FADV_SEQUENTIAL);
off_t offset = 0;
while (offset < static_cast<off_t>(total_size)) {
ssize_t bytes_read = pread(fd, buffer.data(), buffer.size(), offset);
if (bytes_read <= 0) break;
// Decode or upload buffer[0 .. bytes_read) into runtime structures here.
// Evict full pages that have been completely consumed.
const size_t aligned_len =
(static_cast<size_t>(bytes_read) / page_size) * page_size;
if (aligned_len > 0) {
posix_fadvise(fd, offset, static_cast<off_t>(aligned_len),
POSIX_FADV_DONTNEED);
}
offset += bytes_read;
}
}
Recupera le regioni mmap basate su file
Se la tua app mappa in memoria un file di asset di grandi dimensioni con mmap anziché leggerlo con pread, la chiamata a posix_fadvise con POSIX_FADV_DONTNEED da solo non rimuove le pagine ancora mappate nelle tabelle delle pagine del processo. Per eliminare un intervallo utilizzato di una mappatura di file di sola lettura senza annullare la mappatura dell'intera VMA:
- Chiama
madvise(addr, length, MADV_DONTNEED)sull'intervallo di indirizzi virtuali mappati per annullare la mappatura di queste pagine dalle tabelle delle pagine del processo (RssFiledrop). - Chiama
posix_fadvise(fd, offset, length, POSIX_FADV_DONTNEED)sull'offset del file corrispondente in modo che il kernel rimuova anche le pagine pulite non mappate dalla cache delle pagine (fileinmemory.statdrop).
Segui le linee guida per i consigli sulla pagina
Applica queste pratiche quando integri madvise e posix_fadvise in allocatori, caricatori di asset e gestori del ciclo di vita:
- Raggruppa le chiamate in regioni generiche: ogni chiamata
madviseacquisisce il blocco della mappa di memoria del processo (mmap_lock), esamina le tabelle delle pagine e invalida le voci corrispondenti del buffer di traduzione (TLB) su altri core della CPU (con istruzioni di manutenzione del TLB di trasmissione su Arm e interruzioni tra processori su x86). Non chiamaremadviseper ogni piccola deallocazione. Unisci gli intervalli liberi e avvisa il kernel in blocchi da 64 KB a 1 MB o più, oppure esegui lo sweep degli intervalli liberi durante i limiti dei frame e le transizioni in background (onStopeTRIM_MEMORY_UI_HIDDEN). - Recupera la capacità vuota nelle tabelle hash e nei pool: quando una tabella hash, un pool di oggetti o un buffer di gestione temporanea di grandi dimensioni viene cancellato dopo una transizione di scena o una sincronizzazione dei dati, riduci l'allocazione di supporto o invia
MADV_DONTNEEDnell'array di supporto cancellato. Se mantieni una tabella con indirizzamento aperto da 32 MB allocata con tombstone, tutti i 32 MB di pagine fisiche verranno addebitati al tuo processo. - Svuota i dati dei file dirty prima di rimuoverli:
POSIX_FADV_DONTNEEDrimuove solo le pagine della cache delle pagine pulite. Se la tua app scrive un file temporaneo o di cache di grandi dimensioni che non verrà riletto immediatamente, chiamafdatasyncprima diposix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED)in modo che le pagine dirty vengano scritte nello spazio di archiviazione e poi eliminate dalla RAM. - Non rimuovere asset o codice condiviso molto utilizzato: evita di chiamare
POSIX_FADV_DONTNEEDsu DEX, ODEX, VDEX, librerie condivise (.so) o asset dell'interfaccia utente a cui si accede ripetutamente. La rimozione di pagine di codice o asset attivi forza i refault di archiviazione sincroni che bloccano i thread e aumentano la pressione di I/O, come segnalato dalle informazioni sulla pressione di stallo (PSI).
Verifica il recupero delle pagine nella shell
Verifica che madvise e posix_fadvise recuperino la RAM fisica utilizzando showmap
e i file di accounting cgroup v2 della tua app.
Ispeziona le dimensioni virtuali e fisiche con showmap
Quando assegni un nome a un'arena anonima con PR_SET_VMA_ANON_NAME, showmap mostra sia la prenotazione virtuale (virtual size) sia le pagine fisiche residenti (RSS e PSS). Il seguente output di esempio proviene dall'app di esempio MemoryLab in AOSP, che assegna all'arena il nome memorylab-arena. Sostituisci com.example.app e memorylab-arena con il nome del pacchetto e il nome dell'arena. Prima di chiamare madvise con MADV_DONTNEED su un'arena da 64 MB toccata, esegui:
adb shell "showmap \$(pidof com.example.app) | grep -E '^ +virtual|^ +size|memorylab-arena' | head -3"
virtual shared shared private private Anon Shmem File Shared Private
size RSS PSS clean dirty clean dirty swap swapPSS HugePages PmdMapped PmdMapped Hugetlb Hugetlb Locked # object
65536 65536 65536 0 0 0 65536 0 0 0 0 0 0 0 0 1 [anon:memorylab-arena]
Dopo aver chiamato madvise(arena_ptr, 64 * 1024 * 1024, MADV_DONTNEED), esegui di nuovo showmap:
virtual shared shared private private Anon Shmem File Shared Private
size RSS PSS clean dirty clean dirty swap swapPSS HugePages PmdMapped PmdMapped Hugetlb Hugetlb Locked # object
65536 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 [anon:memorylab-arena]
virtual size rimane a 65.536 KB (64 MB), a conferma che l'intervallo di indirizzi virtuali è ancora riservato, mentre RSS, PSS e private dirty scendono a 0 KB.
Ispeziona memory.current e memory.stat di cgroup
Per verificare sia il recupero di pagine anonime sia l'eliminazione della cache di pagine non mappate (che non viene visualizzata nelle tabelle delle pagine di processo se letta con pread), controlla la directory cgroup v2 del processo. Su Android, ogni processo dell'app ha il proprio cgroup (ad esempio /sys/fs/cgroup/apps/uid_<UID>/pid_<PID>/, a seconda del dispositivo).
Il seguente comando trova la directory dalla riga 0:: di /proc/<PID>/cgroup e stampa i contatori:
adb shell '
PID=$(pidof com.example.app)
CG=/sys/fs/cgroup$(sed -n "s/^0:://p" /proc/$PID/cgroup)
echo "memory.current: $(cat $CG/memory.current)"
echo "memory.swap.current: $(cat $CG/memory.swap.current)"
grep -E "^(anon|file) " $CG/memory.stat
'
Prima di rilasciare un'arena anonima da 64 MB ed espellere un file di asset mappato in memoria da 128 MB dalla cache della pagina:
memory.current: 242962432
memory.swap.current: 16633856
anon 100671488
file 140009472
Subito dopo aver chiamato madvise con MADV_DONTNEED nell'arena e nella mappatura del file degli asset, quindi posix_fadvise con POSIX_FADV_DONTNEED nel descrittore del file degli asset:
memory.current: 41766912
memory.swap.current: 16502784
anon 33722368
file 5914624
anon scende di 64 MB e file scende di 128 MB, riducendo
memory.current di circa 192 MB senza chiudere il descrittore del file o
annullare la mappatura dello spazio degli indirizzi virtuali dell'arena. memory.swap.current cambia appena, perché quasi nessuna delle pagine dell'arena è stata scambiata con ZRAM. I valori dipendono dal dispositivo e dalla quantità di file che il kernel ha già rimosso autonomamente. Per saperne di più su come il kernel addebita e recupera la memoria per cgroup, consulta Recupero della memoria: espulsione e swap.
Analizza il recupero delle pagine in Perfetto
Perfetto ti consente di correlare le chiamate madvise e posix_fadvise con i contatori della tabella delle pagine e della cache delle pagine del kernel su una cronologia unificata.
Registra una traccia
Acquisisci una traccia con linux.process_stats per i contatori dei processi periodici e linux.ftrace per gli eventi RSS e della cache di pagina in tempo reale, quindi estraila sul tuo computer:
adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/page_advice.perfetto-trace <<EOF
buffers: {
size_kb: 65536
fill_policy: RING_BUFFER
}
data_sources: {
config {
name: "linux.process_stats"
process_stats_config {
scan_all_processes_on_start: true
proc_stats_poll_ms: 100
}
}
}
data_sources: {
config {
name: "linux.ftrace"
ftrace_config {
ftrace_events: "kmem/rss_stat"
ftrace_events: "filemap/mm_filemap_add_to_page_cache"
ftrace_events: "filemap/mm_filemap_delete_from_page_cache"
}
}
}
duration_ms: 20000
EOF
adb pull /data/misc/perfetto-traces/page_advice.perfetto-trace
Ispeziona le tracce nella UI di Perfetto
Apri la traccia nell'interfaccia utente di Perfetto, espandi il gruppo di processi della tua app e fissa le seguenti tracce dei contatori in modo che rimangano nella parte superiore della cronologia:
mem.virt: rimane invariato tra le chiamateMADV_DONTNEED, confermando che la prenotazione dell'indirizzo virtuale (mmap) non è cambiata.mem.rss.anon: scende bruscamente nel timestamp esatto in cui l'app chiamamadviseconMADV_DONTNEEDsu aree anonime dirty o regioni heap.mem.rss.file: diminuisce quando la tua app chiamamadviseconMADV_DONTNEEDsu una regionemmapsupportata da file, accompagnata da un burst di eventi ftracemm_filemap_delete_from_page_cachequandoposix_fadviseconPOSIX_FADV_DONTNEEDrimuove le pagine pulite dalla cache delle pagine.mem.swap: si riduce quandoMADV_DONTNEEDelimina le pagine anonime che erano state precedentemente compresse in ZRAM.
La Figura 2 mostra queste tracce per l'app di esempio MemoryLab. Per esplorare la stessa visualizzazione, apri la traccia di esempio nell'interfaccia utente di Perfetto.
Esegui query sulla memoria di picco e finale con PerfettoSQL
Esegui la seguente query PerfettoSQL nell'interfaccia utente di Perfetto (scheda Query (SQL)) per confrontare i valori di picco e finali di mem.virt, mem.rss.anon, mem.rss.file e mem.swap per il processo di destinazione:
WITH counters AS (
SELECT t.name AS counter_name, c.ts, c.value
FROM counter c
JOIN process_counter_track t ON c.track_id = t.id
JOIN process p USING (upid)
WHERE p.name = 'com.example.app'
AND t.name IN ('mem.virt', 'mem.rss.anon', 'mem.rss.file', 'mem.swap')
)
SELECT
counter_name,
CAST(MAX(value) / 1048576 AS INT) AS peak_mb,
CAST((
SELECT value FROM counters AS last
WHERE last.counter_name = counters.counter_name
ORDER BY ts DESC LIMIT 1
) / 1048576 AS INT) AS final_mb
FROM counters
GROUP BY counter_name
ORDER BY counter_name;
Esempio di output per la traccia nella Figura 2:
counter_name peak_mb final_mb
mem.rss.anon 111 48
mem.rss.file 212 84
mem.swap 36 36
mem.virt 13952 13952
mem.rss.anon scende di 63 MB e mem.rss.file scende di 128 MB rispetto ai picchi, mentre mem.virt termina al suo picco perché le prenotazioni virtuali rimangono invariate.
Risolvi le pagine rimosse nei file
Gli eventi mm_filemap_add_to_page_cache e mm_filemap_delete_from_page_cache identificano i file in base al numero di inode (i_ino) anziché al percorso. Per verificare che le pagine rimosse appartengano al file che hai indicato, conta le rimozioni per inode:
SELECT
EXTRACT_ARG(arg_set_id, 'i_ino') AS inode,
COUNT(*) AS evicted_pages
FROM ftrace_event
WHERE name = 'mm_filemap_delete_from_page_cache'
GROUP BY inode
ORDER BY evicted_pages DESC
LIMIT 5;
Esempio di output per la traccia nella Figura 2:
inode evicted_pages
9761 35781
76792192 767
126 668
0 612
11587712 514
Mentre il dispositivo che ha registrato la traccia è ancora connesso, risolvi ogni inode in un percorso. I numeri di inode sono univoci solo all'interno di un file system, quindi limita la ricerca al file system che contiene i dati della tua app:
INODE=<inode from the query>
adb shell "find /data -xdev -inum $INODE 2>/dev/null"
Esempio di output per l'inode 9761:
/data/user/0/com.android.memorylab/files/asset_pack.bin
/data/data/com.android.memorylab/files/asset_pack.bin
Entrambi i percorsi fanno riferimento allo stesso file, perché /data/user/0 e /data/data sono due nomi per la stessa directory dei dati dell'app. Il conteggio delle rimozioni per il file di asset (35.781 pagine) è maggiore del file stesso (32.768 pagine a 4 KB), perché kswapd aveva già rimosso parte del file sotto pressione della memoria prima che l'app lo leggesse di nuovo e chiamasse posix_fadvise.
Per uno script che risolve molti inode contemporaneamente, consulta Risoluzione degli errori relativi agli inode, quindi ai file.
← Interazione tra kswapd e lmkd | ↑ Su | Processo in background snello →