En plus de la gestion des threads, l’API de Concurrence inclut des interfaces et des classes qui vous aident à coordonner l’accès aux collections partagées par plusieurs tâches. Par collections, nous faisons bien sûr référence au Framework de Collections Java que nous avons présenté dans le Chapitre 9, “Collections et Génériques.” Dans cette section, nous présentons de nombreuses classes concurrentes disponibles lorsque vous utilisez l’API de Concurrence.
Comprendre les Erreurs de Cohérence Mémoire
Le but des classes de collections concurrentes est de résoudre les erreurs courantes de cohérence mémoire. Une erreur de cohérence mémoire se produit lorsque deux threads ont des vues incohérentes de ce qui devrait être les mêmes données. Conceptuellement, nous voulons que les écritures sur un thread soient disponibles pour un autre thread s’il accède à la collection concurrente après que l’écriture a eu lieu.
Lorsque deux threads essaient de modifier la même collection non concurrente, la JVM peut lancer une ConcurrentModificationException
à l’exécution. En fait, cela peut se produire même avec un seul thread. Regardez l’extrait de code suivant :
var donneesNourriture = new HashMap<String, Integer>();
donneesNourriture.put("pingouin", 1);
donneesNourriture.put("flamant", 2);
for(String cle: donneesNourriture.keySet())
donneesNourriture.remove(cle);
Cet extrait lancera une ConcurrentModificationException
pendant la deuxième itération de la boucle, car l’itérateur sur keySet()
n’est pas correctement mis à jour après la suppression du premier élément. Changer la première ligne pour utiliser un ConcurrentHashMap
empêchera le code de lancer une exception à l’exécution.
var donneesNourriture = new ConcurrentHashMap<String, Integer>();
Bien que nous ne modifions généralement pas une variable de boucle, cet exemple met en évidence le fait que le ConcurrentHashMap
ordonne l’accès en lecture/écriture de telle sorte que tous les accès à la classe sont cohérents. Dans cet extrait de code, l’itérateur créé par keySet()
est mis à jour dès qu’un objet est supprimé de la Map.
Les classes concurrentes ont été créées pour aider à éviter les problèmes courants où plusieurs threads ajoutent et suppriment des objets des mêmes collections. À tout moment, tous les threads doivent avoir la même vue cohérente de la structure de la collection.
Travailler avec des Classes Concurrentes
Vous devriez utiliser une classe de collection concurrente chaque fois que vous avez plusieurs threads qui modifient une collection en dehors d’un bloc ou d’une méthode synchronisée, même si vous ne vous attendez pas à un problème de concurrence. Sans les collections concurrentes, plusieurs threads accédant à une collection pourraient entraîner le lancement d’une exception ou, pire, corrompre les données !
Si la collection est immuable (et contient des objets immuables), les collections concurrentes ne sont pas nécessaires. Les objets immuables peuvent être accédés par n’importe quel nombre de threads et ne nécessitent pas de synchronisation. Par définition, ils ne changent pas, donc il n’y a aucun risque d’erreur de cohérence mémoire.
Lors de la transmission d’une collection concurrente, un appelant peut avoir besoin de connaître la classe d’implémentation particulière. Cela dit, il est considéré comme une bonne pratique de transmettre une référence d’interface non concurrente lorsque c’est possible, similaire à la façon dont nous instancions un HashMap
mais transmettons souvent une référence Map
:
Map<String,Integer> map = new ConcurrentHashMap<>();
Le tableau 13.9 liste les classes concurrentes courantes avec lesquelles vous devriez vous familiariser.
Nom de classe | Interfaces Java Collections | Trié? | Bloquant? |
---|---|---|---|
ConcurrentHashMap | Map ConcurrentMap | Non | Non |
ConcurrentLinkedQueue | Queue | Non | Non |
ConcurrentSkipListMap | Map SortedMap NavigableMap ConcurrentMap ConcurrentNavigableMap | Oui | Non |
ConcurrentSkipListSet | Set SortedSet NavigableSet | Oui | Non |
CopyOnWriteArrayList | List | Non | Non |
CopyOnWriteArraySet | Set | Non | Non |
LinkedBlockingQueue | Queue BlockingQueue | Non | Oui |
La plupart des classes du tableau 13.9 sont simplement des versions concurrentes de leurs classes homologues non concurrentes, comme ConcurrentHashMap
vs. Map
, ou ConcurrentLinkedQueue
vs. Queue
. Vous avez juste besoin de connaître les méthodes héritées, comme get()
et set()
pour les instances de List
.
Les classes Skip peuvent sembler étranges, mais ce sont simplement des versions “triées” des collections concurrentes associées. Quand vous voyez une classe avec Skip dans le nom, pensez simplement “collections concurrentes triées”, et le reste devrait suivre naturellement.
Les classes CopyOnWrite
se comportent un peu différemment que les autres exemples concurrents que vous avez vus. Ces classes créent une copie de la collection chaque fois qu’une référence est ajoutée, supprimée ou modifiée dans la collection, puis mettent à jour la référence de collection originale pour pointer vers la copie. Ces classes sont couramment utilisées pour s’assurer qu’un itérateur ne voit pas les modifications apportées à la collection.
Voyons comment cela fonctionne avec un exemple :
List<Integer> nombresPrefs = new CopyOnWriteArrayList<>(List.of(4, 3, 42));
for (var n : nombresPrefs) {
System.out.print(n + " "); // 4 3 42
nombresPrefs.add(n+1);
}
System.out.println();
System.out.println("Taille: " + nombresPrefs.size()); // Taille: 6
Malgré l’ajout d’éléments, l’itérateur n’est pas modifié, et la boucle s’exécute exactement trois fois. Alternativement, si nous avions utilisé un objet ArrayList
normal, une ConcurrentModificationException
aurait été lancée à l’exécution. Les classes CopyOnWrite
peuvent utiliser beaucoup de mémoire, car une nouvelle structure de collection est créée chaque fois que la collection est modifiée. Par conséquent, elles sont couramment utilisées dans des environnements multithread où les lectures sont beaucoup plus courantes que les écritures.
Une instance CopyOnWrite
est similaire à un objet immuable, car une nouvelle structure sous-jacente est créée chaque fois que la collection est modifiée. Contrairement à un véritable objet immuable, cependant, la référence à l’objet reste la même même si les données sous-jacentes sont modifiées.
Enfin, le tableau 13.9 inclut LinkedBlockingQueue
, qui implémente l’interface concurrente BlockingQueue
. Cette classe est comme une Queue
normale, sauf qu’elle inclut des versions surchargées de offer()
et poll()
qui prennent un délai d’attente. Ces méthodes attendent (ou bloquent) jusqu’à un temps spécifique pour terminer une opération.
Obtenir des Collections Synchronisées
Outre les classes de collection concurrentes que nous avons couvertes, l’API de Concurrence inclut également des méthodes pour obtenir des versions synchronisées d’objets de collection non concurrents existants. Ces méthodes synchronisées sont définies dans la classe Collections
. Elles opèrent sur la collection entrée et renvoient une référence qui est du même type que la collection sous-jacente. Nous listons ces méthodes statiques dans le tableau 13.10.
synchronizedCollection(Collection<T> c) |
synchronizedList(List<T> list) |
synchronizedMap(Map<K,V> m) |
synchronizedNavigableMap(NavigableMap<K,V> m) |
synchronizedNavigableSet(NavigableSet<T> s) |
synchronizedSet(Set<T> s) |
synchronizedSortedMap(SortedMap<K,V> m) |
synchronizedSortedSet(SortedSet<T> s) |
Si vous écrivez du code pour créer une collection et qu’elle nécessite une synchronisation, vous devriez utiliser les classes définies dans le tableau 13.9. D’autre part, si on vous passe une collection non concurrente et que vous avez besoin de synchronisation, utilisez les méthodes du tableau 13.10.