Comprendre ConcurrentModificationException malgré la synchronisation de Listes en Java

Il est fréquent de rencontrer la ConcurrentModificationException en Java, même lorsque des mécanismes de synchronisation comme synchronized ou Collections.synchronizedList() sont mis en œuvre pour des opérations sur une List. Cette exception peut déconcerter, car elle suggère un problème de concurrence que la synchronisation devrait justement prévenir. Cependant, la cause n'est pas toujours un manque de synchronisation des accès concurrents, mais plutôt une caractéristique de conception des itérateurs de certaines implémentations de listes, notamment ArrayList.

Le Scénario Problématique

Considérons le code Java suivant, qui simule deux threads tentant d'ajouter des éléments à une liste synchronisée. Un thread ajoute simplement un élément, tandis que l'autre itère sur la liste et tente d'ajouter un élément pour chaque élément existant.

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList; // Présent mais non utilisé initialement

public class GestionListeConcurrente {
    // Utilisation d'une liste synchronisée fournie par Collections
    private static List<String> listePartagee = Collections.synchronizedList(new ArrayList<String>());

    public static void main(String[] args) {
        // Thread 1: Ajoute continuellement des éléments
        new Thread(() -> {
            while (true) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    e.printStackTrace();
                }
                synchronized (listePartagee) { // Synchronise l'accès à la liste
                    listePartagee.add("Element-A");
                }
                System.out.println("Thread 1: Ajouté 'Element-A'");
            }
        }).start();

        // Thread 2: Itère et ajoute des éléments
        new Thread(() -> {
            while (true) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    e.printStackTrace();
                }
                synchronized (listePartagee) { // Synchronise l'accès à la liste
                    for (String item : listePartagee) { // Itération sur la liste
                        listePartagee.add("Element-B"); // Ajout pendant l'itération
                    }
                }
                System.out.println("Thread 2: Opération terminée");
            }
        }).start();
    }
}

L'exécution de ce code produit une ConcurrentModificationException, comme le montre la sortie partielle ci-dessous :

Thread 1: Ajouté 'Element-A'
Exception in thread "Thread-1" java.util.ConcurrentModificationException
    at java.util.AbstractList$Itr.checkForComodification(AbstractList.java:372)
    at java.util.AbstractList$Itr.next(AbstractList.java:343)
    at GestionListeConcurrente.lambda$main$1(GestionListeConcurrente.java:31)
    at GestionListeConcurrente$$Lambda$2/1785233621.run(Unknown Source)
    at java.lang.Thread.run(Thread.java:748)
Thread 1: Ajouté 'Element-A'
Thread 1: Ajouté 'Element-A'
...

Malgré l'utilisation du bloc synchronized (listePartagee) autour des opérations d'ajout et d'itération, l'exception est levée. Cela indique que le problème n'est pas lié à un accès non synchronisé à la ressource partagée par différents threads, mais à une modification de la structure de la liste par le *même thread* pendant qu'il l'itère.

L'Explication : Le Comportement "Fail-Fast"

Pour comprendre pourquoi cette exception se produit, examinons un exemple similaire mais mono-thread. Le même problème survient, prouvant que ce n'est pas un problème de synchronisation entre threads, mais de modification de la liste pendant son itération par un même thread.

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class TestModificationListe {
    private static List<String> maListe = Collections.synchronizedList(new ArrayList<String>());

    public static void main(String[] args) {
        maListe.add("Item Alpha");
        maListe.add("Item Beta");

        // Itération et modification dans le même bloc de code
        for (String element : maListe) {
            System.out.println("Actuellement: " + element);
            maListe.add("Item Gamma"); // Modification de la liste pendant l'itération
            System.out.println("Ajouté 'Item Gamma'");
        }
    }
}

Cette version mono-thread produit une sortie similaire :

Actuellement: Item Alpha
Ajouté 'Item Gamma'
Exception in thread "main" java.util.ConcurrentModificationException
    at java.util.AbstractList$Itr.checkForComodification(AbstractList.java:372)
    at java.util.AbstractList$Itr.next(AbstractList.java:343)
    at TestModificationListe.main(TestModificationListe.java:14)

L'exception est levée lors de la deuxième tentative d'itération (appel à next()). Ceci est dû au mécanisme "fail-fast" (échec rapide) des itérateurs des collections comme ArrayList. Ces itérateurs sont conçus pour détecter toute modification structurelle de la liste sous-jacente (ajout, suppression, etc.) qui n'a pas été effectuée via l'itérateur lui-même. Si une telle modification est détectée, ils lancent immédiatement une ConcurrentModificationException.

Comment ça marche ?

La classe AbstractList (dont ArrayList hérite) utilise un champ nommé modCount. Ce compteur est incrémenté chaque fois que la structure de la liste est modifiée (par exemple, lors d'un appel à add(), remove(), etc.).

Lorsqu'un itérateur est créé pour une liste, il stocke une copie de la valeur actuelle de modCount dans un champ interne appelé expectedModCount. À chaque appel de la méthode next() de l'itérateur, une vérification est effectuée : la valeur actuelle de modCount de la liste est comparée à expectedModCount. Si elles sont différentes, cela signifie qu'une modification structurelle a eu lieu depuis la création de l'itérateur, et une ConcurrentModificationException est levée.

Extraits simplifiés du code source d'ArrayList et de son itérateur :

// Dans ArrayList (ou AbstractList)
protected transient int modCount = 0; // Compteur de modifications

public boolean add(E e) {
    // ...
    ensureCapacity(size + 1); // Peut augmenter la capacité, incrémente modCount
    elementData[size++] = e;
    return true;
}

// ensureCapacity() est une méthode interne qui peut appeler modCount++
public void ensureCapacity(int minCapacity) {
    modCount++; // Incrémentation cruciale ici !
    // ...
}

// Classe interne de l'itérateur (Itr)
private class Itr implements Iterator<E> {
    int expectedModCount = modCount; // Capture la valeur de modCount à la création de l'itérateur

    public E next() {
        checkForComodification(); // Vérification avant de passer à l'élément suivant
        // ... Logique pour récupérer l'élément ...
    }

    final void checkForComodification() {
        if (modCount != expectedModCount) { // Si modCount a changé
            throw new ConcurrentModificationException(); // Lève l'exception
        }
    }
}

Dans notre exemple, après la première itération où "Item Alpha" est affiché, l'appel à maListe.add("Item Gamma") incrémente modCount. Lors de la tentative d'avancer l'itérateur au prochain élément (la deuxième itération), checkForComodification() compare modCount (modifié) et expectedModCount (inchangé), détecte la différence et lève l'exception.

La Solution : CopyOnWriteArrayList

Si la modification d'une liste pendant son itération est un comportement souhaité, la classe CopyOnWriteArrayList du package java.util.concurrent est la solution appropriée. Cette liste est conçue pour être thread-safe et pour gérer les modifications de manière à ne pas perturber les itérations en cours.

Le principe de CopyOnWriteArrayList est simple : toutes les opérations de modification (add, set, remove, etc.) créent une *nouvelle* copie du tableau sous-jacent. Les itérateurs opèrent sur une capture instantanée (snapshot) du tableau existant au moment de leur création. Ainsi, un itérateur ne voit jamais les modifications effectuées après sa création, évitant ainsi la ConcurrentModificationException.

import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

public class GestionListeAvecCopyOnWrite {
    // Utilisation de CopyOnWriteArrayList
    private static List<String> listeConcurrente = new CopyOnWriteArrayList<String>();

    public static void main(String[] args) {
        listeConcurrente.add("Item Un");
        listeConcurrente.add("Item Deux");

        // Itération et modification avec CopyOnWriteArrayList
        for (String element : listeConcurrente) {
            System.out.println("Élément actuel: " + element);
            listeConcurrente.add("Item Trois"); // Cette modification n'affectera pas l'itérateur actuel
            System.out.println("Ajouté 'Item Trois' via la liste");
        }
        System.out.println("--- Itération terminée ---");
        System.out.println("Contenu final de la liste: " + listeConcurrente);
    }
}

Cette fois, l'exécution se déroule sans exception :

Élément actuel: Item Un
Ajouté 'Item Trois' via la liste
Élément actuel: Item Deux
Ajouté 'Item Trois' via la liste
--- Itération terminée ---
Contenu final de la liste: [Item Un, Item Deux, Item Trois, Item Trois]

Comme on peut le voir, l'itération a continué sans problème. Les éléments ajoutés pendant l'itération sont visibles dans la liste après l'itération, mais pas par l'itérateur qui était actif au moment de l'ajout. C'est le comportement attendu de CopyOnWriteArrayList.

Limitations de CopyOnWriteArrayList

Bien que CopyOnWriteArrayList soit une solution efficace pour ce problème, elle a des coûts :

  • Coût des écritures : Chaque modification crée une nouvelle copie du tableau sous-jacent, ce qui peut être coûteux en termes de performances et de mémoire pour de très grandes listes avec de fréquentes écritures.
  • Cohérence : Les itérateurs ne reflètent pas les modifications récentes. Ils offrent une vue cohérente du moment de leur création.

Dans les scénarios où CopyOnWriteArrayList est trop coûteuse, une alternative est de collecter les modificatoins (éléments à ajouter ou supprimer) dans une liste temporaire pendant l'itération, puis d'appliquer ces modifications à la liste principale une fois l'itération terminée. Cette approche garantit la cohérence tout en évitant les surcoûts de copie pour chaque écriture.

Étiquettes: Java Concurrency ConcurrentModificationException ArrayList CopyOnWriteArrayList

Publié le 14 juillet à 17h19