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
| Forma | Objeto de bloqueo | Cuándo usar |
|---|---|---|
synchronized void method() | this | Clases pequeñas y simples. El bloqueo público está bien. |
synchronized static void method() | ClassName.class | Mutar estado por clase desde cualquier instancia. |
synchronized (obj) { ... } | obj | Casi 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 atomicLa 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.
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, unObjectprivado, laClass) pero cada una protegió la operación de lectura-modificación-escritura de la misma manera. Asynchronizedno 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.classcomo bloqueo. Cada hilo, cada prueba, cada otra pieza de código que se sincronice sobreV3.classcontenderí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 (
thiso laClass). Cualquiera puede hacersynchronized (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
synchronizedpara que la contención sea solo por elputreal. 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.