Perdita di memoria (memory leak) — situazione in cui un'applicazione non rilascia la memoria occupata da oggetti che non sono più necessari. Nello sviluppo mobile questo è particolarmente critico: l'heap limitato e l'assenza di swap portano a OutOfMemoryError e al crash dell'applicazione. Secondo Purdue University (2022), il 35% delle applicazioni Android su Google Play contiene almeno una perdita di memoria. Analizziamo scenari tipici, strumenti diagnostici e metodi di correzione.
Punti chiave
Perdita di memoria — situazione in cui la memoria allocata non viene restituita al sistema dopo che l'oggetto non è più necessario al programma. Il garbage collector considera tale oggetto come vivo perché esiste una catena di riferimenti attiva da un GC Root che punta ad esso.
In Java/Kotlin, il garbage collector funziona automaticamente, ma non può determinare che un oggetto è logicamente non necessario se esiste un riferimento tecnico ad esso. Lo sviluppatore deve interrompere esplicitamente le connessioni non necessarie. In Swift/Objective-C, ARC conta automaticamente i riferimenti, ma i retain cycle bloccano il decremento del contatore.
Il pericolo principale delle perdite è l'effetto cumulativo. Ogni perdita consuma una piccola quantità di memoria, ma con transizioni ripetute tra schermate (rotazioni dello schermo, apertura/chiusura di Activity), le perdite si accumulano fino a esaurire il limite dell'heap.
Perdita — l'oggetto è inaccessibile al codice ma non viene rimosso dal GC. Gonfiore — l'oggetto è logicamente necessario ma viene memorizzato in quantità eccessiva. Esempio di gonfiore: una cache di immagini di 100 MB con un set di lavoro di 30 MB. Entrambi i problemi portano a OOM, ma le cause e i metodi di trattamento sono diversi.
ART (Android Runtime) utilizza la garbage collection generazionale con compattazione concorrente. La memoria è divisa in generazione giovane (Young), generazione vecchia (Old) e oggetti grandi (Large). Gli oggetti che sopravvivono a diversi cicli di GC vengono spostati nella generazione Old, dove la raccolta avviene meno frequentemente — questo accelera i cicli normali.
Il GC inizia quando l'heap raggiunge una certa soglia di occupazione (di solito 75-85%). Durante il GC, tutti i thread dell'applicazione vengono sospesi (STW — Stop The World). Più oggetti vivi ci sono, più lunga è la pausa. Le perdite aumentano il numero di oggetti vivi, allungando le pause del GC.
Il collector determina gli oggetti vivi attraversando il grafo dalle GC Roots: campi statici, variabili di stack dei thread attivi, riferimenti JNI. Qualsiasi oggetto raggiungibile tramite riferimenti da queste radici è considerato vivo — anche se lo sviluppatore sa che non è più necessario.
// Esempio: collezione statica come GC Root — perdita permanente
object GlobalHolder {
val listeners = mutableListOf<WeakReference<Any>>()
}
class LeakingFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle ?= null) {
super.onCreate(savedInstanceState)
GlobalHolder.listeners.add(WeakReference(this))
// WeakReference non impedisce il GC — comportamento corretto
}
}
WeakReference risolve il problema: il GC ignora i riferimenti deboli nel determinare gli oggetti vivi. Se rimangono solo riferimenti deboli a un oggetto, verrà raccolto nel ciclo GC più vicino.
Activity Context — lo scenario di perdita più massiccio in Android. Se un singleton, un campo statico o un servizio di lunga durata memorizza un riferimento a un Activity Context, l'intera Activity con tutte le sue View non può essere raccolta dal GC. Soluzione: utilizzare Application Context per gli oggetti di lunga durata.
Handler e messaggi inviati — Handler.postDelayed(runnable, delay) inserisce un messaggio nella coda del Main Looper. Se l'Activity viene distrutta prima della scadenza del ritardo, il messaggio è ancora nella coda e mantiene un riferimento tramite Runnable → classe anonima → classe esterna (Activity).
class SafeActivity : AppCompatActivity() {
private val mainHandler = Handler(Looper.getMainLooper())
private val callback = Runnable { /* update UI */ }
override fun onResume() {
super.onResume()
mainHandler.postDelayed(callback, 5000)
}
override fun onPause() {
mainHandler.removeCallbacks(callback) // obbligatorio: pulire la coda
super.onPause()
}
}
Classi interne — una classe interna non statica ha un riferimento implicito all'istanza della classe esterna. Se la classe esterna è un'Activity e la classe interna viene passata all'esterno (ad esempio, a RecyclerView.Adapter), l'Activity non può essere raccolta.
Android Studio Memory Profiler — strumento integrato per il monitoraggio dell'heap in tempo reale. Mostra un grafico della memoria utilizzata, il numero di allocazioni e oggetti per tipo. Consente di registrare un heap dump ed esportarlo in formato HPROF per l'analisi in MAT.
Eclipse MAT (Memory Analyzer Tool) — analizzatore desktop di heap dump. Costruisce automaticamente report Leak Suspects, evidenziando gli oggetti con il retained size maggiore e suggerendo la presunta catena GC Root per ogni oggetto sospetto.
Xcode Memory Graph Debugger — per iOS. Sospende l'applicazione e visualizza il grafo degli oggetti. I retain cycle sono evidenziati in rosso; è possibile cliccare su qualsiasi oggetto per vedere il suo retain count e i riferimenti.
| Strumento | Capacità | Complessità |
|---|---|---|
| Memory Profiler | Grafico in tempo reale, heap dump, Object Allocation Tracking | Bassa |
| Eclipse MAT | Dominator tree, Leak Suspects, query OQL | Media |
| LeakCanary | Rilevamento automatico, traccia della perdita nella notifica | Minima |
| Xcode Memory Graph | Grafico visivo dei retain cycle, elenco oggetti vivi | Bassa |
Secondo l'Uber Engineering Blog, l'integrazione della profilazione automatica della memoria (LeakCanary + analisi heap dump) nella pipeline CI/CD riduce gli incidenti legati alla memoria in produzione del 60% entro 3 mesi.
Sostituire Context — se un oggetto sopravvive all'Activity, utilizzare applicationContext. Tutti gli oggetti di lunga durata (singleton, repository, helper di database) dovrebbero ricevere Application Context, non Activity Context. Eccezione: componenti UI che necessitano di accesso al tema o alle risorse specifiche dell'Activity.
Componenti lifecycle-aware — l'uso di LifecycleObserver, DefaultLifecycleObserver o estensioni reattive annulla automaticamente le sottoscrizioni in onDestroy. Android Jetpack fornisce lifecycleScope e viewModelScope, che vengono puliti dall'evento del ciclo di vita corrispondente.
Classe interna statica — se la classe interna non ha bisogno di accedere ai campi della classe esterna, renderla static. Una classe interna statica non ha un riferimento implicito alla classe esterna. Se l'accesso è necessario, utilizzare WeakReference per il riferimento esplicito.
class MyActivity : AppCompatActivity() {
// ❌ Classe interna non statica — riferimento implicito a MyActivity
inner class BadListener : SomeListener {
override fun onEvent() { /*...*/ }
}
// ✅ Classe interna statica — nessun riferimento implicito
class GoodListener(private val activityRef: WeakReference<MyActivity>) : SomeListener {
override fun onEvent() { /*...*/ }
}
}
In iOS, utilizzare le liste di cattura: [weak self] nelle closure che possono sopravvivere al loro creatore. Per i delegati, utilizzare riferimenti deboli (weak var delegate). Per le closure che sono garantite essere chiamate solo durante la vita di self, è possibile utilizzare [unowned self], ma con cautela — l'accesso a un oggetto deallocato causerà un crash.
Domande frequenti
In Android, eseguire diverse transizioni tra schermate (Activity A → B → A → B) e verificare adb shell dumpsys meminfo package_name. Se il Total PSS cresce costantemente e non torna al valore originale — c'è una perdita. In iOS, analogamente: utilizzare Debug Memory Graph in Xcode per l'ispezione visiva.
Sì, se il CoroutineScope non viene annullato alla distruzione del componente. Una coroutine avviata in GlobalScope continua a eseguire anche dopo finish() dell'Activity. Soluzione: utilizzare viewModelScope (annullato in onCleared) o lifecycleScope (annullato in onDestroy). Per scope personalizzati, creare scope lifecycle-aware tramite LifecycleOwner.
Bitmap memorizza i dati dei pixel nell'heap nativo, non nell'heap Java. Ciò significa che il GC Java non vede la dimensione reale del Bitmap. Se Bitmap non viene riciclato con recycle() o il riferimento non viene annullato, la memoria nativa non viene liberata. Utilizzare BitmapFactory con inSampleSize per caricare copie ridotte e Glide/Coil per la gestione automatica della cache.
Un campo statico è un GC Root. Vive finché la classe è caricata (in Android — finché il Process è vivo). Se un campo statico referenzia un'Activity, Bitmap, View o qualsiasi altro oggetto pesante, quell'oggetto non verrà mai raccolto dal GC. Un campo statico è un riferimento eterno. Soluzione: memorizzare solo WeakReference o annullare il campo statico in onDestroy.
ARC rilascia automaticamente gli oggetti quando il contatore dei riferimenti forti scende a zero. Un retain cycle è l'unico modo per avere una perdita con ARC. Utilizzare sempre weak per i riferimenti genitore→figlio dove il figlio può sopravvivere al genitore (delegati, data source). Per le closure, utilizzare la lista di cattura [weak self] e verificare che self non sia nil all'interno della closure.
Riepilogo
Svilupperemo un'applicazione mobile chiavi in mano
IT Sectr crea applicazioni iOS e Android per startup e aziende dal 2017. Ti consulteremo e ti proporremo la soluzione migliore.
Leggi anche