W3docs

Interfaz Lock de Java

La interfaz java.util.concurrent.locks.Lock — qué ofrece que `synchronized` no puede, y las reglas para usarla de forma segura.

synchronized es la herramienta pequeña y precisa. Es rápida, automática y cubre la mayoría de las necesidades de exclusión mutua. Pero una vez que la superas — cuando necesitas un tiempo de espera, una forma de cancelar, o más de una variable de condición — Java ofrece una segunda API de bloqueo más rica: la interfaz java.util.concurrent.locks.Lock y sus implementaciones. Este capítulo presenta la interfaz; los dos capítulos siguientes cubren las dos implementaciones (ReentrantLock, ReentrantReadWriteLock) que realmente usarás.

Qué ofrece la interfaz

public interface Lock {
  void lock();
  void lockInterruptibly() throws InterruptedException;
  boolean tryLock();
  boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
  void unlock();
  Condition newCondition();
}

Seis métodos. Cinco de ellos sirven para adquirir o liberar el lock; uno devuelve un Condition (la respuesta de Lock a wait/notify).

Las cuatro formas de adquirir son lo que synchronized no te da:

  • lock() — bloquea hasta adquirirlo. Lo más parecido a synchronized.
  • lockInterruptibly() — bloquea hasta adquirirlo, pero lanza InterruptedException si se interrumpe. Permite cancelar un hilo que está esperando un lock.
  • tryLock() — intenta una vez, devuelve true/false inmediatamente. No bloquea.
  • tryLock(time, unit) — intenta hasta un tiempo límite y luego se rinde. La herramienta de prevención de deadlock del capítulo anterior.

synchronized solo tiene un modo de adquisición — bloquear indefinidamente hasta obtenerlo. Eso es adecuado para la mayoría del código; no lo es cuando necesitas un plazo límite o un punto de cancelación.

El patrón obligatorio try/finally

synchronized libera el monitor automáticamente cuando el bloque termina — tanto en una finalización normal como en caso de excepción. Lock no lo hace. Si olvidas llamar a unlock, el lock se mantiene para siempre y todo lo que venga después queda bloqueado.

El patrón correcto, en todo momento:

lock.lock();
try {
  // critical section
} finally {
  lock.unlock();
}

El unlock debe estar en un bloque finally para que se ejecute incluso si el cuerpo lanza una excepción. No existe try-with-resources para Lock directamente (no es AutoCloseable), pero existen patrones envolventes que lo simulan. El patrón estándar anterior es el que usa casi todo el código en producción.

tryLock y tiempo de espera

Las dos sobrecargas de tryLock son la forma en que Lock permite manejar "¿qué pasa si no podemos obtenerlo?":

if (lock.tryLock()) {
  try {
    doWork();
  } finally {
    lock.unlock();
  }
} else {
  // didn't get the lock — do something else, maybe retry later
}
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {       // wait up to 500ms
  try {
    doWork();
  } finally {
    lock.unlock();
  }
} else {
  throw new TimeoutException("couldn't acquire " + name);
}

Esa segunda forma es lo que hace posible la recuperación ante deadlocks. Con synchronized, un hilo que espera un monitor está bloqueado hasta que el poseedor lo libera — no hay salida excepto que la JVM falle. Con tryLock(timeout), te rindes tras un plazo límite y puedes reintentar, fallar la operación o tomar un camino alternativo.

lockInterruptibly — adquisición de lock cancelable

synchronized no responde a Thread.interrupt() mientras espera. Un hilo BLOCKED en un monitor permanece bloqueado incluso si lo interrumpes — la JVM simplemente establece la bandera y la ignora.

lock.lockInterruptibly() sí responde. Si otro hilo llama a interrupt() sobre ti mientras esperas el lock, la llamada lanza InterruptedException inmediatamente:

try {
  lock.lockInterruptibly();
} catch (InterruptedException e) {
  Thread.currentThread().interrupt();
  return;                                              // gave up on the work
}
try {
  doWork();
} finally {
  lock.unlock();
}

Esto es esencial en código de servidor: llega una petición, un hilo intenta adquirir un lock, la petición se cancela (desconexión del cliente, tiempo de espera de un balanceador de carga), el supervisor llama a interrupt() sobre el trabajador. Con synchronized, el trabajador sigue esperando; con lockInterruptibly, se rinde.

Condition — el wait/notify para Lock

El equivalente del monitor intrínseco — wait/notify en un bloque synchronized — te da exactamente un conjunto de espera por objeto. Un solo Lock puede tener varios objetos Condition:

Lock lock = new ReentrantLock();
Condition notFull  = lock.newCondition();
Condition notEmpty = lock.newCondition();

Posees el lock, haces await() en una condición (que libera el lock y te suspende), y otro hilo hace signal() en la condición (que te mueve al estado BLOCKED esperando el lock). El mapeo con wait/notify:

Lock + ConditionMonitor intrínseco
lock.lock()entrar en synchronized (obj)
condition.await()obj.wait()
condition.signal()obj.notify()
condition.signalAll()obj.notifyAll()
lock.unlock()salir de synchronized

La ventaja sobre wait/notify: múltiples condiciones por lock. Un buffer acotado puede tener una condición para "no lleno" y otra para "no vacío" — los productores hacen signal(notEmpty) tras insertar un elemento; los consumidores hacen signal(notFull) tras extraer. Solo se despierta el lado correcto. El enfoque de notifyAll con un único monitor debe despertar a todos y esperar.

Veremos la reescritura del buffer acotado en el capítulo de ReentrantLock.

Cuándo usar Lock y cuándo quedarse con synchronized

Una regla de decisión pragmática:

  • Usa synchronized por defecto para la exclusión mutua simple. Es automático, no puede tener fugas, y la JVM lo optimiza ampliamente.
  • Recurre a Lock cuando necesites cualquiera de lo siguiente: un tiempo de espera en la adquisición, la posibilidad de cancelar un hilo en espera mediante interrupt, múltiples Conditions en el mismo lock, o distinción lectura-escritura (ReentrantReadWriteLock).
  • Recurre a Lock cuando la contención sea alta y necesites una opción de orden justo (new ReentrantLock(true) es la versión justa; los monitores intrínsecos son injustos). El orden justo intercambia rendimiento por predecibilidad.

No debes "actualizar" synchronized a Lock sin motivo. Los dos son equivalentes en el caso básico; el resto del capítulo trata sobre cuándo las capacidades adicionales compensan.

Lo que pierdes

Lock tiene costos que synchronized no tiene:

  • Sin liberación automática. Olvida el finally y el lock se pierde. La JVM no puede salvarte.
  • Sin verificación de anidamiento estructurado. Con synchronized, el compilador hace cumplir el emparejamiento lock/unlock; con Lock, puedes llamar a unlock() desde un método o ruta diferente y el compilador no lo detecta.
  • Sin optimizaciones nativas del entorno de ejecución. La JVM tiene optimizaciones especiales para los monitores intrínsecos (biased locking, engrosamiento de locks, elisión de locks en algunos casos) que no se aplican a Lock. Para código con muy poca contención, synchronized puede ser ligeramente más rápido.
  • Más superficie para el mal uso. tryLock y lockInterruptibly deben ir acompañados de una comprobación; omitirla genera un error silencioso de "lock no adquirido".

Usa Lock por sus capacidades, no por su sintaxis.

Un ejemplo práctico: Lock haciendo lo que synchronized no puede

El programa a continuación usa ReentrantLock (la implementación estándar de Lock) para demostrar las tres cosas que synchronized no ofrece: tryLock con tiempo de espera, lockInterruptibly, y un Condition personalizado.

java— editable, runs on the server

Qué extraer de la ejecución:

  • El patrón try/finally de la sección 1 es lo que necesita cada punto de llamada a Lock. No hay protección sintáctica — si eliminas el finally, el código compila, y el lock se pierde la primera vez que el cuerpo lanza una excepción. Memoriza la forma: lock(), try { ... } finally { unlock(); }.
  • El tryLock(100, MS) de la sección 2 devolvió false después de aproximadamente 100 ms porque el hilo poseedor aún estaba en su pausa de 500 ms. Ese es el contrato del plazo límite — la llamada devuelve false tras el tiempo de espera sin importar qué. Con synchronized, este hilo habría bloqueado hasta que el poseedor liberase, sin salida posible.
  • El hilo en espera de la sección 3 fue interrumpido mientras esperaba el lock, y lockInterruptibly lanzó InterruptedException. Compáralo con lock.lock() o synchronized — ninguno responde a interrupt() mientras espera. Esta es la diferencia entre un servidor que puede cancelar peticiones con tiempo agotado y uno que simplemente acumula hilos atascados.
  • La sección 4 usó dos Conditions en un lock — notFull para productores, notEmpty para consumidores. Cuando el productor añadió un elemento, hizo signal específicamente en notEmpty; solo se despertó un consumidor. Con wait/notifyAll en un monitor intrínseco, se despiertan todos los hilos en espera y vuelven a comprobar; el par de Condition envía el aviso al lado correcto de la cola y ahorra el ciclo de despertar-y-volver-a-comprobar.
  • El signal() (singular) en lugar de signalAll() es seguro aquí porque todos los que hacen await en cada condición son intercambiables — cualquier productor puede llenar el hueco que acabamos de abrir. Si los que esperan no fueran intercambiables (por ejemplo, si esperaban claves específicas diferentes), signalAll seguiría siendo la opción más segura por defecto.

Qué sigue

El siguiente capítulo, Java ReentrantLock, profundiza en la implementación estándar de Lock — su reentrada, su política de equidad y la API de diagnóstico getHoldCount/isHeldByCurrentThread.

Práctica

Práctica
Estás escribiendo código que necesita adquirir un lock con un plazo límite — rendirse después de 200 ms si el lock no está disponible y ejecutar una acción alternativa. ¿Cuál es el enfoque correcto?
Estás escribiendo código que necesita adquirir un lock con un plazo límite — rendirse después de 200 ms si el lock no está disponible y ejecutar una acción alternativa. ¿Cuál es el enfoque correcto?
Was this page helpful?