Cerraste el módulo 7 con una frase incómoda: "tu portátil tiene ocho núcleos y BiblioTech usa uno". La aplicación recuerda, se configura sin recompilar, valida sus importaciones y no corrompe sus ficheros. Pero hace las cosas una detrás de otra, y por eso el menú se queda mudo durante una importación, los doscientos avisos tardan diez minutos y el procesador está parado mientras alguien decide qué opción teclear.
Esta lección no escribe todavía la solución: escribe el modelo mental sin el cual la solución es imposible de razonar. Vas a entender qué es exactamente un hilo, en qué se diferencia de un proceso, qué comparten dos hilos y qué no —el diagrama que explica literalmente la mitad de los errores de concurrencia que verás en tu carrera—, en qué se distinguen concurrencia y paralelismo, por qué el número correcto de hilos depende de si tu tarea gasta CPU o espera a un disco, y qué peligros aparecen en cuanto dos hilos tocan lo mismo.
Y terminarás con un programa de veinte líneas que falla de verdad: dos hilos que suman uno a un contador dos millones de veces y producen un resultado menor que dos millones, de forma reproducible. Ese programa es el gancho del módulo entero. Todo lo que viene después —synchronized, volatile, las variables atómicas, las colecciones concurrentes— existe para que ese número vuelva a ser el que debería.
Aviso de método. La concurrencia es el único tema de este curso donde leer no basta. Ejecuta los ejemplos. Ejecútalos varias veces. Los que fallan no fallan siempre, y ver el mismo programa dar dos resultados distintos en dos ejecuciones seguidas es la lección que ningún párrafo puede sustituir.
Contenido
- El problema real: BiblioTech espera
- Proceso frente a hilo
- Qué comparten y qué no comparten dos hilos
- Concurrencia frente a paralelismo
- Por qué se usan hilos: los tres motivos
- Tareas limitadas por CPU y limitadas por E/S
- El hilo principal y el hilo actual
- Hilos demonio y cuándo termina la JVM
- El coste real de un hilo
- Los tres peligros: carrera, interbloqueo, inanición
- El primer ejemplo que falla: el contador compartido
- Errores Comunes y Consejos
- Ejercicios
- El problema real: BiblioTech espera
Antes de la teoría, los tres casos concretos que la justifican. Son tres situaciones reales del código que ya has escrito.
Caso A — la importación bloquea el menú. ImportadorCatalogo lee un fichero de cincuenta mil líneas, valida cada una y construye un Informe. Tarda ocho segundos. Durante esos ocho segundos, MenuBiblioTech no lee teclado, no pinta nada, no responde. El usuario no sabe si el programa está trabajando o se ha colgado, y no puede cancelar.
Caso B — los avisos se envían de uno en uno. ColaAvisos tiene doscientos préstamos vencidos. Enviar un aviso implica escribir un fichero de notificación y esperar a que el disco confirme. Supongamos 300 ms por aviso. Doscientos avisos son sesenta segundos de reloj, durante los cuales el procesador está prácticamente inactivo: está esperando al disco, no calculando.
Caso C — dos empleados a la vez. Hoy BiblioTech es de un solo usuario. Si mañana Marta Ruiz y Diego Alonso registraran préstamos simultáneamente sobre el mismo Catalogo, nada en el código impediría que uno pisara el trabajo del otro. No hay ni un synchronized en todo el proyecto porque hasta ahora no hacía falta.
Los tres casos tienen la misma raíz —un único flujo de ejecución— y tres soluciones distintas que este módulo desarrolla. El caso A necesita mover el trabajo a otro flujo. El caso B necesita varios flujos solapando esperas. El caso C necesita proteger lo compartido.
- Proceso frente a hilo
Cuando lanzas java BiblioTechApp, el sistema operativo crea un proceso: una unidad de ejecución con su propio espacio de memoria virtual, sus propios descriptores de fichero y su propia identidad de seguridad. Dentro de ese proceso, la JVM crea hilos: flujos de ejecución independientes que comparten toda esa memoria.
La diferencia esencial cabe en una frase: dos procesos no se ven la memoria; dos hilos del mismo proceso comparten toda la memoria del montón. De ahí se derivan todas las demás diferencias.
| Aspecto | Proceso | Hilo |
|---|---|---|
| Espacio de memoria | Propio y aislado | Compartido con los demás hilos del proceso |
| Coste de creación | Alto (milisegundos, copiar tablas de páginas) | Bajo (microsegundos) |
| Coste de cambio de contexto | Alto (cambiar tablas de páginas, vaciar TLB) | Bajo (cambiar registros y puntero de pila) |
| Comunicación entre ellos | Cara: ficheros, tuberías, sockets, memoria compartida explícita | Inmediata: una variable |
| Aislamiento ante fallos | Total: si uno cae, el otro sigue | Nulo: un OutOfMemoryError tumba todo el proceso |
| Uso típico de memoria | Decenas o cientos de MB | ~1 MB de pila reservada (Linux x64, por defecto) |
| Quién lo gestiona | El sistema operativo | El sistema operativo, expuesto por la JVM |
flowchart TB
subgraph P1["Proceso A - BiblioTechApp"]
direction TB
H1["Montón compartido<br/>Catalogo, RegistroPrestamos, estáticos"]
subgraph HilosA[" "]
direction LR
T1["Hilo main<br/>pila propia"]
T2["Hilo importador<br/>pila propia"]
T3["Hilo avisos<br/>pila propia"]
end
T1 --- H1
T2 --- H1
T3 --- H1
end
subgraph P2["Proceso B - editor de texto"]
H2["Montón propio<br/>invisible desde el proceso A"]
T4["Hilo main<br/>pila propia"]
T4 --- H2
end
P1 -.->|"solo por fichero, tubería o socket"| P2
Las flechas continuas dentro del proceso A son acceso directo a memoria: el hilo importador puede escribir en el mismo objeto Catalogo que está leyendo el hilo main, sin pedir permiso a nadie y sin que el sistema operativo intervenga. La flecha discontinua entre procesos representa lo contrario: para que el editor de texto vea algo del proceso A hace falta un mecanismo explícito y caro.
Esa flecha continua es a la vez toda la potencia y todo el peligro del multihilo. Compartir memoria es lo que hace baratos los hilos, y es lo que hace que un contador pueda perder incrementos.
Nota histórica y práctica. El modelo de "un proceso por tarea" no está obsoleto: es exactamente lo que hacen los navegadores modernos con cada pestaña, precisamente por el aislamiento ante fallos. Cuando el aislamiento importa más que la comunicación barata, procesos. Cuando la comunicación importa más, hilos.
- Qué comparten y qué no comparten dos hilos
Este es el apartado más importante de la lección. Si interiorizas este diagrama, la mayoría de los errores de concurrencia dejan de ser magia negra y pasan a ser consecuencias previsibles.
Cada hilo de la JVM tiene su propia pila, su propio contador de programa y, en consecuencia, sus propias variables locales y parámetros. Todo lo demás —el montón, donde viven los objetos, y el área de estáticos— es compartido.
flowchart TB
subgraph JVM["Proceso JVM"]
direction TB
subgraph Compartido["COMPARTIDO por todos los hilos"]
direction LR
MONTON["Montón (heap)<br/>todos los objetos<br/>new Libro(...), arrays, cadenas"]
EST["Área de clases<br/>campos static<br/>metadatos, constantes"]
end
subgraph Privado["PRIVADO de cada hilo"]
direction LR
subgraph HA["Hilo main"]
PA["Pila<br/>marcos de método<br/>variables locales<br/>Contador de programa"]
end
subgraph HB["Hilo importador"]
PB["Pila<br/>marcos de método<br/>variables locales<br/>Contador de programa"]
end
end
PA -->|"referencias apuntan a"| MONTON
PB -->|"referencias apuntan a"| MONTON
PA --> EST
PB --> EST
end
Traducido a código:
public class QueSeComparte {
// COMPARTIDO: un unico valor para toda la JVM.
// Si dos hilos lo modifican, se pisan.
private static int contadorGlobal = 0;
// El objeto al que apunta esta referencia vive en el monton:
// si dos hilos tienen la misma referencia, ven el MISMO objeto.
private final Catalogo catalogo;
public QueSeComparte(Catalogo catalogo) {
this.catalogo = catalogo;
}
public void procesar(String isbn) {
// PRIVADO: 'encontrado' y 'reintentos' viven en la pila del hilo
// que esta ejecutando 'procesar'. Si tres hilos llaman a este
// metodo a la vez, hay TRES copias independientes, una por pila.
boolean encontrado = false;
int reintentos = 0;
// 'catalogo' es una referencia (copia privada en la pila),
// pero el OBJETO al que apunta es compartido.
// Modificarlo afecta a todos los hilos.
Material m = catalogo.buscar(isbn);
encontrado = (m != null);
contadorGlobal++; // <-- COMPARTIDO. Aqui esta el problema.
}
}Las tres reglas que se deducen y que debes memorizar:
- Las variables locales son seguras por construcción. Un
int ideclarado dentro de un método no puede tener una condición de carrera: cada hilo tiene el suyo. Esto es confinamiento en la pila y es la forma más barata de seguridad que existe. - Los campos de objeto y los
staticson compartidos si el objeto es alcanzable desde varios hilos. UnLibrocreado y usado por un solo hilo es tan seguro como una variable local. El peligro no es el campo: es la compartición. - Una referencia local a un objeto compartido no protege nada. Es el error de intuición más frecuente de quien empieza:
Material mes local, pero elMaterialque hay al otro lado no lo es.
Regla práctica que usarás mil veces. Antes de preguntarte "¿esto necesita
synchronized?", pregúntate "¿puede este objeto ser alcanzado por dos hilos a la vez?". Si la respuesta es no, no hay nada que sincronizar. La mitad de la sincronización que se escribe en la industria es innecesaria por no hacerse esta pregunta.
- Concurrencia frente a paralelismo
Los dos términos se usan como sinónimos en la conversación informal y no lo son. La distinción es la que hace Rob Pike en una frase citadísima: "la concurrencia es tratar con muchas cosas a la vez; el paralelismo es hacer muchas cosas a la vez".
- Concurrencia: estructura. Varias tareas están en curso durante el mismo periodo de tiempo. Pueden ejecutarse intercaladas en un único núcleo, turnándose en rodajas de tiempo de unos pocos milisegundos.
- Paralelismo: ejecución. Varias tareas ejecutan instrucciones en el mismo instante físico, lo que exige varios núcleos.
flowchart TB
subgraph C["CONCURRENCIA - 1 núcleo, tareas intercaladas"]
direction LR
c1["A"] --> c2["B"] --> c3["A"] --> c4["B"] --> c5["A"] --> c6["B"]
end
subgraph P["PARALELISMO - 2 núcleos, tareas simultáneas"]
direction TB
subgraph N1["Núcleo 1"]
direction LR
p1["A"] --> p2["A"] --> p3["A"]
end
subgraph N2["Núcleo 2"]
direction LR
p4["B"] --> p5["B"] --> p6["B"]
end
end
Tres consecuencias que importan de verdad:
Primera: la concurrencia sirve aunque solo tengas un núcleo. El caso A de BiblioTech —el menú que responde mientras se importa— se resuelve con concurrencia pura. Aunque la máquina tuviera un solo núcleo, intercalar las dos tareas haría que el menú respondiera. No va más rápido: va más receptivo, que es otra cosa.
Segunda: el paralelismo es la única forma de que un cálculo termine antes. Si tienes que calcular las multas de cincuenta mil préstamos y eso es puro cálculo, intercalar en un núcleo no ahorra ni un milisegundo. Solo repartir entre núcleos lo hace.
Tercera y la que más duele: tu código concurrente se ejecutará en paralelo, quieras o no. Es tentador razonar "mi máquina de desarrollo hace las cosas por turnos, así que el error de carrera no aparecerá". El servidor de producción tiene treinta y dos núcleos y la reordenación de memoria que verás en 08-04 se manifiesta ahí y no en tu portátil. Un programa concurrente correcto tiene que serlo para cualquier entrelazado posible.
| Concurrencia | Paralelismo | |
|---|---|---|
| Qué es | Una propiedad del diseño | Una propiedad de la ejecución |
| Núcleos necesarios | Uno basta | Dos o más |
| Qué mejora | Receptividad, aprovechamiento de esperas | Tiempo total de cálculo |
| Caso BiblioTech | Menú vivo durante la importación | 200 avisos repartidos entre 8 núcleos |
| Se consigue con | Hilos, tareas, asincronía | Hilos ejecutando a la vez sobre varios núcleos |
- Por qué se usan hilos: los tres motivos
Solo hay tres razones legítimas para introducir hilos en un programa. Si tu motivo no es una de ellas, probablemente estés añadiendo complejidad sin ganancia.
Motivo 1: aprovechar varios núcleos. Un programa secuencial en una máquina de ocho núcleos usa el 12,5 % de la capacidad de cálculo. Si el trabajo es divisible, repartirlo puede acercarse a un factor de ocho. Puede: la ley de Amdahl dice que la aceleración está limitada por la fracción que no se puede paralelizar. Si el 20 % de tu trabajo es inherentemente secuencial, la aceleración máxima con infinitos núcleos es 5×, no infinita.
Motivo 2: no bloquear la interfaz. Todo programa con interacción —consola, escritorio, web— tiene un flujo que debe seguir respondiendo. Es el caso A: el menú de BiblioTech no puede quedarse mudo ocho segundos. La solución no es hacer la importación más rápida: es sacarla del hilo que atiende al usuario.
Motivo 3: solapar espera de E/S con trabajo útil. Este es el motivo más rentable y el peor entendido. Cuando un hilo llama a Files.readAllLines, el sistema operativo lo suspende hasta que el disco responda. Durante esos milisegundos el hilo no consume CPU: está aparcado. Si tienes doscientos avisos que escribir y cada uno espera 300 ms al disco, un solo hilo suma sesenta segundos de espera; veinte hilos solapan esas esperas y bajan a tres segundos —sin necesitar veinte núcleos, porque no están calculando, están esperando.
Los tres motivos mapean exactamente sobre los tres casos del apartado 1:
| Caso BiblioTech | Motivo | Qué se gana |
|---|---|---|
| A: importación bloquea el menú | No bloquear la interfaz | Receptividad y cancelación |
| B: 200 avisos de uno en uno | Solapar espera de E/S | De 60 s a ~3 s |
| C: dos empleados a la vez | (Ninguno: es el coste) | Nada; hay que pagar protección |
El caso C es importante precisamente porque no es un motivo, es una factura. En cuanto introduces hilos por los motivos 1, 2 o 3, aparece la obligación de proteger lo compartido. La concurrencia no es gratis: cambia rendimiento y receptividad por complejidad y clases enteras de errores nuevos.
- Tareas limitadas por CPU y limitadas por E/S
Toda tarea cae, aproximadamente, en una de dos categorías. La categoría determina cuántos hilos conviene usar, y equivocarse aquí es la causa habitual de que "añadí hilos y va más lento".
- Limitada por CPU (CPU-bound): el hilo pasa casi todo su tiempo ejecutando instrucciones. Calcular multas, ordenar un millón de elementos, comprimir, cifrar, analizar texto.
- Limitada por E/S (I/O-bound): el hilo pasa casi todo su tiempo esperando a un dispositivo. Leer un fichero, escribir en disco, esperar a una base de datos, esperar a la red.
import java.util.concurrent.TimeUnit;
public class TiposDeTarea {
/** Limitada por CPU: el nucleo trabaja al 100% durante todo el metodo. */
static long calcularHash(String texto, int vueltas) {
long h = 0;
for (int v = 0; v < vueltas; v++) {
for (int i = 0; i < texto.length(); i++) {
h = h * 31 + texto.charAt(i);
}
}
return h;
}
/** Limitada por E/S: el hilo esta suspendido, el nucleo queda libre. */
static void escribirAviso(String destinatario) throws InterruptedException {
// Simulamos la latencia del disco. Durante estos 300 ms el hilo
// NO consume CPU: el sistema operativo lo tiene aparcado.
TimeUnit.MILLISECONDS.sleep(300);
}
}La regla de dimensionado, que se desarrollará con pool de hilos en 08-05:
| Limitada por CPU | Limitada por E/S | |
|---|---|---|
| Dónde está el hilo | Ejecutando | Esperando, suspendido |
| Hilos útiles | ≈ número de núcleos | Muchos más que núcleos (decenas o cientos) |
| Qué pasa si pones más | Peor: los cambios de contexto son puro coste | Mejor: más esperas solapadas |
| Qué pasa si pones menos | Núcleos ociosos | Tiempo de reloj desperdiciado esperando |
| Fórmula orientativa | N = núcleos (o N+1) |
N × (1 + espera/cálculo) |
| Caso BiblioTech | Recalcular todas las multas | Enviar los 200 avisos |
Java te dice cuántos núcleos hay:
public class Nucleos {
public static void main(String[] args) {
int nucleos = Runtime.getRuntime().availableProcessors();
System.out.println("Procesadores disponibles: " + nucleos);
// Dimensionado orientativo para tareas de calculo puro.
System.out.println("Pool sugerido (CPU): " + nucleos);
// Para E/S con ratio espera/calculo ~= 9 (300 ms esperando por
// cada 30 ms calculando), la formula da N * (1 + 9) = N * 10.
System.out.println("Pool sugerido (E/S, ratio 9): " + (nucleos * 10));
}
}Dos matices sobre availableProcessors() que evitan sorpresas en producción:
- Devuelve hilos de hardware, no núcleos físicos. Un procesador de 4 núcleos con hyperthreading devuelve 8. Para tareas de cálculo puro, los hilos lógicos rinden bastante menos que un núcleo real.
- En un contenedor con límite de CPU, las JVM modernas (Java 10+) respetan el límite del cgroup. En Java 8 antiguo devolvía los núcleos de la máquina anfitriona, lo que provocaba pools gigantes en contenedores diminutos. Es un clásico de las migraciones.
- El hilo principal y el hilo actual
No has escrito nunca un programa de un solo hilo. Cuando la JVM arranca, crea el hilo main y le hace ejecutar tu método main; además hay varios hilos internos —el recolector de basura, el compilador JIT, el finalizador— trabajando en segundo plano desde el primer instante.
Thread.currentThread() devuelve el hilo que está ejecutando esa línea en ese momento. Es la herramienta de diagnóstico número uno de este módulo:
public class HiloActual {
public static void main(String[] args) {
Thread actual = Thread.currentThread();
System.out.println("Nombre : " + actual.getName());
System.out.println("Id : " + actual.threadId()); // Java 19+; antes getId()
System.out.println("Estado : " + actual.getState());
System.out.println("Prio. : " + actual.getPriority());
System.out.println("Demonio: " + actual.isDaemon());
System.out.println("Grupo : " + actual.getThreadGroup().getName());
saludar();
}
static void saludar() {
// El hilo actual no depende del metodo: depende de QUIEN lo llama.
System.out.println("saludar() ejecuta en: "
+ Thread.currentThread().getName());
}
}Salida:
Nombre : main
Id : 1
Estado : RUNNABLE
Prio. : 5
Demonio: false
Grupo : main
saludar() ejecuta en: mainFíjate en el detalle del método saludar(): un método no "pertenece" a un hilo. El mismo método puede estar ejecutándose simultáneamente en cinco hilos distintos, cada uno con su propio juego de variables locales en su propia pila. Esto vuelve a ser el apartado 3, visto desde el código.
Consejo que aplicarás en todo el módulo. Cuando algo raro pase, imprime o registra
Thread.currentThread().getName(). La pregunta "¿en qué hilo se está ejecutando esto?" resuelve una proporción sorprendente de los misterios de concurrencia. En BiblioTech,RegistroOperacionesdebería incluir el nombre del hilo en cada entrada; lo harás en el ejercicio 3.
- Hilos demonio y cuándo termina la JVM
Java distingue dos clases de hilos:
- No demonio (user thread): el hilo normal.
mainlo es. - Demonio (daemon): hilo de servicio, pensado para tareas de apoyo que no deben impedir el cierre del programa.
La regla es tajante y hay que saberla de memoria:
La JVM termina cuando no queda ningún hilo NO demonio vivo. Los hilos demonio se abortan en ese instante, sin previo aviso, sin ejecutar
finally, sin cerrar recursos.
Que main termine no apaga la JVM: si un hilo no demonio sigue vivo, el proceso sigue en pie. Es la causa clásica de "el programa no termina y no sé por qué".
public class DemonioVsUsuario {
public static void main(String[] args) throws InterruptedException {
Runnable tarea = () -> {
for (int i = 1; i <= 5; i++) {
System.out.println(Thread.currentThread().getName() + " paso " + i);
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
System.out.println(Thread.currentThread().getName() + " TERMINADO");
};
Thread usuario = new Thread(tarea, "hilo-usuario");
Thread demonio = new Thread(tarea, "hilo-demonio");
demonio.setDaemon(true); // OBLIGATORIO antes de start()
usuario.start();
demonio.start();
Thread.sleep(500);
System.out.println(">>> main termina aqui");
}
}Salida típica:
hilo-usuario paso 1
hilo-demonio paso 1
hilo-usuario paso 2
hilo-demonio paso 2
hilo-usuario paso 3
hilo-demonio paso 3
>>> main termina aqui
hilo-usuario paso 4
hilo-usuario paso 5
hilo-usuario TERMINADOLéelo con cuidado, porque hay dos hechos en esa salida:
maintermina en el paso 3 y el proceso no muere:hilo-usuarioes no demonio y la JVM lo espera hasta que imprimeTERMINADO.hilo-demonionunca llega a los pasos 4 y 5. En el instante en quehilo-usuarioacaba, no queda ningún hilo no demonio, la JVM se apaga y el demonio se corta a mitad de camino.
| No demonio | Demonio | |
|---|---|---|
| Impide que la JVM termine | Sí | No |
| Al apagarse la JVM | Se le espera | Se aborta sin avisar |
Ejecuta su finally al apagar |
Sí, termina normalmente | No garantizado |
| Uso típico | Trabajo del negocio | Monitorización, limpieza periódica, cachés |
| Cómo se marca | Por defecto | setDaemon(true) antes de start() |
Reglas prácticas:
setDaemon(true)después destart()lanzaIllegalThreadStateException. Siempre antes.- Un hilo hereda por defecto la condición de demonio del hilo que lo crea.
- Nunca pongas trabajo que no se pueda perder en un hilo demonio. Si un demonio está escribiendo el fichero del catálogo cuando la JVM se apaga, el fichero queda a medias. Para trabajo que debe completarse, hilo no demonio y espera explícita con
join()(08-02). - El shutdown hook que añadiste a BiblioTech en el módulo 7 sí se ejecuta al apagar, y es el sitio correcto para el guardado de estado. No lo sustituyas por un demonio.
- El coste real de un hilo
Un hilo parece gratis porque new Thread(...) es una línea. No lo es.
Memoria. Cada hilo reserva una pila. En un Linux de 64 bits el valor por defecto es de 1 MB (ajustable con -Xss). Es espacio de direcciones reservado y la memoria física se va tocando según crece la pila, pero el orden de magnitud manda: diez mil hilos son diez gigabytes de pilas reservadas. En una máquina normal, la JVM cae con OutOfMemoryError: unable to create native thread mucho antes.
Creación. Crear un hilo de plataforma implica una llamada al sistema y que el kernel monte sus estructuras. El orden es de decenas a cientos de microsegundos: mil veces más caro que crear un objeto normal. Si creas un hilo por cada petición y las peticiones son cortas, el coste de creación puede superar al trabajo útil.
Cambio de contexto. Cuando el planificador quita un hilo y pone otro, hay que guardar y restaurar registros, y —lo caro— la caché de datos del núcleo queda llena de datos del hilo anterior. El coste directo ronda el microsegundo; el indirecto, por fallos de caché, puede ser mucho mayor. Con más hilos activos que núcleos, la máquina puede pasar más tiempo cambiando de hilo que trabajando: es el thrashing de planificación.
public class CosteDeCrearHilos {
public static void main(String[] args) throws InterruptedException {
int n = 10_000;
long t0 = System.nanoTime();
Thread[] hilos = new Thread[n];
for (int i = 0; i < n; i++) {
hilos[i] = new Thread(() -> { /* no hace nada */ });
}
long tCreacion = System.nanoTime() - t0;
long t1 = System.nanoTime();
for (Thread h : hilos) h.start();
for (Thread h : hilos) h.join();
long tArranque = System.nanoTime() - t1;
System.out.printf("Construir %d objetos Thread: %.1f ms%n",
n, tCreacion / 1_000_000.0);
System.out.printf("Arrancar y esperar %d hilos: %.1f ms%n",
n, tArranque / 1_000_000.0);
System.out.printf("Coste medio por hilo arrancado: %.1f us%n",
tArranque / 1_000.0 / n);
}
}Salida orientativa (varía mucho por máquina):
Construir 10000 objetos Thread: 4,7 ms
Arrancar y esperar 10000 hilos: 812,3 ms
Coste medio por hilo arrancado: 81,2 usOchenta microsegundos por hilo sin hacer absolutamente nada. Si tu tarea real dura menos que eso, crear un hilo para ella es una pérdida neta.
De aquí sale la conclusión que gobierna la práctica moderna: "un hilo por tarea" no escala. La solución clásica es el pool de hilos, un número fijo de hilos reutilizados que van consumiendo tareas de una cola; es 08-05. La solución nueva son los hilos virtuales de Java 21, que vuelven el coste tan bajo que "un hilo por tarea" vuelve a ser viable; se explican en 10-06.
- Los tres peligros: carrera, interbloqueo, inanición
Antes de escribir código concurrente conviene conocer los tres modos de fallo. Aquí solo se presentan con una intuición; resolverlos es el trabajo de 08-04 y siguientes.
Condición de carrera. El resultado depende del orden en que se entrelacen los hilos, y ese orden no lo controlas.
Intuición: dos empleados miran a la vez la misma hoja de inventario, ambos leen "quedan 3 ejemplares", ambos anotan "2" después de prestar uno. Se han prestado dos ejemplares y el inventario dice que se prestó uno.
Interbloqueo (deadlock). Dos hilos se esperan mutuamente para siempre, cada uno reteniendo lo que el otro necesita.
Intuición: Marta tiene la llave del archivo y necesita la del almacén; Diego tiene la del almacén y necesita la del archivo. Ninguno suelta la suya. Los dos esperan indefinidamente y no hay error, ni excepción, ni traza: simplemente nada avanza.
Inanición (starvation). Un hilo nunca consigue el recurso que necesita porque otros se lo llevan siempre.
Intuición: la impresora de la biblioteca atiende primero los trabajos cortos. El informe anual de 400 páginas de Nuria Vidal nunca llega a imprimirse, porque siempre hay alguien con un trabajo de dos páginas por delante.
| Fallo | Síntoma | Se estudia y resuelve en |
|---|---|---|
| Condición de carrera | Resultados incorrectos, intermitentes, irreproducibles | 08-04 (synchronized, atómicas), 08-06 |
| Interbloqueo | Bloqueo total y silencioso, sin error | 08-04 (orden global, tryLock), 08-03 (diagnóstico) |
| Inanición | Una tarea nunca progresa mientras las demás sí | 08-04 (equidad), 08-05 (pools) |
Añade una cuarta característica común a los tres que hace la concurrencia especialmente traicionera: son heisenbugs. Añadir un System.out.println para depurar cambia los tiempos y hace desaparecer el fallo. Ejecutar con el depurador, igual. Y un programa con una condición de carrera puede funcionar correctamente durante meses hasta que un día, con más carga o en una máquina con más núcleos, produce un dato incorrecto. Por eso este módulo insiste tanto en la corrección por diseño y no en probar hasta que parezca que funciona.
- El primer ejemplo que falla: el contador compartido
Aquí está el programa prometido. Dos hilos, un contador compartido, cada hilo suma uno un millón de veces. El resultado esperado es 2.000.000. Casi nunca lo es.
public class ContadorRoto {
// Compartido por los dos hilos: un unico int en el monton.
private int valor = 0;
public void incrementar() {
valor++; // Parece una operacion. Son TRES. Ver 08-04.
}
public int valor() {
return valor;
}
public static void main(String[] args) throws InterruptedException {
final int VUELTAS = 1_000_000;
// Repetimos el experimento varias veces: el fallo es intermitente
// y una sola ejecucion podria enganarte.
for (int intento = 1; intento <= 5; intento++) {
ContadorRoto contador = new ContadorRoto();
Thread h1 = new Thread(() -> {
for (int i = 0; i < VUELTAS; i++) contador.incrementar();
}, "sumador-1");
Thread h2 = new Thread(() -> {
for (int i = 0; i < VUELTAS; i++) contador.incrementar();
}, "sumador-2");
h1.start();
h2.start();
// join() espera a que el hilo termine. Sin esto leeriamos
// el contador mientras todavia esta cambiando. Detalle en 08-02.
h1.join();
h2.join();
int esperado = VUELTAS * 2;
int real = contador.valor();
System.out.printf("Intento %d -> esperado %d, real %d, perdidos %d (%.2f%%)%n",
intento, esperado, real, esperado - real,
100.0 * (esperado - real) / esperado);
}
}
}Salida real de una ejecución (los números cambian en cada ejecución, ese es justo el punto):
Intento 1 -> esperado 2000000, real 1362544, perdidos 637456 (31,87%)
Intento 2 -> esperado 2000000, real 1198801, perdidos 801199 (40,06%)
Intento 3 -> esperado 2000000, real 2000000, perdidos 0 (0,00%)
Intento 4 -> esperado 2000000, real 1455913, perdidos 544087 (27,22%)
Intento 5 -> esperado 2000000, real 1734022, perdidos 265978 (13,30%)Cuatro observaciones, todas importantes:
Primera: no se pierde un incremento, se pierden cientos de miles. No es un caso rarísimo que se da una vez entre un millón: es un fallo masivo. La razón se explica en 08-04, pero el resumen es que valor++ se compila a tres instrucciones —leer el valor, sumarle uno, escribir el resultado— y cualquier entrelazado entre esas tres pierde trabajo.
Segunda: el intento 3 dio el resultado correcto. Esto es lo verdaderamente peligroso. Si hubieras ejecutado el programa una sola vez y hubiera salido el intento 3, habrías concluido que el código está bien. Que un programa concurrente dé el resultado correcto no demuestra nada.
Tercera: nunca sale un valor mayor de 2.000.000. Los incrementos se pierden, no se duplican. Con un int de 32 bits las escrituras son atómicas —cada escritura escribe un valor completo—, así que el error es siempre por defecto. Con un long en una JVM de 32 bits podían verse valores imposibles, mitad de una escritura y mitad de otra; también en 08-04.
Cuarta: si sustituyes VUELTAS por 1000, probablemente salga siempre correcto. Con poco trabajo, el primer hilo termina antes de que el segundo arranque y no llega a haber solapamiento. Es la razón por la que estos errores no aparecen en pruebas pequeñas y sí en producción.
Un experimento que conviene hacer. Cambia private int valor por private volatile int valor y vuelve a ejecutar. Sigue fallando. Es la demostración de que volatile no resuelve esto, y una de las confusiones más extendidas del tema; el porqué está en 08-04.
Deja este programa a mano. Volverás a él con synchronized en 08-04 y con AtomicInteger en 08-06, y verás las dos formas correctas de arreglarlo, con su coste respectivo.
Errores Comunes y Consejos
Error 1: creer que "no lo he visto fallar" significa "es correcto". Es el error conceptual más grave del módulo. Un entrelazado desfavorable puede ser raro en tu portátil y frecuente en un servidor de 32 núcleos con carga. Razona la corrección; no la pruebes a base de ejecuciones.
Error 2: confundir "es local" con "es seguro". Material m = catalogo.buscar(isbn) declara una referencia local a un objeto compartido. Lo seguro es la referencia; el objeto no.
Error 3: pensar que más hilos siempre van más rápido. Para tareas de cálculo, pasar de 8 a 80 hilos en 8 núcleos empeora el rendimiento por cambios de contexto. Mide antes de decidir.
Error 4: llamar a setDaemon(true) después de start(). Lanza IllegalThreadStateException. Y peor: poner trabajo importante en un demonio y descubrir que se corta a media escritura.
Error 5: suponer que main al terminar apaga la JVM. Con un hilo no demonio vivo, el proceso sigue. Si tu programa "no termina", busca hilos no demonio que siguen corriendo (verás cómo diagnosticarlo con volcados de hilos en 08-03).
Error 6: añadir hilos sin un motivo de los tres del apartado 5. Complejidad, errores nuevos y diagnóstico difícil a cambio de nada.
Error 7: depurar concurrencia con System.out.println. println está internamente sincronizado, así que cambia el comportamiento: puede hacer desaparecer la carrera que intentas ver. Usa el logger de 06-07, incluye el nombre del hilo, y acumula datos en memoria para volcarlos al final cuando midas.
Consejo 1: piensa en "tareas", no en "hilos". El resto del módulo va en esa dirección: describes qué trabajo hay que hacer y dejas que un ejecutor decida dónde se hace.
Consejo 2: la mejor sincronización es no compartir. Si cada hilo trabaja sobre sus propios datos y solo se combinan los resultados al final, no hay carreras posibles. Es el principio de confinamiento, y se desarrolla en 08-04.
Consejo 3: nombra todos tus hilos desde el primer día. Un volcado con Thread-0, Thread-1 y Thread-2 no dice nada; con bibliotech-importador, bibliotech-avisos-3 te dice dónde mirar. Se hace en 08-02.
Consejo 4: mide con System.nanoTime(), no con currentTimeMillis(). nanoTime es un reloj monótono pensado para medir intervalos; currentTimeMillis puede saltar hacia atrás si el sistema ajusta la hora.
Ejercicios
Ejercicio 1: Diagnóstico del entorno de ejecución
Escribe una clase DiagnosticoConcurrencia con un main que imprima un informe del entorno: número de procesadores disponibles, memoria máxima de la JVM, nombre, identificador, prioridad y condición de demonio del hilo actual, y el número total de hilos vivos en la JVM en ese momento. Además, para una tarea limitada por E/S con una espera media de 400 ms y un cálculo medio de 50 ms por tarea, calcula e imprime el tamaño de pool recomendado según la fórmula N × (1 + espera/cálculo).
Pista: Thread.activeCount() da una aproximación de los hilos vivos del grupo actual; Runtime.getRuntime().maxMemory() da la memoria máxima en bytes.
Ejercicio 2: Demostrar la pérdida de incrementos en función de la carga
Amplía el ejemplo del apartado 11 para responder empíricamente a esta pregunta: ¿a partir de cuántas vueltas empieza a fallar? Escribe UmbralDeCarrera que, para cada valor de vueltas en {100, 1_000, 10_000, 100_000, 1_000_000}, repita el experimento 10 veces con dos hilos y cuente en cuántas de las 10 repeticiones el resultado fue incorrecto. Imprime una tabla con la carga, el porcentaje de ejecuciones fallidas y la pérdida media.
Ejercicio 3: Trazas con identidad de hilo
Añade a BiblioTech un método de utilidad RegistroOperaciones.trazaHilo(String operacion) que registre en el logger de 06-07, en nivel INFO, una línea con el nombre del hilo actual, su identificador y la operación. Después escribe un main de demostración que lance dos hilos llamados bibliotech-importador y bibliotech-avisos, cada uno de los cuales llame a trazaHilo tres veces con pausas de 100 ms, y comprueba en la salida que las líneas de los dos hilos aparecen intercaladas y no en bloques.
Soluciones
Solución al Ejercicio 1
public class DiagnosticoConcurrencia {
public static void main(String[] args) {
Runtime rt = Runtime.getRuntime();
Thread actual = Thread.currentThread();
int nucleos = rt.availableProcessors();
System.out.println("=== ENTORNO DE EJECUCION ===");
System.out.println("Java : " + System.getProperty("java.version"));
System.out.println("Sistema operativo : " + System.getProperty("os.name"));
System.out.println("Procesadores dispon. : " + nucleos);
System.out.printf ("Memoria maxima JVM : %.0f MB%n", rt.maxMemory() / (1024.0 * 1024));
System.out.printf ("Memoria total actual : %.0f MB%n", rt.totalMemory() / (1024.0 * 1024));
System.out.println();
System.out.println("=== HILO ACTUAL ===");
System.out.println("Nombre : " + actual.getName());
System.out.println("Id : " + actual.threadId());
System.out.println("Prioridad : " + actual.getPriority());
System.out.println("Demonio : " + actual.isDaemon());
System.out.println("Estado : " + actual.getState());
// activeCount() cuenta los hilos vivos del grupo actual y sus subgrupos.
// Es una ESTIMACION: el numero puede cambiar mientras se calcula.
System.out.println("Hilos vivos (aprox.): " + Thread.activeCount());
System.out.println();
System.out.println("=== DIMENSIONADO DE POOL ===");
// Tarea limitada por CPU: un hilo por nucleo mantiene los nucleos
// ocupados sin pagar cambios de contexto innecesarios.
System.out.println("Pool para tareas de CPU : " + nucleos);
// Tarea limitada por E/S: formula N * (1 + espera/calculo).
// Con 400 ms de espera y 50 ms de calculo, el ratio es 8,
// asi que cada nucleo puede sostener 9 hilos utiles.
double espera = 400.0;
double calculo = 50.0;
double ratio = espera / calculo;
int poolIo = (int) Math.round(nucleos * (1 + ratio));
System.out.printf("Ratio espera/calculo : %.1f%n", ratio);
System.out.println("Pool para tareas de E/S : " + poolIo);
System.out.println();
System.out.println("Nota: son puntos de partida, no verdades. "
+ "El tamano definitivo se decide midiendo (08-05).");
}
}Puntos didácticos:
availableProcessors()puede devolver valores distintos en ejecuciones distintas si la JVM corre en un contenedor con límites dinámicos. No lo caches en unstatic finalsi el proceso es de larga duración.activeCount()es explícitamente aproximado: los hilos pueden nacer y morir mientras se cuenta. Para diagnóstico serio se usaThreadMXBean(08-03).- El pool de E/S sale mucho mayor que el número de núcleos, y esa es exactamente la intuición del apartado 6: los hilos que esperan no compiten por CPU.
Solución al Ejercicio 2
public class UmbralDeCarrera {
/** Contador deliberadamente inseguro. */
static class Contador {
private int valor = 0;
void incrementar() { valor++; }
int valor() { return valor; }
}
/**
* Ejecuta un experimento: dos hilos incrementando 'vueltas' veces.
* Devuelve cuantos incrementos se han perdido (0 si todo fue bien).
*/
static int experimento(int vueltas) throws InterruptedException {
Contador c = new Contador();
// Un unico Runnable compartido por los dos hilos: ambos
// incrementan EL MISMO objeto Contador.
Runnable tarea = () -> {
for (int i = 0; i < vueltas; i++) c.incrementar();
};
Thread h1 = new Thread(tarea, "sumador-1");
Thread h2 = new Thread(tarea, "sumador-2");
h1.start();
h2.start();
h1.join();
h2.join();
return (vueltas * 2) - c.valor();
}
public static void main(String[] args) throws InterruptedException {
int[] cargas = { 100, 1_000, 10_000, 100_000, 1_000_000 };
int REPETICIONES = 10;
System.out.printf("%-12s | %-14s | %-16s%n",
"Vueltas", "Fallos /10", "Perdida media");
System.out.println("-------------|----------------|------------------");
for (int vueltas : cargas) {
int fallos = 0;
long perdidaTotal = 0;
for (int r = 0; r < REPETICIONES; r++) {
int perdidos = experimento(vueltas);
if (perdidos != 0) {
fallos++;
perdidaTotal += perdidos;
}
}
double perdidaMedia = fallos == 0 ? 0 : (double) perdidaTotal / fallos;
System.out.printf("%-12d | %-14s | %-16.1f%n",
vueltas, fallos + " / " + REPETICIONES, perdidaMedia);
}
System.out.println();
System.out.println("Conclusion: el fallo NO desaparece con cargas pequenas,");
System.out.println("simplemente se vuelve menos probable. Un codigo que 'nunca");
System.out.println("ha fallado' con 100 vueltas es exactamente igual de");
System.out.println("incorrecto que uno que falla siempre con 1.000.000.");
}
}Salida orientativa:
Vueltas | Fallos /10 | Perdida media
-------------|----------------|------------------
100 | 0 / 10 | 0,0
1000 | 1 / 10 | 412,0
10000 | 6 / 10 | 2871,3
100000 | 10 / 10 | 39104,7
1000000 | 10 / 10 | 548219,2Lo importante de este ejercicio no es el número: es la forma de la curva. Con 100 vueltas no falla nunca porque el primer hilo termina antes de que el segundo empiece a solaparse; con un millón falla siempre. Un programa de producción es la columna de la derecha, y tu prueba unitaria suele ser la de la izquierda.
Solución al Ejercicio 3
package com.nexussoftware.bibliotech.infraestructura;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Ampliacion de RegistroOperaciones (06-07) con identidad de hilo.
* A partir del modulo 8, TODA traza deberia decir en que hilo ocurre:
* sin ese dato, un log concurrente es ilegible.
*/
public final class RegistroOperaciones {
private static final Logger LOG =
Logger.getLogger(RegistroOperaciones.class.getName());
private RegistroOperaciones() { }
/** Registra una operacion incluyendo el hilo que la ejecuta. */
public static void trazaHilo(String operacion) {
Thread h = Thread.currentThread();
LOG.log(Level.INFO, "[hilo={0} id={1}] {2}",
new Object[] { h.getName(), h.threadId(), operacion });
}
// --- Demostracion ---
public static void main(String[] args) throws InterruptedException {
Runnable trabajo = () -> {
for (int paso = 1; paso <= 3; paso++) {
trazaHilo("paso " + paso);
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
// Protocolo correcto de 08-02: restaurar la bandera y salir.
Thread.currentThread().interrupt();
return;
}
}
};
Thread importador = new Thread(trabajo, "bibliotech-importador");
Thread avisos = new Thread(trabajo, "bibliotech-avisos");
trazaHilo("arrancando hilos de trabajo");
importador.start();
avisos.start();
importador.join();
avisos.join();
trazaHilo("todos los hilos han terminado");
}
}Salida (con el formateador de ConfiguracionLog):
INFO: [hilo=main id=1] arrancando hilos de trabajo
INFO: [hilo=bibliotech-importador id=21] paso 1
INFO: [hilo=bibliotech-avisos id=22] paso 1
INFO: [hilo=bibliotech-avisos id=22] paso 2
INFO: [hilo=bibliotech-importador id=21] paso 2
INFO: [hilo=bibliotech-importador id=21] paso 3
INFO: [hilo=bibliotech-avisos id=22] paso 3
INFO: [hilo=main id=1] todos los hilos han terminadoTres cosas que enseña esta salida:
- Las líneas están intercaladas y el orden entre hilos cambia en cada ejecución. En el paso 2 el hilo de avisos se adelantó al importador; en otra ejecución será al revés. No hay ningún orden garantizado entre hilos distintos.
- Dentro de un mismo hilo, el orden sí está garantizado:
paso 1siempre precede apaso 2en el mismo hilo. Esta es la primera intuición del modelo de memoria: dentro de un hilo, todo ocurre en el orden del programa. java.util.logginges seguro para varios hilos: losHandlersincronizan la publicación, por eso las líneas no salen entremezcladas carácter a carácter. Es otra razón para usar el logger de 06-07 y noSystem.out.printlna partir de ahora.
Conclusión
Ya tienes el modelo mental. No has escrito todavía concurrencia útil, pero has construido las tres ideas sin las cuales el resto del módulo sería memorización.
La primera: un hilo es un flujo de ejecución dentro de un proceso, mucho más barato de crear y de conmutar que un proceso, y con una diferencia decisiva: comparte toda la memoria del montón y los estáticos con los demás hilos, y solo tiene privadas su pila, sus variables locales y su contador de programa. Ese reparto —compartido arriba, privado abajo— explica por qué una variable local nunca sufre una condición de carrera y por qué un objeto alcanzable desde dos hilos siempre puede sufrirla. La pregunta correcta nunca es "¿es este campo peligroso?", sino "¿puede este objeto ser alcanzado por dos hilos a la vez?".
La segunda: concurrencia y paralelismo no son lo mismo. La concurrencia es una propiedad del diseño —varias tareas en curso a la vez, aunque se intercalen en un solo núcleo— y sirve para que el menú de BiblioTech siga vivo durante una importación. El paralelismo es una propiedad de la ejecución —varias tareas avanzando en el mismo instante— y es lo único que hace que un cálculo termine antes. Y la consecuencia práctica: tu código se ejecutará en máquinas con muchos más núcleos que la tuya, así que tiene que ser correcto para cualquier entrelazado, no solo para el que ves.
La tercera: los hilos se introducen por tres motivos y solo tres —aprovechar núcleos, no bloquear la interfaz, solapar espera de E/S—, y cuestan dinero: alrededor de 1 MB de pila y decenas de microsegundos por hilo, más el cambio de contexto. De ahí que el número adecuado de hilos dependa de si la tarea está limitada por CPU (≈ núcleos) o limitada por E/S (muchos más, según el ratio espera/cálculo), y de que "un hilo por tarea" no escale, que es la razón de ser de los pools de 08-05.
Sabes además distinguir el hilo main del hilo actual, consultar Thread.currentThread().getName() como primera herramienta de diagnóstico, y la regla del apagado: la JVM termina cuando no queda ningún hilo no demonio, y los demonios se abortan sin ejecutar su finally —por eso nunca se les confía trabajo que no se pueda perder—.
Y conoces los tres modos de fallo que definen el resto del módulo: la condición de carrera que da resultados incorrectos e intermitentes, el interbloqueo que para todo en silencio y sin excepción, y la inanición que deja a un hilo esperando para siempre. Con la advertencia que los hace especialmente traicioneros: son heisenbugs, y añadir una traza para verlos puede hacerlos desaparecer.
Sobre todo, tienes el contador roto. Dos hilos, un valor++, dos millones de incrementos y un resultado que unas veces es 1.362.544, otras 1.734.022 y de vez en cuando —lo más peligroso de todo— exactamente 2.000.000. Ese programa de veinte líneas contiene el módulo entero en miniatura: un fallo real, reproducible, invisible en pruebas pequeñas y masivo bajo carga.
En la próxima lección, Creación de Hilos, dejas de contemplar el problema y empiezas a construir. Verás las cuatro formas de crear un hilo y por qué Runnable gana a extender Thread; el error clásico de llamar a run() en lugar de start(), con la demostración de que ejecuta en el hilo equivocado; cómo esperar con join(), cómo nombrar hilos para que los logs sirvan de algo, y por qué las prioridades casi nunca hacen lo que crees; el protocolo completo de interrupción —interrupt(), InterruptedException, y por qué tragarse esa excepción es uno de los peores errores que se pueden cometer en Java—; y qué pasa cuando un hilo lanza una excepción que nadie captura. Al terminar, la importación del catálogo de BiblioTech se ejecutará en su propio hilo, con nombre propio, y se podrá cancelar.
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
