La méthode computeIfAbsent de ConcurrentHashMap fournit un calcul atomique et thread-safe des valeurs en utilisant un verrouillage granulaire au niveau du compartiment de hachage plutôt qu'en verrouillant toute la table. Un risque de réentrance critique émerge lorsque la mappingFunction fournie à cette méthode tente d'accéder récursivement à la même clé dans la même instance de carte pendant son exécution, créant ainsi une dépendance circulaire potentielle.
Dans Java 8, cet accès récursif a causé un blocage car l'implémentation verrouillait le compartiment de hachage spécifique pendant le calcul, et l'appel récursif tentait d'acquérir le même verrou déjà détenu par le thread actuel. À partir de Java 9, l'implémentation détecte cette récursion en insérant un espace réservé ReservationNode dans le compartiment pendant le calcul pour le marquer comme "en cours". Si le même thread rencontre ce ReservationNode tout en parcourant la même clé, la méthode lance une IllegalStateException avec le message "Mise à jour récursive" plutôt que de provoquer un blocage, fournissant un retour immédiat sur la récursion invalide.
Ce mécanisme de détection précoce empêche l'ennui des threads et les problèmes de vivacité au sein du ForkJoinPool et d'autres contextes d'exécution où les blocages seraient catastrophiques. Cependant, cela nécessite que les développeurs structurent soigneusement leur logique de calcul pour éviter les dépendances circulaires entre les clés, nécessitant souvent une détection explicite des cycles au niveau du domaine.
Nous avons rencontré ce risque dans un moteur de tarification à haut débit qui mettait en cache des calculs dérivés pour des instruments financiers afin d'éviter des simulations Monte Carlo redondantes. Le cache utilisait ConcurrentHashMap<String, CompletableFuture<BigDecimal>> avec computeIfAbsent pour garantir que les demandes de tarification d'options identiques étaient dédupliquées et calculées exactement une fois par tick de données du marché. Ce modèle est courant dans les scénarios de chargement de données asynchrones où des calculs coûteux doivent être partagés entre plusieurs demandes concurrentes.
Le problème s'est manifesté lors du calcul de dérivés complexes qui faisaient involontairement référence à d'autres dérivés dans le même cache en raison d'une erreur de modélisation des données. Plus précisément, la formule de tarification pour l'Instrument A faisait référence à l'Instrument B comme sous-jacent, tandis que la formule de l'Instrument B faisait de manière inattendue référence à l'Instrument A, créant ainsi une dépendance circulaire. Cela a causé l'appel computeIfAbsent pour A à déclencher un autre appel computeIfAbsent pour A dans le même thread pendant la phase d'initialisation de la valeur.
Notre première solution envisagée a consisté à encapsuler l'accès au cache dans des blocs synchronized à gros grains pour empêcher toute possibilité de modification concurrente pendant le calcul. Bien que cette approche élimine le risque de blocage, elle sérialiserait tous les calculs de tarification à travers toute la carte, réduisant effectivement le débit à celui d'un HashMap à thread unique et détruisant les caractéristiques de performance requises pour le trading en temps réel.
La deuxième approche a consisté à utiliser putIfAbsent avec des instances de CompletableFuture pré-calculées créées via supplyAsync() avant l'opération sur la carte. Cela éviterait de retenir des verrous pendant le calcul mais initierait de manière proactive des calculs de tarification coûteux même lorsque la clé était déjà présente dans le cache, gaspillant des ressources CPU significatives sur des calculs redondants et contrecarrant l'objectif du cache.
Notre troisième solution a mis en œuvre une détection explicite des cycles en maintenant un ThreadLocal<Set<String>> contenant "les clés actuellement en cours de calcul" dans la pile d'appels du thread actuel. Avant d'initier toute opération computeIfAbsent, le système vérifierait cet ensemble pour la clé cible, lançant une DomainException pour des références circulaires avant d'atteindre le niveau de carte. Cela a préservé la concurrence sans verrou de ConcurrentHashMap tout en fournissant un contexte commercial significatif sur les hiérarchies d'instruments invalides.
Nous avons choisi la troisième solution car elle s'attaquait à la cause profonde—des modèles financiers circulaires invalides—plutôt que de masquer simplement les symptômes, tout en préservant pleinement les caractéristiques de performance concurrentes de ConcurrentHashMap. La validation explicite fournissait des pistes d'audit claires montrant quels instruments spécifiques formaient des dépendances circulaires invalides, permettant à l'équipe de données de remédier aux erreurs de données sources plutôt que de simplement éviter les plantages.
L'implémentation a éliminé les plantages de IllegalStateException en production et réduit les calculs de tarification redondants d'environ 40 %, tout en maintenant des exigences de latence sub-millisecondes pour la plateforme de trading. La détection explicite des cycles a également amélioré la qualité des données en forçant la correction des hiérarchies d'instruments erronées à la source plutôt qu'en les gérant silencieusement dans le code.
Pourquoi ConcurrentHashMap rejette-t-il les clés et les valeurs null alors que HashMap les permet ?
ConcurrentHashMap utilise null comme valeur sentinelle interne dans ses opérations atomiques concurrentes pour distinguer entre "clé non présente" et "calcul en cours". Des méthodes comme computeIfAbsent et merge s'appuient sur cette sentinelle pour indiquer sans ambiguïté l'absence lors des mises à jour atomiques sans nécessiter de recherches supplémentaires pouvant créer des conditions de concurrence. Étant donné que la méthode get renvoie null pour les clés manquantes et les clés mappées à null, permettre des valeurs null rendrait impossible de déterminer si une clé existe réellement dans la carte pendant les modifications concurrentes, brisant les garanties d'atomicité des opérations composites.
Comment le verrouillage au niveau des compartiments de Java 8+ diffère-t-il de la concurrence basée sur les segments de Java 7 ?
Java 7 utilisait un tableau fixe de 16 segments, chacun protégé par un ReentrantLock indépendant, ce qui limitait artificiellement la concurrence d'écriture maximale à 16 threads, quelle que soit la puissance matérielle disponible. Java 8+ a éliminé cette segmentation en faveur d'un verrouillage granulaire au niveau de chaque compartiment de hachage, utilisant des blocs synchronized sur le premier nœud de chaque seau combinés à des opérations CAS sans verrou pour des écritures et des lectures sans contention. Cette architecture permet à des milliers de threads d'écrire simultanément dans différents compartiments sans contention, tandis que les opérations de redimensionnement utilisent un transfert progressif avec des pointeurs de table suivante volatile pour permettre aux lectures de se poursuivre pendant la migration.
Quand faut-il préférer computeIfAbsent à putIfAbsent, et quelles implications de verrouillage doivent être prises en compte ?
computeIfAbsent est essentiel lorsque la création de valeurs est coûteuse et doit se produire de manière atomique uniquement si la clé est absente, car elle accepte une Function qui s'exécute uniquement lorsque cela est nécessaire. Cependant, l'implémentation verrouille tout le compartiment de hachage pendant la durée de l'exécution de la fonction, ce qui signifie que les calculs longs vont sérialiser tout l'accès aux clés hachées à ce compartiment, créant potentiellement un goulot d'étranglement de performance. putIfAbsent nécessite que la valeur soit pré-calculée avant l'appel, ce qui signifie que la création coûteuse a lieu indépendamment de la présence de la clé, mais le verrou est maintenu uniquement pour la brève vérification d'insertion, ce qui le rend préférable lorsque la création de valeur est bon marché ou idempotente.