W3docs

Java ReadWriteLock

Permite lectores concurrentes y escritores exclusivos en Java con ReadWriteLock, y cuándo StampedLock es una mejor opción.

Un ReentrantLock (o un bloque synchronized) otorga acceso exclusivo a un hilo — lectores y escritores comparten el mismo slot. Para cargas de trabajo con muchas lecturas eso es un desperdicio: si cien hilos quieren leer un valor y uno quiere escribirlo, no hay conflicto real entre los lectores, solo entre lectores y el escritor. La interfaz ReadWriteLock y su implementación estándar ReentrantReadWriteLock dividen el lock en dos partes — un lock de lectura que múltiples hilos pueden mantener simultáneamente, y un lock de escritura que es exclusivo frente a todo lo demás. Usado correctamente, reduce drásticamente la contención. Usado incorrectamente, es más lento que un lock simple.

La interfaz

public interface ReadWriteLock {
  Lock readLock();
  Lock writeLock();
}

Dos Locks, vinculados entre sí por su padre: el lock de lectura y el lock de escritura obedecen la regla de que o bien el lock de escritura está en manos de exactamente un hilo o bien el lock de lectura está en manos de cero o más hilos. Nunca ambos, nunca uno de cada uno.

La implementación estándar:

ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
Lock r = rw.readLock();
Lock w = rw.writeLock();

Ambos locks tienen la API estándar de Locklock, tryLock, lockInterruptibly, unlock. El contrato para el par rw es:

  • Muchos hilos pueden mantener r al mismo tiempo. Ninguno bloquea a los demás.
  • Solo un hilo puede mantener w a la vez.
  • Un hilo que intenta adquirir w espera a que todos los lectores actuales liberen el lock.
  • Los hilos que intentan adquirir r esperan si w está en manos de alguien o (dependiendo de la política) si ya hay un escritor en cola.

El último punto es la política de prevención de inanición de escritores — que se explica a continuación.

El caso de uso caché

El ejemplo del libro de texto. Un caché que se lee constantemente y se actualiza ocasionalmente:

class ConfigCache {
  private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
  private final Lock r = rw.readLock();
  private final Lock w = rw.writeLock();
  private Map<String, String> data = new HashMap<>();

  public String get(String k) {
    r.lock();
    try {
      return data.get(k);                               // many readers, no contention
    } finally { r.unlock(); }
  }

  public void reload(Map<String, String> fresh) {
    w.lock();
    try {
      data = new HashMap<>(fresh);                       // exclusive: blocks readers and other writers
    } finally { w.unlock(); }
  }
}

Con una carga de trabajo típica del 99% de lecturas, esto escala mucho mejor que un único ReentrantLock. Los lectores no se bloquean entre sí; el escritor ocasional detiene brevemente el mundo y luego todos continúan.

Se aplica la misma disciplina try/finally que con Lock — cada lock() debe ir acompañado de unlock() en un bloque finally. El lock de lectura no es más indulgente que el lock de escritura ante las fugas.

Equidad e inanición de escritores

ReentrantReadWriteLock tiene dos políticas:

new ReentrantReadWriteLock();          // non-fair (default)
new ReentrantReadWriteLock(true);      // fair (FIFO)

La política no equitativa por defecto permite que nuevos lectores entrantes adquieran el lock incluso si un escritor ya está esperando — alto rendimiento, pero los escritores pueden sufrir inanición bajo carga continua de lectura. La política equitativa encola a cada solicitante en orden FIFO: un escritor en espera bloquea a los lectores posteriores, y los lectores esperan su turno.

El valor por defecto correcto sigue siendo el no equitativo. Si observas que los escritores en producción están en la cola para siempre (algo que getQueueLength expone), cambia al modo equitativo.

También hay una protección más sutil. Incluso en modo no equitativo, si un escritor está "el siguiente en la cola" (al principio de la cola), los lectores entrantes quedan bloqueados. Esto evita la peor forma de inanición; los nuevos lectores aún pueden colarse si no hay ningún escritor en cola.

Degradación de lock: escritura → lectura

Un truco útil: puedes mantener el lock de escritura, adquirir también el lock de lectura y luego liberar el lock de escritura — sin dejar entrar a ningún otro escritor. Esto se llama degradación:

w.lock();
try {
  data = recompute();                                   // exclusive write
  r.lock();                                              // before releasing w
} finally { w.unlock(); }
// now holding only r — readers can join, but no writer can sneak in until I release r
try {
  process(data);                                         // read-only work, multiple threads can do it
} finally { r.unlock(); }

El objetivo de la degradación: hacer la mutación real bajo el lock de escritura, luego continuar leyendo el resultado sin bloquear a los demás del acceso a los datos. El intermedio "adquirir lectura mientras se mantiene escritura" funciona porque el lock lo permite — estás reajustando la reserva del lock de escritura de "exclusiva" a "compartida, pero tú específicamente aún tienes acceso".

Lo inverso — actualización de lectura → escritura — no funciona. Intentar adquirir w mientras se mantiene r provoca un deadlock: el lock de escritura espera a que todos los lectores liberen el lock, y tú eres uno de ellos. El lock te bloqueará para siempre esperándote a ti mismo.

r.lock();
try {
  if (needsRefresh()) {
    w.lock();                                            // DEADLOCK on the same thread
    ...
  }
} finally { r.unlock(); }

Para pasar de lectura → escritura debes liberar primero el lock de lectura, luego adquirir el lock de escritura y volver a comprobar la condición (alguien más puede haber actualizado los datos mientras estabas sin lock).

Cuándo ReadWriteLock supera a ReentrantLock

Una regla general aproximada. ReentrantReadWriteLock gana cuando:

  • Las lecturas superan ampliamente a las escrituras (por ejemplo, 100:1 o más).
  • La sección protegida por lectura no es trivial — lo suficientemente larga como para que ejecutarla concurrentemente con muchos hilos sea una ganancia significativa.
  • La escritura también es lo suficientemente larga como para que bloquear brevemente a los lectores esté bien.

Pierde (o empata) cuando:

  • Las lecturas son extremadamente cortas (una búsqueda en un map). El overhead de adquisición del lock es comparable al trabajo; sería mejor usar un ReentrantLock simple o una instantánea inmutable mediante AtomicReference.
  • La proporción lector/escritor no es extrema.
  • Tienes muchos hilos. La contabilidad interna que hace el lock de lectura/escritura para contar lectores se vuelve más costosa a medida que escala. Para estructuras de datos con mucha lectura en muchos núcleos, StampedLock o copy-on-write suele ser una mejor opción.

StampedLock — la alternativa moderna

Java 8 añadió java.util.concurrent.locks.StampedLock con tres modos — escritura, lectura y lectura optimista. El modo optimista permite que un lector proceda sin adquirir ningún lock; después de la lectura, verifica que el valor no cambió mediante un stamp. Si cambió, el lector recurre a la adquisición de un lock de lectura adecuado.

StampedLock sl = new StampedLock();
long stamp = sl.tryOptimisticRead();
String val = data.get(k);                                // read without locking
if (!sl.validate(stamp)) {                                // somebody wrote during our read
  stamp = sl.readLock();
  try {
    val = data.get(k);                                    // re-read under proper lock
  } finally { sl.unlockRead(stamp); }
}

Para cargas de trabajo dominadas por lecturas, StampedLock suele ser más rápido que ReentrantReadWriteLock. El coste: no es reentrante, no admite Condition, y la API es mucho más fácil de usar incorrectamente. Recurre a él cuando tengas un profiler apuntando a un ReadWriteLock; usa ReentrantReadWriteLock por defecto para una mejor ergonomía.

Un ejemplo trabajado: caché con muchas lecturas, tres contendientes

El programa a continuación contrasta tres implementaciones del mismo caché con muchas lecturas bajo 16 lectores y 2 escritores: un map synchronized, un map protegido por ReentrantLock, y un map protegido por ReentrantReadWriteLock.

java— editable, runs on the server

Lo que se puede extraer de la ejecución — y el resultado probablemente no es el que esperarías:

  • La sección crítica aquí es un único HashMap.get — unos pocos nanosegundos. A ese tamaño, ReentrantReadWriteLock en realidad pierde frente a un ReentrantLock simple (y frente a synchronized). En una ejecución típica, el rwlock hace menos lecturas, no más, porque el trabajo dentro del lock queda eclipsado por el coste de adquirirlo. Esto es lo más importante que hay que interiorizar: un lock de lectura-escritura no es una mejora gratuita.
  • Por qué pierde aquí: r.lock() tiene que incrementar atómicamente un contador de lectores compartido, y ese contador en disputa se convierte en el nuevo cuello de botella. Dieciséis núcleos golpeando un campo de tipo AtomicInteger generan tanto cache-bounce como lo harían con un único mutex — a veces peor, porque la contabilidad del rwlock es más pesada que la de un lock simple. La ganancia teórica de "los lectores no se bloquean entre sí" nunca se materializa cuando la lectura es demasiado corta para solaparse de forma significativa.
  • El rwlock solo supera al otro cuando cada lectura mantiene el lock el tiempo suficiente para que el solapamiento real compense — piensa en una búsqueda, una deserialización, o cualquier cosa en el rango de microsegundos o más, no en una simple búsqueda en un map. Reemplaza el cuerpo de get() con trabajo de solo lectura genuinamente costoso y vuelve a ejecutar: ahora los lectores concurrentes ganan de forma decisiva. Perfila tu carga de trabajo real antes de recurrir a un ReadWriteLock.
  • El coste de ReadWriteLock es más estado que mantener (un contador de lectores, un indicador de espera de escritor, la política de equidad) — cada r.lock() es más costoso que un ReentrantLock.lock(). Para baja contención o secciones críticas muy cortas, el lock más simple es más rápido. Para carga extremadamente lectura-pesada en muchos núcleos, StampedLock (las lecturas optimistas no adquieren nada) o una instantánea inmutable detrás de un AtomicReference generalmente supera a ambos.
  • Cualquiera que sea el lock que elijas, los escritores siguen obteniendo acceso exclusivo a través de la ruta de escritura y nunca corrompen el map — la corrección es la misma en los tres casos. La diferencia es puramente el rendimiento, y el rendimiento depende enteramente de lo que hay dentro del lock.
  • La degradación (wr → liberar w) es lo que usa el código en producción cuando la reconstrucción tiene que ocurrir bajo un lock de escritura pero el resto de la solicitud puede permanecer bajo un lock de lectura. Actualizar en sentido inverso provoca un deadlock; libera primero r, toma w, vuelve a comprobar el estado, y luego continúa.

Qué sigue

El próximo capítulo, Java Thread Pools, comienza la historia del framework de ejecutores de alto nivel — la idea de que dejas de crear hilos a mano y en su lugar envías trabajo a un pool que posee los hilos por ti.

Práctica

Práctica
Mantienes el lock de lectura de un `ReentrantReadWriteLock` y decides actualizar al lock de escritura llamando a `w.lock()` mientras aún mantienes `r`. ¿Qué ocurre?
Mantienes el lock de lectura de un `ReentrantReadWriteLock` y decides actualizar al lock de escritura llamando a `w.lock()` mientras aún mantienes `r`. ¿Qué ocurre?
Was this page helpful?