Synchronized, volatile et verrous

Publié le · Mis à jour le

L'idée en une phrase

Quand deux requêtes modifient la même donnée au même moment, une des deux modifications peut se perdre sans aucune erreur. Cette fiche explique pourquoi, et comment l'éviter dans un projet Spring Boot, où le problème se pose surtout… dans la base de données.

Une image pour comprendre : la dernière place du concert

Deux guichetiers vendent des places pour le même concert. Sur le tableau, il reste 1 place.

  1. Le guichetier A regarde le tableau : 1 place. Il commence la vente.
  2. Le guichetier B regarde le tableau : 1 place. Il commence aussi la vente.
  3. A vend la place et écrit « 0 » sur le tableau.
  4. B vend la place et écrit « 0 » sur le tableau.

Résultat : deux places vendues pour une seule, et le tableau affiche 0 comme si tout allait bien. Personne n'a fait d'erreur… individuellement.

Le problème : « regarder, décider, écrire » n'est pas fait d'un seul bloc. Entre la lecture et l'écriture, quelqu'un d'autre peut passer.

Le problème dans le code

En Java, même compteur++ n'est pas fait d'un seul bloc. C'est en réalité trois étapes : lire, ajouter 1, écrire.

Étape Requête A Requête B
1 lit 41 lit 41
2 calcule 42 calcule 42
3 écrit 42 écrit 42

Deux incréments, un seul compté. Aucune exception, aucun log : le résultat est juste faux. Et le bug n'apparaît que sous charge, jamais quand tu testes seul en local.

La meilleure protection : ne rien partager

Rappel de la fiche sur les threads : un service Spring existe en un seul exemplaire, utilisé par toutes les requêtes en même temps. Un champ modifiable dans un service est donc partagé par tout le monde.

La solution la plus sûre est de ne pas avoir de données modifiables dans les services. Les données d'une requête passent en paramètres et en variables locales. Pas de partage, pas de problème de concurrence.

Dans 95 % des cas, c'est la bonne réponse. Les sections suivantes couvrent les cas restants.

Si tu dois vraiment partager une valeur en mémoire

Un compteur : AtomicInteger ou AtomicLong

Les classes Atomic… font « lire, modifier, écrire » d'un seul bloc :

@Service
public class StatistiqueService {

    // ❌ private int appels = 0;  → appels++ perd des incréments
    private final AtomicLong appels = new AtomicLong(); // ✅

    public void enregistrerAppel() {
        appels.incrementAndGet(); // lire + ajouter 1 + écrire, d'un seul bloc
    }
}

Note : pour compter des événements dans une application Spring Boot, il existe déjà un outil fait pour ça : les métriques de Micrometer, visibles dans Grafana (voir la fiche sur les métriques). Un compteur en mémoire disparaît au redémarrage de l'application.

Une Map : ConcurrentHashMap, et ses méthodes « d'un seul bloc »

Une HashMap modifiée par plusieurs requêtes en même temps peut perdre des données ou se corrompre. ConcurrentHashMap est faite pour être partagée.

Mais attention : même avec une ConcurrentHashMap, enchaîner « je vérifie, puis j'écris » reproduit le problème du concert :

Map<String, Integer> visites = new ConcurrentHashMap<>();

// ❌ deux requêtes peuvent passer le test en même temps
if (!visites.containsKey(page)) {
    visites.put(page, 1);
} else {
    visites.put(page, visites.get(page) + 1);
}

// ✅ une seule opération, faite d'un seul bloc par la Map
visites.merge(page, 1, Integer::sum);
Je veux… J'écris
ajouter seulement si la clé n'existe pas putIfAbsent(cle, valeur)
calculer une valeur seulement si elle manque computeIfAbsent(cle, k -> calcul(k))
incrémenter ou initialiser merge(cle, 1, Integer::sum)

Astuce : si ta Map sert de cache, n'en fabrique pas une : utilise @Cacheable avec une taille maximale (voir la fiche sur la mémoire en Java).

Et synchronized ?

Tu verras synchronized dans des tutoriels : ce mot-clé empêche deux threads d'exécuter le même bloc en même temps. Dans une application Spring Boot, tu ne devrais presque jamais en avoir besoin, pour deux raisons :

  1. Il ne protège qu'une seule instance de l'application. En production, la même application tourne souvent sur plusieurs serveurs (ou plusieurs conteneurs). Un synchronized sur le serveur 1 ne bloque pas le serveur 2.
  2. Il ralentit tout le monde, puisque les requêtes font la queue.

Si tu penses en avoir besoin, c'est souvent que la donnée devrait être protégée dans la base. C'est la suite.

Note : tu croiseras aussi volatile. Il sert à des cas très particuliers de code bas niveau, et ne rend pas compteur++ sûr. Tu n'en auras pas besoin dans un projet Spring Boot classique.

La vraie concurrence : dans la base de données

C'est le cas que tu rencontreras réellement. Plusieurs requêtes (venant éventuellement de plusieurs serveurs) lisent et modifient la même ligne en base.

Le piège : lire, modifier en Java, enregistrer

@Transactional
public void commander(Long produitId) {
    Produit produit = produitRepository.findById(produitId).orElseThrow(); // lit stock = 1
    if (produit.getStock() <= 0) {
        throw new RuptureDeStockException();
    }
    produit.setStock(produit.getStock() - 1); // calcule 0
    // à la fin de la transaction, Hibernate écrit stock = 0
}

C'est exactement le concert : deux requêtes lisent stock = 1, passent le test, et écrivent toutes les deux 0. Deux produits vendus, un seul en stock.

@Transactional ne règle pas ce problème : la transaction garantit que tes modifications sont enregistrées ensemble (ou pas du tout), pas que personne d'autre n'a modifié la ligne entre-temps.

Solution 1 : faire le calcul dans la base

La base sait faire « vérifier et modifier » d'un seul bloc, dans une seule requête :

public interface ProduitRepository extends JpaRepository<Produit, Long> {

    @Modifying
    @Query("""
            UPDATE Produit p
            SET p.stock = p.stock - 1
            WHERE p.id = :id AND p.stock > 0
            """)
    int decrementerStock(Long id); // renvoie le nombre de lignes modifiées
}
@Transactional
public void commander(Long produitId) {
    int modifies = produitRepository.decrementerStock(produitId);
    if (modifies == 0) {
        throw new RuptureDeStockException(); // ✅ le stock était déjà à 0
    }
}

Si deux requêtes arrivent en même temps, la base les traite l'une après l'autre sur cette ligne : la première passe, la seconde ne trouve plus de stock et ne modifie rien. Ça fonctionne même avec plusieurs serveurs.

C'est la meilleure solution pour les compteurs : stock, nombre de places, solde, nombre de vues.

Solution 2 : le verrou optimiste avec @Version

Quand un utilisateur modifie une fiche complète (un formulaire d'édition, par exemple), on ne peut pas tout écrire en une requête SQL. On ajoute alors un numéro de version à l'entité :

@Entity
public class Produit {

    @Id
    private Long id;

    private String nom;
    private BigDecimal prix;

    @Version
    private Long version; // ✅ géré automatiquement par Hibernate
}

À chaque enregistrement, Hibernate vérifie que la version n'a pas changé depuis la lecture, puis l'augmente de 1. Si quelqu'un a modifié la ligne entre-temps, l'enregistrement échoue avec une ObjectOptimisticLockingFailureException, au lieu d'écraser silencieusement le travail de l'autre.

C'est à toi de décider quoi faire de cette erreur. Le plus souvent : prévenir l'utilisateur (« Cette fiche a été modifiée par quelqu'un d'autre, recharge la page ») plutôt que d'écraser.

Astuce : « optimiste » signifie qu'on ne bloque personne à l'avance : on parie que les conflits sont rares, et on les détecte au moment d'écrire. C'est adapté à la grande majorité des applications de gestion.

Solution 3 : une contrainte d'unicité contre les doublons

Le même piège existe pour empêcher les doublons :

if (!utilisateurRepository.existsByEmail(email)) { // ❌ deux requêtes passent le test
    utilisateurRepository.save(new Utilisateur(email));
}

Un double-clic sur « Créer mon compte » suffit pour créer deux comptes avec le même e-mail.

La protection fiable est une contrainte d'unicité dans la base : c'est elle qui refusera le second enregistrement, quel que soit le nombre de serveurs.

@Entity
public class Utilisateur {

    @Column(unique = true, nullable = false)
    private String email;
}

Si les tables sont gérées par des scripts de migration (Flyway, Liquibase), la contrainte doit aussi y figurer. Le doublon déclenche alors une DataIntegrityViolationException, que tu peux transformer en message clair : « Cet e-mail est déjà utilisé ».

Le existsByEmail peut rester pour afficher un message rapide dans le cas normal, mais c'est la contrainte qui protège vraiment.

Quel outil pour quel besoin

Situation Outil
données d'une requête variables locales, paramètres : rien à partager
compteur en mémoire AtomicLong (ou mieux, une métrique Micrometer)
Map partagée en mémoire ConcurrentHashMap + merge / computeIfAbsent
cache @Cacheable avec une taille maximale
compteur en base (stock, places, solde) UPDATE ... SET x = x - 1 WHERE ...
modification d'une fiche par plusieurs utilisateurs @Version
éviter les doublons contrainte unique en base

Les bonnes habitudes

  1. Ne garde pas de données modifiables dans un service. C'est la protection la plus simple et la plus sûre.
  2. Méfie-toi de « je vérifie, puis j'écris », en Java comme en base. Entre les deux, quelqu'un d'autre peut passer.
  3. Pour un compteur en base, fais le calcul dans la requête SQL, pas en Java.
  4. Ajoute @Version aux entités modifiées par plusieurs utilisateurs via des formulaires.
  5. Protège l'unicité par une contrainte en base, pas seulement par un exists.
  6. N'utilise pas synchronized pour protéger des données en base : il ne protège qu'un seul serveur.
  7. Teste à plusieurs. Ces bugs n'apparaissent jamais quand tu cliques seul. Un double-clic rapide sur un bouton est un premier test simple.

À retenir

  • « Lire, modifier, écrire » en plusieurs étapes peut perdre des modifications sans erreur quand deux requêtes arrivent en même temps.
  • La meilleure protection est de ne rien partager : pas de données modifiables dans les services.
  • En mémoire : AtomicLong pour un compteur, ConcurrentHashMap avec merge ou computeIfAbsent pour une Map.
  • En base : le calcul dans la requête SQL pour un compteur, @Version pour une fiche, une contrainte unique contre les doublons.
  • synchronized ne protège qu'une seule instance de l'application : en production, il y en a souvent plusieurs.

Avis sur l'article

Pas encore de note : soyez le premier à donner votre avis.

Les avis sont publiés après validation par le modérateur.

Votre note