W3docs

Java ReentrantLock

Usa ReentrantLock para bloqueos explícitos y flexibles en Java: tryLock, lockInterruptibly, política de equidad y API de diagnóstico.

ReentrantLock es la implementación estándar de la interfaz Lock. "Reentrant" significa que el mismo hilo puede adquirir el bloqueo varias veces sin bloquearse a sí mismo — la misma propiedad que tiene synchronized. Todo lo que describió el capítulo de LocktryLock, lockInterruptibly, Condition — lo proporciona esta única clase.

Este capítulo es el análisis profundo: los dos constructores, la opción de equidad, los métodos de diagnóstico, el emparejamiento con Condition para productor/consumidor, y los casos concretos donde ReentrantLock justifica su código adicional de try/finally frente al synchronized simple.

Qué cubre esta página

Dos constructores

Lock lock = new ReentrantLock();        // non-fair (default) — high throughput
Lock fair = new ReentrantLock(true);    // fair — FIFO wait queue

No equitativo (el predeterminado) significa que cuando el bloqueo queda disponible, gana el hilo en espera que el planificador ejecute primero. Los hilos entrantes también pueden "colarse" — adquirir el bloqueo sin esperar en la cola si está libre en el momento de su llamada. Esto es rápido: sin manipulación de cola, sin sugerencia al planificador. La desventaja es la posibilidad de inanición — un hilo puede estar en la cola de espera mucho tiempo mientras los que se cuelan siguen tomando el bloqueo.

Equitativo significa que el bloqueo se otorga al hilo que lleva más tiempo esperando. La cola de espera es un verdadero FIFO. Esto elimina la inanición. El costo: un rendimiento notablemente menor, porque cada adquisición implica una decisión del planificador y la JVM no puede tomar atajos de camino rápido.

El valor predeterminado correcto es no equitativo. Usa el equitativo solo cuando hayas identificado un problema real de inanición (típicamente detectado por una consulta getWaitQueueLength que sigue creciendo) o cuando la corrección de la aplicación depende del orden de procesamiento.

Reentrada y getHoldCount

Al igual que synchronized, un ReentrantLock puede ser readquirido por el hilo que ya lo tiene:

ReentrantLock lock = new ReentrantLock();
lock.lock();
lock.lock();                          // same thread re-enters — fine
try {
  doStuff();
} finally {
  lock.unlock();
  lock.unlock();                      // must unlock as many times as locked
}

Cada lock() incrementa un contador de retención interno; cada unlock() lo decrementa. El bloqueo se libera realmente — visible para otros hilos — solo cuando el contador llega a cero. La llamada de diagnóstico:

int n = lock.getHoldCount();          // how many times THIS thread has acquired without unlocking

getHoldCount es útil para aserciones ("este método debe llamarse con el bloqueo retenido") y para verificar invariantes en pruebas.

La regla de unlock coincidente es estricta. Si bloqueas dos veces y desbloqueas una, el bloqueo permanece retenido — fuga silenciosa. Si desbloqueas más veces de las que bloqueaste, se lanza IllegalMonitorStateException inmediatamente. Siempre empareja la adquisición y la liberación dentro del mismo método cuando sea posible; distribuirlo entre métodos hace que el control sea frágil rápidamente.

Otros métodos de diagnóstico

ReentrantLock expone bastante introspección que synchronized no tiene:

lock.isLocked();                       // is anybody holding it?
lock.isHeldByCurrentThread();          // do I hold it?
lock.getQueueLength();                 // how many threads are waiting?
lock.hasQueuedThreads();               // are any waiting?
lock.hasQueuedThread(t);               // is thread t waiting?
lock.getHoldCount();                   // how many times have I re-entered?

Estos son principalmente para monitoreo y pruebas — la lógica de producción no debería depender de "¿hay alguien esperando?" porque la respuesta es inconsistente en el momento en que la compruebas. Pero para métricas, "ratio de adquisiciones con contención" es una señal útil de que la granularidad de tu bloqueo es incorrecta.

Cuándo ReentrantLock supera a synchronized

Las cuatro razones para elegir ReentrantLock:

  1. Adquisición con plazo límite. Necesitas fallar rápido o retroceder si el bloqueo no está disponible dentro de un tiempo acotado. tryLock(timeout) hace esto; synchronized no.
  2. Cancelación. Necesitas interrumpir un hilo que está esperando el bloqueo. lockInterruptibly hace esto; synchronized ignora las interrupciones durante la entrada al monitor.
  3. Múltiples variables de condición. Necesitas señalar por separado diferentes categorías de hilos en espera. Lock.newCondition() hace esto; el monitor intrínseco tiene exactamente un conjunto de espera.
  4. Equidad. Necesitas ordenar a los hilos en espera con FIFO. new ReentrantLock(true) es la única manera integrada.

Cualquier cosa fuera de esas cuatro — código bien escrito sin necesidades especiales — debería quedarse con synchronized. Las optimizaciones de la JVM para los monitores intrínsecos son reales, y la disciplina de try/finally que necesita Lock es algo que puedes olvidar. No busques Lock "porque es más moderno."

El par Condition, en detalle

El patrón productor/consumidor con un ReentrantLock y dos condiciones es el ejemplo clásico. Se repite aquí en forma independiente porque también es la estructura que ArrayBlockingQueue usa internamente:

class BoundedBuffer<T> {
  private final ReentrantLock lock = new ReentrantLock();
  private final Condition notFull  = lock.newCondition();
  private final Condition notEmpty = lock.newCondition();
  private final Object[] items;
  private int count, head, tail;

  BoundedBuffer(int cap) { items = new Object[cap]; }

  public void put(T x) throws InterruptedException {
    lock.lock();
    try {
      while (count == items.length) notFull.await();          // release lock, park, re-acquire on wake
      items[tail] = x;
      tail = (tail + 1) % items.length;
      count++;
      notEmpty.signal();                                       // wake exactly one consumer
    } finally { lock.unlock(); }
  }

  @SuppressWarnings("unchecked")
  public T take() throws InterruptedException {
    lock.lock();
    try {
      while (count == 0) notEmpty.await();
      T x = (T) items[head];
      items[head] = null;
      head = (head + 1) % items.length;
      count--;
      notFull.signal();                                        // wake exactly one producer
      return x;
    } finally { lock.unlock(); }
  }
}

La ventaja sobre wait/notifyAll: un productor despierta a un consumidor, no a todos los hilos en espera. Bajo alta contención, esa es la diferencia entre tormentas de notifyAll (todos los hilos en espera despiertan, compiten por el bloqueo, todos menos uno vuelven a dormir) y una entrega limpia.

signal vs signalAll sigue la misma regla que notify vs notifyAll: prefiere signalAll a menos que puedas demostrar que todos los hilos en espera de esta condición son intercambiables. En este búfer, todos los que esperan en notEmpty son consumidores que quieren un slot — son intercambiables; signal es seguro.

El intercambio CAS-loop / monitor

Una pregunta común: ¿cuándo gana una variable atómica como AtomicInteger sobre un ReentrantLock? Aproximadamente:

  • Para un solo campo con una actualización simple, los atómicos ganan — son instrucciones CAS, sin llamada al kernel, sin estacionamiento. AtomicInteger.incrementAndGet es más rápido que ReentrantLock.lock + int++ + unlock.
  • Para múltiples campos que deben actualizarse juntos o para semántica de bloqueo (esperar a que una cola no esté vacía), el bloqueo gana — puedes agrupar el trabajo y señalar a través de él.

Una comprobación de solo lectura como "¿es válida la caché?" es volatile; un incremento es atómico; un "intercambiar un elemento por otro en una cola" es un bloqueo. Usa la herramienta más ligera que requiera el trabajo.

tryLock para composición sin deadlock

El patrón más simple para combinar dos bloqueos sin ordenar:

boolean done = false;
while (!done) {
  lockA.lock();
  try {
    if (lockB.tryLock(50, TimeUnit.MILLISECONDS)) {           // bounded wait for the second lock
      try {
        doCriticalWork();
        done = true;
      } finally { lockB.unlock(); }
    }
    // else: couldn't get lockB in time — fall through, release lockA, retry
  } finally {
    lockA.unlock();                                           // always release lockA before looping
  }
}

Si tryLock en lockB agota el tiempo, el finally libera lockA y el bucle vuelve a intentarlo desde el principio. Dado que ningún hilo retiene un bloqueo mientras bloquea para siempre en otro, la condición clásica de deadlock de retener-y-esperar se elimina.

Esto evita la trampa del deadlock por ordenamiento sin requerir una regla de orden global de adquisición de bloqueos. El inconveniente es más código, potencial de livelock bajo alta contención y peor comportamiento de caché. Úsalo para bloqueos transversales (bloqueos en objetos que no escribiste); prefiere un orden fijo de adquisición de bloqueos cuando controlas ambos lados.

Un ejemplo práctico: contención, equidad y reentrada

El programa siguiente contrasta un ReentrantLock equitativo vs no equitativo bajo alta contención, y luego demuestra la reentrada y la contabilidad del contador de retención.

java— editable, runs on the server

Lo que se puede extraer de la ejecución:

  • El bloqueo no equitativo distribuyó el pool compartido de adquisiciones de forma desigual — el spread = max-min reportado fue grande (comúnmente varios miles de 50,000 por hilo). Eso es el camino rápido en acción: la JVM no impone ordenamiento, por lo que un hilo que acaba de liberar el bloqueo puede colarse de inmediato y ganarlo de nuevo antes de que se programe un hilo en cola.
  • El bloqueo equitativo distribuyó las adquisiciones casi uniformemente — su dispersión fue una pequeña fracción de la dispersión no equitativa, porque la cola de espera FIFO le da a cada hilo su turno. El tiempo total de reloj fue notablemente mayor. El ordenamiento equitativo intercambia rendimiento por progreso predecible. No pagues ese costo a menos que hayas medido un problema de inanición. (Los números exactos varían según la ejecución y la máquina; lo que es estable es que la dispersión no equitativa es mucho mayor que la dispersión equitativa.)
  • La sección de reentrada mostró el contador de retención subiendo y bajando con cada lock/unlock. El bloqueo se libera realmente solo cuando el contador llega a cero; hasta entonces los otros hilos que esperan permanecen BLOCKED. Esta es la misma semántica que synchronized; la diferencia es que ReentrantLock expone el contador a la inspección.
  • El unlock() adicional después de que el contador de retención llegó a cero lanzó IllegalMonitorStateException inmediatamente — no hay un "doble desbloqueo" silencioso que tenga éxito. Eso es la JVM haciendo cumplir el invariante del bloqueo: solo el titular puede liberarlo, y solo tantas veces como lo adquirió.
  • La lectura de getQueueLength de 3 confirmó que los tres hilos en espera estaban genuinamente en cola detrás de nosotros. En producción, este método es útil para alertas de "¿está aumentando la contención?" — una longitud de cola que crece con el tiempo es una señal de que el trabajo dentro del bloqueo es demasiado lento.

Qué sigue

El siguiente capítulo, Java ReadWriteLock, cubre ReentrantReadWriteLock — el bloqueo que divide la adquisición en "muchos lectores O un escritor", para las cargas de trabajo con muchas lecturas donde los bloqueos exclusivos son excesivos.

Práctica

Práctica
Llamas a `lock.lock()` dos veces desde el mismo hilo en un `ReentrantLock` y luego llamas a `lock.unlock()` una vez. ¿Se libera el bloqueo para que otros hilos puedan adquirirlo?
Llamas a `lock.lock()` dos veces desde el mismo hilo en un `ReentrantLock` y luego llamas a `lock.unlock()` una vez. ¿Se libera el bloqueo para que otros hilos puedan adquirirlo?
Was this page helpful?