W3docs

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:

  1. Intenta adquirir el monitor del objeto sobre el que está synchronized.
  2. Si el monitor no tiene propietario, lo toma (ahora owner == self) y continúa.
  3. Si el monitor pertenece a otro hilo, este hilo pasa al estado BLOCKED y se une a la cola de espera.
  4. 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:

  1. No bloquea los datos. synchronized (list) no impide que otro código toque list; impide que otro hilo mantenga el mismo monitor. Si otro camino de código opera sobre list sin adquirir el mismo monitor, la protección desaparece.
  2. 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.
  3. No acelera nada. Los bloqueos son pura sobrecarga. Úsalos solo donde la corrección lo requiera.
  4. 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 aunque containsKey y put sean individualmente seguros para hilos — el hueco entre las dos llamadas está desprotegido. Usa putIfAbsent o 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 hacer synchronized (tuInstancia); eso permite que un llamador mantenga tu bloqueo todo el tiempo que quiera. Un final Object lock = new Object(); privado es tuyo y nadie más puede tomarlo.
  • No sincronices sobre literales String ni primitivas encuadradas. Son internados/cacheados; dos bloques synchronized ("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) donde myField puede 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.

java— editable, runs on the server

Qué interpretar de la ejecución:

  • La línea unsafe perdió actualizaciones de forma consistente — el valor final quedó por debajo del esperado 1_000_000. Dos hilos haciendo n++ 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 lee value() es el último escrito por increment — 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 method y sync block fueron ambos notablemente más altos que unsafe. 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 lock es lo que usa el código de producción. La forma sync method bloquea sobre this, 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 de this; 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).

Práctica

Práctica
Dos hilos llaman cada uno a `counter.increment()` en un `Counter` cuyo campo `n` es un `int` no sincronizado. Después de que ambos completen 1,000,000 de incrementos, ¿qué muestra típicamente `counter.n`?
Dos hilos llaman cada uno a `counter.increment()` en un `Counter` cuyo campo `n` es un `int` no sincronizado. Después de que ambos completen 1,000,000 de incrementos, ¿qué muestra típicamente `counter.n`?
Was this page helpful?