Table of Contents
Réduction de contention sur les accès synchronisés
Introduction : Comprendre la Concurrence et la Contention
La programmation concurrente est l’un des défis les plus complexes du développement logiciel moderne. Dans un monde où les processeurs multi-cœurs sont la norme, savoir gérer efficacement l’accès concurrent aux ressources partagées est devenu une compétence essentielle pour tout développeur Java.
Qu’est-ce que la contention ? La contention survient lorsque plusieurs threads tentent d’accéder simultanément à une même ressource protégée. Imaginez une file d’attente devant un guichet unique : plus il y a de personnes, plus le temps d’attente augmente. En programmation, cette attente se traduit par des threads bloqués, des context switches coûteux et une dégradation significative des performances.
Les applications modernes, qu’il s’agisse de serveurs web gérant des milliers de requêtes simultanées, de systèmes de trading haute fréquence ou d’applications mobiles réactives, doivent toutes faire face à ce défi. La bonne nouvelle ? Java fournit depuis la version 5 un ensemble d’outils puissants pour gérer cette complexité : les types atomiques.
Dans ce tutoriel approfondi, nous allons explorer :
- Les mécanismes fondamentaux de la contention et ses impacts
- L’utilisation pratique des différents types atomiques (AtomicInteger, AtomicLong, AtomicReference, AtomicBoolean)
- Une comparaison détaillée entre synchronized, Lock et types atomiques
- Des cas d’utilisation réels avec exemples de code
- Les bonnes pratiques et les pièges à éviter
Problèmes liés à la contention
Mécanisme de blocage des threads
Lorsqu’un thread essaye d’accéder à un objet synchronisé pendant que ce dernier est déjà occupé par un autre thread, il sera bloqué. Si plusieurs threads sont en attente de leur tour, seuls certains seront traités, tandis que les autres resteront bloqués.
Cela peut entraîner plusieurs types de problèmes :
- Blocage prolongé : Une forte contention sur le verrou (lock) peut faire qu’un thread soit bloqué pendant une longue période, impactant la réactivité de l’application.
- Context switching coûteux : Lorsqu’un thread est en attente de son tour, l’OS tentera de passer à un autre thread. Ce context switching implique la sauvegarde et restauration de l’état du thread, ce qui peut avoir un impact significatif sur les performances (généralement entre 1000 et 10000 cycles CPU).
- Famine (Starvation) : Certains threads peuvent être systématiquement défavorisés et ne jamais obtenir l’accès à la ressource.
- Deadlocks : Dans les cas complexes impliquant plusieurs verrous, des situations d’interblocage peuvent survenir.
Impact sur les performances
Pour illustrer l’impact de la contention, considérons un benchmark simple :
// Test avec synchronized
public class SynchronizedBenchmark {
private int counter = 0;
private final Object lock = new Object();
public synchronized void increment() {
counter++;
}
public synchronized int get() {
return counter;
}
}
// Test avec AtomicInteger
public class AtomicBenchmark {
private final AtomicInteger counter = new AtomicInteger(0);
public void increment() {
counter.incrementAndGet();
}
public int get() {
return counter.get();
}
}
Avec 8 threads effectuant 1 million d’incrémentations chacun, les résultats typiques montrent :
- synchronized : ~800ms
- AtomicInteger : ~200ms
Soit une amélioration de 4x en utilisant les types atomiques !
Utilisation des types atomiques
Pour minimiser la contention et améliorer les performances, nous pouvons utiliser les types atomiques (AtomicXXX). Ces derniers fournissent des méthodes permettant de manipuler les données de manière thread-safe sans nécessiter un verrou classique.
Exemple avec AtomicInteger
Supposons que nous voulions incrémenter une valeur en mode concurrentiel. La solution traditionnelle utilisant le verrou (synchronized) pourrait être la suivante :
public class Counters {
private final int[] counters;
public Counters(int nosCounters) {
counters = new int[nosCounters];
}
public void count(int number) {
synchronized (counters[number]) {
counters[number]++;
}
}
public int getCount(int number) {
synchronized (counters[number]) {
return counters[number];
}
}
}
Or, nous pouvons utiliser AtomicInteger pour faire de même :
public class Counters {
private final AtomicInteger[] counters;
public Counters(int nosCounters) {
counters = new AtomicInteger[nosCounters];
for (int i = 0; i < nosCounters; i++) {
counters[i] = new AtomicInteger();
}
}
public void count(int number) {
if (number >= 0 && number < counters.length) {
counters[number].incrementAndGet();
}
}
public int getCount(int number) {
return (number >= 0 && number < counters.length) ? counters[number].get() : 0;
}
}
Dans cet exemple, nous avons remplacé l’array d’int par un array d’AtomicInteger. Nous utilisons les méthodes incrementAndGet et get pour incrémenter la valeur et la récupérer respectivement.
Exemple avec AtomicLong
AtomicLong est idéal pour les compteurs de grande taille ou les identifiants uniques :
public class UniqueIdGenerator {
private static final AtomicLong idCounter = new AtomicLong(0);
public static long generateId() {
return idCounter.incrementAndGet();
}
public static long generateBatchId(int batchSize) {
return idCounter.addAndGet(batchSize);
}
}
// Utilisation pour les métriques
public class MetricsCollector {
private final AtomicLong totalRequests = new AtomicLong(0);
private final AtomicLong totalBytes = new AtomicLong(0);
private final AtomicLong errorCount = new AtomicLong(0);
public void recordRequest(long bytes, boolean success) {
totalRequests.incrementAndGet();
totalBytes.addAndGet(bytes);
if (!success) {
errorCount.incrementAndGet();
}
}
public double getErrorRate() {
long requests = totalRequests.get();
return requests > 0 ? (double) errorCount.get() / requests : 0.0;
}
public long getAverageBytesPerRequest() {
long requests = totalRequests.get();
return requests > 0 ? totalBytes.get() / requests : 0;
}
}
Exemple avec AtomicBoolean
AtomicBoolean est parfait pour les flags de contrôle et les états binaires :
public class ServiceController {
private final AtomicBoolean isRunning = new AtomicBoolean(false);
private final AtomicBoolean isShuttingDown = new AtomicBoolean(false);
public boolean start() {
// compareAndSet retourne true si la valeur était false et a été mise à true
if (isRunning.compareAndSet(false, true)) {
System.out.println("Service démarré");
initializeResources();
return true;
}
System.out.println("Service déjà en cours d'exécution");
return false;
}
public boolean stop() {
if (isShuttingDown.compareAndSet(false, true)) {
System.out.println("Arrêt en cours...");
cleanupResources();
isRunning.set(false);
isShuttingDown.set(false);
return true;
}
System.out.println("Arrêt déjà en cours");
return false;
}
public boolean isRunning() {
return isRunning.get() && !isShuttingDown.get();
}
private void initializeResources() { /* ... */ }
private void cleanupResources() { /* ... */ }
}
Exemple avec AtomicReference
AtomicReference permet de gérer des objets de manière atomique, idéal pour les caches et les configurations :
public class ConfigurationManager<T> {
private final AtomicReference<T> currentConfig = new AtomicReference<>();
public void updateConfig(T newConfig) {
T oldConfig;
do {
oldConfig = currentConfig.get();
} while (!currentConfig.compareAndSet(oldConfig, newConfig));
System.out.println("Configuration mise à jour : " + newConfig);
}
public T getConfig() {
return currentConfig.get();
}
// Mise à jour conditionnelle
public boolean updateIfCurrent(T expectedConfig, T newConfig) {
return currentConfig.compareAndSet(expectedConfig, newConfig);
}
}
// Exemple d'utilisation avec un cache thread-safe
public class SimpleCache<K, V> {
private final AtomicReference<Map<K, V>> cache =
new AtomicReference<>(new HashMap<>());
public void put(K key, V value) {
Map<K, V> oldMap, newMap;
do {
oldMap = cache.get();
newMap = new HashMap<>(oldMap);
newMap.put(key, value);
} while (!cache.compareAndSet(oldMap, newMap));
}
public V get(K key) {
return cache.get().get(key);
}
public void clear() {
cache.set(new HashMap<>());
}
}
Comment fonctionnent les types atomiques ?
Les types atomiques sont implémentés en utilisant des instructions spécifiques de l’instruction set du processeur cible (par exemple, CAS - Compare and Swap). Ces instructions permettent d’exécuter une séquence d’opérations sur la mémoire de manière atomique, sans nécessiter de verrou système.
Le mécanisme CAS fonctionne ainsi :
- Lire la valeur actuelle
- Calculer la nouvelle valeur
- Tenter de remplacer l’ancienne valeur par la nouvelle uniquement si l’ancienne valeur n’a pas changé
- Si un autre thread a modifié la valeur entre-temps, recommencer
Par exemple, voici un extrait de code en C qui illustre comment fonctionne la méthode incrementAndGet :
private volatile num;
int increment() {
while (TRUE) {
int old = num;
int new = old + 1;
if (old == compare_and_swap(&num, old, new)) {
return new;
}
}
}
Dans ce cas, l’instruction CAS est utilisée pour incrémenter la valeur de manière atomique. Si il n’y a pas de contention, l’opération se termine rapidement. Sinon, les threads en attente “spin” pendant quelques cycles avant de réessayer.
Comparaison : synchronized vs Lock vs Types Atomiques
Tableau comparatif
| Critère | synchronized | ReentrantLock | Types Atomiques |
|---|---|---|---|
| Performance | Moyenne | Bonne | Excellente |
| Flexibilité | Limitée | Élevée | Moyenne |
| Complexité | Simple | Moyenne | Simple |
| Risque de deadlock | Oui | Oui (si mal utilisé) | Non |
| Équité (fairness) | Non garantie | Configurable | N/A |
| Timeout | Non | Oui (tryLock) | N/A |
| Cas d’usage | Sections critiques complexes | Contrôle fin | Opérations simples |
Exemple comparatif complet
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.atomic.AtomicInteger;
// Version avec synchronized
class SynchronizedCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int get() {
return count;
}
}
// Version avec ReentrantLock
class LockCounter {
private int count = 0;
private final ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int get() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
// Version avec AtomicInteger
class AtomicCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}
public int get() {
return count.get();
}
}
Quand utiliser quoi ?
Utilisez synchronized quand :
- La section critique contient plusieurs opérations sur différentes variables
- Vous avez besoin d’un comportement wait/notify
- La simplicité du code est prioritaire
Utilisez ReentrantLock quand :
- Vous avez besoin de tryLock avec timeout
- Vous voulez un verrouillage équitable (fair locking)
- Vous devez pouvoir interrompre un thread en attente
- Vous avez besoin de plusieurs conditions (Condition objects)
Utilisez les types atomiques quand :
- L’opération porte sur une seule variable
- Les performances sont critiques
- Vous faites des opérations simples (incrément, compare-and-set)
Cas d’Utilisation Pratiques
1. Compteur de connexions actives
public class ConnectionPool {
private final AtomicInteger activeConnections = new AtomicInteger(0);
private final int maxConnections;
public ConnectionPool(int maxConnections) {
this.maxConnections = maxConnections;
}
public Connection acquireConnection() throws NoConnectionAvailableException {
int current;
do {
current = activeConnections.get();
if (current >= maxConnections) {
throw new NoConnectionAvailableException("Pool exhausted");
}
} while (!activeConnections.compareAndSet(current, current + 1));
return createNewConnection();
}
public void releaseConnection(Connection conn) {
closeConnection(conn);
activeConnections.decrementAndGet();
}
public int getActiveCount() {
return activeConnections.get();
}
public int getAvailableCount() {
return maxConnections - activeConnections.get();
}
private Connection createNewConnection() { /* ... */ return null; }
private void closeConnection(Connection conn) { /* ... */ }
}
2. Rate Limiter simple
public class SimpleRateLimiter {
private final AtomicLong lastRequestTime = new AtomicLong(0);
private final AtomicInteger requestCount = new AtomicInteger(0);
private final int maxRequests;
private final long windowMs;
public SimpleRateLimiter(int maxRequests, long windowMs) {
this.maxRequests = maxRequests;
this.windowMs = windowMs;
}
public boolean tryAcquire() {
long now = System.currentTimeMillis();
long lastTime = lastRequestTime.get();
// Réinitialiser si nouvelle fenêtre
if (now - lastTime > windowMs) {
if (lastRequestTime.compareAndSet(lastTime, now)) {
requestCount.set(1);
return true;
}
}
// Vérifier la limite
int current = requestCount.get();
if (current >= maxRequests) {
return false;
}
return requestCount.compareAndSet(current, current + 1);
}
}
3. Flag d’initialisation thread-safe (Lazy Initialization)
public class LazyInitializer<T> {
private final AtomicReference<T> instance = new AtomicReference<>();
private final AtomicBoolean initializing = new AtomicBoolean(false);
private final Supplier<T> supplier;
public LazyInitializer(Supplier<T> supplier) {
this.supplier = supplier;
}
public T get() {
T result = instance.get();
if (result != null) {
return result;
}
if (initializing.compareAndSet(false, true)) {
try {
result = supplier.get();
instance.set(result);
return result;
} finally {
initializing.set(false);
}
}
// Un autre thread initialise, attendre
while ((result = instance.get()) == null) {
Thread.yield();
}
return result;
}
}
4. Statistiques en temps réel
public class RealtimeStats {
private final AtomicLong totalOperations = new AtomicLong(0);
private final AtomicLong successfulOperations = new AtomicLong(0);
private final AtomicLong totalLatencyNs = new AtomicLong(0);
private final AtomicLong minLatencyNs = new AtomicLong(Long.MAX_VALUE);
private final AtomicLong maxLatencyNs = new AtomicLong(0);
public void recordOperation(long latencyNs, boolean success) {
totalOperations.incrementAndGet();
totalLatencyNs.addAndGet(latencyNs);
if (success) {
successfulOperations.incrementAndGet();
}
// Mise à jour atomique du min
long currentMin;
do {
currentMin = minLatencyNs.get();
if (latencyNs >= currentMin) break;
} while (!minLatencyNs.compareAndSet(currentMin, latencyNs));
// Mise à jour atomique du max
long currentMax;
do {
currentMax = maxLatencyNs.get();
if (latencyNs <= currentMax) break;
} while (!maxLatencyNs.compareAndSet(currentMax, latencyNs));
}
public double getAverageLatencyMs() {
long ops = totalOperations.get();
return ops > 0 ? (totalLatencyNs.get() / ops) / 1_000_000.0 : 0;
}
public double getSuccessRate() {
long ops = totalOperations.get();
return ops > 0 ? (double) successfulOperations.get() / ops * 100 : 0;
}
}
Bonnes Pratiques
1. Privilégiez l’immutabilité quand possible
Avant d’utiliser des types atomiques, demandez-vous si vous pouvez rendre vos données immutables. Les objets immutables sont naturellement thread-safe et ne nécessitent aucune synchronisation.
// Préférez ceci
public final class ImmutableConfig {
private final String host;
private final int port;
public ImmutableConfig(String host, int port) {
this.host = host;
this.port = port;
}
public ImmutableConfig withPort(int newPort) {
return new ImmutableConfig(host, newPort);
}
}
// Avec AtomicReference pour la mise à jour
private final AtomicReference<ImmutableConfig> config = new AtomicReference<>();
2. Utilisez les méthodes appropriées
Chaque type atomique offre plusieurs méthodes. Choisissez celle qui correspond exactement à votre besoin :
AtomicInteger counter = new AtomicInteger(0);
// Pour un simple incrément, utilisez :
counter.incrementAndGet(); // Retourne la nouvelle valeur
counter.getAndIncrement(); // Retourne l'ancienne valeur
// Pour des opérations conditionnelles :
counter.compareAndSet(expected, newValue); // Retourne true si succès
// Pour des mises à jour avec fonction :
counter.updateAndGet(x -> x * 2); // Double la valeur atomiquement
counter.accumulateAndGet(5, Integer::sum); // Ajoute 5 atomiquement
3. Évitez les opérations composées non atomiques
// MAUVAIS - Pas atomique !
AtomicInteger value = new AtomicInteger(10);
if (value.get() > 0) {
value.decrementAndGet(); // Un autre thread peut modifier entre get et decrement
}
// BON - Atomique
AtomicInteger value = new AtomicInteger(10);
value.updateAndGet(v -> v > 0 ? v - 1 : v);
// OU avec boucle CAS
int current;
do {
current = value.get();
if (current <= 0) break;
} while (!value.compareAndSet(current, current - 1));
4. Préférez LongAdder pour les compteurs très sollicités
Pour les compteurs avec beaucoup de contention (beaucoup de threads qui incrémentent), LongAdder est plus performant que AtomicLong :
import java.util.concurrent.atomic.LongAdder;
public class HighThroughputCounter {
// Plus performant sous forte contention
private final LongAdder counter = new LongAdder();
public void increment() {
counter.increment();
}
public long get() {
return counter.sum();
}
}
5. Documentez vos invariants de concurrence
/**
* Gestionnaire de sessions thread-safe.
*
* Invariants de concurrence :
* - sessionCount est toujours >= 0
* - La somme des sessions par utilisateur == sessionCount
* - Les opérations sur un même userId sont sérialisées
*/
public class SessionManager {
private final AtomicInteger sessionCount = new AtomicInteger(0);
// ...
}
Pièges Courants à Éviter
1. Le piège du check-then-act
// PIÈGE : Race condition entre get() et set()
AtomicInteger max = new AtomicInteger(0);
public void updateMax(int newValue) {
// MAUVAIS !
if (newValue > max.get()) {
max.set(newValue); // Un autre thread peut avoir mis à jour entre-temps
}
}
// CORRECT
public void updateMax(int newValue) {
max.updateAndGet(current -> Math.max(current, newValue));
}
2. L’illusion de l’atomicité sur plusieurs variables
// PIÈGE : Deux variables atomiques ne font pas une opération atomique
AtomicInteger numerator = new AtomicInteger(0);
AtomicInteger denominator = new AtomicInteger(1);
public void updateRatio(int num, int denom) {
// MAUVAIS ! Un autre thread peut lire un état incohérent
numerator.set(num);
denominator.set(denom);
}
// CORRECT : Utilisez AtomicReference avec un objet immutable
AtomicReference<Ratio> ratio = new AtomicReference<>(new Ratio(0, 1));
public void updateRatio(int num, int denom) {
ratio.set(new Ratio(num, denom));
}
record Ratio(int numerator, int denominator) {}
3. La boucle CAS infinie
// PIÈGE : Boucle potentiellement infinie sous très forte contention
public void riskyIncrement(AtomicInteger value) {
int current;
do {
current = value.get();
} while (!value.compareAndSet(current, current + 1));
// Si contention extrême, cette boucle peut tourner très longtemps
}
// MIEUX : Utilisez simplement incrementAndGet
public void safeIncrement(AtomicInteger value) {
value.incrementAndGet(); // Gère la boucle de manière optimisée
}
4. Oublier la visibilité mémoire
// PIÈGE : La modification d'un champ non-volatile n'est pas visible
public class BrokenVisibility {
private boolean ready = false; // Devrait être volatile ou AtomicBoolean
private int result = 0;
public void compute() {
result = 42;
ready = true; // Un autre thread peut ne jamais voir ce changement !
}
}
// CORRECT
public class CorrectVisibility {
private final AtomicBoolean ready = new AtomicBoolean(false);
private volatile int result = 0;
public void compute() {
result = 42;
ready.set(true); // Garantit la visibilité
}
}
Conclusion
La gestion de la contention est essentielle lorsque vous développez des applications concurrentielles. Les types atomiques offrent une solution efficace et performante pour minimiser la contention et améliorer les performances, particulièrement pour les opérations simples sur des variables individuelles.
Points clés à retenir
-
Les types atomiques utilisent le mécanisme CAS (Compare-And-Swap), évitant les coûts de verrouillage traditionnels et les context switches.
-
Choisissez le bon outil :
AtomicInteger/AtomicLong: compteurs, identifiantsAtomicBoolean: flags, états binairesAtomicReference: objets immutables, configurationsLongAdder: compteurs haute performance sous forte contention
-
Les types atomiques ne sont pas une solution universelle : pour les opérations complexes impliquant plusieurs variables, préférez
synchronizedouReentrantLock. -
La performance n’est pas tout : la clarté et la maintenabilité du code sont également importantes. Un code concurrent correct mais complexe vaut mieux qu’un code rapide mais bogué.
Pour aller plus loin
- Étudiez le package
java.util.concurrent.atomicdans la documentation officielle Java - Explorez les classes
LongAdderetLongAccumulatorpour les cas de haute contention - Découvrez le framework
VarHandleintroduit en Java 9 pour des opérations atomiques plus flexibles - Lisez “Java Concurrency in Practice” de Brian Goetz, la référence en la matière
La maîtrise de la programmation concurrente demande de la pratique et de l’expérience. En comprenant les mécanismes sous-jacents et en suivant les bonnes pratiques exposées dans cet article, vous serez en mesure de développer des applications Java robustes, performantes et thread-safe.
In-Article Ad
Dev Mode
Mahmoud DEVO
Senior Full-Stack Developer
I'm a passionate full-stack developer with 10+ years of experience building scalable web applications. I write about Vue.js, Node.js, PostgreSQL, and modern DevOps practices.
Enjoyed this article?
Subscribe to get more tech content delivered to your inbox.
Related Articles
Java Multithreading avec CountDownLatch : Guide Complet de Synchronisation
Maitrisez la synchronisation des threads en Java avec CountDownLatch. Exemples pratiques, bonnes pratiques et pieges courants pour eviter les deadlocks et creer des applications concurrentes robustes.
Gestion des interruptions dans les tâches Java : techniques
Voici une proposition de meta description : "Apprenez à gérer les interruptions dans vos programmes Java avec des exemples concrets et des conseils pratiques.
Generer des Nombres Aleatoires en Java : Guide Complet avec Random, ThreadLocalRandom et SecureRandom
Apprenez a generer des nombres aleatoires en Java avec les classes Random, ThreadLocalRandom et SecureRandom. Guide complet avec exemples de code, bonnes pratiques et pieges a eviter.