W3docs

Métodos y bloques synchronized en Java

Usa métodos y bloques synchronized en Java para proteger secciones críticas y elegir el objeto de bloqueo correcto.

El capítulo anterior estableció qué hace synchronized. Este es el capítulo sintáctico: las tres formas que puede adoptar la palabra clave, qué bloqueo usa cada una y cómo elegir la correcta. La forma que elijas tiene consecuencias en rendimiento y corrección; "simplemente agregar synchronized al método" funciona en casos triviales y falla cuando la clase crece.

Tres formas, tres objetos de bloqueo

FormaObjeto de bloqueoCuándo usar
synchronized void method()thisClases pequeñas y simples. El bloqueo público está bien.
synchronized static void method()ClassName.classMutar estado por clase desde cualquier instancia.
synchronized (obj) { ... }objCasi todo lo demás. Usa un bloqueo privado por seguridad.

La tercera forma es la más flexible. Las dos primeras son azúcar sintáctica para ella.

synchronized en un método de instancia

public synchronized void deposit(int x) {
  balance += x;
}

Se compila en un bloque que bloquea sobre this. Solo un hilo a la vez puede estar ejecutando cualquier método de instancia sincronizado en este objeto específico. (Las diferentes instancias de Account tienen diferentes referencias this y, por lo tanto, diferentes monitores.) Los métodos estáticos y los métodos no sincronizados no se ven afectados.

El problema. this es parte de la referencia pública. Cualquier código que tenga un identificador del objeto puede hacer synchronized (account) { ... } y mantener el mismo bloqueo que account.deposit(). Eso incluye entornos de prueba, depuradores, código de framework y cualquier otro sitio de llamada que no controles. Un llamador con mal comportamiento puede mantener tu bloqueo todo el tiempo que quiera y quedarás bloqueado.

En clases pequeñas serás el único llamador — está bien. En bibliotecas, en código que otras personas usarán, o en clases que más tarde podrías refactorizar, prefiere un objeto de bloqueo privado.

synchronized en un método estático

public class Counters {
  private static int total;

  public static synchronized void bump() {
    total++;
  }
}

Se compila en un bloque que bloquea sobre Counters.class. El monitor es global por clase — cada hilo, cada instancia, compite por el mismo bloqueo al llamar a bump(). La misma advertencia que con this aplica: cualquier otro código también puede hacer synchronized (Counters.class) { ... } y mantener el bloqueo.

Para estado por clase, esta forma está bien en clases utilitarias pequeñas. Para las más grandes, prefiere un bloqueo estático privado:

public class Counters {
  private static final Object LOCK = new Object();
  private static int total;

  public static void bump() {
    synchronized (LOCK) { total++; }
  }
}

synchronized sobre un objeto explícito — la forma de producción

public class Cache {
  private final Object lock = new Object();
  private final Map<String, String> data = new HashMap<>();

  public String get(String k) {
    synchronized (lock) {
      return data.get(k);
    }
  }

  public void put(String k, String v) {
    synchronized (lock) {
      data.put(k, v);
    }
  }
}

Dos propiedades que te da esta forma:

  • Bloqueo privado. Ningún llamador puede adquirirlo; nadie puede bloquearte.
  • Alcance quirúrgico. Solo el interior del bloque mantiene el bloqueo. Todo lo de afuera — validación de argumentos, formateo de valores de retorno, registro — se ejecuta sin contención.

Por la misma razón que mantienes campos private final privados, mantienes tu bloqueo privado. El objeto de bloqueo forma parte de tu implementación, no de tu interfaz.

Regla: mantén la sección crítica ajustada

Cuanto más código se ejecute mientras se mantiene un bloqueo, más contención se genera. El patrón correcto es hacer lo mínimo necesario dentro del bloque:

// Bad: I/O inside the lock — everybody waits while one thread talks to disk
public synchronized void load(String k) {
  String v = Files.readString(Path.of("/tmp/" + k));         // bad
  cache.put(k, v);
}

// Good: read outside the lock, lock only the mutation
public void load(String k) throws IOException {
  String v = Files.readString(Path.of("/tmp/" + k));
  synchronized (lock) {
    cache.put(k, v);
  }
}

El principio general: bloquea solo mientras modificas estado compartido, nunca mientras realizas trabajo arbitrario que pueda bloquear.

Acciones compuestas y doble bloqueo

synchronized protege un bloque. Si dos operaciones juntas deben ser atómicas, ambas deben estar en el mismo bloque:

// Wrong: the if and the put are individually synchronised by HashMap... no they're not,
// but even if they were, the gap between them is not.
if (!map.containsKey(k)) {                            // someone else could insert here
  map.put(k, v);
}

// Right: one block protects both ops
synchronized (lock) {
  if (!map.containsKey(k)) {
    map.put(k, v);
  }
}

// Even better: a single atomic operation
map.putIfAbsent(k, v);                                // for ConcurrentHashMap, fully atomic

La carrera entre containsKey y put — conocida como la carrera check-then-act — es la fuente de más errores de concurrencia que el propio bloqueo. Siempre que escribas if (...) doThing(), pregúntate: entre el if y el doThing, ¿puede otro hilo cambiar la respuesta? Si es así, atomiza.

Los bloqueos no se componen — cuidado con el orden de bloqueos

Dos bloques synchronized adquiridos en órdenes diferentes por hilos distintos pueden producir un deadlock:

// Thread A
synchronized (account1) {
  synchronized (account2) { transfer(account1, account2, 100); }
}

// Thread B simultaneously
synchronized (account2) {
  synchronized (account1) { transfer(account2, account1, 100); }
}

Cada hilo mantiene un bloqueo y espera el otro. Ambos hilos quedan BLOCKED para siempre. La solución es el orden consistente — adquirir siempre los bloqueos en el mismo orden global:

void transfer(Account a, Account b, int x) {
  Account first  = a.id() < b.id() ? a : b;          // ordering by stable key
  Account second = a.id() < b.id() ? b : a;
  synchronized (first) {
    synchronized (second) {
      a.debit(x);
      b.credit(x);
    }
  }
}

El orden basado en hash, el orden basado en System.identityHashCode o un bloqueo de desempate son los tres enfoques habituales. El capítulo sobre deadlocks los cubre en profundidad.

¿Qué hay de synchronized sobre un primitivo?

No puedes hacerlo. synchronized requiere un objeto — un long o int no tiene monitor. Puedes encapsularlo (Long/Integer) y bloquear sobre él sintácticamente, pero nunca hagas esto: los primitivos encapsulados en la caché de auto-boxing se comparten. Dos fragmentos de código que bloqueen Integer.valueOf(1) están bloqueando el mismo objeto — aunque no tengan nada que ver entre sí.

synchronized (Integer.valueOf(1)) {                   // never do this
  ...
}

Para objetos de bloqueo, siempre asigna un Object privado. El propósito de un monitor es la identidad, no el valor.

synchronized y las excepciones

Si el cuerpo de un bloque sincronizado lanza una excepción, el monitor se libera a medida que se desenrolla la excepción. No necesitas un finally para el desbloqueo — la JVM lo gestiona. Esa es una de las principales razones por las que synchronized es difícil de usar mal: no hay "fuga de bloqueo" como ocurre con el API Lock explícita con lock()/unlock() (donde el desbloqueo es una llamada de método separada que debes recordar poner en un finally).

El otro lado: cualquier estado compartido que hayas modificado a medias dentro del bloque es visible para el próximo adquirente. Si la excepción deja tus invariantes rotos, el bloqueo por sí solo no te salva — restaura los invariantes en el catch o diseña la mutación para que no pueda completarse a medias.

Un ejemplo trabajado: las cuatro formas lado a lado

El programa a continuación usa cada forma con el mismo estado compartido y termina con una comparación lado a lado.

java— editable, runs on the server

Qué extraer de la ejecución:

  • Las tres formas de contador produjeron el recuento esperado de 800,000. Cada forma eligió un objeto de bloqueo diferente (this, un Object privado, la Class) pero cada una protegió la operación de lectura-modificación-escritura de la misma manera. A synchronized no le importa qué es el objeto de bloqueo — solo que cada hilo en contención use el mismo.
  • La forma de método estático (V3) usó el monitor de V3.class como bloqueo. Cada hilo, cada prueba, cada otra pieza de código que se sincronice sobre V3.class contendería por el mismo bloqueo. Eso es apropiado para estado por clase; usarlo para estado por instancia es un error de contención — estarías bloqueando trabajo no relacionado entre sí.
  • Las formas de método estático y de instancia son convenientes pero bloquean sobre un objeto de acceso público (this o la Class). Cualquiera puede hacer synchronized (someObject) y mantener el mismo monitor. La forma de objeto de bloqueo privado (V2) es lo que usa el código de producción precisamente porque nadie fuera de la clase puede alcanzar el bloqueo.
  • La clase V4 (definida pero no comparada en el ejemplo anterior) muestra la forma incorrecta: trabajo similar a I/O dentro de la sección crítica. La siguiente versión correcta saca el formateo y la llamada de bloqueo (simulada) fuera del bloque synchronized para que la contención sea solo por el put real. La misma corrección, con un rendimiento mucho mayor bajo carga.
  • El bloque de doble bloqueo al final adquirió dos bloqueos no relacionados en el orden determinado por System.identityHashCode. Esa regla de ordenamiento, aplicada en todas partes del programa, es la estrategia más simple de prevención de deadlocks cuando debes mantener dos bloqueos a la vez. La volveremos a ver en el capítulo sobre deadlock.

Qué sigue

El siguiente capítulo, Comunicación entre hilos en Java, introduce la otra mitad del API del monitor intrínseco — wait, notify y notifyAll — la forma en que los hilos se señalan entre sí dentro de una sección crítica.

Práctica

Práctica
Escribes `public synchronized void deposit(int x)` en un método de `class Account`. ¿Qué monitor adquiere el método?
Escribes `public synchronized void deposit(int x)` en un método de `class Account`. ¿Qué monitor adquiere el método?
Was this page helpful?