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 asynchronized.lockInterruptibly()— bloquea hasta adquirirlo, pero lanzaInterruptedExceptionsi se interrumpe. Permite cancelar un hilo que está esperando un lock.tryLock()— intenta una vez, devuelvetrue/falseinmediatamente. 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 + Condition | Monitor 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
synchronizedpor defecto para la exclusión mutua simple. Es automático, no puede tener fugas, y la JVM lo optimiza ampliamente. - Recurre a
Lockcuando necesites cualquiera de lo siguiente: un tiempo de espera en la adquisición, la posibilidad de cancelar un hilo en espera medianteinterrupt, múltiplesConditions en el mismo lock, o distinción lectura-escritura (ReentrantReadWriteLock). - Recurre a
Lockcuando 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
finallyy 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; conLock, puedes llamar aunlock()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,synchronizedpuede ser ligeramente más rápido. - Más superficie para el mal uso.
tryLockylockInterruptiblydeben 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.
Qué extraer de la ejecución:
- El patrón
try/finallyde la sección 1 es lo que necesita cada punto de llamada aLock. No hay protección sintáctica — si eliminas elfinally, 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ófalsedespué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 devuelvefalsetras el tiempo de espera sin importar qué. Consynchronized, 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
lockInterruptiblylanzóInterruptedException. Compáralo conlock.lock()osynchronized— ninguno responde ainterrupt()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 —notFullpara productores,notEmptypara consumidores. Cuando el productor añadió un elemento, hizosignalespecíficamente ennotEmpty; solo se despertó un consumidor. Conwait/notifyAllen un monitor intrínseco, se despiertan todos los hilos en espera y vuelven a comprobar; el par deConditionenvía el aviso al lado correcto de la cola y ahorra el ciclo de despertar-y-volver-a-comprobar. - El
signal()(singular) en lugar designalAll()es seguro aquí porque todos los que hacenawaiten 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),signalAllseguirí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.