Esta es la lección central del módulo. Todo lo anterior fue preparación: sabes qué es un hilo, sabes crearlo, cancelarlo y observar en qué estado está. Ahora toca resolver el problema que da sentido a todo el tema, y que sigue abierto desde 08-01: dos hilos que suman uno a un contador dos millones de veces y obtienen un millón trescientos mil.
La lección tiene dos mitades. La primera es teoría que no puedes saltarte: por qué contador++ no es una operación sino tres, qué es exactamente una operación atómica, y —lo más profundo del módulo— el modelo de memoria de Java, que explica por qué un hilo puede escribir un valor y otro no verlo nunca, aunque pasen minutos. Esa parte es incómoda porque contradice la intuición de que la memoria es un sitio donde se escribe y se lee; pero sin ella, volatile y synchronized son magia que se aplica por superstición.
La segunda mitad es la caja de herramientas: volatile para visibilidad, synchronized para exclusión mutua, ReentrantLock cuando synchronized no llega, ReadWriteLock cuando se lee mucho más de lo que se escribe, y las dos estrategias que ganan a todas: inmutabilidad y confinamiento. Entre medias, el interbloqueo reproducible y sus soluciones.
Al terminar, Catalogo y RegistroPrestamos serán seguros para varios hilos, y el caso C de 08-01 —Marta Ruiz y Diego Alonso registrando préstamos a la vez— dejará de ser una amenaza.
Cómo estudiar esta lección. Es larga y densa a propósito: es la que sostiene el resto del módulo y buena parte de tu carrera escribiendo servicios. Ejecuta los ejemplos que fallan. Especialmente el del apartado 5, la bandera de parada que sin
volatileno se ve nunca: la primera vez que lo veas colgarse entenderás el modelo de memoria mejor que con diez páginas de texto.
Contenido
- La condición de carrera al nivel del bytecode
- Qué es una operación atómica
- El modelo de memoria de Java
- La relación happens-before
volatile: lo que garantizavolatile: lo que NO garantizasynchronized: el monitor intrínseco- Las formas de
synchronized - Qué sincronizar y qué no
- Interbloqueo: el ejemplo reproducible
- Las soluciones al interbloqueo
- Inanición y livelock
ReentrantLockfrente asynchronizedCondition: sustituto dewait/notifyReadWriteLock: leer mucho, escribir poco- Publicación segura e inmutabilidad
- Confinamiento: la mejor sincronización es no compartir
- BiblioTech: un catálogo seguro para varios hilos
- Errores Comunes y Consejos
- Ejercicios
- La condición de carrera al nivel del bytecode
contador++ parece una operación. No lo es. Compílalo y míralo:
public void incrementar();
Code:
0: aload_0 // apilar la referencia 'this'
1: dup // duplicarla (hace falta dos veces)
2: getfield #7 // LEER el campo 'valor' <-- operacion 1
5: iconst_1 // apilar la constante 1
6: iadd // SUMAR <-- operacion 2
7: putfield #7 // ESCRIBIR el campo 'valor'<-- operacion 3
10: returnTres operaciones separadas: leer, sumar, escribir. Entre cualquiera de ellas el planificador puede quitar el hilo y poner otro. Ese hueco es la condición de carrera.
El entrelazado que pierde un incremento, paso a paso, partiendo de valor = 10:
sequenceDiagram
participant A as sumador-1
participant M as memoria (valor)
participant B as sumador-2
Note over M: valor = 10
A->>M: getfield -> lee 10
Note over A: registro A = 10
B->>M: getfield -> lee 10
Note over B: registro B = 10
Note over A: iadd -> A = 11
Note over B: iadd -> B = 11
A->>M: putfield 11
Note over M: valor = 11
B->>M: putfield 11
Note over M: valor = 11 (¡otra vez!)
Note over A,B: Dos incrementos, valor solo subio 1.<br/>Un incremento PERDIDO.
Los dos hilos leyeron 10, los dos calcularon 11, los dos escribieron 11. Se han ejecutado dos incrementos y el contador ha subido uno. Repite esto un millón de veces con entrelazados aleatorios y tienes exactamente la salida de 08-01: cientos de miles de incrementos perdidos.
Este patrón tiene nombre: leer-modificar-escribir (read-modify-write). Aparece en muchas más formas de las que parece, y todas son inseguras sin protección:
valor++; // leer, sumar, escribir
valor--; // idem
valor += 5; // idem
saldo = saldo - importe; // idem
if (!mapa.containsKey(k)) { // COMPROBAR...
mapa.put(k, v); // ...LUEGO ACTUAR: otro hilo puede colarse en medio
}
if (instancia == null) { // el singleton perezoso clasico
instancia = new Servicio();
}
lista.add(lista.size(), x); // leer tamano, luego usarloLas dos últimas familias —comprobar-luego-actuar y leer-modificar-escribir— son las dos formas canónicas de condición de carrera. Si ves cualquiera de ellas sobre estado compartido, hay un bug.
- Qué es una operación atómica
Una operación es atómica si, desde el punto de vista de los demás hilos, ocurre entera o no ocurre: no hay estado intermedio observable.
En Java son atómicas:
- La lectura y la escritura de cualquier variable de tipo primitivo salvo
longydouble, y de cualquier referencia. - La lectura y la escritura de un
longodoubledeclaradovolatile. - Las operaciones de las clases de
java.util.concurrent.atomic(08-06).
No son atómicas:
valor++,valor--,valor += n(son tres operaciones).- Cualquier secuencia de dos o más operaciones que deba verse como una sola.
- La lectura o escritura de un
long/doublenovolatile.
El caso curioso de long y double. La especificación permite que la JVM implemente la escritura de un valor de 64 bits como dos escrituras de 32 bits. En una JVM de 32 bits eso ocurría de verdad, y producía un fenómeno inquietante: un hilo podía leer un long cuya mitad alta viniera de una escritura y la mitad baja de otra, obteniendo un valor que ningún hilo escribió nunca. Se llamaba word tearing.
public class LongPartido {
// Sin volatile, en una JVM de 32 bits, un lector podia ver
// 0x00000000FFFFFFFF: mitad de un valor y mitad de otro.
private long contadorPrestamos = 0;
// Con volatile, la escritura de 64 bits es atomica por especificacion.
private volatile long contadorSeguro = 0;
}En las JVM de 64 bits actuales esto ya no ocurre en la práctica, pero la especificación lo sigue permitiendo, así que el código correcto no depende de ello: si compartes un long o un double, declaralo volatile o protégelo.
El punto que hay que llevarse: la atomicidad de una operación individual no basta. Aunque cada lectura y cada escritura de valor sean atómicas, valor++ sigue siendo incorrecto porque son tres operaciones atómicas separadas. La atomicidad que necesitas es la de la operación compuesta, y esa hay que construirla.
- El modelo de memoria de Java
Aquí llega la parte que contradice la intuición. Hasta ahora has supuesto que la memoria es un cuaderno compartido: un hilo escribe una línea y otro la lee. Esa imagen es falsa en cualquier procesador de los últimos treinta años.
3.1 Las tres razones por las que la memoria no se comporta como esperas
Razón 1: cada núcleo tiene sus propias cachés.
Acceder a la memoria principal cuesta unos cientos de ciclos. Acceder a la caché L1 del núcleo cuesta unos pocos. Por eso cada núcleo mantiene copias locales de los datos que usa. Cuando el núcleo 1 escribe contador = 5, ese 5 puede quedarse en su caché L1 durante un tiempo indeterminado antes de llegar a un nivel visible para el núcleo 2.
flowchart TB
subgraph CPU["Procesador"]
direction LR
subgraph N1["Núcleo 1 - hilo A"]
R1["Registros"] --- L11["Caché L1<br/>contador = 5"]
end
subgraph N2["Núcleo 2 - hilo B"]
R2["Registros"] --- L12["Caché L1<br/>contador = 0"]
end
end
L11 --- L2["Caché L2/L3 compartida"]
L12 --- L2
L2 --- RAM["Memoria principal<br/>contador = 0"]
En ese dibujo, el hilo A ha escrito 5 y el hilo B sigue leyendo 0. Los dos están en lo cierto según su propia caché. Y no hay ninguna garantía temporal de cuándo —ni de si— B verá el 5.
Razón 2: el compilador reordena instrucciones.
Tanto javac como, sobre todo, el compilador JIT de la JVM reordenan libremente el código mientras el resultado sea el mismo para un solo hilo. Esa cláusula es la clave: la garantía se llama as-if-serial y solo aplica dentro de un hilo.
// Lo que escribes:
datos = cargarCatalogo(); // (1)
listo = true; // (2)
// Lo que el compilador puede generar, porque para ESTE hilo
// el resultado es indistinguible:
listo = true; // (2)
datos = cargarCatalogo(); // (1)Para otro hilo que haga if (listo) usar(datos), esa reordenación es catastrófica: puede ver listo == true con datos == null.
Razón 3: la CPU también reordena.
Los procesadores modernos ejecutan fuera de orden y tienen buffers de escritura. Aunque el compilador emita las instrucciones en orden, el hardware puede hacerlas efectivas en otro. En x86 el modelo es relativamente fuerte; en ARM —tu móvil, muchos servidores— es mucho más débil y las reordenaciones se observan con facilidad.
3.2 La consecuencia: dos hilos pueden ver historias distintas
Sin sincronización, no hay ninguna garantía de que un hilo vea las escrituras de otro, ni en el mismo orden, ni nunca. No es que tarde: puede no verlas jamás.
Este programa lo demuestra, y es el ejemplo más impactante del módulo:
public class BanderaSinVolatile {
// SIN volatile. Ahi esta el problema.
private static boolean detener = false;
public static void main(String[] args) throws InterruptedException {
Thread trabajador = new Thread(() -> {
long vueltas = 0;
// El JIT puede transformar esto en 'while (true)' porque
// dentro de ESTE hilo 'detener' nunca cambia: es una
// optimizacion legal llamada elevacion (hoisting).
while (!detener) {
vueltas++;
}
System.out.println("[trabajador] parado tras " + vueltas + " vueltas");
}, "bibliotech-trabajador");
trabajador.start();
Thread.sleep(1000);
System.out.println("[main] escribiendo detener = true");
detener = true;
trabajador.join(3000);
if (trabajador.isAlive()) {
System.out.println("[main] EL TRABAJADOR NO SE HA ENTERADO. Sigue vivo.");
System.exit(1);
}
}
}Salida típica con el JIT en marcha (compila con javac y ejecuta normalmente, sin depurador):
El hilo trabajador no para nunca. main escribió true y el trabajador siguió leyendo false indefinidamente. No es un retraso de microsegundos: es para siempre.
Por qué ocurre: el compilador JIT ve que dentro del bucle nadie modifica detener, así que saca la lectura fuera del bucle —optimización estándar y perfectamente legal según la garantía as-if-serial— y lo convierte en el equivalente de:
Si al ejecutarlo sí para, prueba con más tiempo de calentamiento (el JIT tarda unos miles de iteraciones en compilar), o añade
-XX:+PrintCompilationpara ver cuándo compila el método. En un depurador o con-Xint(solo intérprete) casi siempre para, porque el intérprete no aplica esa optimización. Ese es precisamente el peligro: el bug desaparece bajo el depurador.
Cambia boolean detener por volatile boolean detener y ejecuta de nuevo:
Para inmediatamente. Esa palabra clave es la diferencia entre un programa correcto y uno que se cuelga.
- La relación happens-before
El modelo de memoria de Java (JSR 133, incorporado en Java 5) no describe cachés ni reordenaciones: define una relación de orden parcial llamada happens-before entre acciones, y una única garantía:
Si la acción A happens-before la acción B, entonces los efectos de A son visibles para B, y B ve a A como ocurrida antes.
Y su contrapositiva, que es la que usarás en la práctica:
Si dos acciones no están relacionadas por happens-before y al menos una escribe, hay una carrera de datos y no hay ninguna garantía sobre lo que se observa.
Las reglas que establecen happens-before —memorízalas, son la caja de herramientas entera:
| Regla | Establece que… |
|---|---|
| Orden del programa | Dentro de un mismo hilo, cada acción happens-before las siguientes en el código |
| Monitor | Soltar un monitor happens-before cualquier adquisición posterior del mismo monitor |
volatile |
Escribir una variable volatile happens-before cualquier lectura posterior de esa misma variable |
Thread.start() |
Todo lo hecho antes de start() happens-before la primera acción del hilo nuevo |
Thread.join() |
Todo lo hecho por el hilo happens-before el retorno de join() |
Campos final |
La inicialización de un campo final en el constructor happens-before que otro hilo vea el objeto correctamente construido |
| Transitividad | Si A hb B y B hb C, entonces A hb C |
Utilidades de j.u.c. |
Poner en una cola concurrente hb sacar de ella; countDown hb await; etc. (08-05, 08-06) |
Aplicado a los casos que ya conoces:
// REGLA start(): main escribe, el hilo nuevo lo ve garantizado.
catalogo.cargar(); // A
Thread h = new Thread(tarea);
h.start(); // A happens-before todo lo del hilo nuevo
// dentro de 'tarea': ve el catalogo cargado, seguro.
// REGLA join(): el hilo escribe, main lo ve garantizado.
h.start();
h.join(); // todo lo del hilo hb el retorno de join
System.out.println(tarea.resultado()); // valor correcto garantizado
// REGLA monitor: transferencia de visibilidad entre secciones criticas.
synchronized (candado) { x = 42; } // soltar hb...
// ... otro hilo:
synchronized (candado) { leer(x); } // ...adquirir: ve x == 42
// REGLA volatile + transitividad: el idioma de la "publicacion segura".
datos = cargarCatalogo(); // (1) escritura normal
listo = true; // (2) escritura VOLATILE
// ... otro hilo:
if (listo) { // (3) lectura VOLATILE
usar(datos); // (4) ve datos correctamente. ¿Por que?
}
// (1) hb (2) por orden del programa;
// (2) hb (3) por la regla volatile;
// (3) hb (4) por orden del programa;
// por transitividad: (1) hb (4). El acceso a 'datos' es seguro
// AUNQUE 'datos' no sea volatile.Ese último caso es importante y sorprende: una única variable volatile puede hacer visibles escrituras normales que la preceden. Es lo que se llama publicación segura, y es la base del patrón del apartado 16.
La forma práctica de usar todo esto, sin memorizar la especificación: cada vez que dos hilos toquen el mismo dato y al menos uno escriba, pregúntate "¿qué regla happens-before conecta esas dos acciones?". Si no sabes contestar, no hay ninguna, y tienes una carrera de datos.
volatile: lo que garantiza
volatile: lo que garantizavolatile es el mecanismo de sincronización más ligero de Java. Aporta dos garantías, ni una más:
Garantía 1: visibilidad. Una escritura volatile se hace visible inmediatamente a cualquier hilo que lea después esa variable. En la práctica, la JVM emite barreras de memoria: la escritura vacía el buffer hacia memoria compartida y la lectura invalida la copia cacheada.
Garantía 2: no reordenación. Las lecturas y escrituras volatile no se reordenan entre sí, y actúan como barrera para las que las rodean: nada que esté antes de una escritura volatile puede moverse después, y nada que esté después de una lectura volatile puede moverse antes.
Además, como efecto derivado: las lecturas y escrituras de long/double volatile son atómicas (adiós al word tearing).
El caso de uso canónico, y prácticamente el único que necesitarás: la bandera de estado escrita por un hilo y leída por otros.
package com.nexussoftware.bibliotech.persistencia;
/**
* Servicio de importacion con parada limpia mediante bandera volatile.
* Es el complemento de la interrupcion de 08-02: la bandera propia
* permite una parada ordenada sin depender del estado de interrupcion.
*/
public class ServicioImportacion implements Runnable {
// volatile: escrito por el hilo del menu, leido por el hilo trabajador.
// SIN volatile, el trabajador podria no enterarse NUNCA (apartado 3.2).
private volatile boolean detener = false;
// Progreso publicado hacia el hilo del menu. Solo lo escribe ESTE hilo,
// asi que no hay leer-modificar-escribir concurrente: volatile basta.
private volatile int lineasProcesadas = 0;
public void detener() {
detener = true;
}
@Override
public void run() {
while (!detener && !Thread.currentThread().isInterrupted()) {
procesarLote();
lineasProcesadas += 1000; // seguro: UN solo escritor
}
}
public int lineasProcesadas() { return lineasProcesadas; }
private void procesarLote() { /* ... */ }
}Fíjate en el comentario de lineasProcesadas: += 1000 es leer-modificar-escribir y en general sería inseguro… pero solo hay un escritor. Con un único hilo escribiendo, no hay entrelazado posible entre lectura y escritura de ese hilo consigo mismo, y volatile da a los lectores la visibilidad que necesitan. Este razonamiento es válido y frecuente; lo que lo invalidaría es un segundo escritor.
Cuándo volatile es suficiente, en resumen:
- La escritura no depende del valor actual (o hay un único escritor).
- No forma parte de un invariante junto con otras variables.
- No hace falta bloqueo por ningún otro motivo.
Si se cumplen las tres, volatile es la opción correcta y es mucho más barata que un candado: no bloquea, no crea contención y no puede provocar interbloqueo.
volatile: lo que NO garantiza
volatile: lo que NO garantizaAquí está la confusión más extendida del tema, y conviene destruirla con una demostración.
volatileNO hace atómicas las operaciones compuestas.
public class ContadorVolatileSigueRoto {
// volatile SI garantiza que los dos hilos vean el valor mas reciente.
// volatile NO garantiza que 'valor++' sea indivisible.
private volatile int valor = 0;
public void incrementar() {
valor++; // sigue siendo LEER, SUMAR, ESCRIBIR
}
public int valor() { return valor; }
public static void main(String[] args) throws InterruptedException {
final int VUELTAS = 1_000_000;
for (int intento = 1; intento <= 3; intento++) {
ContadorVolatileSigueRoto c = new ContadorVolatileSigueRoto();
Thread h1 = new Thread(() -> { for (int i = 0; i < VUELTAS; i++) c.incrementar(); });
Thread h2 = new Thread(() -> { for (int i = 0; i < VUELTAS; i++) c.incrementar(); });
h1.start(); h2.start();
h1.join(); h2.join();
System.out.printf("Intento %d: esperado %d, real %d, perdidos %d%n",
intento, VUELTAS * 2, c.valor(), VUELTAS * 2 - c.valor());
}
}
}Salida:
Intento 1: esperado 2000000, real 1223981, perdidos 776019
Intento 2: esperado 2000000, real 1341102, perdidos 658898
Intento 3: esperado 2000000, real 1189447, perdidos 810553Sigue perdiendo casi el 40 % de los incrementos. volatile resolvió la visibilidad —los dos hilos ven ahora el valor más reciente en cada lectura— pero el hueco entre leer y escribir sigue ahí, y sigue siendo la carrera del apartado 1.
Los dos ejemplos juntos son la lección completa:
| Ejemplo | Sin volatile |
Con volatile |
|---|---|---|
| Bandera de parada (apartado 3.2) | Se cuelga: nunca ve el cambio | Funciona: para inmediatamente |
Contador valor++ |
Pierde incrementos | Sigue perdiendo incrementos |
Tabla resumen de garantías:
| Propiedad | volatile |
synchronized |
AtomicInteger (08-06) |
|---|---|---|---|
| Visibilidad | Sí | Sí | Sí |
| No reordenación | Sí | Sí | Sí |
| Atomicidad de lectura/escritura simple | Sí (incl. long/double) |
Sí | Sí |
| Atomicidad de leer-modificar-escribir | No | Sí | Sí |
| Exclusión mutua de un bloque | No | Sí | No |
| Puede provocar interbloqueo | No | Sí | No |
| Coste | Muy bajo | Medio | Bajo |
El otro error frecuente con volatile: creer que protege el objeto al que apunta una referencia.
// 'catalogo' volatile garantiza que ves la referencia mas reciente...
private volatile List<Material> catalogo = new ArrayList<>();
// ...pero NO protege el CONTENIDO de la lista.
catalogo.add(nuevoLibro); // sigue siendo una carrera de datosvolatile protege la variable, no el objeto. Para el contenido hacen falta candados, una colección concurrente (08-06) o inmutabilidad.
synchronized: el monitor intrínseco
synchronized: el monitor intrínsecoCada objeto de Java tiene un monitor asociado —también llamado bloqueo intrínseco o candado—. No lo declaras: existe por el hecho de ser un objeto. synchronized es la palabra clave que lo adquiere y lo suelta.
Semántica exacta:
- Al entrar, el hilo adquiere el monitor de
objeto. Si otro hilo lo tiene, quedaBLOCKED(08-03) hasta que se libere. - Al salir —por el final del bloque, por un
return, o por una excepción—, el monitor se libera. Esto último es importante:synchronizedes a prueba de excepciones por construcción, a diferencia deReentrantLock. - Se establecen las relaciones happens-before de la regla del monitor: soltar hb adquirir después.
synchronized da dos cosas a la vez, y esa es su virtud:
- Exclusión mutua: solo un hilo a la vez ejecuta el bloque.
- Visibilidad: al entrar, el hilo ve todo lo que hizo el hilo anterior que soltó ese mismo monitor.
La segunda se olvida constantemente y es la mitad del valor. Un synchronized no solo evita que dos hilos pisen el dato: garantiza que el segundo vea lo que hizo el primero.
El contador arreglado:
public class ContadorSincronizado {
private int valor = 0;
// Metodo sincronizado de instancia: adquiere el monitor de 'this'.
public synchronized void incrementar() {
valor++; // ahora las tres operaciones son indivisibles
// respecto a otros hilos que usen ESTE monitor
}
// TAMBIEN sincronizado. Si no lo estuviera, un lector podria ver
// un valor obsoleto: la exclusion mutua sin visibilidad no basta.
public synchronized int valor() {
return valor;
}
public static void main(String[] args) throws InterruptedException {
final int VUELTAS = 1_000_000;
for (int intento = 1; intento <= 3; intento++) {
ContadorSincronizado c = new ContadorSincronizado();
Thread h1 = new Thread(() -> { for (int i = 0; i < VUELTAS; i++) c.incrementar(); });
Thread h2 = new Thread(() -> { for (int i = 0; i < VUELTAS; i++) c.incrementar(); });
h1.start(); h2.start();
h1.join(); h2.join();
System.out.printf("Intento %d: esperado %d, real %d%n",
intento, VUELTAS * 2, c.valor());
}
}
}Salida:
Intento 1: esperado 2000000, real 2000000
Intento 2: esperado 2000000, real 2000000
Intento 3: esperado 2000000, real 2000000Exacto, siempre, en todas las ejecuciones. El problema abierto desde 08-01 está resuelto.
El detalle que se olvida: el
gettertambién va sincronizado. Sivalor()no lo estuviera, un lector podría ver un valor obsoleto de su caché, aunque los escritores fueran perfectamente correctos. Todos los accesos a un dato compartido —lecturas incluidas— deben usar el mismo mecanismo de sincronización. Es la regla que más se incumple después delifen lugar delwhile.
Reentrada. Los monitores de Java son reentrantes: si un hilo ya tiene el monitor, puede volver a adquirirlo sin bloquearse. La JVM lleva un contador de adquisiciones y solo libera cuando llega a cero.
public class Reentrada {
public synchronized void registrarPrestamo(String isbn) {
validar(isbn);
// Llamada a OTRO metodo sincronizado del MISMO objeto.
// Sin reentrada, el hilo se bloquearia a si mismo: interbloqueo
// instantaneo consigo mismo.
actualizarIndice(isbn);
}
public synchronized void actualizarIndice(String isbn) { /* ... */ }
private void validar(String isbn) { /* ... */ }
}Sin reentrada, la herencia sería inviable: un método sincronizado de una subclase que llame a super.metodoSincronizado() se bloquearía consigo mismo. La reentrada hace que esto simplemente funcione.
- Las formas de
synchronized
synchronizedHay tres, y no son equivalentes.
Forma 1: método sincronizado de instancia. Bloquea this.
public synchronized void anadir(Material m) { /* ... */ }
// Es EXACTAMENTE equivalente a:
public void anadir(Material m) {
synchronized (this) { /* ... */ }
}Forma 2: método sincronizado estático. Bloquea el objeto Class.
public static synchronized void registrarEnGlobal(String evento) { /* ... */ }
// Equivalente a:
public static void registrarEnGlobal(String evento) {
synchronized (Catalogo.class) { /* ... */ }
}Consecuencia crítica que sorprende a mucha gente: un método de instancia sincronizado y un método estático sincronizado de la misma clase usan monitores distintos y no se excluyen entre sí. Si ambos tocan el mismo estado estático, hay una carrera.
public class DosMonitoresDistintos {
private static int contadorGlobal = 0;
// Bloquea 'this'
public synchronized void incrementarMal() { contadorGlobal++; }
// Bloquea 'DosMonitoresDistintos.class'
public static synchronized void incrementarEstatico() { contadorGlobal++; }
// Estos dos metodos NO se excluyen: usan candados diferentes.
// El estado estatico que comparten NO esta protegido. BUG.
}Forma 3: bloque sincronizado con objeto de bloqueo explícito.
private final Object candado = new Object();
public void anadir(Material m) {
// Trabajo que NO necesita proteccion, fuera del bloqueo:
validar(m);
String clave = normalizar(m.isbn());
synchronized (candado) {
// Seccion critica: lo minimo imprescindible
materiales.add(m);
indice.put(clave, m);
}
// Mas trabajo no critico
registrarEnAuditoria(m);
}Esta tercera forma es la preferible, por dos razones:
Razón A: granularidad. El método sincronizado bloquea todo el método, incluido el trabajo que no lo necesita. Si validar() tarda 50 ms, todos los demás hilos esperan 50 ms para nada. El bloque protege solo lo imprescindible.
Razón B: control del candado. synchronized (this) expone el monitor al mundo: cualquier código externo puede hacer synchronized (miCatalogo) y bloquear tu clase desde fuera, deliberadamente o por accidente. No es teórico: Vector y Hashtable sincronizan sobre this y ese es uno de los motivos por los que se desaconsejan.
public class Catalogo {
// Candado PRIVADO y FINAL: nadie fuera puede adquirirlo,
// y nadie puede reasignarlo (un candado reasignado deja de
// proteger: dos hilos podrian bloquear objetos distintos).
private final Object candado = new Object();
// Y NUNCA uses como candado algo asi:
// private final Integer candado = 42; // <- puede estar internado
// private final String candado = "cat"; // <- literal internado: COMPARTIDO
// private final Boolean candado = false; // <- Boolean.FALSE, compartido
// Un literal String o un Integer pequeno es un objeto UNICO en toda la JVM:
// otra clase que use el mismo literal comparte tu candado sin saberlo.
}Tabla comparativa:
| Forma | Candado | Granularidad | Expuesto | Recomendación |
|---|---|---|---|---|
synchronized de instancia |
this |
Todo el método | Sí | Solo en clases pequeñas y controladas |
synchronized estático |
Clase.class |
Todo el método | Sí | Solo para estado estático |
synchronized (candadoPrivado) |
Objeto privado | La que decidas | No | Preferida |
- Qué sincronizar y qué no
Sincronizar de más es tan malo como sincronizar de menos: convierte un programa concurrente en uno secuencial con sobrecoste añadido.
Regla 1: la sección crítica, lo más corta posible.
// MAL: 300 ms de E/S dentro del bloqueo. Todos los demas hilos esperan.
public void registrarPrestamo(Prestamo p) {
synchronized (candado) {
prestamos.put(p.id(), p);
escribirEnDisco(p); // 300 ms de E/S
enviarNotificacion(p.empleado()); // 200 ms mas
}
}
// BIEN: solo el cambio de estado en memoria esta protegido.
public void registrarPrestamo(Prestamo p) {
synchronized (candado) {
prestamos.put(p.id(), p); // microsegundos
}
escribirEnDisco(p); // fuera del bloqueo
enviarNotificacion(p.empleado()); // fuera del bloqueo
}Regla 2: nunca hagas E/S dentro de un bloqueo. Es un caso particular de la regla 1, pero merece su propia entrada porque es el error de rendimiento más frecuente. Una lectura de disco puede tardar 10 ms; una consulta remota, cientos. Mantener un candado durante ese tiempo serializa toda la aplicación.
Regla 3: nunca llames a código ajeno con un candado en la mano. Código ajeno es cualquier cosa que no controles: un método sobrescribible, un callback, un oyente registrado por el usuario.
// PELIGROSO: 'oyentes' contiene codigo que no controlas.
synchronized (candado) {
for (OyenteCatalogo o : oyentes) {
o.materialAnadido(m); // ¿que hace? ¿bloquea? ¿llama de vuelta?
}
}
// SEGURO: copiar bajo el candado, notificar fuera.
List<OyenteCatalogo> copia;
synchronized (candado) {
copia = new ArrayList<>(oyentes);
}
for (OyenteCatalogo o : copia) {
o.materialAnadido(m); // sin candado: no puede bloquearnos
}Un oyente que intente adquirir otro candado desde ahí puede provocar un interbloqueo; uno que llame de vuelta a tu clase, una recursión inesperada. El patrón de copiar bajo el candado y notificar fuera es estándar, y en 08-06 verás que CopyOnWriteArrayList lo hace por ti.
Regla 4: no sincronices lo que no se comparte. Sincronizar una variable local o un objeto confinado a un hilo es coste puro. (La JVM a veces lo detecta y elimina el bloqueo —lock elision—, pero no cuentes con ello.)
Regla 5: documenta la política de sincronización. Un comentario en el campo diciendo qué lo protege vale por media hora de lectura de código:
public class RegistroPrestamos {
private final Object candado = new Object();
/** Protegido por 'candado'. */
private final Map<String, Prestamo> porId = new HashMap<>();
/** Protegido por 'candado'. Invariante: contiene los mismos prestamos que 'porId'. */
private final Map<Empleado, List<Prestamo>> porEmpleado = new HashMap<>();
}Ese /** Protegido por 'candado'. */ es la anotación @GuardedBy del libro Java Concurrency in Practice escrita a mano. Cuesta una línea y evita que alguien —tú, en seis meses— toque el campo sin el candado.
- Interbloqueo: el ejemplo reproducible
Viste un interbloqueo en 08-03 desde el punto de vista del diagnóstico. Ahora, desde el del diseño.
Un interbloqueo necesita cuatro condiciones simultáneas (condiciones de Coffman):
- Exclusión mutua: los recursos no se comparten.
- Retener y esperar: un hilo retiene uno y pide otro.
- Sin expropiación: no se le puede quitar un candado a un hilo.
- Espera circular: existe un ciclo de hilos esperándose.
Con synchronized, las tres primeras están dadas por la propia semántica del lenguaje. La única sobre la que puedes actuar es la cuarta, y esa es toda la estrategia.
Ejemplo realista de BiblioTech: transferir un préstamo entre dos empleados requiere bloquear las cuentas de ambos.
package com.nexussoftware.bibliotech.servicio;
import java.util.concurrent.TimeUnit;
public class TransferenciaPrestamos {
/** Cada empleado tiene su propio candado y su lista de prestamos. */
static class CuentaEmpleado {
final String nombre;
final Object candado = new Object();
int prestamos;
CuentaEmpleado(String nombre, int prestamos) {
this.nombre = nombre;
this.prestamos = prestamos;
}
}
/**
* VERSION CON INTERBLOQUEO.
* Bloquea 'origen' y luego 'destino'. Si dos hilos transfieren
* en sentidos opuestos, se forma el ciclo.
*/
static void transferirMal(CuentaEmpleado origen, CuentaEmpleado destino, int n) {
synchronized (origen.candado) {
dormir(100); // amplia la ventana del fallo
synchronized (destino.candado) {
origen.prestamos -= n;
destino.prestamos += n;
System.out.printf(" %s -> %s : %d prestamos%n",
origen.nombre, destino.nombre, n);
}
}
}
public static void main(String[] args) throws InterruptedException {
CuentaEmpleado marta = new CuentaEmpleado("Marta Ruiz", 5);
CuentaEmpleado diego = new CuentaEmpleado("Diego Alonso", 5);
// Hilo 1: marta -> diego (bloquea marta, luego diego)
Thread t1 = new Thread(() -> transferirMal(marta, diego, 1), "hilo-marta-diego");
// Hilo 2: diego -> marta (bloquea diego, luego marta) <-- ORDEN INVERSO
Thread t2 = new Thread(() -> transferirMal(diego, marta, 1), "hilo-diego-marta");
t1.start(); t2.start();
t1.join(3000);
t2.join(3000);
if (t1.isAlive() || t2.isAlive()) {
System.out.println("INTERBLOQUEO: los dos hilos siguen vivos y bloqueados.");
System.out.println("Estado t1: " + t1.getState());
System.out.println("Estado t2: " + t2.getState());
System.exit(1);
}
}
}Salida:
Los dos hilos en BLOCKED para siempre, sin excepción, sin traza, sin consumo de CPU. Diagrama de lo ocurrido:
sequenceDiagram
participant T1 as hilo-marta-diego
participant CM as candado de Marta
participant CD as candado de Diego
participant T2 as hilo-diego-marta
T1->>CM: adquiere OK
T2->>CD: adquiere OK
Note over T1,T2: los dos duermen 100 ms
T1->>CD: pide - lo tiene T2
Note over T1: BLOCKED
T2->>CM: pide - lo tiene T1
Note over T2: BLOCKED
Note over T1,T2: espera circular: ninguno suelta.<br/>INTERBLOQUEO PERMANENTE
- Las soluciones al interbloqueo
Solución A: orden global de adquisición
Define un orden total sobre todos los candados y adquiérelos siempre en ese orden. Sin espera circular no hay interbloqueo; es una garantía matemática, no una probabilidad.
El orden puede ser cualquiera con tal de que sea total y consistente. Aquí se usa System.identityHashCode, que da un entero estable por objeto:
/**
* VERSION CORRECTA: orden global de adquisicion.
* Los candados se toman SIEMPRE en orden creciente de identityHashCode,
* independientemente de la direccion de la transferencia.
*/
static void transferirBien(CuentaEmpleado origen, CuentaEmpleado destino, int n) {
int ho = System.identityHashCode(origen);
int hd = System.identityHashCode(destino);
if (ho < hd) {
synchronized (origen.candado) {
synchronized (destino.candado) {
mover(origen, destino, n);
}
}
} else if (ho > hd) {
synchronized (destino.candado) { // orden INVERTIDO respecto al negocio,
synchronized (origen.candado) { // pero CONSISTENTE entre hilos
mover(origen, destino, n);
}
}
} else {
// Caso rarisimo: colision de identityHashCode entre dos objetos
// distintos. Un tercer candado global desempata y garantiza
// que solo un hilo entre en este camino a la vez.
synchronized (CANDADO_DESEMPATE) {
synchronized (origen.candado) {
synchronized (destino.candado) {
mover(origen, destino, n);
}
}
}
}
}
private static final Object CANDADO_DESEMPATE = new Object();
private static void mover(CuentaEmpleado origen, CuentaEmpleado destino, int n) {
origen.prestamos -= n;
destino.prestamos += n;
}Con esto, los dos hilos del apartado 10 adquieren los candados en el mismo orden, uno de los dos gana, hace su trabajo y suelta. No hay interbloqueo posible, ni en tu portátil ni en producción ni dentro de diez años.
Si tus objetos tienen un identificador natural —un ISBN, un identificador de empleado—, úsalo: es más legible y estable que el identityHashCode.
// Con identificador natural, mucho mas claro:
CuentaEmpleado primero = origen.id().compareTo(destino.id()) < 0 ? origen : destino;
CuentaEmpleado segundo = (primero == origen) ? destino : origen;
synchronized (primero.candado) {
synchronized (segundo.candado) {
mover(origen, destino, n);
}
}Solución B: tryLock con tiempo límite
Si no puedes imponer un orden —porque los candados los eligen componentes que no controlas—, la alternativa es no esperar indefinidamente: intentar adquirir con plazo y, si falla, soltar todo y reintentar. Requiere ReentrantLock (apartado 13), porque synchronized no tiene tryLock.
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class TransferenciaConTryLock {
static class CuentaEmpleado {
final String nombre;
final ReentrantLock candado = new ReentrantLock();
int prestamos;
CuentaEmpleado(String nombre, int prestamos) {
this.nombre = nombre; this.prestamos = prestamos;
}
}
/**
* Adquiere los dos candados con plazo. Si no consigue ambos,
* suelta lo que tenga, espera un tiempo ALEATORIO y reintenta.
* El azar es esencial: sin el, dos hilos podrian reintentar
* sincronizados eternamente (livelock, apartado 12).
*/
static boolean transferir(CuentaEmpleado origen, CuentaEmpleado destino,
int n, int maxIntentos) throws InterruptedException {
for (int intento = 0; intento < maxIntentos; intento++) {
boolean tengoOrigen = false;
boolean tengoDestino = false;
try {
tengoOrigen = origen.candado.tryLock(50, TimeUnit.MILLISECONDS);
if (tengoOrigen) {
tengoDestino = destino.candado.tryLock(50, TimeUnit.MILLISECONDS);
}
if (tengoOrigen && tengoDestino) {
origen.prestamos -= n;
destino.prestamos += n;
return true; // exito
}
} finally {
// SIEMPRE soltar lo adquirido, en orden inverso.
if (tengoDestino) destino.candado.unlock();
if (tengoOrigen) origen.candado.unlock();
}
// Retroceso aleatorio antes de reintentar.
TimeUnit.MILLISECONDS.sleep(
ThreadLocalRandom.current().nextInt(10, 60));
}
return false; // agotados los intentos
}
}Comparación de las dos soluciones:
| Orden global | tryLock con plazo |
|
|---|---|---|
| Garantía | Absoluta: el interbloqueo es imposible | Probabilística: se evita, no se prohíbe |
| Coste | Ninguno en tiempo de ejecución | Reintentos y esperas |
| Requisito | Poder ordenar los candados | Poder abandonar y reintentar la operación |
| Complejidad del código | Baja | Media-alta |
| Cuándo usarla | Siempre que sea posible | Cuando el orden no se puede imponer |
La recomendación es clara: orden global siempre que puedas. tryLock es el plan B.
Solución C: la mejor de todas, un solo candado
Antes de complicarse: ¿de verdad hacen falta dos candados? Muchas veces un único candado más grueso resuelve el problema con un coste de contención perfectamente asumible. Un candado no puede interbloquearse consigo mismo.
// Si las transferencias no son el 90% de la carga, esto es
// mas simple, mas seguro y probablemente igual de rapido.
private static final Object CANDADO_TRANSFERENCIAS = new Object();
static void transferir(CuentaEmpleado origen, CuentaEmpleado destino, int n) {
synchronized (CANDADO_TRANSFERENCIAS) {
origen.prestamos -= n;
destino.prestamos += n;
}
}Mide antes de optimizar. La granularidad fina es una optimización, y como toda optimización, tiene un coste en complejidad y en riesgo.
- Inanición y livelock
Inanición (starvation). Un hilo nunca consigue el recurso que necesita porque otros se lo llevan siempre. Causas habituales:
- Prioridades muy dispares (08-02: no las uses).
- Un candado no equitativo que favorece sistemáticamente al hilo que acaba de soltarlo, porque su caché sigue caliente.
- Un hilo que retiene un candado demasiado tiempo (E/S dentro del bloqueo, apartado 9).
Solución: secciones críticas cortas y, si hace falta de verdad, un candado equitativo:
// El parametro true activa la politica FIFO: el candado se concede
// al hilo que lleva mas tiempo esperando.
// COSTE: mucho menor rendimiento (a menudo 10x o mas), porque impide
// la reentrada oportunista y fuerza cambios de contexto.
// Usalo solo si has medido inanicion real.
ReentrantLock equitativo = new ReentrantLock(true);Livelock. Los hilos sí están activos y ejecutando, pero ninguno progresa: reaccionan continuamente unos a otros. Es el caso de dos personas que se cruzan en un pasillo y se apartan las dos hacia el mismo lado, una y otra vez.
En código, el tryLock sin retroceso aleatorio es el ejemplo clásico:
// LIVELOCK: si los dos hilos entran en fase, pueden reintentar
// eternamente, cediendo siempre a la vez.
while (true) {
if (a.tryLock()) {
if (b.tryLock()) { hacerTrabajo(); return; }
a.unlock(); // cede
}
// sin espera aleatoria: los dos hilos vuelven a intentarlo
// exactamente a la vez, otra vez, y otra
}La solución es el retroceso aleatorio (randomized backoff), como en el ejemplo del apartado 11: dormir un tiempo aleatorio antes de reintentar rompe la sincronía. Es la misma idea que usa Ethernet para las colisiones.
Diferencia con el interbloqueo, que importa al diagnosticar: en un interbloqueo los hilos están en BLOCKED y consumen 0 % de CPU; en un livelock están en RUNNABLE y consumen CPU al máximo. Si tu aplicación no avanza pero la CPU está al 100 %, sospecha livelock, no interbloqueo — y jstack no te lo dirá con un mensaje Found one Java-level deadlock.
ReentrantLock frente a synchronized
ReentrantLock frente a synchronizedjava.util.concurrent.locks.ReentrantLock hace lo mismo que synchronized y añade cosas que synchronized no puede hacer. A cambio, hay que soltarlo a mano.
El patrón obligatorio, sin excepciones:
import java.util.concurrent.locks.ReentrantLock;
public class ContadorConLock {
private final ReentrantLock candado = new ReentrantLock();
private int valor = 0;
public void incrementar() {
candado.lock();
try {
valor++;
} finally {
// OBLIGATORIO en finally: si el cuerpo lanza una excepcion
// y el unlock esta fuera, el candado NO se libera JAMAS
// y toda la aplicacion se bloquea. Es el error numero uno
// de ReentrantLock, y el motivo por el que synchronized
// sigue siendo preferible cuando no necesitas nada mas.
candado.unlock();
}
}
}
lock()va FUERA deltry. Si estuviera dentro y fallara la adquisición, elfinallyharíaunlock()de un candado que no se tiene:IllegalMonitorStateException. El orden correcto eslock(); try { ... } finally { unlock(); }.
Lo que ReentrantLock añade:
1. tryLock(): adquirir sin esperar.
if (candado.tryLock()) { // no bloquea nunca
try { operacion(); } finally { candado.unlock(); }
} else {
// plan B: encolar, avisar al usuario, reintentar mas tarde
}2. tryLock(tiempo, unidad): adquirir con plazo. La base de la solución B al interbloqueo.
3. lockInterruptibly(): espera cancelable.
try {
candado.lockInterruptibly(); // responde a interrupt() mientras espera
try { operacion(); } finally { candado.unlock(); }
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}Esto es imposible con synchronized: un hilo BLOCKED esperando un monitor no responde a interrupt(). Si la operación puede tardar y quieres poder cancelarla, ReentrantLock es la única opción.
4. Equidad opcional: new ReentrantLock(true).
5. Varias Condition por candado (apartado 14).
6. Introspección: isLocked(), getHoldCount(), getQueueLength() — útiles para diagnóstico y métricas.
Tabla de decisión:
| Característica | synchronized |
ReentrantLock |
|---|---|---|
| Liberación automática | Sí, incluso con excepción | No: finally obligatorio |
| Sintaxis | Palabra clave, imposible olvidarse | Código explícito |
| Intentar sin bloquear | No | tryLock() |
| Plazo de espera | No | tryLock(t, u) |
| Espera interrumpible | No | lockInterruptibly() |
| Equidad | No | Opcional |
| Condiciones de espera | Una (wait/notify) |
Varias (newCondition()) |
| Visible en volcados | Sí, - locked <0x...> |
Sí (con jcmd Thread.print -l) |
| Rendimiento | Igual desde Java 6 | Igual |
| Legibilidad | Mayor | Menor |
La recomendación: usa synchronized por defecto. Es más corto, imposible de olvidar liberar y perfectamente rápido desde que Java 6 introdujo el sesgo y el ensanchamiento de bloqueos. Pasa a ReentrantLock solo cuando necesites tryLock, plazo, interrumpibilidad, equidad o varias condiciones.
Condition: sustituto de wait/notify
Condition: sustituto de wait/notifyUn ReentrantLock puede crear varios objetos Condition, cada uno con su propia cola de espera. Es la solución elegante al problema de notify frente a notifyAll de 08-03: puedes señalizar exactamente al grupo correcto.
Object (con synchronized) |
Condition (con Lock) |
|---|---|
wait() |
await() |
wait(ms) |
await(t, unidad) |
notify() |
signal() |
notifyAll() |
signalAll() |
| Una sola cola por objeto | Tantas como quieras |
| — | awaitUninterruptibly() |
Cola acotada con dos condiciones separadas:
package com.nexussoftware.bibliotech.servicio;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
/**
* Cola acotada de reservas con dos condiciones separadas.
*
* Ventaja sobre wait/notifyAll (08-03): al liberar un hueco solo se
* despierta a un PRODUCTOR, y al anadir un elemento solo a un CONSUMIDOR.
* Con notifyAll se despertaba a todos y la mayoria volvia a dormirse.
*
* NOTA: en produccion usarias ArrayBlockingQueue (08-06), que es
* exactamente esto ya implementado y probado.
*/
public class ColaReservasConCondition {
private final ReentrantLock candado = new ReentrantLock();
// DOS condiciones sobre el MISMO candado.
private final Condition hayHueco = candado.newCondition();
private final Condition hayElemento = candado.newCondition();
private final Deque<String> cola = new ArrayDeque<>();
private final int capacidad;
public ColaReservasConCondition(int capacidad) {
this.capacidad = capacidad;
}
public void poner(String reserva) throws InterruptedException {
candado.lock();
try {
// WHILE, igual que con wait(): las mismas tres razones de 08-03.
while (cola.size() == capacidad) {
hayHueco.await(); // suelta el candado y espera
}
cola.addLast(reserva);
hayElemento.signal(); // despierta a UN consumidor.
// Seguro: en esta cola solo esperan
// consumidores, y todos son
// intercambiables.
} finally {
candado.unlock();
}
}
public String tomar() throws InterruptedException {
candado.lock();
try {
while (cola.isEmpty()) {
hayElemento.await();
}
String r = cola.pollFirst();
hayHueco.signal(); // despierta a UN productor
return r;
} finally {
candado.unlock();
}
}
public int tamano() {
candado.lock();
try {
return cola.size();
} finally {
candado.unlock();
}
}
}Con Condition separadas, signal() sí es seguro, porque todos los hilos de esa cola esperan exactamente la misma condición y son intercambiables. Es la diferencia entre despertar a los diez hilos que esperan —de los que nueve se vuelven a dormir— y despertar al único que puede avanzar.
ReadWriteLock: leer mucho, escribir poco
ReadWriteLock: leer mucho, escribir pocoUn candado exclusivo trata igual a lectores y escritores. Pero dos lectores no se estorban: leer no modifica nada. Si tu estructura se lee cien veces por cada escritura —exactamente el caso del Catalogo de BiblioTech—, un candado exclusivo desperdicia casi todo el paralelismo disponible.
ReentrantReadWriteLock ofrece dos candados coordinados:
- Candado de lectura: compartido. Muchos hilos a la vez.
- Candado de escritura: exclusivo. Excluye a lectores y a otros escritores.
| Situación | ¿Se permite? |
|---|---|
| Lector + lector | Sí, simultáneos |
| Lector + escritor | No |
| Escritor + escritor | No |
package com.nexussoftware.bibliotech.servicio;
import com.nexussoftware.bibliotech.dominio.Material;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* Catalogo seguro para varios hilos, optimizado para lectura.
*
* Justificacion del ReadWriteLock: en BiblioTech se consulta el catalogo
* decenas de veces por cada alta (buscar por ISBN, listar, filtrar por tipo),
* y las altas llegan en lotes durante la importacion.
*/
public class CatalogoSeguro {
private final ReentrantReadWriteLock candado = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock lectura = candado.readLock();
private final ReentrantReadWriteLock.WriteLock escritura = candado.writeLock();
/** Protegido por 'candado'. */
private final List<Material> materiales = new ArrayList<>();
/** Protegido por 'candado'. Invariante: una entrada por material. */
private final Map<String, Material> indice = new HashMap<>();
/** Protegido por 'candado'. Invariante: mismos ISBN que 'indice'. */
private final Set<String> isbnRegistrados = new HashSet<>();
// ---------- ESCRITURAS: candado exclusivo ----------
public boolean anadir(Material m) {
escritura.lock();
try {
if (!isbnRegistrados.add(m.isbn())) {
return false; // ya existia
}
materiales.add(m);
indice.put(m.isbn(), m);
return true;
} finally {
escritura.unlock();
}
}
public boolean eliminar(String isbn) {
escritura.lock();
try {
Material m = indice.remove(isbn);
if (m == null) return false;
materiales.remove(m);
isbnRegistrados.remove(isbn);
return true;
} finally {
escritura.unlock();
}
}
/** Reemplazo atomico completo: lo que usa la importacion de 08-02. */
public void reemplazarTodo(List<Material> nuevos) {
escritura.lock();
try {
materiales.clear();
indice.clear();
isbnRegistrados.clear();
for (Material m : nuevos) {
if (isbnRegistrados.add(m.isbn())) {
materiales.add(m);
indice.put(m.isbn(), m);
}
}
} finally {
escritura.unlock();
}
}
// ---------- LECTURAS: candado compartido ----------
public Material buscarPorIsbn(String isbn) {
lectura.lock();
try {
return indice.get(isbn);
} finally {
lectura.unlock();
}
}
public int tamano() {
lectura.lock();
try {
return materiales.size();
} finally {
lectura.unlock();
}
}
/**
* Devuelve una COPIA. Devolver la lista interna seria un fallo de
* seguridad de hilos: quien la reciba podria iterarla sin candado
* mientras otro hilo la modifica -> ConcurrentModificationException
* (05-02) o algo peor.
*/
public List<Material> listar() {
lectura.lock();
try {
return new ArrayList<>(materiales); // copia bajo el candado
} finally {
lectura.unlock();
}
}
}Cuándo compensa un ReadWriteLock:
| Condición | Por qué importa |
|---|---|
| Lecturas ≫ escrituras (≥ 5:1, idealmente 20:1) | Con pocas lecturas, el mayor coste del candado no se amortiza |
| Las lecturas duran algo | Si duran nanosegundos, la sobrecarga domina |
| Hay contención real | Sin varios hilos concurrentes no gana nada |
Cuándo NO compensa: operaciones muy cortas, proporción equilibrada de lecturas y escrituras, o poca concurrencia. En esos casos un synchronized sencillo suele ser más rápido, porque ReentrantReadWriteLock mantiene más estado interno.
Degradación y promoción. Se puede degradar —tener el candado de escritura y adquirir el de lectura antes de soltarlo— pero no promocionar: intentar adquirir el de escritura teniendo el de lectura produce un interbloqueo instantáneo consigo mismo.
// PROHIBIDO: promocion. Se cuelga para siempre.
lectura.lock();
escritura.lock(); // espera a que se suelten TODAS las lecturas,
// incluida la NUESTRA. Interbloqueo.
// PERMITIDO: degradacion (escritura -> lectura).
escritura.lock();
try {
modificar();
lectura.lock(); // adquirir lectura ANTES de soltar escritura
} finally {
escritura.unlock(); // ahora tenemos solo lectura
}
try {
leerLoQueAcabamosDeEscribir();
} finally {
lectura.unlock();
}Alternativa moderna:
StampedLock(Java 8). Añade lectura optimista: lees sin bloquear nada y luego validas si hubo escrituras entretanto; si las hubo, reintentas con lectura real. Es más rápido bajo mucha carga de lectura, pero no es reentrante y su API es fácil de usar mal. Menciónalo, mídelo si tienes un cuello de botella demostrado, y no lo uses por defecto.
- Publicación segura e inmutabilidad
Publicar un objeto es hacerlo visible a otros hilos. Hacerlo mal produce un fallo desconcertante: otro hilo puede ver un objeto a medio construir.
// PUBLICACION INSEGURA
public class Registro {
public static Catalogo instancia; // sin volatile, sin final
public static void inicializar() {
instancia = new Catalogo(); // (1) reservar (2) construir (3) asignar
// Las fases (2) y (3) pueden REORDENARSE: otro hilo puede ver
// 'instancia' no nula apuntando a un objeto sin construir.
}
}Formas seguras de publicar:
- Inicializarlo desde un inicializador estático (la JVM sincroniza la carga de clases).
- Guardarlo en un campo
volatileo en unaAtomicReference(08-06). - Guardarlo en un campo
finalde un objeto correctamente construido. - Guardarlo en un campo protegido por un candado, y leerlo con el mismo candado.
- Ponerlo en una colección concurrente (08-06).
La garantía de los campos final merece un párrafo propio, porque es la que hace que la inmutabilidad funcione:
Si un objeto solo tiene campos
finaly no deja escaparthisdurante la construcción, cualquier hilo que obtenga una referencia a él verá sus campos correctamente inicializados, sin necesidad de ninguna sincronización.
Un objeto inmutable es aquel que:
- Tiene todos sus campos
final. - No expone ningún método que cambie su estado.
- No deja escapar
thisen el constructor. - Si contiene referencias a objetos mutables, hace copias defensivas al entrar y al salir.
Un objeto inmutable es seguro para cualquier número de hilos, sin ningún candado, para siempre. Es la estrategia más potente de toda la concurrencia.
Y aquí viene lo bueno: los record de 04-07 son inmutables por construcción.
package com.nexussoftware.bibliotech.dominio;
/**
* Ficha inmutable: todos sus campos son final por ser un record.
* Compartible entre cualquier numero de hilos sin sincronizacion.
*/
public record Ficha(String isbn, String titulo, String autor, int ejemplares) { }
/**
* ResumenSesion inmutable. Ojo: si un record contiene una coleccion,
* la coleccion debe ser tambien inmutable, o el record solo es
* "superficialmente" inmutable.
*/
public record ResumenSesion(long inicioMs, long finMs,
int prestamos, int devoluciones,
List<String> incidencias) {
/** Constructor compacto con copia defensiva: hace la lista inmutable. */
public ResumenSesion {
incidencias = List.copyOf(incidencias); // copia INMUTABLE
}
}El truco del constructor compacto es importante: sin List.copyOf, quien construyó el record podría seguir modificando la lista original, y el record dejaría de ser inmutable de verdad.
Cómo se cambia el estado de un objeto inmutable: no se cambia. Se crea uno nuevo y se reemplaza la referencia de forma atómica:
public class EstadisticasBiblioTech {
/** Instantanea inmutable de las estadisticas. */
public record Instantanea(long prestamos, long devoluciones, long multas) {
Instantanea conPrestamo() { return new Instantanea(prestamos + 1, devoluciones, multas); }
Instantanea conDevolucion() { return new Instantanea(prestamos, devoluciones + 1, multas); }
}
// La REFERENCIA es volatile: los lectores siempre ven una
// instantanea completa y coherente, nunca una a medias.
private volatile Instantanea actual = new Instantanea(0, 0, 0);
/** Lectura sin ningun bloqueo: coherente por inmutabilidad. */
public Instantanea instantanea() {
return actual;
}
/**
* La escritura SI necesita sincronizacion: 'actual = actual.conPrestamo()'
* es leer-modificar-escribir, y volatile no lo hace atomico (apartado 6).
* En 08-06 esto se resolvera con AtomicReference.updateAndGet, sin candado.
*/
public synchronized void registrarPrestamo() {
actual = actual.conPrestamo();
}
}Este patrón —estado inmutable + referencia volatile— da lecturas sin ningún bloqueo y siempre coherentes. Es enormemente valioso cuando se lee mucho más de lo que se escribe.
- Confinamiento: la mejor sincronización es no compartir
Todas las técnicas anteriores gestionan la compartición. La mejor estrategia es eliminarla.
Confinamiento en la pila. Una variable local vive en la pila del hilo: es privada por construcción (08-01). Si construyes un objeto dentro de un método, lo usas y no lo dejas escapar, no hay nada que sincronizar.
public Informe procesarLote(List<String> lineas) {
// TODO local: cada hilo tiene su propia lista y su propio contador.
// Ningun candado, ninguna carrera posible.
List<Material> aceptados = new ArrayList<>();
int descartados = 0;
for (String l : lineas) {
try {
aceptados.add(lector.aMaterial(l));
} catch (FormatoInvalidoException e) {
descartados++;
}
}
// El unico punto de contacto con estado compartido, y esta protegido:
catalogo.anadirTodos(aceptados);
return new Informe(aceptados.size(), descartados);
}Confinamiento en un hilo con ThreadLocal. Cada hilo tiene su propia copia de la variable:
import java.text.NumberFormat;
import java.util.Locale;
public class FormatoMultas {
// NumberFormat NO es seguro para varios hilos: compartir una instancia
// produce resultados corruptos bajo carga (un clasico muy dificil de
// diagnosticar porque falla poco y de forma silenciosa).
// ThreadLocal da una instancia por hilo: seguro y sin candados.
private static final ThreadLocal<NumberFormat> FORMATO =
ThreadLocal.withInitial(() -> NumberFormat.getCurrencyInstance(
Locale.forLanguageTag("es-ES")));
public static String formatear(double importe) {
return FORMATO.get().format(importe);
}
/**
* IMPORTANTE: en un pool de hilos (08-05) los hilos se reutilizan
* indefinidamente, asi que un ThreadLocal nunca se libera solo.
* Si guarda algo grande o sensible, hay que limpiarlo:
*/
public static void limpiar() {
FORMATO.remove();
}
}Aviso sobre
ThreadLocaly pools. Es una fuente conocida de fugas de memoria y de contaminación de datos entre peticiones: un hilo de pool que atendió a Marta Ruiz y no limpió suThreadLocalpuede exponer ese dato a la petición de Diego Alonso. Regla: si usasThreadLocalen un pool, límpialo en unfinally.
Confinamiento por diseño: divide, procesa aparte y combina al final.
// Cada hilo procesa SU trozo y produce SU resultado parcial.
// No hay estado compartido durante el calculo, solo al combinar.
// Es el modelo de ForkJoinPool (08-05) y de los streams paralelos (10-04).
List<Informe> parciales = new ArrayList<>();
// ... cada hilo devuelve su Informe, y al final:
Informe total = combinar(parciales); // un solo hilo, sin candadosLa jerarquía de estrategias, de mejor a peor:
| Estrategia | Coste de sincronización | Complejidad | Cuándo |
|---|---|---|---|
| 1. No compartir (confinamiento) | Ninguno | Mínima | Siempre que se pueda |
| 2. Compartir inmutable | Ninguno | Baja | Datos que no cambian |
3. Compartir con volatile |
Muy bajo | Baja | Banderas, un solo escritor |
| 4. Compartir con atómicos (08-06) | Bajo | Baja | Contadores, referencias |
| 5. Compartir con colección concurrente (08-06) | Bajo-medio | Baja | Mapas, listas, colas |
| 6. Compartir con candado | Medio | Media | Invariantes entre varios campos |
| 7. Compartir sin nada | — | — | Bug |
Empieza siempre por arriba y baja solo cuando sea necesario. La mayoría del código concurrente que se escribe en la industria está en el nivel 6 cuando podría estar en el 1 o el 2.
- BiblioTech: un catálogo seguro para varios hilos
Aplicación completa. RegistroPrestamos tiene dos mapas que deben mantenerse coherentes entre sí, lo que obliga a un candado —un invariante que abarca dos estructuras no se puede mantener con atómicos ni con colecciones concurrentes independientes—.
package com.nexussoftware.bibliotech.servicio;
import com.nexussoftware.bibliotech.dominio.Empleado;
import com.nexussoftware.bibliotech.dominio.Prestamo;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* Registro de prestamos seguro para varios hilos.
*
* POLITICA DE SINCRONIZACION
* --------------------------
* Todo el estado mutable esta protegido por 'candado'.
* Las consultas usan el candado de lectura (compartido);
* las modificaciones, el de escritura (exclusivo).
*
* INVARIANTE: 'porId' y 'porEmpleado' contienen exactamente el mismo
* conjunto de prestamos. Este invariante abarca DOS estructuras, y por
* eso no basta con usar dos ConcurrentHashMap (08-06): haria falta que
* las dos actualizaciones fueran una sola operacion atomica, y eso solo
* lo da un candado.
*/
public class RegistroPrestamosSeguro {
private final ReentrantReadWriteLock candado = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock lectura = candado.readLock();
private final ReentrantReadWriteLock.WriteLock escritura = candado.writeLock();
/** Protegido por 'candado'. */
private final Map<String, Prestamo> porId = new HashMap<>();
/** Protegido por 'candado'. Invariante: coherente con 'porId'. */
private final Map<Empleado, List<Prestamo>> porEmpleado = new HashMap<>();
// ---------- ESCRITURA ----------
/**
* Registra un prestamo. Las dos actualizaciones ocurren bajo el mismo
* candado, asi que ningun lector puede ver el estado intermedio en el
* que el prestamo esta en 'porId' pero aun no en 'porEmpleado'.
*/
public void registrar(Prestamo p) {
escritura.lock();
try {
if (porId.putIfAbsent(p.id(), p) != null) {
throw new IllegalStateException("Prestamo duplicado: " + p.id());
}
porEmpleado.computeIfAbsent(p.empleado(), e -> new ArrayList<>()).add(p);
} finally {
escritura.unlock();
}
}
public Prestamo devolver(String idPrestamo) {
escritura.lock();
try {
Prestamo p = porId.remove(idPrestamo);
if (p == null) {
throw new PrestamoNoEncontradoException(idPrestamo);
}
List<Prestamo> deEmpleado = porEmpleado.get(p.empleado());
if (deEmpleado != null) {
deEmpleado.remove(p);
if (deEmpleado.isEmpty()) {
porEmpleado.remove(p.empleado()); // no dejar listas vacias
}
}
return p;
} finally {
escritura.unlock();
}
}
// ---------- LECTURA ----------
public Prestamo buscar(String idPrestamo) {
lectura.lock();
try {
return porId.get(idPrestamo);
} finally {
lectura.unlock();
}
}
/** Devuelve una COPIA: la lista interna nunca sale del candado. */
public List<Prestamo> prestamosDe(Empleado e) {
lectura.lock();
try {
List<Prestamo> l = porEmpleado.get(e);
return l == null ? List.of() : new ArrayList<>(l);
} finally {
lectura.unlock();
}
}
public int activos() {
lectura.lock();
try {
return porId.size();
} finally {
lectura.unlock();
}
}
/**
* OPERACION COMPUESTA ATOMICA.
*
* Este metodo existe precisamente porque hacerlo desde fuera seria
* un comprobar-luego-actuar inseguro:
*
* if (registro.activos() < MAX) registro.registrar(p); // BUG
*
* Entre la comprobacion y la accion, otro hilo puede registrar y
* superar el limite. Encapsular la operacion completa dentro del
* candado es la unica forma correcta.
*/
public boolean registrarSiHayCupo(Prestamo p, int maximoPorEmpleado) {
escritura.lock();
try {
List<Prestamo> actuales = porEmpleado.get(p.empleado());
if (actuales != null && actuales.size() >= maximoPorEmpleado) {
return false;
}
porId.put(p.id(), p);
porEmpleado.computeIfAbsent(p.empleado(), e -> new ArrayList<>()).add(p);
return true;
} finally {
escritura.unlock();
}
}
}Y la prueba de esfuerzo que demuestra que funciona:
package com.nexussoftware.bibliotech.servicio;
import java.util.concurrent.TimeUnit;
public class PruebaConcurrenciaRegistro {
public static void main(String[] args) throws InterruptedException {
final int HILOS = 8;
final int POR_HILO = 5_000;
RegistroPrestamosSeguro registro = new RegistroPrestamosSeguro();
Empleado marta = new Empleado("E-001", "Marta Ruiz");
Thread[] hilos = new Thread[HILOS];
long inicio = System.nanoTime();
for (int h = 0; h < HILOS; h++) {
final int idHilo = h;
hilos[h] = new Thread(() -> {
for (int i = 0; i < POR_HILO; i++) {
registro.registrar(new Prestamo(
"P-" + idHilo + "-" + i, marta, "978-0000000001"));
}
}, "bibliotech-registrador-" + h);
hilos[h].start();
}
for (Thread h : hilos) h.join();
long ms = (System.nanoTime() - inicio) / 1_000_000;
int esperado = HILOS * POR_HILO;
int real = registro.activos();
int enLista = registro.prestamosDe(marta).size();
System.out.println("Esperados : " + esperado);
System.out.println("En porId : " + real);
System.out.println("En porEmpleado : " + enLista);
System.out.println("Invariante coherente: " + (real == enLista));
System.out.println("Tiempo : " + ms + " ms");
System.out.println(real == esperado && real == enLista
? "CORRECTO"
: "FALLO: hay una carrera de datos");
}
}Salida:
Esperados : 40000
En porId : 40000
En porEmpleado : 40000
Invariante coherente: true
Tiempo : 187 ms
CORRECTOEjecuta esta prueba diez veces. Debe dar CORRECTO las diez. Después quita los lock()/unlock() de registrar y vuelve a ejecutarla: verás recuentos distintos en cada ejecución, los dos mapas descuadrados, y con suerte alguna ConcurrentModificationException o incluso un HashMap corrupto con un bucle infinito en get() —un fenómeno real que se explica en 08-06—.
Errores Comunes y Consejos
Error 1: creer que volatile hace atómico un incremento. El error conceptual número uno del tema. volatile da visibilidad, no atomicidad. Para contadores: synchronized o AtomicInteger (08-06).
Error 2: sincronizar los escritores pero no los lectores. La exclusión mutua sin visibilidad no sirve: un lector sin sincronizar puede ver un valor obsoleto indefinidamente. Todos los accesos, lecturas incluidas, deben usar el mismo mecanismo.
Error 3: usar candados distintos para el mismo dato. Un método de instancia sincronizado y otro estático sincronizado usan monitores diferentes y no se excluyen. Si tocan el mismo estado, hay una carrera.
Error 4: sincronizar sobre un objeto reasignable o compartido sin querer. synchronized (this) expone el candado; synchronized sobre un String literal, un Integer pequeño o un Boolean usa un objeto internado que otra clase puede compartir sin saberlo. Usa private final Object candado = new Object().
Error 5: unlock() fuera del finally. Si el cuerpo lanza, el candado no se libera nunca y la aplicación se bloquea. Es el motivo principal para preferir synchronized.
Error 6: lock() dentro del try. Si falla la adquisición, el finally intenta soltar un candado que no se tiene: IllegalMonitorStateException que enmascara el error real.
Error 7: hacer E/S dentro de un bloqueo. Serializa la aplicación entera. Prepara los datos dentro, escribe fuera.
Error 8: llamar a código ajeno con el candado en la mano. Oyentes, callbacks, métodos sobrescribibles: pueden bloquear, llamar de vuelta o interbloquear. Copia bajo el candado, notifica fuera.
Error 9: adquirir dos candados en orden distinto según el camino. La causa del 90 % de los interbloqueos. Impón un orden global y documéntalo.
Error 10: promocionar de lectura a escritura en un ReadWriteLock. Interbloqueo instantáneo consigo mismo. La degradación sí está permitida; la promoción no.
Error 11: devolver la colección interna desde un método sincronizado. El candado protege el acceso, pero quien recibe la referencia puede iterarla sin candado. Devuelve una copia o una vista inmutable.
Error 12: usar synchronized cuando bastaba con no compartir. Antes de poner un candado, comprueba si el objeto puede ser local, inmutable o confinado a un hilo.
Consejo 1: documenta cada campo compartido con /** Protegido por 'X'. */. Una línea que evita horas de arqueología.
Consejo 2: encapsula las operaciones compuestas dentro de la clase. registrarSiHayCupo existe porque if (activos() < MAX) registrar(p) desde fuera es un comprobar-luego-actuar inseguro. Si tu API obliga al cliente a componer dos llamadas, tu API tiene un bug.
Consejo 3: prueba con presión real. Ocho hilos, decenas de miles de operaciones, y repetido diez veces. Un test de dos hilos y diez operaciones no detecta nada.
Consejo 4: prefiere synchronized por defecto. Pasa a ReentrantLock solo cuando necesites tryLock, plazo, interrumpibilidad, equidad o varias Condition.
Consejo 5: mide antes de afinar la granularidad. Un candado grueso es más simple y más seguro. Divídelo solo cuando hayas demostrado que es el cuello de botella.
Consejo 6: cuando dudes, hazlo inmutable. Un record con campos final no necesita sincronización, no se puede corromper y no puede interbloquear.
Ejercicios
Ejercicio 1: Del contador roto al contador correcto
Escribe ComparativaContadores con cuatro implementaciones de un contador que soporte incrementar() y valor():
ContadorInseguro:intsimple.ContadorVolatile:volatile int.ContadorSincronizado:synchronized.ContadorConLock:ReentrantLock.
Somete a cada una a 8 hilos × 500.000 incrementos, comprueba si el resultado es correcto y mide el tiempo con System.nanoTime(). Imprime una tabla con implementación, resultado, error e incrementos por milisegundo. Comenta por qué las dos primeras fallan y por qué las dos últimas tienen tiempos parecidos.
Ejercicio 2: Interbloqueo y su corrección
Modela dos salas de reuniones de BiblioTech (SalaReuniones con su propio candado). Escribe ReservaDobleSala con:
- Un método
reservarMal(SalaReuniones a, SalaReuniones b)que adquiera los dos candados en el orden en que se le pasan, con unsleep(100)entre ambos, y unmainque lo interbloquee de forma reproducible con dos hilos que reserven en sentidos opuestos, detectándolo conjoin(3000)+isAlive(). - Un método
reservarBien(...)que aplique el orden global de adquisición usando el identificador de sala, y demuestre que ya no se interbloquea ni en 1.000 intentos con 8 hilos. - Un método
reservarConTryLock(...)conReentrantLock.tryLock(50, MILLISECONDS), retroceso aleatorio y un máximo de intentos, que informe de cuántas veces tuvo que reintentar.
Ejercicio 3: Caché de fichas con ReadWriteLock y medición
Reimplementa CacheFichas de BiblioTech como caché segura para varios hilos con ReentrantReadWriteLock, con Ficha obtener(String isbn) (que consulta la caché y, si falla, la calcula con un sleep(20) simulado y la guarda), void invalidar(String isbn) y int tamano(). Añade contadores de aciertos y fallos protegidos por el mismo candado.
Después escribe una prueba con 10 hilos que realicen 2.000 consultas cada uno sobre un conjunto de 50 ISBN, mida el tiempo total y el porcentaje de aciertos, y compare el resultado con una versión que use un único synchronized en todos los métodos. Explica el resultado obtenido.
Soluciones
Solución al Ejercicio 1
import java.util.concurrent.locks.ReentrantLock;
public class ComparativaContadores {
interface Contador {
void incrementar();
long valor();
String nombre();
}
/** 1. INSEGURO: valor++ es leer-modificar-escribir sin proteccion. */
static class ContadorInseguro implements Contador {
private long valor = 0;
public void incrementar() { valor++; }
public long valor() { return valor; }
public String nombre() { return "int simple"; }
}
/** 2. VOLATILE: resuelve la visibilidad, NO la atomicidad. */
static class ContadorVolatile implements Contador {
private volatile long valor = 0;
public void incrementar() { valor++; }
public long valor() { return valor; }
public String nombre() { return "volatile"; }
}
/** 3. SYNCHRONIZED: exclusion mutua + visibilidad. Correcto. */
static class ContadorSincronizado implements Contador {
private long valor = 0;
public synchronized void incrementar() { valor++; }
// El getter TAMBIEN sincronizado: sin el, un lector podria
// ver un valor obsoleto de su cache.
public synchronized long valor() { return valor; }
public String nombre() { return "synchronized"; }
}
/** 4. REENTRANTLOCK: equivalente, con unlock en finally. */
static class ContadorConLock implements Contador {
private final ReentrantLock candado = new ReentrantLock();
private long valor = 0;
public void incrementar() {
candado.lock(); // FUERA del try
try { valor++; }
finally { candado.unlock(); } // SIEMPRE en finally
}
public long valor() {
candado.lock();
try { return valor; }
finally { candado.unlock(); }
}
public String nombre() { return "ReentrantLock"; }
}
static long medir(Contador c, int hilos, int porHilo) throws InterruptedException {
Thread[] ts = new Thread[hilos];
long inicio = System.nanoTime();
for (int i = 0; i < hilos; i++) {
ts[i] = new Thread(() -> {
for (int j = 0; j < porHilo; j++) c.incrementar();
}, "sumador-" + i);
ts[i].start();
}
for (Thread t : ts) t.join();
return System.nanoTime() - inicio;
}
public static void main(String[] args) throws InterruptedException {
final int HILOS = 8;
final int POR_HILO = 500_000;
final long ESPERADO = (long) HILOS * POR_HILO;
Contador[] contadores = {
new ContadorInseguro(), new ContadorVolatile(),
new ContadorSincronizado(), new ContadorConLock()
};
// Calentamiento: sin el, las primeras implementaciones pagan
// la compilacion JIT y la comparacion no es justa.
for (Contador c : contadores) medir(c, 2, 10_000);
System.out.printf("%-16s | %12s | %10s | %8s | %12s%n",
"Implementacion", "Resultado", "Perdidos", "ms", "inc/ms");
System.out.println("-----------------|--------------|------------|----------|-------------");
for (Contador plantilla : contadores) {
// Instancia nueva para no arrastrar el calentamiento.
Contador c = switch (plantilla.nombre()) {
case "int simple" -> new ContadorInseguro();
case "volatile" -> new ContadorVolatile();
case "synchronized" -> new ContadorSincronizado();
default -> new ContadorConLock();
};
long ns = medir(c, HILOS, POR_HILO);
long ms = ns / 1_000_000;
long perdidos = ESPERADO - c.valor();
System.out.printf("%-16s | %12d | %10d | %8d | %12d%s%n",
c.nombre(), c.valor(), perdidos, ms,
ms == 0 ? 0 : c.valor() / ms,
perdidos == 0 ? "" : " <-- INCORRECTO");
}
}
}Salida orientativa:
Implementacion | Resultado | Perdidos | ms | inc/ms
-----------------|--------------|------------|----------|-------------
int simple | 1284471 | 2715529 | 31 | 41434 <-- INCORRECTO
volatile | 1893204 | 2106796 | 142 | 13332 <-- INCORRECTO
synchronized | 4000000 | 0 | 213 | 18779
ReentrantLock | 4000000 | 0 | 205 | 19512Análisis:
int simplees el más rápido y el más incorrecto. Va rápido precisamente porque no sincroniza nada: cada núcleo trabaja sobre su caché. Velocidad sin corrección no vale nada.volatilees más lento y sigue fallando. El peor de los mundos: paga las barreras de memoria en cada acceso y no gana atomicidad. Es la demostración práctica del apartado 6.synchronizedyReentrantLockdan el resultado exacto y tienen tiempos casi idénticos. Desde Java 6,synchronizedestá tan optimizado comoReentrantLock; la elección es de expresividad, no de rendimiento.- Los cuatro millones son exactos, siempre. Repite el programa:
synchronizedyReentrantLockno fallan nunca. La corrección no es probabilística.
Solución al Ejercicio 2
package com.nexussoftware.bibliotech.servicio;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
public class ReservaDobleSala {
/** Sala con candado intrinseco y candado explicito, para las tres versiones. */
static class SalaReuniones {
final String id;
final Object candado = new Object();
final ReentrantLock lock = new ReentrantLock();
int reservas = 0;
SalaReuniones(String id) { this.id = id; }
}
// ---------- 1. VERSION CON INTERBLOQUEO ----------
static void reservarMal(SalaReuniones a, SalaReuniones b) {
synchronized (a.candado) {
dormir(100); // amplia la ventana del fallo
synchronized (b.candado) {
a.reservas++;
b.reservas++;
}
}
}
// ---------- 2. ORDEN GLOBAL DE ADQUISICION ----------
/**
* Los candados se toman SIEMPRE en orden creciente de 'id'.
* Sin espera circular no puede haber interbloqueo: es una
* garantia estructural, no una probabilidad.
*/
static void reservarBien(SalaReuniones a, SalaReuniones b) {
SalaReuniones primera = a.id.compareTo(b.id) <= 0 ? a : b;
SalaReuniones segunda = (primera == a) ? b : a;
synchronized (primera.candado) {
dormir(1);
synchronized (segunda.candado) {
a.reservas++;
b.reservas++;
}
}
}
// ---------- 3. TRYLOCK CON RETROCESO ALEATORIO ----------
static int reservarConTryLock(SalaReuniones a, SalaReuniones b, int maxIntentos)
throws InterruptedException {
for (int intento = 1; intento <= maxIntentos; intento++) {
boolean tengoA = false, tengoB = false;
try {
tengoA = a.lock.tryLock(50, TimeUnit.MILLISECONDS);
if (tengoA) {
tengoB = b.lock.tryLock(50, TimeUnit.MILLISECONDS);
}
if (tengoA && tengoB) {
a.reservas++;
b.reservas++;
return intento; // exito: devolvemos el intento
}
} finally {
if (tengoB) b.lock.unlock();
if (tengoA) a.lock.unlock();
}
// Retroceso ALEATORIO: sin el, dos hilos en fase reintentarian
// eternamente a la vez (livelock, apartado 12).
TimeUnit.MILLISECONDS.sleep(ThreadLocalRandom.current().nextInt(5, 40));
}
return -1; // no se consiguio
}
// ---------- DEMOSTRACION ----------
public static void main(String[] args) throws InterruptedException {
// --- 1. Provocar el interbloqueo ---
System.out.println("=== 1. VERSION CON INTERBLOQUEO ===");
SalaReuniones s1 = new SalaReuniones("SALA-A");
SalaReuniones s2 = new SalaReuniones("SALA-B");
Thread t1 = new Thread(() -> reservarMal(s1, s2), "hilo-A-B");
Thread t2 = new Thread(() -> reservarMal(s2, s1), "hilo-B-A");
t1.start(); t2.start();
t1.join(3000); t2.join(3000);
if (t1.isAlive() || t2.isAlive()) {
System.out.println(" INTERBLOQUEADO. t1=" + t1.getState()
+ " t2=" + t2.getState());
System.out.println(" (jstack diria: Found one Java-level deadlock)");
} else {
System.out.println(" Esta vez no ha ocurrido; reintenta. "
+ "La intermitencia es exactamente el problema.");
}
// --- 2. Orden global: 1.000 intentos con 8 hilos ---
System.out.println();
System.out.println("=== 2. ORDEN GLOBAL DE ADQUISICION ===");
SalaReuniones a = new SalaReuniones("SALA-A");
SalaReuniones b = new SalaReuniones("SALA-B");
Thread[] hilos = new Thread[8];
for (int i = 0; i < 8; i++) {
final boolean directo = (i % 2 == 0);
hilos[i] = new Thread(() -> {
for (int k = 0; k < 125; k++) {
if (directo) reservarBien(a, b);
else reservarBien(b, a); // sentido opuesto
}
}, "reservador-" + i);
hilos[i].start();
}
for (Thread h : hilos) h.join(20_000);
boolean alguienVivo = false;
for (Thread h : hilos) alguienVivo |= h.isAlive();
System.out.println(" Reservas SALA-A: " + a.reservas);
System.out.println(" Reservas SALA-B: " + b.reservas);
System.out.println(" Algun hilo bloqueado: " + alguienVivo);
System.out.println(alguienVivo ? " FALLO" : " CORRECTO: 1.000 reservas sin interbloqueo");
// --- 3. tryLock con retroceso ---
System.out.println();
System.out.println("=== 3. TRYLOCK CON RETROCESO ALEATORIO ===");
SalaReuniones c = new SalaReuniones("SALA-C");
SalaReuniones d = new SalaReuniones("SALA-D");
AtomicInteger reintentosTotales = new AtomicInteger();
AtomicInteger fracasos = new AtomicInteger();
Thread[] ts = new Thread[4];
for (int i = 0; i < 4; i++) {
final boolean directo = (i % 2 == 0);
ts[i] = new Thread(() -> {
for (int k = 0; k < 50; k++) {
try {
int intentos = directo
? reservarConTryLock(c, d, 10)
: reservarConTryLock(d, c, 10);
if (intentos < 0) fracasos.incrementAndGet();
else reintentosTotales.addAndGet(intentos - 1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}, "trylock-" + i);
ts[i].start();
}
for (Thread t : ts) t.join();
System.out.println(" Reservas SALA-C : " + c.reservas);
System.out.println(" Reservas SALA-D : " + d.reservas);
System.out.println(" Reintentos totales: " + reintentosTotales.get());
System.out.println(" Fracasos : " + fracasos.get());
System.out.println(" (los reintentos son el precio de no imponer un orden)");
}
static void dormir(long ms) {
try { TimeUnit.MILLISECONDS.sleep(ms); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}Salida orientativa:
=== 1. VERSION CON INTERBLOQUEO ===
INTERBLOQUEADO. t1=BLOCKED t2=BLOCKED
(jstack diria: Found one Java-level deadlock)
=== 2. ORDEN GLOBAL DE ADQUISICION ===
Reservas SALA-A: 1000
Reservas SALA-B: 1000
Algun hilo bloqueado: false
CORRECTO: 1.000 reservas sin interbloqueo
=== 3. TRYLOCK CON RETROCESO ALEATORIO ===
Reservas SALA-C : 200
Reservas SALA-D : 200
Reintentos totales: 37
Fracasos : 0La comparación entre 2 y 3 es la moraleja: el orden global no necesitó ni un reintento, ni una espera, ni una línea extra de código en tiempo de ejecución. El tryLock funcionó pero pagó 37 reintentos y un montón de complejidad. Cuando puedas ordenar los candados, ordénalos.
Solución al Ejercicio 3
package com.nexussoftware.bibliotech.servicio;
import com.nexussoftware.bibliotech.dominio.Ficha;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* Cache de fichas segura para varios hilos, optimizada para lectura.
*
* POLITICA: todo el estado esta protegido por 'candado'.
* Las consultas que aciertan usan el candado de LECTURA (compartido);
* solo los fallos escalan al de ESCRITURA (exclusivo).
*/
public class CacheFichasSegura {
private final ReentrantReadWriteLock candado = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock lectura = candado.readLock();
private final ReentrantReadWriteLock.WriteLock escritura = candado.writeLock();
/** Protegido por 'candado'. */
private final Map<String, Ficha> cache = new HashMap<>();
/** Protegidos por 'candado'. */
private long aciertos = 0;
private long fallos = 0;
public Ficha obtener(String isbn) {
// FASE 1: intento optimista con candado COMPARTIDO.
// Varios hilos pueden estar aqui a la vez, que es el 96% del trafico.
lectura.lock();
try {
Ficha f = cache.get(isbn);
if (f != null) {
// OJO: 'aciertos++' bajo el candado de LECTURA seria una
// carrera: varios lectores simultaneos harian
// leer-modificar-escribir a la vez. Lo contamos en la fase 2.
return f;
}
} finally {
lectura.unlock();
}
// FASE 2: fallo. Calculamos FUERA de todo candado (regla 2 del
// apartado 9: nunca operaciones lentas dentro del bloqueo).
Ficha calculada = calcular(isbn);
// FASE 3: publicacion con candado EXCLUSIVO.
escritura.lock();
try {
// Re-comprobamos: entre la fase 1 y la 3 otro hilo pudo
// calcular la misma ficha. putIfAbsent conserva la primera
// y mantiene la coherencia del recuento.
Ficha yaPuesta = cache.putIfAbsent(isbn, calculada);
if (yaPuesta != null) {
aciertos++; // otro hilo nos gano la carrera
return yaPuesta;
}
fallos++;
return calculada;
} finally {
escritura.unlock();
}
}
public void invalidar(String isbn) {
escritura.lock();
try { cache.remove(isbn); }
finally { escritura.unlock(); }
}
public int tamano() {
lectura.lock();
try { return cache.size(); }
finally { lectura.unlock(); }
}
public String estadisticas() {
lectura.lock();
try {
long total = aciertos + fallos;
return String.format("aciertos=%d fallos=%d tasa=%.1f%%",
aciertos, fallos, total == 0 ? 0.0 : 100.0 * aciertos / total);
} finally {
lectura.unlock();
}
}
/** Simula el coste real de construir una ficha (consulta + formateo). */
private Ficha calcular(String isbn) {
try { TimeUnit.MILLISECONDS.sleep(20); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
return new Ficha(isbn, "Titulo de " + isbn, "Autor", 3);
}
// ---------- VERSION DE CONTRASTE: un solo candado exclusivo ----------
public static class CacheFichasSincronizada {
private final Map<String, Ficha> cache = new HashMap<>();
// TODO el metodo sincronizado, INCLUIDO el calculo de 20 ms.
// Es el error de la regla 2 del apartado 9, a proposito.
public synchronized Ficha obtener(String isbn) {
Ficha f = cache.get(isbn);
if (f == null) {
try { TimeUnit.MILLISECONDS.sleep(20); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
f = new Ficha(isbn, "Titulo de " + isbn, "Autor", 3);
cache.put(isbn, f);
}
return f;
}
public synchronized int tamano() { return cache.size(); }
}
// ---------- PRUEBA ----------
public static void main(String[] args) throws InterruptedException {
final int HILOS = 10;
final int CONSULTAS = 2_000;
final int ISBNS = 50;
// --- Version con ReadWriteLock ---
CacheFichasSegura rw = new CacheFichasSegura();
long msRw = ejecutar(HILOS, CONSULTAS, ISBNS, isbn -> rw.obtener(isbn));
System.out.println("=== ReadWriteLock ===");
System.out.println(" Tiempo : " + msRw + " ms");
System.out.println(" Entradas : " + rw.tamano());
System.out.println(" " + rw.estadisticas());
// --- Version con synchronized total ---
CacheFichasSincronizada sy = new CacheFichasSincronizada();
long msSy = ejecutar(HILOS, CONSULTAS, ISBNS, isbn -> sy.obtener(isbn));
System.out.println();
System.out.println("=== synchronized total ===");
System.out.println(" Tiempo : " + msSy + " ms");
System.out.println(" Entradas : " + sy.tamano());
System.out.println();
System.out.printf("Relacion: %.1fx a favor del ReadWriteLock%n",
(double) msSy / msRw);
}
interface Consulta { Ficha obtener(String isbn); }
static long ejecutar(int hilos, int consultas, int isbns, Consulta c)
throws InterruptedException {
Thread[] ts = new Thread[hilos];
long inicio = System.nanoTime();
for (int i = 0; i < hilos; i++) {
ts[i] = new Thread(() -> {
for (int k = 0; k < consultas; k++) {
c.obtener("978-" + String.format("%010d", k % isbns));
}
}, "consultor-" + i);
ts[i].start();
}
for (Thread t : ts) t.join();
return (System.nanoTime() - inicio) / 1_000_000;
}
}Salida orientativa:
=== ReadWriteLock ===
Tiempo : 152 ms
Entradas : 50
aciertos=19 fallos=50 tasa=27.5%
=== synchronized total ===
Tiempo : 1043 ms
Entradas : 50
Relacion: 6.9x a favor del ReadWriteLockAnálisis del resultado:
- La diferencia de 7× no viene del
ReadWriteLocken sí, sino de sacar el cálculo fuera del candado. En la versión sincronizada, los 20 ms de cálculo ocurren con el candado exclusivo tomado, así que los 50 fallos iniciales se serializan: 50 × 20 ms = 1 segundo mínimo, con nueve hilos parados esperando. Es la regla 2 del apartado 9 costando un factor de siete. - El
ReadWriteLockaporta la segunda mitad: una vez llena la caché, las 19.950 consultas restantes son aciertos que se atienden en paralelo con el candado compartido. Con un candado exclusivo se serializarían también, aunque cada una durase microsegundos. - Los 19 "aciertos" contados en la fase 3 son la carrera benigna: dos hilos fallaron sobre el mismo ISBN a la vez y ambos lo calcularon; el
putIfAbsentconserva uno solo. Se hace trabajo duplicado pero el resultado es correcto. Evitarlo por completo requeriría bloquear durante el cálculo —justo lo que queremos evitar— o unConcurrentHashMap.computeIfAbsent, que lo resuelve elegantemente y es 08-06.
Conclusión
Esta era la lección que sostiene el módulo, y con ella cierras el problema que abriste en 08-01.
Sabes por qué falla el contador. contador++ no es una operación: son tres instrucciones de bytecode —getfield, iadd, putfield— y cualquier entrelazado entre ellas pierde trabajo. Reconoces los dos patrones canónicos —leer-modificar-escribir y comprobar-luego-actuar— y sabes que ver cualquiera de ellos sobre estado compartido es ver un bug. Sabes qué operaciones son atómicas en Java y cuáles no, incluido el caso de long y double, cuya escritura la especificación permite partir en dos mitades.
Conoces el modelo de memoria de Java, que es lo que separa entender la concurrencia de aplicarla por superstición. Sabes que la memoria no es un cuaderno compartido: hay cachés por núcleo, el compilador reordena amparado en una garantía que solo vale dentro de un hilo, y la CPU también reordena. De ahí la consecuencia que más cuesta aceptar y que viste ejecutándose: un hilo puede escribir detener = true y otro no enterarse nunca, porque el JIT sacó la lectura fuera del bucle. Y sabes que la garantía real se expresa con la relación happens-before, con sus reglas —orden del programa, monitor, volatile, start, join, campos final, transitividad—, y con la pregunta que hay que hacerse ante cada dato compartido: "¿qué regla happens-before conecta estas dos acciones?". Si no hay respuesta, hay una carrera de datos.
Sabes exactamente qué hace volatile y qué no. Garantiza visibilidad y no reordenación, y hace atómicas las lecturas y escrituras de 64 bits. No hace atómico un incremento. Los dos ejemplos lo demuestran de forma incontestable: la bandera de parada que sin volatile cuelga el programa para siempre, y el contador que con volatile sigue perdiendo el 40 % de los incrementos —más lento y igual de incorrecto—.
Dominas synchronized. El monitor intrínseco que todo objeto lleva dentro, la liberación automática incluso ante excepciones, la reentrada que hace posible la herencia, y las dos garantías que da a la vez: exclusión mutua y visibilidad, esta última tan importante como la primera y sistemáticamente olvidada —por eso el getter también va sincronizado—. Conoces las tres formas y por qué la del bloque con candado privado y final gana a las otras dos: granularidad y no exponer el candado al mundo. Y conoces las trampas: el método de instancia y el estático usan monitores distintos, y un String literal o un Integer pequeño como candado es un objeto compartido con toda la JVM.
Tienes las cinco reglas de qué sincronizar: sección crítica corta, nunca E/S dentro del bloqueo, nunca código ajeno con el candado en la mano —copiar dentro, notificar fuera—, no sincronizar lo que no se comparte, y documentar la política con un /** Protegido por 'candado'. */ que cuesta una línea y ahorra horas.
Sabes provocar y resolver un interbloqueo. Las cuatro condiciones de Coffman, de las que solo la espera circular está en tu mano; el ejemplo reproducible de dos transferencias en sentidos opuestos, con los dos hilos en BLOCKED para siempre, sin excepción y sin consumo de CPU; y las tres soluciones por orden de preferencia: orden global de adquisición —garantía absoluta, coste cero, la respuesta correcta casi siempre—, tryLock con plazo y retroceso aleatorio cuando no puedes ordenar, y un solo candado más grueso cuando la contención lo permite. Con las dos patologías vecinas: la inanición, que se combate con secciones cortas y, si de verdad hace falta, con un candado equitativo caro; y el livelock, que se distingue del interbloqueo porque consume CPU al 100 % y jstack no lo declara.
Conoces el resto de la caja de herramientas: ReentrantLock con su lock(); try { } finally { unlock(); } obligatorio, y las cinco cosas que aporta —tryLock, plazo, lockInterruptibly, equidad y varias condiciones—, con la recomendación de usar synchronized por defecto y pasar a Lock solo cuando necesites una de ellas. Condition como sustituto de wait/notify, con la ventaja decisiva de tener varias colas de espera por candado, que hace seguro el signal() y elimina el despertar masivo de notifyAll. Y ReadWriteLock, con su tabla de compatibilidad, la proporción de al menos 5:1 que lo justifica, y la regla que se olvida: la degradación está permitida, la promoción es un interbloqueo instantáneo consigo mismo.
Y, sobre todo, tienes las dos estrategias que ganan a todas las anteriores. La inmutabilidad: un objeto con todos sus campos final que no deja escapar this es seguro para cualquier número de hilos, sin candados, para siempre —y los record de 04-07 lo son por construcción, con el detalle del constructor compacto que copia las colecciones—. Y el confinamiento: la mejor sincronización es no compartir, ya sea en la pila, con ThreadLocal —con su aviso sobre pools y fugas— o dividiendo el trabajo y combinando al final. Con la jerarquía que deberías recorrer siempre de arriba abajo: no compartir, compartir inmutable, volatile, atómico, colección concurrente, candado.
BiblioTech ya soporta dos empleados a la vez. CatalogoSeguro protege sus tres estructuras con un ReadWriteLock que deja pasar a todos los lectores simultáneamente y serializa solo las altas, y devuelve copias en lugar de sus colecciones internas. RegistroPrestamosSeguro mantiene el invariante entre porId y porEmpleado bajo un único candado —porque un invariante que abarca dos estructuras no se puede sostener con piezas independientes—, y expone registrarSiHayCupo como operación compuesta atómica, porque obligar al cliente a escribir if (activos() < MAX) registrar(p) sería regalarle una carrera. Ocho hilos y cuarenta mil operaciones dan el resultado exacto, diez veces de diez. El caso C de 08-01 está cerrado.
Pero mira cómo has llegado hasta aquí: candados a mano, finally que no se pueden olvidar, órdenes de adquisición que hay que documentar y respetar, y —lo más caro de todo— un hilo creado a mano por cada tarea. Los 200 avisos de BiblioTech siguen necesitando 200 hilos, cada uno con su megabyte de pila. Escribir concurrencia correcta a este nivel es posible, pero es artesanal y frágil, y toda la industria lleva veinte años sin hacerlo así.
En la próxima lección, Utilidades de Concurrencia, subes por fin de nivel. Verás ExecutorService y las fábricas de Executors —pools fijos, elásticos, de un solo hilo y programados—, con la tabla de cuándo usar cada uno y la advertencia sobre las colas ilimitadas que tumban aplicaciones; cómo construir un ThreadPoolExecutor a medida con su ThreadFactory para nombrar hilos y su política de rechazo; el ciclo de vida correcto de un ejecutor con shutdown, shutdownNow y awaitTermination; Callable y Future, que por fin permiten a una tarea devolver un resultado y propagar su excepción, con get() bloqueante y con plazo, y cancel(true) que no es otra cosa que la interrupción de 08-02; ScheduledExecutorService para los avisos periódicos de vencimiento; y los sincronizadores —CountDownLatch, CyclicBarrier, Semaphore— que sustituyen las coordinaciones a mano por piezas probadas. Al terminar, los doscientos avisos de BiblioTech se enviarán con un pool acotado, con barra de progreso y con cancelación limpia, en unos pocos segundos y con ocho hilos en lugar de doscientos.
Curso de Programación en Java
Módulo 1: Introducción a Java
- Introducción a Java
- Configuración del Entorno de Desarrollo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Operadores
- Entrada y Salida por Consola
- Tu Primer Programa Completo: BiblioTech
Módulo 2: Flujo de Control
- Sentencias Condicionales
- Bucles
- Sentencias Switch
- Break y Continue
- Depuración y Trazas de Ejecución
- Proyecto: Menú Interactivo de BiblioTech
Módulo 3: Programación Orientada a Objetos
- Introducción a la POO
- Clases y Objetos
- Métodos
- Constructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- La Clase Object: equals, hashCode y toString
Módulo 4: Programación Orientada a Objetos Avanzada
- Interfaces
- Clases Abstractas
- Clases Internas
- Clases Anónimas
- Expresiones Lambda
- Interfaces Funcionales y Referencias a Métodos
- Enumeraciones y Registros
Módulo 5: Estructuras de Datos y Colecciones
- Arreglos
- El Framework de Colecciones
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cola y Deque
- Pila
- Ordenación y Búsqueda en Colecciones
Módulo 6: Manejo de Excepciones
- Introducción a las Excepciones
- Bloque Try-Catch
- Throw y Throws
- Excepciones Personalizadas
- Bloque Finally
- Try-with-resources y AutoCloseable
- Estrategias de Manejo de Errores y Logging
Módulo 7: Entrada/Salida de Archivos
- Lectura de Archivos
- Escritura de Archivos
- Flujos de Archivos
- BufferedReader y BufferedWriter
- Serialización
- La API NIO.2: Path y Files
- Formatos de Intercambio: CSV y Properties
Módulo 8: Multihilo y Concurrencia
- Introducción al Multihilo
- Creación de Hilos
- Ciclo de Vida de un Hilo
- Sincronización
- Utilidades de Concurrencia
- Colecciones Concurrentes y Variables Atómicas
- Tareas Asíncronas con CompletableFuture
Módulo 9: Redes
- Introducción a las Redes
- Sockets
- ServerSocket
- DatagramSocket y DatagramPacket
- URL y HttpURLConnection
- El Cliente HTTP Moderno
Módulo 10: Temas Avanzados
- Genéricos
- Anotaciones
- Reflexión
- Características de Java 8: Streams y Optional
- Fechas y Horas con java.time
- Java 9 y Más Allá
- Memoria, Recolección de Basura y Rendimiento
Módulo 11: Frameworks y Librerías de Java
- Introducción a los Frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Pruebas Avanzadas con Mockito
- Librerías Esenciales del Ecosistema
