1.0 Fondamentaux de la Concurrence et des Threads en Java

La programmation concurrente est une discipline fondamentale dans le développement d'applications modernes, en particulier pour les systèmes serveurs. Ces systèmes doivent impérativement gérer de multiples tâches simultanément pour rester réactifs et performants. Un exemple classique est un serveur FTP multi-client, qui doit pouvoir traiter les requêtes d'envoi et de réception de fichiers pour plusieurs utilisateurs en parallèle, tout en maintenant un état partagé cohérent, comme la liste des fichiers disponibles. L'architecture de ces systèmes repose sur un modèle de concurrence efficace, et pour la JVM, ce modèle est exclusivement celui des threads.

Un thread peut être défini comme un "processus léger" qui s'exécute au sein d'un processus classique, dit "lourd". Cette approche offre un modèle puissant pour la concurrence, mais elle s'accompagne de compromis architecturaux qu'il est essentiel de maîtriser.

Avantages Stratégiques Inconvénients et Risques
Efficacité des ressources CPU/Mémoire : Le partage du processeur entre les threads est beaucoup moins gourmand en ressources (mémoire, temps de commutation) que le partage entre des processus lourds. Conflits d'accès : Le partage de mémoire, bien que puissant, crée un risque de conflits d'accès lorsque plusieurs threads tentent de modifier les mêmes données simultanément.
Partage d'état implicite : Les threads s'exécutant au sein du même processus partagent implicitement le même espace mémoire, ce qui facilite grandement la communication et l'échange de données entre eux. Fragilité de l'application : Si un seul thread subit une erreur fatale et plante, il peut entraîner la chute de l'ensemble de l'application, y compris tous les autres threads.

Java privilégie de manière exclusive l'utilisation des threads pour la concurrence. En effet, il est architecturalement impossible de créer des processus lourds directement en Java. De plus, même si cela était possible, la mise en place d'un partage de mémoire efficace entre des processus distincts est une opération complexe et coûteuse. Les threads offrent donc une solution native et intégrée.

L'adoption de ce modèle nous confronte cependant à deux défis majeurs qui forment le cœur de la conception de systèmes concurrents : garantir l'exclusion mutuelle pour protéger les données partagées et orchestrer l'attente sur condition pour coordonner l'ordre d'exécution des threads.

2.0 La Problématique Centrale : La Gestion de l'État Partagé

La gestion de l'état partagé est le défi central de la programmation concurrente. Si le partage de données en mémoire est l'un des principaux avantages des threads, il est également la source des problèmes les plus complexes et des bogues les plus insaisissables : les conditions de concurrence (race conditions). Ces bogues sont souvent non déterministes et difficiles à reproduire.

La création d'un thread en Java est d'une grande simplicité. Dans l'exemple suivant, chaque thread opère sur ses propres variables locales, sans aucune donnée partagée.

// Exemple 1 : Thread avec incrémentation locale
public class ThreadInc extends Thread {
    public int id;
    public int incVal;

    public ThreadInc(int id, int incVal) {
        this.id = id;
        this.incVal = incVal;
    }

    public void run() {
        System.out.println("I am thread " + id);
        int val = id;
        for(int i=0; i<5; i++) {
            System.out.print(val + " ");
            val += incVal;
        }
        System.out.print("I die ");
    }
}

L'exécution de ce code met en évidence une caractéristique fondamentale de la concurrence : le non-déterminisme. L'ordre dans lequel les threads s'exécutent est entièrement géré par la machine virtuelle Java (JVM) et apparaît comme aléatoire pour un observateur humain. Chaque exécution du programme TestThreadInc peut produire un résultat différent.

// Résultats possibles de TestThreadInc

// Exécution 1
1 3 5 7 9 I die 2 7 12 17 22 I die

// Exécution 2
2 7 12 17 22 I die 1 3 5 7 9 I die

// Exécution 3
1 3 2 5 7 12 17 7 9 22 I die I die

La situation se complexifie radicalement lorsque nous introduisons un objet partagé. Considérons une simple Box contenant une valeur entière, partagée entre deux threads.

// L'objet partagé
public class Box {
    public int val;
    public Box() { val = 0; }
    public int get() { return val; }
    public void increment(int incVal) {
        val += incVal;
        System.out.println("after inc : " + val);
    }
}

// Le thread qui utilise l'objet partagé
public class ThreadIncShared extends Thread {
    public int id, incVal;
    public Box b; // Référence à l'objet partagé

    public ThreadIncShared(int id, int incVal, Box b) {
        this.id = id;
        this.incVal = incVal;
        this.b = b;
    }

    public void run() {
        System.out.println("I am thread " + id);
        int val;
        for(int i=0; i<5; i++) {
            val = b.get();
            System.out.println("T" + id + " - " + val);
            b.increment(incVal);
        }
        System.out.println("I die ");
    }
}

L'exécution de TestThreadIncShared révèle des comportements incohérents et erronés. La cause fondamentale de ces erreurs est que la séquence d'opérations "read-modify-write" (lire la valeur, la modifier, puis l'écrire) n'est pas atomique. Pour le développeur, la séquence val = b.get(); ... b.increment(incVal); semble être une seule opération logique. Cependant, pour le planificateur de la JVM, un thread peut être interrompu à n'importe quel moment entre ces instructions. Si le Thread 1 est interrompu après b.get() mais avant b.increment(), le Thread 2 peut s'exécuter et modifier la Box, rendant la valeur lue par le Thread 1 obsolète et conduisant à une corruption des données.

Cette interruption peut conduire à des états incohérents :

Ces phénomènes illustrent une règle fondamentale de la conception concurrente : l'accès en lecture seule à un objet partagé n'est pas problématique, mais tout accès en écriture est une source de danger potentiel pouvant mener à la corruption des données et au plantage de l'application.