Fondamentaux de la Concurrence
La distinction entre concurrence et parallélisme est curciale pour tout développeur système. La concurrence désigne la capacité d'un processeur à gérer plusieurs tâches en alternant leur exécution via des tranches de temps (time-slicing). Le parallélisme, en revanche, nécessite plusieurs cœurs de processeur pour exécuter physiquement plusieurs instructions au même instant précis.
L'utilisation du multi-threading offre des avantages significatifs, notamment une meilleure utilisation des ressources CPU et une réactivité accrue des applications. Cependant, elle introduit des complexités telles que le surcoût lié au changement de contexte (context switching), la consommation de mémoire par les piles de threads, et les risques de conditions de concurrence (race conditions).
Les trois piliers de la sécurité des threads
- Atomicité : Garantit qu'une opération ou un ensemble d'opérations s'exécute de manière indivisible.
- Visibilité : Assure que les modifications apportées par un thread à une variable partagée sont immédiatement perceptibles par les autres threads (souvent via le mot-clé
volatile). - Ordonnancement : Empêche les réorganisations d'instructions par le compilateur ou le processeur qui pourraient briser la logique métier dans un contexte multi-threadé.
Processus vs Threads
Un processus est une instance d'exécution indépendante disposant de son propre espace mémoire protégé par le système d'exploitation. Un thread est une unité d'exécution plus légère résidant à l'intérieur d'un processus. Les threads d'un même processus partagent la zone "Heap" (tas) mais possèdent chacun leur propre pile (Stack) et compteur de programme (PC).
Le Phénomène d'Interblocage (Deadlock)
Un interblocage survient lorsque deux threads ou plus sont bloqués indéfiniment, chacun attendant une ressource détenue par l'autre. Quatre conditions doivent être réunies pour qu'un deadlock se produise : l'exclusion mutuelle, la détention et l'attente, la non-préemption, et l'attente circulaire.
public class ExempleDeadlock {
private final Object verrouAlpha = new Object();
private final Object verrouBeta = new Object();
public void methodeA() {
synchronized (verrouAlpha) {
System.out.println("Thread 1 : Verrou Alpha acquis");
try { Thread.sleep(50); } catch (InterruptedException e) {}
synchronized (verrouBeta) {
System.out.println("Thread 1 : Verrou Beta acquis");
}
}
}
public void methodeB() {
synchronized (verrouBeta) {
System.out.println("Thread 2 : Verrou Beta acquis");
synchronized (verrouAlpha) {
System.out.println("Thread 2 : Verrou Alpha acquis");
}
}
}
}
Pour prévenir les deadlocks, la stratégie la plus simple consiste à toujours acquérir les verrous dans le même ordre hiérarchique ou à utiliser des mécanismes de temporisation comme tryLock().
Méthodes de création de threads en Java
Il existe quatre manières principales d'initier un thread :
- Extension de la classe
Thread. - Implémentation de l'interface
Runnable(recommandé pour la séparation des tâches). - Implémentation de
Callable(permet de retourner un résultat viaFuture). - Utilisation des pools de threads via l'API
ExecutorService.
// Exemple avec Callable et Future
Callable<Integer> calculTache = () -> {
int somme = 0;
for (int i = 0; i < 100; i++) somme += i;
return somme;
};
ExecutorService service = Executors.newSingleThreadExecutor();
Future<Integer> resultat = service.submit(calculTache);
System.out.println("Résultat du calcul : " + resultat.get());
service.shutdown();
Le mot-clé synchronized et l'optimisation JVM
En Java, synchronized permet de vrerouiller un objet ou une classe. Depuis Java 6, la JVM utilise des optimisations pour réduire le coût de ce verrouillage :
- Verrou biaisé (Biased Locking) : Optimise le cas où un seul thread accède au verrou.
- Verrou léger (Lightweight Locking) : Utilise le CAS (Compare-And-Swap) pour éviter d'impliquer le noyau de l'OS.
- Verrou lourd (Heavyweight Locking) : Utilise les mutex du système d'exploitation lorsque la compétition est forte.
Le mot-clé volatile
L'utilisation de volatile garantit que la lecture d'une variable se fait directement depuis la mémoire principale et non depuis le cache local du thread. Il interdit également la réorganisation des instructions autour de cette variable, créant une barrière mémoire. Attention : volatile ne garantit pas l'atomicité sur des opérations composées comme l'incrémentation (n++).
L'architecture AQS (AbstractQueuedSynchronizer)
AQS est le framework fondamental utilisé par la plupart des synchroniseurs de java.util.concurrent (ReentrantLock, Semaphore, CountDownLatch). Il repose sur :
- Un état entier atomique (
state) représentant la disponibilité de la ressource. - Une file d'attente FIFO (First-In-First-Out) pour gérer les threads bloqués.
- Des opérations de manipulation d'état basées sur CAS.
Gestion des Pools de Threads
Plutôt que de créer des threads manuellement, il est préférable d'utiliser ThreadPoolExecutor. Ses paramètres clés sont :
corePoolSize: Nombre de threads maintenus en vie même s'ils sont inactifs.maximumPoolSize: Limite haute du nombre de threads dans le pool.workQueue: File d'attente pour les tâches en attente d'exécution.RejectedExecutionHandler: Stratégie à adopter lorsque la file et le pool sont pleins (ex: AbortPolicy, CallerRunsPolicy).
ThreadLocal et risques de fuite mémoire
ThreadLocal permet de stocker des données propres à chaque thread. Cependant, dans un serveur d'applications utilisant des pools de threads, les instances de ThreadLocal peuvent rester en mémoire si elles ne sont pas explicitement supprimées via la méthode remove(), car les threads du pool sont réutilisés et ne meurent jamais, empêchant la collecte des valeurs associées.
Classes Atomiques
Le package java.util.concurrent.atomic propose des classes comme AtomicInteger ou AtomicReference. Elles permettent des mises à jour thread-safe sans utiliser de verrous (lock-free), en s'appuyant directement sur les instructions matérielles CAS, ce qui les rend extrêmement performantes sous faible ou moyenne contention.