Java Collection non sincronizzate e contesti multi-thread

Java Library & Framework, Java Tech, Java World, Tips&Tricks 0 375
In un’applicazione Java, sia essa una webapp piuttosto che una standalone (multi-thread), dobbiamo stare molto attenti agli aspetti legati alla concorrenza, in particolar modo all’accesso concorrente a singole istanze di oggetti da più thread.

Nel seguente articolo, prendiamo in esame in particolare l’accesso concorrente ad oggetti Collection.

Nel caso si fosse sicuri di creare una nuova istanza dell’oggetto Collection su ognuno dei thread in esecuzione contemporaneamente, ci si troverebbe già in un contesto thread-safe.

Vediamo il seguente caso, in cui abbiamo la classe MyCache {v1} (vedi codice che segue) definita come “singleton” (quindi come singola istanza in tutta l’applicazione) all’interno ad esempio di una webapp Java:

import java.util.HashMap;
import java.util.Map;

public class MyCache {

    //singleton MyCache
    private static final MyCache _singleton = new MyCache();

    private final Map<String, String> _map;

    private MyCache(){
        this._map = new HashMap<String, String>();
    }

    public String get(String key){
        return _map.get(key);
    }
    public void put(String key, String value){
        _map.put(key, value);
    }

    //obtaining singleton MyCache
    public static MyCache getInstance(){
        return _singleton;
    }

}

Sappiamo che in Java esistono due tipi di Collection:

  1. Collection “thread-safe” (sincronizzate);
  2. Collection “non thread-safe” (non sincronizzate).

Nella classe MyCache di cui sopra, di tipo “singleton”, viene utilizzata una classe HashMap, che è di tipo “non thread-safe”.

Se la classe MyCache fosse utilizzata in un contesto single-thread, ciò non comporterebbe alcun rischio o problema. Ma se invece la classe venisse utilizzata in un contesto multi-thread, ciò potrebbe portare a dei malfunzionamenti.

Scenario 1

In caso di accesso concorrente di più thread, la classe MyCache {v1} andrebbe modificata nel modo seguente:

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

public class MyCache {

    //singleton MyCache
    private static final MyCache _singleton = new MyCache();

    private final Map<String, String> _map;

    private MyCache(){
        this._map = Collections.synchronizedMap(new HashMap<String, String>());
    }

    public String get(String key){
        return _map.get(key);
    }
    public void put(String key, String value){
        _map.put(key, value);
    }

    //obtaining singleton MyCache
    public static MyCache getInstance(){
        return _singleton;
    }

}

In questo modo la classe HashMap utilizzata dalla classe MyCache è thread-safe. Si noti che per come è costruita la classe MyCache, la classe HashMap, e di conseguenza la Map restituita dal metodo “Collections.synchronizedMap(…)”, rimane privata, mentre i client accedono ai soli metodi pubblici di istanza di MyCache, e cioè “get” e “set”.

Scenario 2

Una soluzione alternativa è rappresentata dalla classe ConcurrentHashMap (introdotta a partire da Java 1.5), che diventa la soluzione migliore nel seguente scenario:

  1. si opera un un contesto in cui la scalabilità è un fattore importante;
  2. le operazioni di lettura sono molto più frequenti rispetto a quelle di scrittura.

A queste condizioni, l’utilizzo della classe ConcurrentHashMap è più efficiente e allo stesso tempo è thread-safe.

La classe MyCache {v3} andrebbe scritta nel modo seguente:

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class MyCache {

    //singleton MyCache
    private static final MyCache _singleton = new MyCache();

    private final Map<String, String> _map;

    private MyCache(){
        this._map = new ConcurrentHashMap<String, String>();
    }

    public String get(String key){
        return _map.get(key);
    }
    public void put(String key, String value){
        _map.put(key, value);
    }

    //obtaining singleton MyCache
    public static MyCache getInstance(){
        return _singleton;
    }

}

In entrambi gli scenari (Scenario 1 MyCache {v2} e Scenario 2 MyCache {v3}) rimane il vincolo che nel caso si dovessero/volessero  utilizzare le “collection interne” alla Map (cosa che al momento la classe MyCache non fa), sarebbe necessario “garantire” una sincronizzazione manuale.

Ecco cosa recita la documentazione Java: (Java 1.6 apidoc ConcurrentHashMap)

However, iterators are designed to be used by only one thread at a time.

A seguire l’esempio citato nella documentazione Java:

It is imperative that the user manually synchronize on the returned map when iterating over any of its collection views:

Map m = Collections.synchronizedMap(new HashMap());

Set s = m.keySet(); // Needn't be in synchronized block

synchronized(m) { // Synchronizing on m, not s!
     Iterator i = s.iterator(); // Must be in synchronized block
     while (i.hasNext())
          foo(i.next());
     }
}

Failure to follow this advice may result in non-deterministic behavior.

Per confermare, se mai ce ne fosse bisogno, che l’aspetto della concorrenza sulle Collection può diventare “critico”, e riservare brutte sorprese se non si seguono tutte le regole, vorrei citarvi un caso reale, avvenuto in un contesto di accesso “concorrente” a una Map, più precisamente ad una LinkedHashMap (di tipo access-ordered).

La classe MyCache {v4} sarebbe così fatta:

import java.util.LinkedHashMap;
import java.util.Map;

public class MyCache {

    //singleton MyCache
    private static final MyCache _singleton = new MyCache();
    private static final int MAX_ENTRIES = 200;

    private final Map<String, String> _map;

    private MyCache(){
        this._map = new LinkedHashMap<String, String>(100, 0.75F, true){
            //true in constructor stands for access-ordered linked hash map
            @Override
            protected boolean removeEldestEntry(Map.Entry<String, String> entry) {
                return size() > MAX_ENTRIES;
            }

        };
    }

    public String get(String key){
        return _map.get(key);
    }
    public void put(String key, String value){
        _map.put(key, value);
    }

    //obtaining singleton MyCache
    public static MyCache getInstance(){
        return _singleton;
    }

}

In un utilizzo concorrente della classe MyCache {v4} si è verificato, per alcuni thread in esecuzione che stavano invocando metodi della classe MyCache, una situazione di “loop infinito”.

Il thread-dump del thread in loop risultava simile al seguente (da notare lo stato RUNNABLE, che vuol dire che il thread è in esecuzione):

“http-8445-171” daemon prio=10 tid=0x00002abc904b1000 nid=0x3644 runnable [0x00002abc8e270000] java.lang.Thread.State: RUNNABLE
at java.util.HashMap.getEntry(HashMap.java:349)
at java.util.LinkedHashMap.get(LinkedHashMap.java:280)
at MyCache.get(MyCache.java:24)

Quindi il thread risultava “in esecuzione” sul metodo getEntry della classe Java HashMap.

Nella versione 1.6 di Java (1.6.0_45) il corpo del metodo “getEntry” della classe HashMap è così definito:

    final Entry<K,V> getEntry(Object key) {
        int hash = (key == null) ? 0 : hash(key.hashCode());
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }
approfondimento
Sappiamo che per definizione (così come recita la documentazione) anche la LinkedHashMap non è “nativamente” thread-safe. Nel nostro caso, stiamo usando una LinkedHashMap di tipo “access-ordered”, che in un certo senso complica le cose.

Vediamo nella versione 1.6 di Java (1.6.0_45) il corpo del metodo “get” della classe LinkedHashMap:

    public V get(Object key) {
        Entry<K,V> e = (Entry<K,V>)getEntry(key);
        if (e == null)
            return null;
        e.recordAccess(this);
        return e.value;
    }

La riga sulla quale vi invito a porre l’attenzione è la 283, dove viene invocato il metodo “recordAccess” sulla classe “Entry<K,V>” definita come inner class all’interno della LinkedHashMap stessa. Tale metodo è così definito:

        void recordAccess(HashMap<K,V> m) {
            LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;
            if (lm.accessOrder) {
                lm.modCount++;
                remove();
                addBefore(lm.header);
            }
        }

Si noti che nel caso di “lm.accessOrder==true” (che è il nostro caso, perchè la LinkedHashMap è di tipo access-ordered), vengono fatte delle modifiche allo stato interno della LinkedHashMap (invocazione di “remove()” e “addBefore(…)“).
Questa cosa non può che aggravare i problemi che si possono manifestare a fronte di un accesso concorrente, perchè lo stato interno della LinkedHashMap non viene modificato solo con il metodo “put”, ma anche con il “get”.

Tornando alla classe HashMap, più esattamente alla riga 349, ci si trova all’interno del loop “for” che inizia alla riga 345, la cui condizione di “permanenza” è “e != null“; di conseguenza la condizione di uscita dallo stesso è “e == null“.

A causa della concorrenza di più thread sulla stessa LinkedHashMap, in questa specifico caso, la condizione di uscita dal loop non si verifica mai, e quindi il thread in running su questa porzione di codice rimane in un loop infinito, consumando così costantemente CPU.

La soluzione è analoga a quella applicata nella classe MyCache (v2).

La versione “thread-safe” della classe MyCache {v4} che usa la LinkedHashMap è la classe classe MyCache {v5} che segue:

import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;

public class MyCache {

    //singleton MyCache
    private static final MyCache _singleton = new MyCache();
    private static final int MAX_ENTRIES = 200;

    private final Map<String, String> _map;

    private MyCache(){
        this._map = Collections.synchronizedMap(
                new LinkedHashMap<String, String>(100, 0.75F, true){
                    //true in constructor stands for access-ordered linked hash map
                    @Override
                    protected boolean removeEldestEntry(Map.Entry<String, String> entry) {
                        return size() > MAX_ENTRIES;
                    }
                }
        );
    }

    public String get(String key){
        return _map.get(key);
    }
    public void put(String key, String value){
        _map.put(key, value);
    }

    //obtaining singleton MyCache
    public static MyCache getInstance(){
        return _singleton;
    }

}

Da notare la riga 14 e 15, dove viene invocato il metodo “Collections.synchronizedMap“, al quale viene passata la nostra LinkedHashMap.

Tale metodo ci restituisce una nuova Map che “incapsula” la nostra LinkedHashMap, ma che a questo punto risulta sincronizzata.

Così facendo il problema di loop, ed eventuali altri, causati dall’uso concorrente della Map non si verificano più, e la nostra nuova Map risulta essere “thread-safe”.

Riferimenti:

About the author / 

Salvatore Di Loro

Related Posts

Leave a reply

Your email address will not be published. Required fields are marked *

Instagram

Flickr