Tutto quello che dovete sapere sulle perdite di memoria in Android.

Ali Asadi
Jun 30, 2019 – 16 min read

Uno dei vantaggi principali di Java, o per essere più precisi, della JVM (Java Virtual Machine), è il garbage collector (GC). Possiamo creare nuovi oggetti senza preoccuparci di liberarli dalla memoria. Il garbage collector si occuperà di allocare e liberare la memoria per noi.

Non esattamente! Possiamo evitare che il garbage collector liberi la memoria per noi se non capiamo bene come funziona il GC.

Scrivere un codice senza una buona comprensione di come funziona il GC potrebbe creare delle perdite di memoria nell’applicazione. Queste perdite possono influenzare la nostra applicazione sprecando memoria non rilasciata e alla fine causano eccezioni e ritardi di memoria.

Che cos’è la perdita di memoria?

Mancato rilascio di oggetti inutilizzati dalla memoria

Mancato rilascio di oggetti inutilizzati dalla memoria significa che ci sono oggetti inutilizzati nell’applicazione che il GC non può eliminare dalla memoria.

Quando il GC non può eliminare gli oggetti inutilizzati dalla memoria, siamo nei guai. L’unità di memoria che contiene gli oggetti inutilizzati sarà occupata fino alla fine dell’applicazione o (fino alla fine del metodo).

Fino alla fine del metodo? Sì, proprio così. Abbiamo due tipi di perdite, le perdite che occupano l’unità di memoria fino alla fine dell’applicazione e le perdite che occupano l’unità di memoria fino alla fine del metodo. Il primo è chiaro. Il secondo ha bisogno di maggiori chiarimenti. Facciamo un esempio per spiegarlo! Supponiamo di avere il metodo X. Il metodo X sta facendo qualche compito di lunga durata in background, e ci vorrà un minuto per finire. Inoltre, il metodo X sta tenendo oggetti inutilizzati mentre lo fa. In questo caso, l’unità di memoria sarà occupata, e gli oggetti inutilizzati non possono essere cancellati per un minuto fino alla fine del compito. Dopo la fine del metodo, il GC può cancellare gli oggetti inutilizzati e recuperare la memoria.

Questo è quello che voglio che sappiate per ora ci torneremo più tardi con del codice e della visualizzazione. SARÀ DIVERTENTE. 👹😜

Aspetta un minuto!!!🥴

Prima di saltare al contesto, iniziamo dalle basi.

RAM, o Random access memory, è la memoria nei dispositivi android o computer che viene utilizzata per memorizzare le applicazioni in esecuzione e i loro dati.

Vi spiegherò due personaggi principali della RAM, il primo è l’Heap, e il secondo è lo Stack. Passiamo alla parte divertente 🤩🍻.

Heap & Stack

Non la farò troppo lunga. Andiamo subito al punto, una breve descrizione, la Pila è usata per l’allocazione statica della memoria mentre l’Heap è usato per l’allocazione dinamica della memoria. Basta tenere a mente che sia l’Heap che lo Stack sono memorizzati nella RAM.

Più sulla memoria Heap

La memoria Heap di Java è usata dalla macchina virtuale per allocare gli oggetti. Ogni volta che si crea un oggetto, questo viene sempre creato nell’heap. Le macchine virtuali, come la JVM o la DVM, eseguono regolarmente la garbage collection (GC), rendendo la memoria heap di tutti gli oggetti che non sono più referenziati disponibile per allocazioni future.

Per fornire un’esperienza utente fluida, Android imposta un limite rigido alla dimensione heap per ogni applicazione in esecuzione. Il limite di dimensione dell’heap varia tra i dispositivi e si basa su quanta RAM ha un dispositivo. Se la tua applicazione colpisce questo limite di heap e cerca di allocare più memoria, riceverà un OutOfMemoryError e terminerà.

Ti sei mai chiesto quale sia la dimensione dell’heap per la tua applicazione?

Scopriamolo insieme. In Android, abbiamo la Dalvik VM (DVM). La DVM è un’unica macchina virtuale Java ottimizzata per i dispositivi mobili. Ottimizza la macchina virtuale per la memoria, la durata della batteria e le prestazioni, ed è responsabile della distribuzione della quantità di memoria per ogni applicazione.

Parliamo di due linee nella DVM:

  1. dalvik.vm.heapgrowthlimit: Questa linea è basata su come Dalvik inizierà la dimensione dell’heap della vostra applicazione. È la dimensione heap predefinita per ogni applicazione. Il massimo che la vostra applicazione può raggiungere!
  2. dalvik.vm.heapsize: Questa linea rappresenta la dimensione massima dell’heap per un heap più grande. Puoi ottenere questo chiedendo ad Android un heap più grande nel manifesto della tua applicazione (android:largeHeap=”true”).

Non usare un heap più grande nella tua app. Fatelo SOLO se conoscete esattamente l’effetto collaterale di questo passo. Qui vi darò abbastanza informazioni per continuare la ricerca sull’argomento.

Qui c’è una tabella che mostra quale dimensione di heap avete in base alla RAM del vostro dispositivo:

+==========================+=========+=========+===================+
| DVM | 1GB RAM | 2GB RAM | 3GB RAM OR HIGHER |
+==========================+=========+=========+===================+
| DEFAULT(heapgrowthlimit) | 64m | 128m | 256m |
+--------------------------+---------+---------+-------------------+
| LARGE(heapsize) | 128m | 256m | 512m |
+--------------------------+---------+---------+-------------------+

Ricordate che più ram avete più alta sarà la dimensione di heap. Tieni a mente che non tutti i dispositivi con maggiore ram vanno oltre i 512m, fai una ricerca sul tuo dispositivo se il tuo dispositivo ha più di 3GB per vedere se la dimensione dell’heap è più grande di 512m.

Come puoi controllare la dimensione dell’heap delle app per il tuo dispositivo?

Usando l’ActivityManager. È possibile controllare la dimensione massima dell’heap in fase di esecuzione utilizzando i metodi getMemoryClass() o getLargeMemoryClass() (quando è abilitato un heap grande).

  • getMemoryClass(): Restituisce la dimensione massima di heap predefinita.
  • getLargeMemoryClass(): Restituisce la massima dimensione di heap disponibile dopo aver abilitato il flag large heap nel manifest.
ActivityManager am = getSystemService(ACTIVITY_SERVICE);
Log.d("XXX", "dalvik.vm.heapgrowthlimit: " + am.getMemoryClass());
Log.d("XXX", "dalvik.vm.heapsize: " + am.getLargeMemoryClass());

Come funziona nel mondo reale?

Useremo questa semplice applicazione per capire quando usiamo l’heap e quando lo stack.

applicazione

L’immagine sottostante mostra una rappresentazione dell’heap e dello stack dell’applicazione e dove ogni oggetto punta e viene memorizzato quando eseguiamo l’applicazione.

Passeremo in rassegna l’esecuzione dell’app, fermeremo ogni linea, spiegheremo quando l’applicazione alloca gli oggetti e li memorizza nell’heap o nello stack. Vedremo anche quando l’applicazione rilascia gli oggetti dallo stack e dall’heap.

  • Linea 1 – La JVM crea un blocco di memoria stack per il metodo main.

  • Linea 2 – In questa linea, creiamo una variabile locale primitiva. La variabile sarà creata e memorizzata nella memoria dello stack del metodo principale.

  • Linea 3 – Qui ho bisogno della vostra attenzione! In questa linea, creiamo un nuovo oggetto. L’oggetto viene creato nello stack del metodo principale e memorizzato nell’heap. Lo stack memorizza il riferimento, l’indirizzo di memoria oggetto-heap (puntatore), mentre l’heap memorizza l’oggetto originale.

  • Linea 4 – La stessa della linea 3.

  • Linea 5 – La JVM crea un blocco di memoria stack per il metodo foo.

  • Linea 6 -Crea un nuovo oggetto. L’oggetto viene creato nella memoria dello stack del metodo foo, e noi memorizziamo nello stack l’indirizzo di memoria heap dell’oggetto che abbiamo creato nella linea 3. Il valore (indirizzo di memoria heap dell’oggetto nella linea 3) lo abbiamo passato nella linea 5. Tenete presente che Java passa sempre i riferimenti per valore.

  • Linea 7 – Stiamo creando un nuovo oggetto. L’oggetto creato nello stack e puntato al pool di stringhe nell’heap.

  • Linea 8 – Nella linea finale del metodo foo, il metodo termina. E gli oggetti saranno rilasciati dal blocco dello stack del metodo foo.

  • Linea 9- Lo stesso della linea 8, Nella linea finale del metodo main, il metodo termina. E il blocco dello stack del metodo principale si libera.

Che dire della liberazione della memoria dall’heap? Ci arriveremo presto. Prendi un caffè☕️, e continua 😼.

Cosa succede quando i metodi terminano?

Ogni metodo ha il suo ambito. Quando il metodo viene terminato, gli oggetti vengono rilasciati e recuperati automaticamente dallo stack.

figura 1

Nella figura 1, quando il metodo foo termina. La memoria dello stack o il blocco dello stack del metodo foo sarà rilasciato e recuperato automaticamente.

figura 2

Nella figura 2, lo stesso. Quando il metodo main è terminato. La memoria dello stack o il blocco dello stack del metodo principale sarà rilasciato e recuperato automaticamente.

Conclusione

Ora, ci è chiaro che gli oggetti nello stack sono temporanei. Una volta che il metodo termina, gli oggetti saranno rilasciati e recuperati.

Lo stack è una struttura dati LIFO (Last-In-First-Out). Si può vedere come una scatola. Usando questa struttura, il programma può facilmente gestire tutte le sue operazioni usando due semplici operazioni: push e pop.

Ogni volta che hai bisogno di salvare qualcosa come una variabile o un metodo spinge e sposta il puntatore dello stack in alto. Ogni volta che si esce da un metodo, si toglie tutto dal puntatore dello stack fino al ritorno all’indirizzo del metodo precedente. Nel nostro esempio tornando dal metodo foo al metodo main.

E l’Heap?

L’heap è diverso dallo stack. Per rilasciare e recuperare gli oggetti dalla memoria heap, abbiamo bisogno di aiuto.

Per questo, Java, o per essere più precisi, la JVM ha creato un supereroe che ci aiuta. L’abbiamo chiamato Garbage Collector. Lui farà il lavoro duro per noi. E preoccupandosi di rilevare gli oggetti inutilizzati, rilasciarli, e recuperare più spazio nella memoria.

Come funziona il garbage collector?

Semplice. Il garbage collector cerca oggetti inutilizzati o irraggiungibili. Quando c’è un oggetto nell’heap che non ha alcun riferimento puntato su di esso, il garbage collector si occuperà di liberarlo dalla memoria e recuperare più spazio.

GC

Le radici del GC sono oggetti referenziati dalla JVM. Sono gli oggetti iniziali dell’albero. Ogni oggetto nell’albero ha uno o più oggetti radice. Finché l’applicazione o le radici GC possono raggiungere quelle radici o quegli oggetti, l’intero albero è raggiungibile. Una volta che diventano irraggiungibili dall’applicazione o dalle radici del GC, saranno considerati come oggetti inutilizzati o oggetti irraggiungibili.

Cosa succede quando il garbage collector viene eseguito?

Per ora, questo è lo stato attuale della memoria dell’applicazione. Lo stack è libero, e l’heap è pieno di oggetti inutilizzati.

Prima del GC

Dopo l’esecuzione del GC i risultati saranno i seguenti:

Dopo GC

Il GC rilascerà e cancellerà tutti gli oggetti inutilizzati dall’heap.

Uomo! E la perdita di memoria che stiamo aspettando? LOL, ancora un po’ e ci saremo. Nella nostra semplice applicazione, l’applicazione è stata scritta alla grande e semplice. Non c’era nulla di sbagliato nel codice che potesse impedire al GC di rilasciare gli oggetti dell’heap. E per questo motivo, il GC rilascia e recupera tutti gli oggetti dalla memoria dell’heap. Continuate. Abbiamo molti esempi di perdite di memoria nella prossima sezione😃.

Quando e come avvengono le perdite di memoria?

Una perdita di memoria avviene quando lo stack fa ancora riferimento a oggetti inutilizzati nell’heap.

C’è una semplice rappresentazione visiva nell’immagine sottostante per una migliore comprensione del concetto.

Nella rappresentazione visiva, vediamo che quando abbiamo oggetti referenziati dallo stack ma non più in uso. Il garbage collector non li rilascerà mai o li libererà dalla memoria perché mostra che quegli oggetti sono in uso mentre non lo sono.

Come possiamo causare una perdita?

Ci sono vari modi per causare una perdita di memoria in Android. E si può fare facilmente usando AsyncTasks, Handlers, Singleton, Threads, e altro.

Vi mostrerò alcuni esempi usando threads, singleton, e ascoltatori per spiegare come possiamo causare una perdita ed evitarla e correggerla.

Guarda il mio repository Github. Ho alcuni esempi di codice.

Come possiamo causare una perdita usando il thread?

In questo esempio, stiamo per avviare un’attività che esegue un thread in background. Il thread eseguirà un’attività che impiegherà 20 secondi per terminare.

Come è noto, le classi interne mantengono un riferimento implicito alla classe che le racchiude.

Dietro le quinte, questo è in realtà come appare l’attività.

Il DownloadTask sta tenendo un riferimento alla ThreadActivity.

Cosa succede dopo l’avvio del task o del thread?

Ci sono due possibili flussi per l’utilizzo dell’applicazione. Un flusso regolare che funziona come previsto senza errori e un flusso leak che causa una perdita di memoria.

Flusso regolare

Nell’immagine, stiamo presentando l’heap e lo stack dell’applicazione.

L’utente avvia l’applicazione, apre la ThreadActivity, e aspetta sullo schermo fino alla fine del compito di download. L’utente ha aspettato 20 secondi. Perché 20 secondi? Perché questo è il tempo che il thread impiega per completare il compito.

Task Running

Il compito è in esecuzione in background. L’utente ha aspettato per 20 secondi il completamento del compito di download. Quando il compito è finito, lo stack rilascia il blocco del metodo run().

Non ci sono riferimenti che tengano il DownloadTask. Il GC ha considerato l’oggetto DownladTask come un oggetto inutilizzato, e per questo motivo, il prossimo ciclo GC lo cancellerà dalla memoria heap.

Il GC cancella gli oggetti inutilizzati dall’heap. Ora, quando l’utente chiude l’attività. Il metodo main verrà rilasciato dallo stack, e nel prossimo ciclo del GC, il GC cancellerà il ThreadActivity dalla memoria dell’heap.

Perfetto!