Sincronización en Java
Coordina el acceso al estado compartido entre hilos en Java con la palabra clave synchronized y los bloqueos intrínsecos.
La introducción al multihilo advirtió sobre tres modos de fallo: condiciones de carrera, errores de visibilidad y deadlocks. synchronized es la primera respuesta de Java a los dos primeros. Ofrece a un bloque de código dos garantías a la vez: exclusión mutua (solo un hilo puede entrar a la vez) y visibilidad de memoria (las escrituras realizadas dentro del bloque por un hilo son vistas por el siguiente hilo que lo entre). Esas dos garantías, combinadas, son suficientes para hacer correcto un enorme volumen de código multihilo.
Este capítulo es el conceptual: qué hace synchronized, qué es un monitor intrínseco, qué tipos de carreras corrige y cuáles no. El siguiente capítulo, bloques synchronized, muestra las formas sintácticas y cómo elegir entre ellas.
La carrera que existe para corregir la palabra clave
class Counter {
int n;
void increment() { n++; }
}
Counter c = new Counter();
// Thread A and Thread B both call c.increment() a million times.
// After both finish, what is c.n?n++ es una línea de código fuente y tres operaciones de bytecode: cargar n, sumar 1, almacenar n. Si el hilo A carga n=42, luego el hilo B carga n=42 antes de que A almacene, ambos suman 1 y ambos almacenan 43. Se pierde un incremento. Ejecuta el programa un millón de veces por hilo y c.n será consistentemente menor que 2_000_000.
synchronized es la solución:
class Counter {
int n;
synchronized void increment() { n++; }
}Ahora solo un hilo a la vez ejecuta increment en este Counter. El otro espera en la puerta. Resultado: c.n == 2_000_000, en cada ejecución.
Qué es un monitor
Cada objeto Java tiene, oculto dentro de la JVM, un bloqueo asociado llamado monitor intrínseco (o monitor lock). Es simplemente una estructura de datos tipo long con dos piezas de estado: un hilo propietario (o null) y una cola de espera. Un hilo que entra a un bloque synchronized:
- Intenta adquirir el monitor del objeto sobre el que está
synchronized. - Si el monitor no tiene propietario, lo toma (ahora
owner == self) y continúa. - Si el monitor pertenece a otro hilo, este hilo pasa al estado
BLOCKEDy se une a la cola de espera. - Cuando el propietario sale del bloque, la JVM libera el monitor y uno de los que esperan lo gana.
El monitor es por objeto. Dos instancias de Counter tienen dos monitores separados; los hilos que operan sobre diferentes Counters no se bloquean entre sí. Eso es importante: la sincronización es sobre el objeto, no sobre "el método".
synchronized (someObject) {
// critical section: only one thread at a time
// holds someObject's monitor inside this block
}synchronized en un método de instancia es azúcar sintáctico para synchronized (this). En un método estático, es azúcar para synchronized (Counter.class) — el monitor del objeto Class.
Visibilidad, no solo exclusión
La exclusión mutua es la parte obvia. La parte menos obvia — y más importante — es la relación happens-before que la JVM te ofrece de forma gratuita:
Todo lo que un hilo hace antes de liberar un monitor está garantizado que sea visible para cualquier hilo que después adquiera el mismo monitor.
Esa frase es lo que hace correcto a synchronized, no meramente "primero en llegar, primero en ser atendido". Sin ella, dos hilos pueden usar un bloque synchronized, acordar la exclusión mutua y aún así ver las escrituras del otro en el orden incorrecto — porque las cachés de la CPU y el JIT, de lo contrario, son libres de reordenar. El par liberación/adquisición instala una barrera de memoria que obliga a la CPU y al JIT a vaciar y recargar.
La implicación: cualquier campo que un programa multihilo lea o escriba fuera de un bloque synchronized (y no mediante volatile, un atómico u otra primitiva de java.util.concurrent) no tiene garantía de visibilidad. Un hilo puede escribir done = true y otro hilo puede ver done = false para siempre. Volveremos a esto cuando cubramos volatile y el modelo de memoria de Java.
Lo que synchronized no corrige
Cuatro cosas que los principiantes suelen esperar de synchronized que no ofrece:
- No bloquea los datos.
synchronized (list)no impide que otro código toquelist; impide que otro hilo mantenga el mismo monitor. Si otro camino de código opera sobrelistsin adquirir el mismo monitor, la protección desaparece. - No se compone entre objetos.
synchronized (a); synchronized (b);son dos adquisiciones separadas; si otro hilo las adquiere en el orden opuesto, tienes un deadlock. - No acelera nada. Los bloqueos son pura sobrecarga. Úsalos solo donde la corrección lo requiera.
- No corrige todas las carreras. Las acciones compuestas como "verificar y actuar" siguen teniendo carreras incluso con cada operación individual sincronizada.
if (map.containsKey(k)) map.put(k, v)es incorrecto aunquecontainsKeyyputsean individualmente seguros para hilos — el hueco entre las dos llamadas está desprotegido. UsaputIfAbsento un solo bloque sincronizado alrededor de ambas.
Reentrancia
El monitor intrínseco es reentrante: un hilo que ya posee un monitor puede entrar a otro bloque synchronized sobre el mismo objeto sin bloquearse a sí mismo. Por eso esto funciona:
class Account {
synchronized void deposit(int x) { balance += x; }
synchronized void transferTo(Account other, int x) {
deposit(-x); // re-enters same monitor — fine
other.deposit(x); // acquires other's monitor too
}
}Si los monitores no fueran reentrantes, la llamada interna a deposit se bloquearía sobre el monitor que ya sostiene la llamada externa — un deadlock consigo mismo inmediato. La reentrancia hace que llamar a otro método sincronizado sobre el mismo objeto sea seguro.
La otra cara: cada adquisición necesita una liberación correspondiente. La JVM mantiene un contador; el monitor se libera cuando el contador llega a cero.
Sobre qué sincronizar
Algunas reglas que previenen la mayoría de los errores de mal uso de bloqueos:
- Sincroniza sobre un objeto de bloqueo privado, no sobre
this. El código externo también puede hacersynchronized (tuInstancia); eso permite que un llamador mantenga tu bloqueo todo el tiempo que quiera. Unfinal Object lock = new Object();privado es tuyo y nadie más puede tomarlo. - No sincronices sobre literales
Stringni primitivas encuadradas. Son internados/cacheados; dos bloquessynchronized ("foo")en diferentes partes de tu código comparten un monitor con cualquiera que también haya escrito"foo". - No sincronices sobre una referencia que puede cambiar.
synchronized (myField)dondemyFieldpuede ser reasignado son dos monitores distintos a lo largo del tiempo. El compilador no puede detectarlo; el error es silencioso. - Mantén la sección crítica pequeña. Cuanto más hagas dentro de un bloque
synchronized, más tiempo esperan todos los demás. Mantén el bloqueo mientras cambias el estado compartido, no mientras realizas el I/O circundante.
Un ejemplo práctico: con y sin el bloqueo
El programa a continuación ejecuta la misma carga de trabajo con un contador compartido de tres maneras: sin sincronización, con método synchronized y con bloque synchronized sobre un objeto de bloqueo dedicado. Los números muestran que la primera forma pierde actualizaciones y las otras dos no.
Qué interpretar de la ejecución:
- La línea
unsafeperdió actualizaciones de forma consistente — el valor final quedó por debajo del esperado1_000_000. Dos hilos haciendon++compiten en lectura-modificación-escritura; algunos incrementos desaparecen. Incluso cuando la prueba pasa en una ejecución por casualidad, el JIT, el planificador del SO o una CPU diferente eventualmente lo pondrán a prueba. La mutación no sincronizada de un campo compartido es incorrecta. - Ambas variantes seguras produjeron exactamente el conteo esperado, en todo momento. La exclusión mutua es la parte obvia de lo que hace
synchronized; la parte menos visible es que el valor que leevalue()es el último escrito porincrement— esa es la garantía de visibilidad. Sin el par de monitores, la lectura podría legítimamente ver una copia en caché desactualizada. - Los números de tiempo real para
sync methodysync blockfueron ambos notablemente más altos queunsafe. Los bloqueos no son gratuitos — cada entrada/salida hace una barrera de memoria y (bajo contención) un cambio de contexto de hilo. Sincroniza donde la corrección lo requiera; no añadas bloqueos por "seguridad". - La variante
sync block on private lockes lo que usa el código de producción. La formasync methodbloquea sobrethis, que cualquier llamador externo también puede adquirir — pueden privarte de recursos manteniendo tu propio bloqueo. Un objeto de bloqueo privado que nunca expones es exclusivamente tuyo. - El bloque de reentrancia se ejecutó sin deadlock.
outer()ya mantenía el monitor dethis;inner()lo volvió a entrar sin bloquearse. Por eso un método sincronizado puede llamar libremente a otro método sincronizado sobre el mismo objeto — sin reentrancia, la mitad de la biblioteca estándar causaría deadlocks.
Qué sigue
El siguiente capítulo, Bloques synchronized en Java, profundiza en las formas sintácticas — método, bloque, estático — y las reglas para elegir el objeto de bloqueo correcto. Para una coordinación de más alto nivel entre hilos, consulta la comunicación entre hilos (wait/notify).