Antes de la especificación JSR 133 (Java 5), el Modelo de Memoria de Java carecía de reglas formales de ocurre antes, lo que hacía que las condiciones de carrera benignas fueran peligrosas. String siempre ha sido una clase inmutable crítica para el rendimiento, utilizada en gran medida en las operaciones de HashMap. Las primeras versiones de JDK introdujeron la caché de hash perezosa para evitar recomputar el hash para cadenas grandes repetidamente. La decisión de omitir volatile en el campo hash fue una optimización deliberada que precedió a los modernos primitivos de concurrencia, confiando en la naturaleza idempotente de la computación y las garantías de atomicidad específicas añadidas a la JLS en Java 5.
Cuando múltiples hilos invocan hashCode() concurrentemente en una String recién creada, pueden todos observar el valor predeterminado de 0 en el campo hash. Sin sincronización, esto crea una condición de carrera donde varios hilos podrían calcular simultáneamente el valor del hash e intentar escribirlo de nuevo. El desafío es asegurar que ningún hilo jamás observe un valor de hash parcialmente escrito (torn) o un estado inconsistente, mientras se evitan los costos prohibitivos de las barreras de memoria asociadas con las lecturas y escrituras volatile en cada invocación de hashCode().
La solución se basa en dos propiedades fundamentales del JMM. Primero, la Especificación del Lenguaje Java (§17.7) garantiza que las escrituras a valores primitivos de 32 bits (int) son atómicas, evitando el desgarro de palabras. Segundo, el constructor de String establece una relación de ocurre antes a través de su campo value final, asegurando que el array de respaldo sea completamente visible para cualquier hilo que reciba la referencia. Dado que la computación del hash es una función pura de estos datos inmutables y publicados de manera segura, la carrera para poblar la caché es benigna. Si un hilo lee un 0 obsoleto, simplemente recomputa el mismo valor; si lee el valor en caché, lo utiliza. La escritura atómica asegura que el valor sea completamente observado o no, nunca corrompido.
public int hashCode() { int h = hash; // Lectura no volatile: puede ver 0 o el valor en caché if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; // Escritura atómica: la asignación de 32 bits es indivisible } return h; }
Estábamos diseñando un servicio de ingestión de alto rendimiento que procesaba millones de registros CSV por segundo. Cada registro generaba múltiples claves String para una caché ConcurrentHashMap. La supervisión reveló que los cálculos de hashCode() consumían el 15% del tiempo de CPU debido a las grandes claves de cadena.
Solución A: campo hash volatile. Consideramos agregar volatile al campo hash en un envoltorio de String personalizado. Los pros incluían visibilidad inmediata en todos los núcleos y una consistencia secuencial estricta. Sin embargo, los contras fueron severos: las pruebas de rendimiento de JMH mostraron una degradación del 400% en el rendimiento debido al tráfico de coherencia de caché y los costos de la barrera de memoria en cada operación de mapa.
Solución B: synchronized hashCode(). Probamos sincronizar el cálculo. Los pros eran simplicidad y corrección absoluta. Los contras fueron una contención catastrófica; con 32 hilos, la latencia se disparó de 2 nanosegundos a 800 nanosegundos por operación a medida que los hilos se alineaban para el monitor.
Solución C: Carrera benigna (implementación actual). Mantuvimos la caché de idempotencia no sincronizada. Los pros eran cero sobrecarga de sincronización y escalabilidad perfecta con el recuento de núcleos. Los contras eran teóricos: un cálculo redundante ocasional si los hilos competían durante el primer acceso. Elegimos Solución C porque el costo de recomputar un hash (falla de caché) era negligible en comparación con el costo de los protocolos de coherencia de caché (volatile) o de contención (synchronized).
Resultado: El sistema mantuvo 2.5 millones de operaciones por segundo por núcleo sin que hashCode() apareciera en los 100 métodos más exigentes, validando que la carrera de datos benigna fue el intercambio arquitectónico correcto para esta estructura de datos inmutable.
¿Por qué la falta de volatile no viola la relación ocurre antes entre el hilo que crea la String y el hilo que calcula su hash?
La relación ocurre antes se establece en realidad por la publicación segura del objeto String en sí, no por el campo hash. Cuando se construye una String, su campo value final garantiza que el contenido del array de respaldo sea visible para cualquier hilo que reciba la referencia. El campo hash es simplemente una caché; observar su valor predeterminado de 0 es un estado válido del programa que simplemente desencadena la computación. El JMM asegura que el array inmutable value sea consistente, y dado que el hash se deriva puramente de estos datos visibles, la computación produce el mismo resultado independientemente de qué hilo lo realice.
¿Se podría aplicar la misma optimización a un valor de hash de tipo long de 64 bits sin usar volatile?
No. El JMM solo garantiza atomicidad para primitivos de 32 bits (int, float) en todas las arquitecturas. Para primitivos de 64 bits (long, double), la especificación permite el desgarro de palabras en JVMs de 32 bits o ciertas arquitecturas sin volatile o sincronización. Teóricamente, un hilo podría observar los 32 bits altos de un hash calculado y los 32 bits bajos de otro, resultando en un valor de hash incorrecto y no cero que corruptería la colocación de cubos en HashMap. Por lo tanto, almacenar hashes de 64 bits requiere volatile o AtomicLong.
¿Cómo difiere esto de la mala frase de "Double-Checked Locking" para la inicialización de singleton?
La distinción crítica radica en la publicación segura y la idempotencia. En un Double-Checked Locking roto, el problema es observar una referencia no nula a un objeto cuyo constructor no se ha completado (reordenación de la asignación de referencia frente a la ejecución del constructor). En String.hashCode(), el objeto String ya está publicado de manera segura y completamente construido; el campo hash es simplemente una caché de inicialización perezosa de datos puros. Ver 0 (no inicializado) no es una construcción parcial sino un estado inicial válido. Además, la operación es idempotente: múltiples hilos escribiendo el mismo valor calculado producen el mismo resultado que un hilo, mientras que DCL requiere exactamente una creación de instancia.