Avant la spécification JSR 133 (Java 5), le Modèle Mémoire Java manquait de règles formelles happens-before, rendant les courses de données bénignes dangereuses. String a toujours été une classe immuable critique pour la performance, largement utilisée dans les opérations de HashMap. Les premières versions de JDK ont introduit la mise en cache paresseuse des hachages pour éviter de recalculer le hachage pour de grandes chaînes plusieurs fois. La décision d'omettre volatile sur le champ hash était une optimisation délibérée antérieure aux primitives de concurrence modernes, reposant sur la nature idempotente du calcul et sur des garanties d'atomicité spécifiques ajoutées à la JLS dans Java 5.
Lorsque plusieurs threads invoquent hashCode() simultanément sur une String nouvellement créée, ils peuvent tous observer la valeur par défaut de 0 dans le champ hash. Sans synchronisation, cela crée une course de données où plusieurs threads pourraient simultanément calculer la valeur de hachage et essayer de l'écrire. Le défi consiste à s'assurer qu'aucun thread n'observe jamais une valeur de hachage partiellement écrite (déchirée) ou un état incohérent, tout en évitant le coût prohibitif des barrières mémoire associées aux lectures et écritures volatile à chaque invocation de hashCode().
La solution repose sur deux propriétés fondamentales du JMM. Tout d'abord, la spécification du Langage Java (§17.7) garantit que les écritures sur des valeurs primitives de 32 bits (int) sont atomiques, empêchant la déchirure de mots. Deuxièmement, le constructeur de String établit une relation happens-before à travers son champ value final, assurant que le tableau de fond est entièrement visible à tout thread recevant la référence. Puisque le calcul de hachage est une fonction pure de ces données immuables et publiées en toute sécurité, la course pour peupler le cache est bénigne. Si un thread lit un 0 obsolète, il recompute simplement la même valeur ; s'il lit la valeur mise en cache, il l'utilise. L'écriture atomique garantit que la valeur est soit entièrement observée, soit non, jamais corrompue.
public int hashCode() { int h = hash; // Lire non-volatile : peut voir 0 ou valeur mise en cache if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; // Écriture atomique : assignation 32 bits est indivisible } return h; }
Nous concevions un service d'ingestion à haut débit traitant des millions d'enregistrements CSV par seconde. Chaque enregistrement générait plusieurs clés String pour un cache ConcurrentHashMap. Le profilage a révélé que les calculs hashCode() consommaient 15 % du temps CPU en raison de grandes clés en chaîne.
Solution A : champ de hachage volatile. Nous avons envisagé d'ajouter volatile au champ hash dans un wrapper personnalisé String. Les avantages incluaient une visibilité immédiate sur tous les cœurs et une cohérence séquentielle stricte. Cependant, les inconvénients étaient sévères : les benchmarks JMH ont montré une dégradation de 400 % du débit en raison des coûts de trafic de cohérence de cache et de barrière mémoire à chaque opération de carte.
Solution B : synchronized hashCode(). Nous avons testé la synchronisation du calcul. Les avantages étaient la simplicité et l'exactitude absolue. Les inconvénients étaient une contention catastrophique ; sous 32 threads, la latence est passée de 2 nanosecondes à 800 nanosecondes par opération alors que les threads attendaient pour le moniteur.
Solution C : Course bénigne (implémentation actuelle). Nous avons conservé la mise en cache idempotente non synchronisée. Les avantages étaient zéro surcharge de synchronisation et évolutivité parfaite avec le nombre de cœurs. Les inconvénients étaient théoriques : calculs redondants occasionnels si des threads se battaient lors du premier accès. Nous avons choisi Solution C parce que le coût de recalculer un hachage (raté de cache) était négligeable par rapport au coût des protocoles de cohérence de cache (volatile) ou de contention (synchronized).
Résultat : Le système a soutenu 2,5 millions d'opérations par seconde par cœur sans que hashCode() n'apparaisse dans les 100 méthodes les plus sollicitées, validant que la course de données bénigne était le bon compromis architectural pour cette structure de données immuable.
Pourquoi le manque de volatile ne viole-t-il pas la relation happens-before entre le thread qui crée la String et le thread qui calcule son hachage ?
La relation happens-before est en fait établie par la publication sûre de l'objet String lui-même, pas le champ de hachage. Lorsqu'un String est construit, son champ value final garantit que le contenu du tableau de fond est visible à tout thread recevant la référence. Le champ hash n'est qu'un cache ; observer sa valeur par défaut de 0 est un état de programme valide qui déclenche simplement le calcul. Le JMM garantit que le tableau immuable value est cohérent, et puisque le hachage est dérivé uniquement de ces données visibles, le calcul donne le même résultat, peu importe quel thread le réalise.
Cette même optimisation pourrait-elle être appliquée à une valeur de hachage long 64 bits sans utiliser volatile ?
Non. Le JMM ne garantit l'atomicité que pour les primitives de 32 bits (int, float) sur toutes les architectures. Pour les primitives de 64 bits (long, double), la spécification permet la déchirure de mots sur les JVM 32 bits ou certaines architectures sans volatile ni synchronisation. Un thread pourrait théoriquement observer les 32 bits supérieurs d'un hachage calculé et les 32 bits inférieurs d'un autre, entraînant une valeur de hachage incorrecte et non nulle qui corromprait le placement de seau HashMap. Par conséquent, la mise en cache de hachages de 64 bits nécessite volatile ou AtomicLong.
En quoi cela diffère-t-il de l'idiome "Double-Checked Locking" cassé pour l'initialisation de singleton ?
La distinction critique réside dans la publication sûre et l'idempotence. Dans le Double-Checked Locking cassé, le problème est d'observer une référence non nulle à un objet dont le constructeur n'est pas encore terminé (réordonnancement de l'assignation de référence par rapport à l'exécution du constructeur). Dans String.hashCode(), l'objet String lui-même est déjà publié en toute sécurité et entièrement construit ; le champ hash n'est qu'un cache initialisé paresseusement de données pures. Voir 0 (non initialisé) n'est pas une construction partielle mais un état initial valide. De plus, l'opération est idempotente : plusieurs threads écrivant la même valeur calculée produisent le même résultat qu'un seul thread, tandis que DCL nécessite exactement une création d'instance.