1. unlock/release oublié : un piège pour les inattentifs
L’une des erreurs les plus insidieuses avec les outils modernes de synchronisation tels que ReentrantLock ou Semaphore, c’est d’oublier d’appeler unlock() ou release(). Si vous ne libérez pas le verrou, les autres threads attendront sa libération... indéfiniment. Le programme se figera et vous resterez longtemps devant l’écran à chercher pourquoi rien ne se passe.
Prenons un exemple avec ReentrantLock :
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
// Oups ! On a oublié unlock() — maintenant tout le monde va se bloquer !
count++;
}
}
Tout semble anodin, mais si vous appelez increment() plusieurs fois depuis différents threads, après le premier appel les autres threads attendront la libération du verrou à l’infini.
Pour éviter cette situation, utilisez la construction try-finally :
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
Ainsi, même si une exception se produit au milieu de la méthode, le verrou sera libéré à coup sûr.
C’est comme si quelqu’un occupait les toilettes (verrouillées de l’intérieur), puis oubliait d’ouvrir la porte et sortait par la fenêtre. Les autres attendront qu’il sorte... Ne faites pas ça !
2. Se synchroniser sur le mauvais objet : « Oups, pas le bon verrou ! »
En Java, le mot-clé synchronized peut verrouiller l’accès à un objet. Mais si vous choisissez le mauvais objet à verrouiller, la synchronisation ne fonctionnera pas comme prévu.
Erreur n° 1 : synchronisation sur une variable locale
public void doSomething() {
Object lock = new Object();
synchronized (lock) {
// Un nouvel objet à chaque fois — aucune synchronisation !
// Les threads ne s’attendent pas.
// La section critique n’est pas protégée !
}
}
Ici, chaque thread crée son propre objet lock. En conséquence, aucun véritable verrouillage ne se produit — les threads entrent dans la section critique simultanément.
Correct :
private final Object lock = new Object();
public void doSomething() {
synchronized (lock) {
// Désormais, tous les threads utilisent le même objet lock
// et s’attendent réellement les uns les autres.
}
}
Erreur n° 2 : synchronisation sur un littéral de chaîne
public void doSomething() {
synchronized ("lock") {
// Les littéraux de chaîne sont internés : différentes parties du programme peuvent
// par inadvertance se synchroniser sur la même chaîne !
}
}
Conclusion :
Synchronisez-vous uniquement sur des objets privés, créés spécifiquement à cet effet, et qui ne sont utilisés nulle part ailleurs.
3. Blocage circulaire (deadlock) : « Tu me — je te, et tous deux bloqués »
Le deadlock (blocage mutuel) est un grand classique. Deux (ou plus) threads acquièrent successivement des verrous différents et s’attendent mutuellement, jusqu’à ce que le programme se fige.
Exemple :
public class DeadlockExample {
private final Object lockA = new Object();
private final Object lockB = new Object();
public void method1() {
synchronized (lockA) {
// Attendons un peu pour la clarté de l’expérience
try { Thread.sleep(50); } catch (InterruptedException e) {}
synchronized (lockB) {
// ...
}
}
}
public void method2() {
synchronized (lockB) {
try { Thread.sleep(50); } catch (InterruptedException e) {}
synchronized (lockA) {
// ...
}
}
}
}
Si un thread appelle method1() et un autre — method2(), le premier thread prendra lockA et attendra lockB, tandis que le second fera l’inverse. Résultat : ils s’attendent mutuellement pour l’éternité.
Comment l’éviter ?
- Acquérez toujours les verrous dans le même ordre dans tous les threads.
- Minimisez le nombre de verrous détenus simultanément.
- Utilisez des outils de diagnostic (par exemple, jstack) si le programme est gelé.
Analogie :
C’est comme si deux personnes se rencontraient dans un couloir étroit, et que chacune décidait de céder le passage, mais seulement si l’autre cède d’abord. Au final, elles restent toutes les deux immobiles en attendant que quelqu’un cède en premier.
4. Synchronisation excessive : « Mieux vaut trop que pas assez ? » — pas toujours !
Parfois, par crainte d’erreurs, les développeurs synchronisent tout et n’importe quoi. Résultat : les performances chutent, pour zéro bénéfice.
Exemple :
public synchronized void add(int value) {
// Ici, une seule ligne qui ne nécessite pas de synchronisation !
System.out.println("Ajouté : " + value);
}
Dans ce cas, la synchronisation n’est pas nécessaire : l’affichage via System.out.println est déjà thread-safe, et la méthode elle-même ne manipule pas de ressources partagées.
Où est-ce critique ?
Si vous synchronisez des méthodes fréquemment appelées et qui ne nécessitent pas de protection, vous réduisez drastiquement les performances du programme. Les threads font la queue alors qu’ils pourraient travailler en parallèle.
Bonne pratique :
Ne synchronisez que ce qui est réellement nécessaire. La section critique doit être la plus petite possible.
5. Mauvaise utilisation de volatile : « La visibilité oui, l’atomicité non ! »
Le modificateur volatile en Java garantit que les modifications d’une variable seront visibles par tous les threads. Mais il ne garantit pas l’atomicité des opérations.
Erreur :
private volatile int counter = 0;
public void increment() {
counter++; // Non atomique !
}
L’opération counter++ se compose d’une lecture de la valeur, d’une incrémentation et d’une écriture. Si deux threads exécutent ce code simultanément, la valeur finale peut être inférieure à celle attendue.
Correct :
Pour des opérations atomiques, utilisez synchronized, AtomicInteger ou d’autres classes thread-safe.
import java.util.concurrent.atomic.AtomicInteger;
private final AtomicInteger counter = new AtomicInteger();
public void increment() {
counter.incrementAndGet();
}
Quand utiliser volatile ?
Pour des drapeaux simples (par exemple, « terminer le travail »), lorsqu’aucune atomicité n’est requise.
GO TO FULL VERSION