4 pezzi facili sulle WeakHashMap

In questo post parliamo della classe WeakHashMap, quali siano le sue caratteristiche e quali siano le particolarità a cui fare attenzione quando si utilizzano. La definizione di questo tipo di classe è molto compatta: una WeakHashMap è una HashMap dove le chiavi sono WeakReference. Se si conoscono già i weak reference (le HashMap sono spiegate di Manuele) non c’è molto da aggiungere. Ma per coloro che sentissero per la prima volta parlare di questo tipo di reference è meglio continuare a leggere, con gli altri ci si rivede al secondo punto.

Forte vs debole

Variabili locali, parametri di metodo, variabili membro sono i reference utilizzati abitualmente nella scrittura del codice. Fintanto che nella memoria della JVM c’è almeno un reference ad un oggetto, questo viene mantenuto in memoria. Quando non c’è più nessun reference che punta all’oggetto questo viene scaricato dal Garbage Collector (GC) senza che il programmatore se ne occupi esplicitamente. Questi reference, che poi sono quelli “normali”, vengono definiti strong; questo è il funzionamento standard di Java che tutti conosciamo.

Un reference è invece di tipo weak, quando il Garbage Collector può andare a recuperare la memoria dell’oggetto anche quando il weak reference è ancora visibile al thread purché non esista alcun strong reference. In pratica, con un weak reference, un oggetto è allocato in memoria con una minore resistenza al passaggio del GC e, se non ci sono legami più forti, viene liberata la sua memoria, anche se l’istanza di weak reference è ancora visibile al thread. L’esempio qui di seguito dimostra questo concetto.

Long strong = new Long(10);

WeakReference weak = new WeakReference(strong);
System.out.println("Inizializzo le variabili");
System.out.println("Strong reference è " + strong);
System.out.println("Weak reference è " + weak.get());

System.gc();
System.out.println("Primo passaggio del Garbage Collector");
System.out.println("Strong reference è " + strong);
System.out.println("Weak reference è " + weak.get());
// ora rimuovo SOLO il reference strong
strong = null;
System.gc();
System.out.println("Secondo passaggio del Garbage Collector");
System.out.println("Strong reference è " + strong);
System.out.println("Weak reference è " + weak.get());

Il risultato è il seguente:

Inizializzo le variabili
Strong reference è 10
Weak reference è 10
Primo passaggio del Garbage Collector
Strong reference è 10
Weak reference è 10
Secondo passaggio del Garbage Collector
Strong reference è null
Weak reference è null

Anche se il weak reference non è stato modificato dopo l’inizializzazione, l’oggetto da lui referenziato è stato disallocato dalla memoria. Di conseguenza, weak.get() restituisce null. Una nota importante: a meno di chiamate esplicite al GC, un weak reference può restituire o meno il reference all’oggetto in maniera non deterministica. Ci si potrebbe chiedere, a questo punto, se esistono altri tipi di reference. La risposta è positiva ma va oltre lo scopo di questo post e sarà approfondita in un articolo successivo.

Quando debole è meglio di forte

Utilizzare una mappa per tenere da parte informazioni su di un oggetto è pratica comune. Immaginiamo di avere in memoria un oggetto su cui abbiamo applicato un algoritmo particolarmente costoso. Vogliamo mettere da parte le informazioni ottenute senza ripetere l’esecuzione.

Map messaggi = new HashMap<>();

Messaggio criptato = new Messaggio("pic", "poic", "!adnaihg al anoub");
Messaggio inChiaro = SuperAlgoritmoDecodifica.decodifica(criptato);
// zzz zzz molti minuti dopo..
messaggi.put(criptato, inChiaro);

// più avanti nel codice
System.out.println("Spie dicono: " + messaggi.get(criptato));

// ancora avanti nel codice
System.out.println("Scusa, cosa dicevano? "  + messaggi.get(criptato));

Senza andare a ricalcolare ogni volta la stringa in chiaro, possiamo mettere da parte la decodifica e riutilizzarla quando serve, la mappa è perfetta per questo scopo.

C’è una caratteristica da tenere presente: tutte le cose messe nella mappa restano lì fino a che qualcuno (cioè noi) non le toglie, anche se i reference iniziali vengono “azzerati” o escono fuori dalla visibilità. Se poi la mappa è istanziata in maniera tale da non essere mai deallocata dal GC durante l’esecuzione, tutti gli oggetti messi nella mappa restano allocati fin tanto che l’applicazione non termina. Le righe successive chiariscono queste affermazioni.

// veramente avanti nel codice, qui simuliamo l'uscita dallo scope
criptato = null;
System.gc();

// oh no! il messaggio criptato è ancora in giro
// (anche quello in chiaro a dire il vero)
for (Messaggio m : messaggi.keySet())
        System.out.println(m);

Non aspettiamoci quindi una diminuzione di memoria occupata quando facciamo girare il Garbage Collector, perché i due oggetti Messaggio sono ancora là. Ora, se lo scopo del gioco è quello di tenere quell’informazione per tutta la vita dell’applicazione (e se abbiamo abbastanza memoria per farlo), non ci sono problemi. Ma, se vogliamo minimizzare l’occupazione di memoria ed essere sicuri di scaricare il messaggio in chiaro quando quello criptato non ci serve più, ci viene in aiuto la WeakHashMap. In realtà, l’unica alternativa che abbiamo è quella di fare da soli le veci del GC, capendo quando il messaggio in chiaro diventa inutile e rimuovendolo esplicitamente dalla mappa.

Quindi, per ottenere questa pulizia “automatica” della mappa basta sostituire HashMap con WeakHashMap: tolto di mezzo il messaggio criptato (e qualsiasi altro strong reference a questo oggetto), la mappa ritorna vuota. Di seguito lo stesso codice di prima, ma utilizzando la WeakHashMap.

Map messaggi = new WeakHashMap<>();

Messaggio criptato = new Messaggio("pic", "poic", "!adnaihg al anoub");
Messaggio inChiaro = SuperAlgoritmoDecodifica.decodifica(criptato);
// zzz zzz molti minuti dopo..
messaggi.put(criptato, inChiaro);

// più avanti nel codice
System.out.println("Spie dicono: " + messaggi.get(criptato));

// ancora avanti nel codice
System.out.println("Scusa, cosa dicevano? " + messaggi.get(criptato));

// veramente avanti nel codice, qui simuliamo l'uscita dallo scope
criptato = null;
System.gc();

// le due righe successive non stamperanno nulla perchè la mappa è vuota
for (Messaggio m : messaggi.keySet())
        System.out.println(m);

Come è possibile questo comportamento? Tornando alla sua definizione, la WeakHashMap non memorizza uno strong reference all’oggetto che passiamo come chiave ma un WeakReference. Quindi, nel momento in cui non ci sono altri strong reference alla chiave, questa viene scaricata dal GC e di conseguenza anche l’associazione chiave-valore esistente nella mappa è rimossa.

Un’insidia nascosta nelle proprie classi…

Ora che il codice funziona come ci aspettiamo, estendiamo la classe Messaggio e, senza troppe esitazioni, la arricchiamo con il metodo setMessaggioOriginale che ci permette di associare il messaggio criptato a quello in chiaro:

public void setMessaggioOriginale(Messaggio m) {
     originale = m;
}
// zzz zzz molti minuti dopo..
inChiaro.setMessaggioOriginale(criptato);
messaggi.put(criptato, inChiaro);

Improvvisamente, la mappa debole pare non funzionare a dovere e non viene più ripulita dal Garbage Collector. Cosa è successo? Abbiamo aggiunto all’oggetto valore della mappa, uno strong reference all’oggetto chiave. In pratica, il GC non può scaricare il messaggio criptato (che è weak reference nella mappa), perché il messaggio in chiaro (che è valore) contiene uno strong reference alla chiave.

Fortunatamente, esiste una semplice soluzione a questo tipo di problema. Al posto di inserire direttamente l’oggetto valore, inseriamo un suo weak reference. Quando il Garbage Collector è in azione, se non esiste uno strong reference all’oggetto valore, questo viene deallocato, provocando in successione lo scaricamento della chiave; in pratica si ripristina il comportamento della mappa prima dell’aggiunta del nuovo metodo. Il codice è il seguente.

Map> messaggi = new WeakHashMap<>();

Messaggio criptato = new Messaggio("pic", "poic", "!adnaihg al anoub");
Messaggio inChiaro = SuperAlgoritmoDecodifica.decodifica(criptato);
// zzz zzz molti minuti dopo..
inChiaro.setMessaggioOriginale(criptato);
messaggi.put(criptato, new WeakReference(inChiaro));

// più avanti nel codice
// quello che estraggo dalla mappa non è più un
// oggetto Messaggio ma un WeakReference ad esso
System.out.println("Spie dicono: " + messaggi.get(criptato).get());

// ancora avanti nel codice
// quello che estraggo dalla mappa non è più un
// oggetto Messaggio ma un WeakReference ad esso
System.out.println("Scusa, cosa dicevano? " + messaggi.get(criptato).get());

// veramente avanti nel codice, qui simuliamo l'uscita dallo scope
// per *entrambi* i messaggi
criptato = null;
inChiaro = null;
System.gc();

// nuovamente, le due righe successive non stamperanno
// nulla perchè la mappa è vuota
for (Messaggio m : messaggi.keySet())
        System.out.println(m);

Conviene applicare la soluzione proposta praticamente di default se la struttura dell’oggetto valore è molto complessa. Non è necessario che l’oggetto valore abbia uno strong reference diretto all’oggetto chiave, questo potrebbe essere “nascosto” in strutture più complicate. Inoltre, potrebbe accadere che l’oggetto valore abbia un reference alla chiave di un’altra coppia chiave-valore, rendendo la matassa ancora più intricata.

..e una in quelle di Java

Questa volta partiamo dal codice e utilizziamo solo classi base di Java.

String primaDomanda = "La risposta alla domanda fondamentale";
String secondaDomanda = new String("Quanti territori ha il Risiko?");
String terzaDomanda = new String("Quanti chilometri ha una maratona?").intern();

Map map = new WeakHashMap<>();
map.put(primaDomanda, Long.valueOf(42));
map.put(secondaDomanda, Long.valueOf(42));
map.put(terzaDomanda, Long.valueOf(42));

primaDomanda = null;
secondaDomanda = null;
terzaDomanda = null;

System.gc();

for (String k : map.keySet())
        System.out.printf("%s %d\n", k, map.get(k));

Ora che siamo confidenti con l’argomento, ci si attende che il ciclo non stampi nulla. Invece…

Quanti chilometri ha una maratona? 42
La risposta alla domanda fondamentale 42

Qui ci diremo solo l’essenziale perchè l’argomento meriterebbe un post a parte ed è sempre fonte di confusione.

La differenza tra la prima stringa e la seconda è che la prima è di tipo letterale quindi, al pari di altre stringhe che sono risultato di espressioni costanti, è internizzata. Questo vuol dire che viene tenuta da parte una sola istanza di quella stringa e condivisa con le altre stringhe internizzate che hanno lo stesso valore.

La seconda stringa invece è costruita a runtime e non è il risultato di espressioni costanti, quindi non viene internizzata.

L’effetto finale è che, dopo le assegnazioni a null, la prima stringa ha ancora uno strong reference nell’area di memoria dove viene internizzata, la seconda no. La WeakHashMap si comporta al solito modo, scaricando la seconda chiave (perché la seconda stringa ha solo il weak reference della chiave) e mantenendo la prima. Per quanto riguarda la terza stringa ha le stesse caratteristiche della seconda, ma poi, chiamando il metodo intern viene internizzata, diventando equivalente ad una stringa letterale. In conclusione, utilizzare stringhe internizzate (letterali o meno), vanifica l’effetto della WeakHashMap.

Conclusioni

In questo post abbiamo visto come funzionano le WeakHashMap, con attenzione alla differenza tra strong e weak reference, quali siano i vantaggi nell’utilizzo di questo tipo di mappa e quali piccole insidie possano nascondere. Per motivi di spazio non sono stati approfonditi diversi argomenti interessanti e che potranno essere oggetto di futuri post, tra cui l’impiego delle WeakHashMap con il flyweight pattern, i diversi tipi di reference di Java, la gestione della memoria per le stringhe.

Per approfondire

Giampaolo Trapasso

Sono laureato in Informatica e attualmente lavoro come Software Engineer in Radicalbit. Mi diverto a programmare usando Java e Scala, Akka, RxJava e Cassandra. Qui mio modesto contributo su StackOverflow e il mio account su GitHub