En la lección anterior terminaste con un contador que perdía medio millón de incrementos. Antes de arreglarlo hay que aprender a manejar bien la herramienta que lo rompió: el hilo.
Esta lección es la de los mecanismos. Vas a ver las cuatro formas de crear un hilo en Java y por qué solo una de ellas es la recomendable; el error clásico de llamar a run() cuando querías start(), demostrado con una salida que no deja lugar a dudas; cómo esperar a que un hilo termine con join(); por qué nombrar los hilos no es cosmética sino una necesidad operativa; y —lo más importante de toda la lección— el protocolo de interrupción, que es el único mecanismo de cancelación cooperativa que tiene Java y el que va a permitir que la importación de catálogo de BiblioTech deje de ser un bloque de ocho segundos que no se puede detener.
Al terminar, ImportadorCatalogo correrá en un hilo propio llamado bibliotech-importador, informará de su progreso y responderá a una petición de cancelación en menos de medio segundo, dejando el catálogo en un estado coherente.
Aviso sobre lo que aprendes aquí. Casi nada de esta lección lo escribirás así en producción: en 08-05 aparecen los ejecutores, que crean y reutilizan los hilos por ti. Pero un ejecutor es una capa sobre esto, y cuando falle —y fallará— tendrás que razonar en términos de hilos,
joine interrupción. Esta es la capa de abajo.
Contenido
- Las cuatro formas de crear un hilo
- Por qué
Runnablegana a extenderThread start()frente arun(): el error clásico- El ciclo básico: crear, arrancar, esperar con
join() - Nombrar hilos: por qué es imprescindible
- Prioridades y por qué casi nunca sirven
- Hilos demonio: el momento de llamar a
setDaemon - Dormir:
Thread.sleepyTimeUnit - Interrupción: el protocolo de cancelación
- Excepciones en un hilo: no van a donde crees
- Pasar datos y recoger resultados
- BiblioTech: la importación en su propio hilo, cancelable
- Lo que casi nunca harás en producción
- Errores Comunes y Consejos
- Ejercicios
- Las cuatro formas de crear un hilo
Java ofrece cuatro maneras de asociar un trozo de código a un hilo. Todas acaban en lo mismo —un objeto Thread con un run() que ejecutar— pero difieren mucho en calidad de diseño.
Forma 1: extender Thread y sobrescribir run().
public class HiloAvisos extends Thread {
private final int totalAvisos;
public HiloAvisos(int totalAvisos) {
super("bibliotech-avisos"); // el nombre del hilo
this.totalAvisos = totalAvisos;
}
@Override
public void run() {
for (int i = 1; i <= totalAvisos; i++) {
System.out.println(getName() + " enviando aviso " + i);
}
}
}
// Uso:
HiloAvisos h = new HiloAvisos(3);
h.start();Aquí la clase es un hilo. Funciona, es lo primero que se enseña en todas partes, y es lo que menos deberías usar.
Forma 2: implementar Runnable y pasarlo al constructor de Thread.
public class TareaAvisos implements Runnable {
private final int totalAvisos;
public TareaAvisos(int totalAvisos) {
this.totalAvisos = totalAvisos;
}
@Override
public void run() {
for (int i = 1; i <= totalAvisos; i++) {
System.out.println(Thread.currentThread().getName()
+ " enviando aviso " + i);
}
}
}
// Uso: la TAREA es TareaAvisos; el MECANISMO es Thread.
Thread h = new Thread(new TareaAvisos(3), "bibliotech-avisos");
h.start();Aquí la clase describe un trabajo; quién lo ejecuta es otra decisión. Esta es la forma recomendada, y el apartado 2 explica por qué.
Forma 3: una lambda que implementa Runnable.
Runnable es una interfaz funcional —un único método abstracto void run()—, exactamente el tipo de interfaz que viste en 04-05 y 04-06. Así que se puede escribir como lambda:
Thread h = new Thread(() -> {
for (int i = 1; i <= 3; i++) {
System.out.println(Thread.currentThread().getName()
+ " enviando aviso " + i);
}
}, "bibliotech-avisos");
h.start();Es la forma más concisa y la habitual para tareas cortas. Recuerda de 04-05 que la lambda captura las variables del entorno, y que solo puede capturar variables efectivamente finales: si intentas modificar dentro de la lambda un int declarado fuera, el compilador te lo impide. Esto no es un capricho —es precisamente lo que evita una condición de carrera sobre la pila de un hilo que quizá ya no exista.
Forma 4: una clase anónima.
Thread h = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Ejecutando en " + Thread.currentThread().getName());
}
}, "bibliotech-avisos");Es la forma 3 escrita a la antigua (04-04). Hoy solo tiene sentido cuando la implementación necesita estado propio o varios métodos, cosa que con Runnable no ocurre. Con una interfaz de un solo método, la lambda siempre gana.
Comparación:
| Forma | Gasta la herencia | Reutilizable con ejecutores | Verbosidad | Cuándo usarla |
|---|---|---|---|---|
Extender Thread |
Sí | No (es un hilo, no una tarea) | Media | Casi nunca; solo si necesitas cambiar el comportamiento del propio hilo |
Implementar Runnable |
No | Sí | Media | Tareas con estado, nombre y pruebas propias |
Lambda Runnable |
No | Sí | Mínima | Tareas cortas, el caso más frecuente |
| Clase anónima | No | Sí | Alta | Legado, o si necesitas campos propios sin crear una clase |
- Por qué
Runnable gana a extender Thread
Runnable gana a extender ThreadHay tres razones, y la tercera es la de peso.
Razón 1: la herencia es un recurso único y escaso. Java no tiene herencia múltiple de clases. Si HiloAvisos extends Thread, ya no puede extender nada más. Y en BiblioTech eso duele: si mañana quieres que tu tarea extienda una clase base TareaBiblioTech con la política de errores de 06-07, no puedes.
Razón 2: separa la tarea del mecanismo. Es exactamente la discusión de herencia frente a composición de 03-05. extends Thread dice "esta clase es un hilo", lo cual es falso: TareaAvisos no es un hilo, es un trabajo. Que ese trabajo lo ejecute un hilo, dos hilos, un pool o el hilo actual debería ser una decisión independiente de la definición del trabajo.
Razón 3, la decisiva: Runnable es la moneda de cambio de toda la API de concurrencia.
Runnable tarea = () -> Catalogo.recalcularEstadisticas();
// 1. Ejecutarla en un hilo nuevo.
new Thread(tarea, "estadisticas").start();
// 2. Ejecutarla en un pool (08-05).
ejecutor.submit(tarea);
// 3. Programarla cada 10 minutos (08-05).
programador.scheduleAtFixedRate(tarea, 0, 10, TimeUnit.MINUTES);
// 4. Ejecutarla al apagar la JVM (shutdown hook del modulo 7).
Runtime.getRuntime().addShutdownHook(new Thread(tarea, "cierre"));
// 5. Ejecutarla aqui mismo, sin hilos, para una prueba unitaria.
tarea.run();Cinco destinos completamente distintos para la misma tarea, sin tocar una línea de la tarea. Con extends Thread no puedes hacer ninguno de los cuatro primeros, y el quinto —probar la tarea sin crear hilos— es el que más agradecerás cuando llegues a JUnit en 11-04: probar lógica de negocio arrancando hilos es lento e intermitente; probar el run() de un Runnable llamándolo directamente es una prueba normal y determinista.
Regla. Escribe tareas, no hilos. Deja que quien las usa decida dónde se ejecutan.
start() frente a run(): el error clásico
start() frente a run(): el error clásicoEste es el error de principiante más frecuente y más silencioso del tema, porque compila, se ejecuta y no da ningún error. Simplemente no hay concurrencia.
run()es un método normal. Llamarlo ejecuta el código en el hilo que hace la llamada, como cualquier método.start()pide a la JVM que cree un hilo nuevo del sistema operativo y que ese hilo ejecuterun(). Retorna inmediatamente.
public class StartFrenteARun {
static Runnable tarea = () ->
System.out.println(" tarea ejecutandose en: "
+ Thread.currentThread().getName());
public static void main(String[] args) throws InterruptedException {
System.out.println("main ejecutandose en: "
+ Thread.currentThread().getName());
System.out.println("\n--- Llamando a run() (INCORRECTO) ---");
Thread h1 = new Thread(tarea, "hilo-A");
h1.run(); // NO crea ningun hilo
System.out.println(" estado de hilo-A: " + h1.getState());
System.out.println("\n--- Llamando a start() (CORRECTO) ---");
Thread h2 = new Thread(tarea, "hilo-B");
h2.start(); // crea un hilo de verdad
h2.join();
System.out.println(" estado de hilo-B: " + h2.getState());
}
}Salida:
main ejecutandose en: main
--- Llamando a run() (INCORRECTO) ---
tarea ejecutandose en: main
estado de hilo-A: NEW
--- Llamando a start() (CORRECTO) ---
tarea ejecutandose en: hilo-B
estado de hilo-B: TERMINATEDLas dos líneas que delatan el error:
- Con
run(), la tarea imprimemain: se ejecutó en el hilo principal. No hubo paralelismo, no hubo receptividad, no hubo nada. - El estado de
hilo-Asigue siendoNEW: ese objetoThreadnunca llegó a arrancar. Se quedó ahí, construido y sin usar. Los estados se detallan en 08-03.
Dos reglas relacionadas:
start()sobre un hilo ya arrancado lanzaIllegalThreadStateException. UnThreades de un solo uso: se arranca una vez y, al terminar, ya no se puede reutilizar. Si necesitas repetir la tarea, creas otroThread(o, mejor, usas un pool).start()retorna inmediatamente, no cuando la tarea acaba. El código después destart()sigue corriendo en paralelo con la tarea. Esperar es tarea dejoin().
Cómo detectarlo en una revisión de código. Busca
.run()en el código de producción. Casi cualquier.run()explícito sobre unThreades un error; sobre unRunnablea veces es intencional (ejecución en línea), pero merece un comentario que lo justifique.
- El ciclo básico: crear, arrancar, esperar con
join()
join()El ciclo mínimo tiene tres pasos y una trampa.
import java.util.concurrent.TimeUnit;
public class CicloBasico {
public static void main(String[] args) throws InterruptedException {
// 1. CREAR: solo construye un objeto. Nada se ejecuta todavia.
Thread importador = new Thread(() -> {
System.out.println("[importador] empezando");
dormir(1500);
System.out.println("[importador] terminado");
}, "bibliotech-importador");
// 2. ARRANCAR: a partir de aqui hay dos flujos de ejecucion.
importador.start();
System.out.println("[main] el menu sigue vivo mientras se importa");
// 3. ESPERAR: main se queda parado hasta que el hilo termina.
importador.join();
System.out.println("[main] importacion confirmada, ya puedo usar el resultado");
}
static void dormir(long ms) {
try {
TimeUnit.MILLISECONDS.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}Salida:
[importador] empezando
[main] el menu sigue vivo mientras se importa
[importador] terminado
[main] importacion confirmada, ya puedo usar el resultadoLa trampa: sin join(), el resultado puede no estar listo. Es un error muy común:
Thread h = new Thread(() -> informe = calcularInforme());
h.start();
System.out.println(informe); // BUG: probablemente null, el hilo aun no ha acabadojoin() no es solo "esperar": también es una barrera de memoria. Todo lo que el hilo escribió antes de terminar es visible para quien hace join() después. Esta es la primera relación happens-before concreta que ves, y se explica formalmente en 08-04. Sin join() (o algún otro mecanismo de sincronización), no solo puedes leer antes de tiempo: puedes leer un valor obsoleto aunque el hilo ya hubiera terminado.
join() con tiempo límite. La sobrecarga join(long millis) espera como mucho ese tiempo:
Thread importador = new Thread(new ImportadorTarea(), "bibliotech-importador");
importador.start();
importador.join(5000); // espera como maximo 5 segundos
if (importador.isAlive()) {
// El join termino por TIEMPO, no porque el hilo acabara.
// OJO: join(long) NO lanza excepcion al agotarse el plazo,
// ni cancela nada. Hay que comprobarlo con isAlive().
System.out.println("La importacion tarda demasiado; solicitando cancelacion");
importador.interrupt(); // apartado 9
importador.join(1000); // margen para que termine limpiamente
if (importador.isAlive()) {
System.out.println("El hilo no responde a la interrupcion");
}
} else {
System.out.println("Importacion completada dentro del plazo");
}Detalle importante que sorprende a todo el mundo la primera vez: join(5000) no te dice si el hilo terminó o si se agotó el plazo. Devuelve void. La única forma de distinguirlo es isAlive() justo después. Es una API antigua y torpe; en 08-05 verás Future.get(timeout), que sí lanza TimeoutException y es lo que usarás en la práctica.
| Método | Qué hace | Cuándo usarlo |
|---|---|---|
join() |
Espera indefinidamente a que el hilo termine | Cuando la terminación está garantizada |
join(ms) |
Espera como máximo ms milisegundos |
Cuando quieres un plazo; comprobar isAlive() después |
isAlive() |
¿Ha arrancado y aún no ha terminado? | Diagnóstico y comprobación tras join(ms) |
interrupt() |
Solicita la cancelación cooperativa | Apartado 9 |
join()lanzaInterruptedException. Quien espera puede a su vez ser interrumpido. Trátala con el protocolo del apartado 9, nunca con uncatchvacío.
- Nombrar hilos: por qué es imprescindible
Un hilo sin nombre recibe Thread-0, Thread-1, Thread-2… Eso es suficiente para un ejemplo de tres líneas y absolutamente inútil en un sistema real.
// MAL: nombres automaticos
new Thread(tareaImportacion).start();
new Thread(tareaAvisos).start();
new Thread(tareaCopias).start();
// BIEN: el nombre dice qué está haciendo
new Thread(tareaImportacion, "bibliotech-importador").start();
new Thread(tareaAvisos, "bibliotech-avisos").start();
new Thread(tareaCopias, "bibliotech-copias").start();Con nombres, un volcado de hilos (08-03) o una traza de error dicen inmediatamente qué parte del sistema está implicada:
"bibliotech-importador" #21 prio=5 os_prio=0 tid=0x... nid=0x... waiting on condition
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.base/java.lang.Thread.sleep(Native Method)
at com.nexussoftware.bibliotech.persistencia.ImportadorCatalogo.leer(...)Sin nombre, esa misma línea empieza por "Thread-3" y te obliga a deducir de la traza qué es. Con veinte hilos, no es viable.
Convención recomendada para el proyecto: <aplicacion>-<funcion>[-<indice>].
bibliotech-importadorbibliotech-avisos-1,bibliotech-avisos-2, …bibliotech-programador
Tres motivos concretos por los que esto es una necesidad y no un adorno:
- Volcados de hilos: es la única forma de leer un volcado de una aplicación con muchos hilos.
- Logs:
RegistroOperaciones.trazaHilodel ejercicio de 08-01 incluye el nombre del hilo; sin nombres significativos, la traza no sirve. - Monitorización:
jconsoley VisualVM listan hilos por nombre; un panel lleno depool-1-thread-7no dice de qué pool es.
En 08-05 verás ThreadFactory, que es la forma de conseguir que los hilos de un pool también tengan nombres decentes, porque por defecto se llaman pool-1-thread-1 y eso vuelve a ser inútil cuando hay tres pools.
// El nombre se puede fijar en el constructor o después,
// pero antes de start() (después funciona, aunque confunde los logs previos).
Thread h = new Thread(tarea);
h.setName("bibliotech-importador");
h.start();
// Y se consulta desde dentro:
System.out.println(Thread.currentThread().getName());
- Prioridades y por qué casi nunca sirven
Thread tiene un campo de prioridad entre 1 y 10:
Thread.MIN_PRIORITY // 1
Thread.NORM_PRIORITY // 5 (por defecto)
Thread.MAX_PRIORITY // 10
Thread h = new Thread(tarea, "bibliotech-avisos");
h.setPriority(Thread.MAX_PRIORITY);
h.start();Parece que sirva para decir "este hilo es más importante". En la práctica, no confíes en ello, por cuatro motivos:
- Es una sugerencia, no una orden. La JVM traduce la prioridad a la del sistema operativo, que es quien decide. Puede ignorarla por completo.
- El mapeo depende del sistema. Windows tiene 7 niveles útiles; Linux, con el planificador por defecto, prácticamente ignora las prioridades de los hilos de usuario sin permisos especiales. El mismo programa se comporta distinto en cada sistema.
- No garantiza orden de ejecución. Un hilo de prioridad 1 puede ejecutarse antes que uno de prioridad 10. Si tu corrección depende de eso, tu programa es incorrecto.
- Invita a la inanición. Si un hilo de prioridad alta nunca cede, uno de prioridad baja puede no ejecutarse nunca —el tercer peligro de 08-01—.
// ANTIPATRON: "resolver" una condicion de carrera con prioridades.
escritor.setPriority(Thread.MAX_PRIORITY);
lector.setPriority(Thread.MIN_PRIORITY);
// Esto NO sincroniza nada. Solo hace el fallo mas raro y
// por tanto mas dificil de diagnosticar. La solucion es 08-04.Qué hacer en su lugar: si necesitas que cierto trabajo no compita con el trabajo importante, no bajes su prioridad: ponlo en un pool separado con pocos hilos (08-05). Eso sí es un control real y portable.
Uso legítimo y casi único: bajar la prioridad de hilos de mantenimiento no urgentes (limpieza de caché, recogida de métricas) como pista de que no importa si van lentos. Nunca como mecanismo de corrección.
- Hilos demonio: el momento de llamar a
setDaemon
setDaemonYa viste en 08-01 la regla —la JVM termina cuando no queda ningún hilo no demonio—. Aquí va el detalle de uso.
Thread monitor = new Thread(() -> {
while (true) {
System.out.println("[monitor] hilos vivos: " + Thread.activeCount());
dormir(1000);
}
}, "bibliotech-monitor");
monitor.setDaemon(true); // ANTES de start(). Despues: IllegalThreadStateException
monitor.start();Reglas prácticas:
- Siempre antes de
start(). Después lanzaIllegalThreadStateException. - Se hereda. Un hilo creado desde un hilo demonio nace demonio. Es una fuente de sorpresas: si arrancas un hilo de trabajo desde dentro de un demonio, ese hilo tampoco impedirá el apagado.
- Un demonio no ejecuta su
finallyal apagar la JVM. No hay garantía de que se ejecute nada. Por tanto: nada que escriba ficheros, cierre recursos o confirme transacciones debe vivir en un demonio.
| Tarea de BiblioTech | ¿Demonio? | Por qué |
|---|---|---|
| Importación del catálogo | No | Debe completarse o cancelarse limpiamente |
| Guardado del estado al salir | No (y además es shutdown hook) | Perder datos es inaceptable |
| Monitor de hilos vivos | Sí | Puramente informativo |
Limpieza periódica de CacheFichas |
Sí | Reconstruible; perderla no cuesta nada |
| Envío de avisos | No | Un aviso a medias es un aviso perdido |
- Dormir:
Thread.sleep y TimeUnit
Thread.sleep y TimeUnitThread.sleep(ms) suspende el hilo actual durante al menos ese tiempo. Dos formas equivalentes:
Thread.sleep(2000); // milisegundos: hay que contar ceros
TimeUnit.SECONDS.sleep(2); // legible, sin ambigüedad
TimeUnit.MILLISECONDS.sleep(500);
TimeUnit.MINUTES.sleep(5);TimeUnit (de java.util.concurrent) es preferible por legibilidad: TimeUnit.MINUTES.sleep(5) frente a Thread.sleep(300000). El segundo es un error de un cero esperando a ocurrir. Además TimeUnit es el tipo que usan todas las APIs de concurrencia que verás en 08-05 (awaitTermination, Future.get, scheduleAtFixedRate), así que conviene acostumbrarse.
Cuatro hechos sobre sleep que hay que saber:
1. "Al menos", no "exactamente". El plazo es un mínimo. Si el sistema está cargado, el hilo puede despertar bastante después. No construyas lógica que dependa de precisión de milisegundos.
2. Dormir NO libera bloqueos. Este es el punto crítico y se retomará en 08-04:
Un hilo dormido dentro de un bloque sincronizado mantiene el monitor y bloquea a todos los demás. Es una receta de desastre. Object.wait() —que sí libera el monitor— es lo que se usa para coordinar, y está en 08-03.
3. sleep lanza InterruptedException. Es una excepción comprobada; el compilador te obliga a tratarla. Cómo tratarla bien es el apartado siguiente.
4. sleep(0) no es "no dormir". Puede provocar una cesión al planificador. Si lo que quieres es sugerir una cesión, existe Thread.yield() (08-03), y sigue sin garantizar nada.
En este módulo usaremos
sleeppara simular latencia —escribir en disco, esperar una respuesta lenta— porque las redes son el módulo 9. En producción,sleepen un bucle para "esperar a que algo pase" es un antipatrón (busy waiting con siesta): lo correcto eswait/notify(08-03), unCountDownLatcho unaBlockingQueue(08-05 y 08-06).
- Interrupción: el protocolo de cancelación
Este es el apartado más importante de la lección. Java no tiene forma de matar un hilo. Thread.stop() existía y se eliminó por peligroso (08-03). Lo único que hay es la interrupción: un mecanismo cooperativo en el que un hilo pide a otro que termine, y el otro decide cuándo y cómo hacerlo.
9.1 Las tres piezas
| Elemento | Qué hace | Efecto sobre la bandera |
|---|---|---|
hilo.interrupt() |
Marca la bandera de interrupción del hilo destino | La pone a true |
hilo.isInterrupted() |
Consulta la bandera de un hilo | La deja como está |
Thread.interrupted() |
Consulta la bandera del hilo actual | La borra (¡ojo!) |
InterruptedException |
Se lanza si el hilo estaba bloqueado en sleep, wait, join… |
La borra al lanzarse |
Las dos filas marcadas son la causa de casi todos los errores de este apartado. Thread.interrupted() es consultar y borrar; llamarlo dos veces seguidas devuelve true y luego false. Y una InterruptedException, al lanzarse, limpia la bandera: el hilo interrumpido deja de parecer interrumpido justo cuando más falta hace saberlo.
9.2 Qué pasa según lo que esté haciendo el hilo
- Si el hilo está calculando,
interrupt()solo pone la bandera. No pasa nada más. El hilo debe consultarla para enterarse. - Si el hilo está bloqueado en
sleep,wait,joino en unaBlockingQueue, se despierta conInterruptedException. - Si el hilo está bloqueado en E/S clásica (
InputStream.read), la interrupción no lo despierta. Es una limitación real y molesta. (Los canales de NIO sí son interrumpibles, y los hilos virtuales de 10-06 mejoran esto.)
9.3 El error de tragarse la excepción
// EL PEOR CODIGO DE CONCURRENCIA QUE SE ESCRIBE EN JAVA
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// ignorada
}Lo que acaba de pasar: alguien pidió al hilo que se detuviera; la excepción se lanzó, la bandera se borró, y el catch vacío se lo tragó. El hilo continúa como si nada y ya no queda ni rastro de la petición. El hilo se ha vuelto no cancelable.
En BiblioTech, esto significa que la importación de cincuenta mil líneas no se puede detener y que la aplicación no se cierra al pedírselo.
Demostración del problema:
public class InterrupcionTragada {
public static void main(String[] args) throws InterruptedException {
Thread malo = new Thread(() -> {
while (true) {
System.out.println("[malo] sigo trabajando");
try {
Thread.sleep(300);
} catch (InterruptedException e) {
// MAL: se traga la peticion y borra la bandera
}
}
}, "hilo-inmortal");
malo.start();
Thread.sleep(1000);
System.out.println(">>> main pide la cancelacion");
malo.interrupt();
Thread.sleep(1000);
System.out.println(">>> sigue vivo: " + malo.isAlive());
System.exit(0); // unica forma de acabar con el
}
}Salida:
[malo] sigo trabajando
[malo] sigo trabajando
[malo] sigo trabajando
>>> main pide la cancelacion
[malo] sigo trabajando
[malo] sigo trabajando
[malo] sigo trabajando
>>> sigue vivo: true9.4 Las dos respuestas correctas
Ante una InterruptedException solo hay dos respuestas legítimas:
Respuesta A — propagar. Si tu método puede declarar throws InterruptedException, propágala. Es la mejor opción: quien te llama decide.
public void esperarConfirmacion() throws InterruptedException {
TimeUnit.SECONDS.sleep(2); // la excepcion sube sola
}Respuesta B — restaurar la bandera y terminar. Si no puedes propagar —por ejemplo, dentro de run(), cuya firma no admite excepciones comprobadas—, restaura la bandera y sal ordenadamente.
@Override
public void run() {
try {
while (!Thread.currentThread().isInterrupted()) {
procesarUnLote();
TimeUnit.MILLISECONDS.sleep(100);
}
} catch (InterruptedException e) {
// Restaurar la bandera: el codigo de mas arriba (o el pool)
// debe poder saber que este hilo fue interrumpido.
Thread.currentThread().interrupt();
} finally {
cerrarRecursos(); // limpieza siempre
}
}Lo que nunca debes hacer: un catch vacío, o registrar la excepción y seguir el bucle como si nada.
9.5 El patrón completo de tarea cancelable
Este es el esqueleto que usarás una y otra vez. Combina las dos formas de detectar la interrupción: la bandera para los tramos de cálculo y la excepción para los tramos bloqueantes.
import java.util.concurrent.TimeUnit;
public class TareaCancelable implements Runnable {
private final int totalElementos;
public TareaCancelable(int totalElementos) {
this.totalElementos = totalElementos;
}
@Override
public void run() {
String yo = Thread.currentThread().getName();
int procesados = 0;
try {
for (int i = 0; i < totalElementos; i++) {
// 1) COMPROBAR LA BANDERA en cada vuelta.
// Necesario porque el trabajo de calculo no lanza
// InterruptedException por si solo.
if (Thread.currentThread().isInterrupted()) {
System.out.println("[" + yo + "] cancelacion detectada en el elemento " + i);
return; // el finally se ejecuta igual
}
procesarElemento(i); // trabajo real
procesados++;
// 2) Punto bloqueante: aqui la interrupcion llega
// como InterruptedException, no como bandera.
TimeUnit.MILLISECONDS.sleep(20);
}
System.out.println("[" + yo + "] completado");
} catch (InterruptedException e) {
System.out.println("[" + yo + "] interrumpido mientras esperaba");
// 3) RESTAURAR la bandera: no somos los duenos de esta informacion.
Thread.currentThread().interrupt();
} finally {
// 4) Limpieza SIEMPRE: se llegue por exito, cancelacion o error.
System.out.println("[" + yo + "] elementos procesados: " + procesados);
}
}
private void procesarElemento(int i) { /* trabajo */ }
}Y el uso, con el patrón de cancelación con plazo:
public class UsoTareaCancelable {
public static void main(String[] args) throws InterruptedException {
Thread h = new Thread(new TareaCancelable(10_000), "bibliotech-importador");
h.start();
TimeUnit.MILLISECONDS.sleep(500);
System.out.println(">>> pidiendo cancelacion");
h.interrupt();
h.join(2000); // margen para que termine limpiamente
if (h.isAlive()) {
System.out.println(">>> el hilo NO responde a la interrupcion (bug)");
} else {
System.out.println(">>> cancelado limpiamente");
}
}
}Salida:
>>> pidiendo cancelacion
[bibliotech-importador] interrumpido mientras esperaba
[bibliotech-importador] elementos procesados: 23
>>> cancelado limpiamenteLos cuatro puntos del patrón, resumidos:
- Comprobar
isInterrupted()en cada vuelta del bucle de trabajo. - Capturar
InterruptedExceptionen los puntos bloqueantes. - Restaurar la bandera con
Thread.currentThread().interrupt()antes de salir. - Limpiar en un
finally, que se ejecuta igual en los tres caminos.
Frecuencia de comprobación. La comprobación debe ser lo bastante frecuente como para que la cancelación se note (idealmente, menos de un segundo de latencia) y lo bastante espaciada como para no dominar el coste. En un bucle de líneas de CSV, comprobar cada línea es correcto:
isInterrupted()es una lectura de campo, cuesta nanosegundos.
- Excepciones en un hilo: no van a donde crees
Una excepción lanzada dentro de run() no se propaga al hilo que hizo start(). No puede: para cuando ocurre, el hilo creador está en otro sitio, o ya ha terminado. Cada hilo tiene su propia pila y su propia frontera de errores.
public class ExcepcionEnHilo {
public static void main(String[] args) throws InterruptedException {
Thread h = new Thread(() -> {
System.out.println("[hilo] voy a fallar");
throw new IllegalStateException("catalogo corrupto");
}, "bibliotech-importador");
try {
h.start();
h.join();
System.out.println("[main] join() ha vuelto SIN excepcion");
} catch (RuntimeException e) {
System.out.println("[main] esto NUNCA se imprime: " + e);
}
System.out.println("[main] estado del hilo: " + h.getState());
}
}Salida:
[hilo] voy a fallar
Exception in thread "bibliotech-importador" java.lang.IllegalStateException: catalogo corrupto
at ExcepcionEnHilo.lambda$main$0(ExcepcionEnHilo.java:7)
at java.base/java.lang.Thread.run(Thread.java:1583)
[main] join() ha vuelto SIN excepcion
[main] estado del hilo: TERMINATEDLéelo con atención: la traza se imprime en la consola, pero main no se entera de nada. join() retorna normalmente y el estado es TERMINATED, exactamente igual que si hubiera terminado bien. Si main seguía adelante suponiendo que la importación funcionó, ahora trabaja con un catálogo vacío y no lo sabe.
Ese mensaje Exception in thread "..." lo imprime el manejador de excepciones no capturadas por defecto. Puedes sustituirlo, y es exactamente el Thread.setDefaultUncaughtExceptionHandler que presentaste en 06-07 con ManejadorGlobal:
import java.util.logging.Level;
import java.util.logging.Logger;
public class ManejadorDeHilo {
private static final Logger LOG = Logger.getLogger("bibliotech");
public static void main(String[] args) throws InterruptedException {
// 1) Manejador GLOBAL: cubre a todos los hilos que no tengan el suyo.
// Es el ManejadorGlobal de 06-07, ahora con sentido pleno.
Thread.setDefaultUncaughtExceptionHandler((hilo, error) ->
LOG.log(Level.SEVERE,
"Fallo no capturado en el hilo " + hilo.getName(), error));
// 2) Manejador PARTICULAR de un hilo: tiene prioridad sobre el global.
Thread importador = new Thread(() -> {
throw new IllegalStateException("fichero de catalogo ilegible");
}, "bibliotech-importador");
importador.setUncaughtExceptionHandler((hilo, error) -> {
LOG.log(Level.SEVERE, "La importacion ha fallado; el catalogo "
+ "conserva su estado anterior", error);
// Aqui es donde BiblioTech marcaria la importacion como fallida
// para que el menu pueda informar al usuario.
});
importador.start();
importador.join();
// 3) El manejador global en accion, con otro hilo distinto.
new Thread(() -> { throw new RuntimeException("fallo del monitor"); },
"bibliotech-monitor").start();
}
}Orden de consulta cuando un hilo muere por una excepción no capturada:
- El manejador propio del hilo (
setUncaughtExceptionHandler), si tiene. - El manejador del
ThreadGroup. - El manejador global (
setDefaultUncaughtExceptionHandler). - El comportamiento por defecto: imprimir la traza en
System.err.
Regla de diseño para BiblioTech: una tarea de hilo debería capturar sus propias excepciones de negocio y convertirlas en un resultado —el Resultado que ya tienes de 06-07—, dejando el manejador no capturado solo como red de seguridad para lo imprevisto. Ese es exactamente el problema que Future resuelve limpiamente en 08-05: Future.get() sí te devuelve la excepción del hilo, envuelta en ExecutionException.
- Pasar datos y recoger resultados
Entrada: por el constructor. Es la forma correcta, y hace la tarea inmutable y segura:
public class TareaImportacion implements Runnable {
// final: no cambian tras la construccion. Publicacion segura (08-04).
private final Path fichero;
private final Catalogo catalogo;
public TareaImportacion(Path fichero, Catalogo catalogo) {
this.fichero = fichero;
this.catalogo = catalogo;
}
@Override
public void run() {
// usa fichero y catalogo
}
}Con lambda, la entrada se captura, con la restricción ya conocida de 04-05: las variables capturadas deben ser efectivamente finales.
Path fichero = Path.of("datos/inventario.csv");
Thread h = new Thread(() -> importar(fichero, catalogo), "bibliotech-importador");
// fichero = otraCosa; // <-- si descomentas esto, la lambda NO compilaSalida: el problema. Runnable.run() devuelve void y no puede lanzar excepciones comprobadas. No hay ningún sitio donde poner el resultado. Las soluciones manuales son todas insatisfactorias:
// Solucion manual 1: un campo en la tarea.
public class TareaImportacionConResultado implements Runnable {
private final Path fichero;
private volatile Informe resultado; // volatile: visibilidad (08-04)
private volatile Exception error;
public TareaImportacionConResultado(Path fichero) { this.fichero = fichero; }
@Override
public void run() {
try {
resultado = new ImportadorCatalogo().importar(fichero);
} catch (Exception e) {
error = e; // hay que capturarla a mano
}
}
public Informe resultado() { return resultado; }
public Exception error() { return error; }
}
// Uso:
TareaImportacionConResultado tarea =
new TareaImportacionConResultado(Path.of("datos/inventario.csv"));
Thread h = new Thread(tarea, "bibliotech-importador");
h.start();
h.join(); // OBLIGATORIO antes de leer
if (tarea.error() != null) {
throw new BiblioTechException("La importacion fallo", tarea.error());
}
Informe informe = tarea.resultado();Funciona, pero mira todo lo que has tenido que hacer a mano:
- Declarar los campos
volatilepara que el resultado sea visible. - Capturar la excepción tú mismo y guardarla en otro campo.
- Acordarte de hacer
join()antes de leer, sin nada que te lo recuerde. - Comprobar manualmente si hubo error antes de usar el resultado.
- No tienes forma de esperar con plazo, ni de cancelar y saber si se canceló.
Todo esto está resuelto en la biblioteca. Callable<Informe> es como Runnable pero devuelve un valor y puede lanzar excepciones comprobadas, y Future<Informe> es el objeto que representa "el resultado que llegará":
// ADELANTO de 08-05, no lo uses todavia:
Callable<Informe> tarea = () -> new ImportadorCatalogo().importar(fichero);
Future<Informe> futuro = ejecutor.submit(tarea);
Informe informe = futuro.get(30, TimeUnit.SECONDS); // espera, con plazo,
// y relanza el errorEl <Informe> de Future<Informe> es simplemente el tipo del resultado que ese futuro entregará: un Future<Informe> promete un Informe, un Future<String> promete un String. Los genéricos se estudian a fondo en 10-01; aquí basta leerlos así.
- BiblioTech: la importación en su propio hilo, cancelable
Ahora se junta todo. Este es el caso A de 08-01 resuelto: la importación deja de bloquear el menú, informa de su progreso y responde a la cancelación.
package com.nexussoftware.bibliotech.persistencia;
import com.nexussoftware.bibliotech.servicio.Catalogo;
import com.nexussoftware.bibliotech.dominio.Material;
import java.io.BufferedReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Importacion del catalogo ejecutable en un hilo aparte.
*
* Propiedades:
* - Publica el progreso (lineas leidas) para que el menu pueda mostrarlo.
* - Responde a la interrupcion en menos de una linea de trabajo.
* - Deja el catalogo INTACTO si se cancela: construye una lista aparte
* y solo la vuelca al catalogo si la importacion se completa.
*/
public class TareaImportacion implements Runnable {
private static final Logger LOG =
Logger.getLogger(TareaImportacion.class.getName());
private final Path fichero;
private final Catalogo catalogo;
private final LectorCsv lector = new LectorCsv();
// Estado publicado hacia otros hilos. 'volatile' garantiza que el hilo
// del menu vea valores actualizados; el detalle esta en 08-04.
private volatile int lineasLeidas = 0;
private volatile int importados = 0;
private volatile int descartados = 0;
private volatile boolean completada = false;
private volatile Exception error = null;
public TareaImportacion(Path fichero, Catalogo catalogo) {
this.fichero = fichero;
this.catalogo = catalogo;
}
@Override
public void run() {
String yo = Thread.currentThread().getName();
long inicio = System.nanoTime();
LOG.log(Level.INFO, "[{0}] importando {1}", new Object[] { yo, fichero });
// Lista temporal: el catalogo real no se toca hasta el final.
List<Material> nuevos = new ArrayList<>();
try (BufferedReader br = Files.newBufferedReader(fichero, StandardCharsets.UTF_8)) {
String linea = br.readLine(); // cabecera
while ((linea = br.readLine()) != null) {
// --- PUNTO DE CANCELACION ---
// Una comprobacion por linea: coste nanoscopico,
// latencia de cancelacion practicamente nula.
if (Thread.currentThread().isInterrupted()) {
LOG.log(Level.WARNING,
"[{0}] importacion CANCELADA tras {1} lineas; "
+ "el catalogo conserva su contenido anterior",
new Object[] { yo, lineasLeidas });
return; // finally se ejecuta igual
}
lineasLeidas++;
try {
nuevos.add(lector.aMaterial(linea));
importados++;
} catch (FormatoInvalidoException e) {
descartados++; // politica de 06-07: degradar
LOG.log(Level.FINE, "Linea {0} descartada: {1}",
new Object[] { lineasLeidas, e.getMessage() });
}
// Simulacion de una validacion costosa, para que el ejemplo
// dure lo suficiente como para poder cancelarlo.
if (lineasLeidas % 1000 == 0) {
TimeUnit.MILLISECONDS.sleep(50);
}
}
// Solo si hemos llegado hasta aqui se publica el resultado.
catalogo.reemplazarTodo(nuevos);
completada = true;
long ms = (System.nanoTime() - inicio) / 1_000_000;
LOG.log(Level.INFO, "[{0}] importacion COMPLETADA: {1} importados, "
+ "{2} descartados, {3} ms",
new Object[] { yo, importados, descartados, ms });
} catch (InterruptedException e) {
LOG.log(Level.WARNING, "[{0}] interrumpida durante una pausa", yo);
Thread.currentThread().interrupt(); // RESTAURAR la bandera
} catch (Exception e) {
// No dejamos que muera por excepcion no capturada: la guardamos
// para que el hilo del menu pueda informar al usuario.
error = e;
LOG.log(Level.SEVERE, "[" + yo + "] importacion fallida", e);
} finally {
LOG.log(Level.INFO, "[{0}] hilo de importacion finalizado", yo);
}
}
// --- Estado consultable desde otros hilos ---
public int lineasLeidas() { return lineasLeidas; }
public int importados() { return importados; }
public int descartados() { return descartados; }
public boolean completada(){ return completada; }
public Exception error() { return error; }
}Y el arranque desde la aplicación, con progreso y cancelación por plazo:
package com.nexussoftware.bibliotech.presentacion;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
public class ImportacionEnSegundoPlano {
public static void main(String[] args) throws InterruptedException {
Catalogo catalogo = new Catalogo();
TareaImportacion tarea =
new TareaImportacion(Path.of("datos/inventario.csv"), catalogo);
Thread hilo = new Thread(tarea, "bibliotech-importador");
hilo.start();
// El hilo main NO esta bloqueado: puede pintar progreso,
// y en la aplicacion real atenderia el menu.
long limiteMs = 10_000;
long inicio = System.currentTimeMillis();
while (hilo.isAlive()) {
System.out.printf("\r[main] progreso: %d lineas (%d ok, %d descartadas)",
tarea.lineasLeidas(), tarea.importados(), tarea.descartados());
if (System.currentTimeMillis() - inicio > limiteMs) {
System.out.println("\n[main] plazo agotado, cancelando");
hilo.interrupt();
break;
}
TimeUnit.MILLISECONDS.sleep(200);
}
hilo.join(2000); // margen para el cierre limpio
System.out.println();
if (tarea.completada()) {
System.out.printf("[main] catalogo actualizado: %d materiales%n",
tarea.importados());
} else if (tarea.error() != null) {
System.out.println("[main] la importacion fallo: " + tarea.error().getMessage());
System.out.println("[main] el catalogo conserva su contenido anterior");
} else {
System.out.println("[main] importacion cancelada; catalogo sin cambios");
}
}
}Salida al cancelar:
[main] progreso: 34000 lineas (33871 ok, 129 descartadas)
[main] plazo agotado, cancelando
WARNING: [bibliotech-importador] importacion CANCELADA tras 34218 lineas; el catalogo conserva su contenido anterior
INFO: [bibliotech-importador] hilo de importacion finalizado
[main] importacion cancelada; catalogo sin cambiosLos cuatro puntos de diseño que hacen esto correcto:
- El catálogo no se toca hasta el final. Se construye una lista aparte y solo se vuelca si la importación se completa. Una cancelación a mitad no deja el catálogo mitad viejo y mitad nuevo. Es la misma idea de la escritura atómica de 07-06, aplicada a memoria en lugar de a disco.
- La cancelación se comprueba una vez por línea. Coste despreciable, latencia de cancelación imperceptible.
- Las excepciones se capturan y se publican, no se dejan escapar.
mainpuede distinguir los tres finales posibles: completada, cancelada o fallida. - El estado publicado es
volatile. Sin eso, el hilo demainpodría no ver nunca el progreso actualizado. El porqué exacto está en 08-04, y es más sutil de lo que parece.
Lo que todavía no está bien, y que 08-05 arregla: se crea un hilo a mano por cada importación; no hay forma limpia de obtener el resultado (hay que consultar campos); el patrón de progreso con un bucle que duerme 200 ms es tosco; y si hubiera cinco importaciones simultáneas, no habría ningún control sobre cuántos hilos se crean.
- Lo que casi nunca harás en producción
Después de trece apartados enseñándote a crear hilos, la conclusión honesta: en código de producción moderno casi nunca escribirás new Thread(...).
Los motivos ya los conoces de 08-01: crear un hilo cuesta decenas de microsegundos y ~1 MB de pila, y no hay ningún límite que impida que el código cree diez mil. Un fallo típico —"por cada fichero de la carpeta, un hilo"— con una carpeta de veinte mil ficheros tumba la JVM.
Lo que se usa es el pool de hilos: un conjunto acotado de hilos reutilizados que consumen tareas de una cola. Eso es ExecutorService, y es 08-05. Sustituye:
// Esto (08-02):
Thread h = new Thread(tarea, "bibliotech-importador");
h.start();
h.join();
// Por esto (08-05):
Future<Informe> f = ejecutor.submit(tareaQueDevuelveInforme);
Informe informe = f.get(30, TimeUnit.SECONDS);Aun así, todo lo de esta lección sigue siendo necesario: los hilos del pool son hilos normales, cancel(true) de un Future es un interrupt(), los nombres de los hilos del pool los pone una ThreadFactory que crea Thread, y cuando leas un volcado en 08-03 verás objetos Thread. El ejecutor no sustituye el conocimiento: lo automatiza.
Nota sobre hilos virtuales. Java 21 introdujo los hilos virtuales (Project Loom): hilos gestionados por la JVM, no por el sistema operativo, cuyo coste de creación es de nanosegundos y cuya pila crece dinámicamente en el montón. Con ellos, un millón de hilos concurrentes es viable y el argumento entero de "no crees hilos, usa un pool" se invierte para tareas de E/S. Se crean con
Thread.ofVirtual().start(tarea). No se desarrollan aquí: son 10-06, cuando ya domines el modelo clásico sobre el que se apoyan.
Errores Comunes y Consejos
Error 1: llamar a run() en lugar de start(). Todo se ejecuta en el hilo actual y no hay concurrencia. Se detecta imprimiendo Thread.currentThread().getName() dentro de la tarea: si dice main, ahí está el fallo.
Error 2: tragarse InterruptedException con un catch vacío. Convierte el hilo en no cancelable y hace que la aplicación no se pueda cerrar. Propaga, o restaura la bandera y termina. Sin excepciones a esta regla.
Error 3: usar Thread.interrupted() creyendo que es isInterrupted(). El primero borra la bandera. Si lo usas en la condición del while y además lo consultas en el catch, la segunda consulta dará false y romperás tu propia lógica de cancelación.
Error 4: leer el resultado sin join(). No es solo un problema de tiempo: sin sincronización, el valor que leas puede ser obsoleto aunque el hilo ya haya terminado. join() establece la relación happens-before que hace el resultado visible.
Error 5: start() dos veces sobre el mismo Thread. IllegalThreadStateException. Un Thread es de un solo uso.
Error 6: setDaemon(true) después de start(). IllegalThreadStateException. Y peor aún: confiar trabajo importante a un demonio, que se aborta sin ejecutar su finally.
Error 7: creer que una excepción en un hilo llegará al que hizo start(). No llega. join() retorna normalmente y el estado es TERMINATED igual que en el caso de éxito. Captura y publica el error, o usa Future (08-05).
Error 8: dormir dentro de un bloque sincronizado. sleep no libera el monitor. Se ve entero en 08-04, pero apúntalo ya: es una causa habitual de aplicaciones que se arrastran.
Error 9: crear hilos en un bucle sobre datos de entrada. for (Path p : ficheros) new Thread(...).start(); con veinte mil ficheros es un OutOfMemoryError. Pool, siempre.
Consejo 1: nombra todos los hilos. Sin excepción. El coste es una cadena; el beneficio es poder diagnosticar en producción.
Consejo 2: escribe la tarea como Runnable, nunca como subclase de Thread. Podrás probarla llamando a run() directamente en un test, sin arrancar hilos, y moverla a un pool sin tocarla.
Consejo 3: haz cancelable toda tarea que dure más de un segundo. El patrón de 9.5 es corto y evita una clase entera de quejas de usuarios.
Consejo 4: no publiques resultados a medias. Construye aparte y publica al final, como hace TareaImportacion con su lista temporal. Una cancelación nunca debe dejar el estado a medias.
Consejo 5: usa TimeUnit en vez de milisegundos crudos. TimeUnit.MINUTES.sleep(5) no se puede leer mal; Thread.sleep(300000) sí.
Ejercicios
Ejercicio 1: Las cuatro formas y la demostración de start frente a run
Escribe una clase CuatroFormas que ejecute la misma tarea —imprimir tres veces el nombre del hilo actual con una pausa de 100 ms— usando las cuatro formas del apartado 1: subclase de Thread, clase que implementa Runnable, lambda y clase anónima. Todos los hilos deben tener nombre propio (forma-1-thread, forma-2-runnable, forma-3-lambda, forma-4-anonima) y main debe esperarlos a todos con join(). Añade al final una quinta ejecución que llame a run() en vez de a start() y comenta en la salida por qué el nombre impreso es distinto.
Ejercicio 2: Un contador de palabras cancelable
Escribe ContadorPalabras implements Runnable que reciba un Path en el constructor y cuente las palabras del fichero línea a línea. La tarea debe:
- Comprobar la interrupción cada 100 líneas.
- Publicar el progreso (líneas procesadas y palabras contadas) en campos
volatileconsultables desde fuera. - Restaurar la bandera de interrupción si es interrumpida durante una espera.
- Registrar en el logger, en un
finally, el resultado parcial y el tiempo transcurrido medido conSystem.nanoTime().
Escribe también un main que lo lance sobre un fichero grande, muestre el progreso cada 300 ms y lo cancele a los 2 segundos, informando de si terminó o fue cancelado.
Ejercicio 3: Recolector de resultados de varios hilos
Escribe RecolectorAvisos que lance cuatro hilos, cada uno encargado de "enviar" un lote de 25 avisos de BiblioTech (simula cada envío con TimeUnit.MILLISECONDS.sleep(20)). Cada hilo debe guardar en su propia tarea cuántos avisos envió con éxito y cuántos fallaron (simula un fallo cuando el número de aviso sea múltiplo de 7, lanzando y capturando una RuntimeException). main debe esperar a los cuatro con join(), sumar los resultados e imprimir un resumen, además de medir el tiempo total con System.nanoTime() y compararlo con el tiempo que habría tardado la versión secuencial (100 × 20 ms = 2000 ms).
Instala además un setUncaughtExceptionHandler global que registre cualquier fallo imprevisto, y comprueba que no se dispara.
Soluciones
Solución al Ejercicio 1
import java.util.concurrent.TimeUnit;
public class CuatroFormas {
/** Cuerpo comun de la tarea, para que la comparacion sea justa. */
static void trabajo() {
for (int i = 1; i <= 3; i++) {
System.out.printf(" [%s] paso %d%n",
Thread.currentThread().getName(), i);
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
// --- FORMA 1: extender Thread ---
// La clase ES un hilo: gasta la unica herencia disponible.
static class HiloPropio extends Thread {
HiloPropio() { super("forma-1-thread"); }
@Override public void run() { trabajo(); }
}
// --- FORMA 2: implementar Runnable ---
// La clase describe una TAREA; quien la ejecuta se decide fuera.
static class TareaPropia implements Runnable {
@Override public void run() { trabajo(); }
}
public static void main(String[] args) throws InterruptedException {
System.out.println("=== FORMA 1: extender Thread ===");
Thread h1 = new HiloPropio();
h1.start();
h1.join();
System.out.println("=== FORMA 2: implementar Runnable ===");
Thread h2 = new Thread(new TareaPropia(), "forma-2-runnable");
h2.start();
h2.join();
System.out.println("=== FORMA 3: lambda ===");
// Runnable es una interfaz funcional (04-06): un solo metodo run().
Thread h3 = new Thread(() -> trabajo(), "forma-3-lambda");
h3.start();
h3.join();
System.out.println("=== FORMA 4: clase anonima ===");
Thread h4 = new Thread(new Runnable() {
@Override public void run() { trabajo(); }
}, "forma-4-anonima");
h4.start();
h4.join();
System.out.println("=== ERROR CLASICO: run() en vez de start() ===");
Thread h5 = new Thread(() -> trabajo(), "forma-5-nunca-arranca");
h5.run(); // NO crea hilo: ejecuta aqui mismo
System.out.println(" estado de h5: " + h5.getState()
+ " (NEW: nunca llego a arrancar)");
}
}Salida (fragmento final):
=== ERROR CLASICO: run() en vez de start() ===
[main] paso 1
[main] paso 2
[main] paso 3
estado de h5: NEW (NEW: nunca llego a arrancar)La demostración está en el nombre impreso: dice main, no forma-5-nunca-arranca. El código de la tarea se ejecutó, pero en el hilo equivocado. Y el estado NEW confirma que ese objeto Thread nunca llegó a existir como hilo del sistema operativo.
Solución al Ejercicio 2
package com.nexussoftware.bibliotech.persistencia;
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
public class ContadorPalabras implements Runnable {
private static final Logger LOG =
Logger.getLogger(ContadorPalabras.class.getName());
private final Path fichero;
// Estado publicado hacia el hilo que observa. 'volatile' asegura
// que las escrituras de este hilo sean visibles desde fuera (08-04).
private volatile long lineasProcesadas = 0;
private volatile long palabrasContadas = 0;
private volatile boolean completado = false;
private volatile boolean cancelado = false;
public ContadorPalabras(Path fichero) {
this.fichero = fichero;
}
@Override
public void run() {
String yo = Thread.currentThread().getName();
long inicio = System.nanoTime();
try (BufferedReader br = Files.newBufferedReader(fichero, StandardCharsets.UTF_8)) {
String linea;
while ((linea = br.readLine()) != null) {
// 1) Comprobacion periodica de la bandera. Cada 100 lineas
// es suficiente: la latencia de cancelacion sera de
// microsegundos y el coste de la comprobacion, nulo.
if (lineasProcesadas % 100 == 0
&& Thread.currentThread().isInterrupted()) {
cancelado = true;
LOG.log(Level.WARNING, "[{0}] cancelado en la linea {1}",
new Object[] { yo, lineasProcesadas });
return; // el finally se ejecuta igual
}
lineasProcesadas++;
// Contar palabras: separar por espacios en blanco.
// El trim evita que una linea vacia cuente como una palabra.
String limpia = linea.trim();
if (!limpia.isEmpty()) {
palabrasContadas += limpia.split("\\s+").length;
}
// 2) Simulacion de trabajo pesado para que se pueda cancelar.
if (lineasProcesadas % 500 == 0) {
TimeUnit.MILLISECONDS.sleep(30);
}
}
completado = true;
} catch (InterruptedException e) {
// 3) RESTAURAR la bandera: no somos los duenos de esa informacion.
cancelado = true;
Thread.currentThread().interrupt();
LOG.log(Level.WARNING, "[{0}] interrumpido durante una pausa", yo);
} catch (IOException e) {
LOG.log(Level.SEVERE, "[" + yo + "] error leyendo " + fichero, e);
} finally {
// 4) Informe SIEMPRE, se llegue como se llegue.
long ms = (System.nanoTime() - inicio) / 1_000_000;
LOG.log(Level.INFO,
"[{0}] fin ({1}): {2} lineas, {3} palabras, {4} ms",
new Object[] { yo,
completado ? "completado" : (cancelado ? "cancelado" : "error"),
lineasProcesadas, palabrasContadas, ms });
}
}
public long lineasProcesadas() { return lineasProcesadas; }
public long palabrasContadas() { return palabrasContadas; }
public boolean completado() { return completado; }
public boolean cancelado() { return cancelado; }
// --- Demostracion ---
public static void main(String[] args) throws InterruptedException {
ContadorPalabras tarea =
new ContadorPalabras(Path.of("datos/catalogo-completo.txt"));
Thread hilo = new Thread(tarea, "bibliotech-contador");
long inicio = System.nanoTime();
hilo.start();
while (hilo.isAlive()) {
System.out.printf("[main] %d lineas, %d palabras%n",
tarea.lineasProcesadas(), tarea.palabrasContadas());
if ((System.nanoTime() - inicio) > 2_000_000_000L) { // 2 s
System.out.println("[main] plazo agotado -> interrupt()");
hilo.interrupt();
break;
}
TimeUnit.MILLISECONDS.sleep(300);
}
hilo.join(1000);
if (tarea.completado()) {
System.out.printf("[main] COMPLETADO: %d palabras en %d lineas%n",
tarea.palabrasContadas(), tarea.lineasProcesadas());
} else if (tarea.cancelado()) {
System.out.printf("[main] CANCELADO tras %d lineas (resultado parcial: %d palabras)%n",
tarea.lineasProcesadas(), tarea.palabrasContadas());
} else {
System.out.println("[main] terminado con error; ver el log");
}
}
}Detalles que merecen atención:
- La comprobación de la bandera va condicionada a
lineasProcesadas % 100 == 0para no pagarla en cada línea; en realidadisInterrupted()es tan barato que podría hacerse siempre, pero el patrón condicionado es el que usarás cuando la comprobación sea cara (por ejemplo, consultar un reloj). completadoycanceladoson campos distintos a propósito: permiten distinguir tres finales (éxito, cancelación, error), y el mensaje al usuario es distinto en cada caso.- El
try-with-resourcescierra elBufferedReaderincluso al hacerreturndentro del bucle. Es exactamente la garantía de 06-06 y por eso no hace falta cerrarlo a mano en elfinally.
Solución al Ejercicio 3
package com.nexussoftware.bibliotech.servicio;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
public class RecolectorAvisos {
private static final Logger LOG =
Logger.getLogger(RecolectorAvisos.class.getName());
/** Tarea que envia un lote de avisos y publica su resultado. */
static class LoteAvisos implements Runnable {
private final int desde;
private final int hasta; // exclusivo
private volatile int enviados = 0;
private volatile int fallidos = 0;
LoteAvisos(int desde, int hasta) {
this.desde = desde;
this.hasta = hasta;
}
@Override
public void run() {
String yo = Thread.currentThread().getName();
try {
for (int n = desde; n < hasta; n++) {
if (Thread.currentThread().isInterrupted()) {
LOG.log(Level.WARNING, "[{0}] cancelado en el aviso {1}",
new Object[] { yo, n });
return;
}
try {
enviarAviso(n);
enviados++;
} catch (RuntimeException e) {
// Politica de 06-07: un aviso fallido no aborta el lote.
fallidos++;
LOG.log(Level.FINE, "[{0}] aviso {1} fallido: {2}",
new Object[] { yo, n, e.getMessage() });
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
/** Simula el envio: 20 ms de latencia y fallo en los multiplos de 7. */
private void enviarAviso(int n) throws InterruptedException {
TimeUnit.MILLISECONDS.sleep(20);
if (n % 7 == 0) {
throw new RuntimeException("destinatario " + n + " sin direccion");
}
}
int enviados() { return enviados; }
int fallidos() { return fallidos; }
}
public static void main(String[] args) throws InterruptedException {
// Red de seguridad: cualquier fallo imprevisto queda registrado
// en lugar de imprimirse en System.err y perderse (06-07).
Thread.setDefaultUncaughtExceptionHandler((hilo, error) ->
LOG.log(Level.SEVERE,
"Fallo NO capturado en el hilo " + hilo.getName(), error));
final int LOTES = 4;
final int POR_LOTE = 25;
LoteAvisos[] tareas = new LoteAvisos[LOTES];
Thread[] hilos = new Thread[LOTES];
long inicio = System.nanoTime();
// 1) Crear y arrancar. start() retorna enseguida: los cuatro
// lotes avanzan a la vez.
for (int i = 0; i < LOTES; i++) {
tareas[i] = new LoteAvisos(i * POR_LOTE, (i + 1) * POR_LOTE);
hilos[i] = new Thread(tareas[i], "bibliotech-avisos-" + (i + 1));
hilos[i].start();
}
// 2) Esperar a todos. El join() de cada hilo garantiza ademas
// que sus escrituras sean visibles desde main (08-04).
for (Thread h : hilos) {
h.join();
}
long ms = (System.nanoTime() - inicio) / 1_000_000;
// 3) Agregar resultados.
int totalEnviados = 0;
int totalFallidos = 0;
System.out.println("=== RESULTADO POR LOTE ===");
for (int i = 0; i < LOTES; i++) {
System.out.printf(" %-24s enviados=%2d fallidos=%d%n",
hilos[i].getName(), tareas[i].enviados(), tareas[i].fallidos());
totalEnviados += tareas[i].enviados();
totalFallidos += tareas[i].fallidos();
}
long secuencialMs = (long) LOTES * POR_LOTE * 20;
System.out.println();
System.out.println("=== RESUMEN ===");
System.out.println("Avisos procesados : " + (totalEnviados + totalFallidos));
System.out.println("Enviados : " + totalEnviados);
System.out.println("Fallidos : " + totalFallidos);
System.out.println("Tiempo real : " + ms + " ms");
System.out.println("Tiempo secuencial : " + secuencialMs + " ms (estimado)");
System.out.printf ("Aceleracion : %.2fx%n", (double) secuencialMs / ms);
}
}Salida orientativa:
=== RESULTADO POR LOTE ===
bibliotech-avisos-1 enviados=21 fallidos=4
bibliotech-avisos-2 enviados=21 fallidos=4
bibliotech-avisos-3 enviados=22 fallidos=3
bibliotech-avisos-4 enviados=22 fallidos=3
=== RESUMEN ===
Avisos procesados : 100
Enviados : 86
Fallidos : 14
Tiempo real : 528 ms
Tiempo secuencial : 2000 ms (estimado)
Aceleracion : 3.79xTres lecciones de este resultado:
- La aceleración es de casi 4×, no de 4×. Falta el coste de crear los hilos, el de arrancarlos escalonadamente y el hecho de que los lotes no tardan exactamente lo mismo. Es el caso B de 08-01 resuelto a medias: los 100 avisos pasan de 2 s a 0,5 s.
- Los fallos individuales no tumban el lote. Cada
enviarAvisova en su propiotry; la política de 06-07 —degradar, no abortar— sigue vigente y ahora también dentro de un hilo. - El manejador global no se dispara. Ese es el objetivo: si aparece en la salida, significa que una tarea dejó escapar una excepción, y eso es un bug de la tarea, no una situación normal.
Y la limitación evidente: ¿y si en lugar de 4 lotes fueran 200 avisos individuales? Crearías 200 hilos. Cada uno cuesta ~1 MB de pila y decenas de microsegundos, y para una tarea de 20 ms eso es un despilfarro. La respuesta está en 08-05.
Conclusión
Ya sabes crear hilos y, más importante, sabes cómo se manejan bien.
Las cuatro formas de crear un hilo —extender Thread, implementar Runnable, lambda y clase anónima— acaban en lo mismo, pero solo dos son recomendables: Runnable como clase cuando la tarea tiene estado y merece pruebas propias, y lambda para todo lo demás. La razón para evitar extends Thread no es de estilo: gasta la única herencia disponible, confunde la tarea con el mecanismo —herencia frente a composición, otra vez 03-05— y, sobre todo, deja fuera de tu alcance los pools, los programadores periódicos y la prueba unitaria que llama a run() sin arrancar ningún hilo. Escribe tareas, no hilos.
Conoces el error que no da error: run() ejecuta en el hilo actual, start() crea uno nuevo. Se detecta en un segundo imprimiendo Thread.currentThread().getName() dentro de la tarea, y el getState() del objeto lo confirma: un Thread sobre el que solo se llamó a run() se queda en NEW para siempre. Y sabes que un Thread es de un solo uso: un segundo start() lanza IllegalThreadStateException.
Dominas el ciclo básico —crear, start(), join()— con sus dos matices importantes: join() no es solo esperar, es también hacer visibles las escrituras del hilo que termina, y join(ms) no dice si terminó o si se agotó el plazo, así que hay que preguntárselo a isAlive(). Sabes por qué nombrar los hilos es una necesidad operativa y no un adorno, con la convención bibliotech-<funcion>; por qué las prioridades son una sugerencia que el sistema operativo puede ignorar y que nunca debe formar parte de un argumento de corrección; y cuándo un hilo debe ser demonio —solo si perder su trabajo a media ejecución es aceptable—, con setDaemon(true) siempre antes de start().
Y tienes lo más valioso de la lección: el protocolo de interrupción. Sabes que Java no puede matar un hilo y que lo único que existe es una petición cooperativa; que interrupt() pone una bandera, isInterrupted() la lee, Thread.interrupted() la lee y la borra, y que una InterruptedException, al lanzarse, borra la bandera justo cuando más falta hace. De ahí las dos únicas respuestas legítimas: propagar la excepción, o restaurar la bandera y terminar. Un catch (InterruptedException e) { } vacío convierte un hilo en no cancelable y una aplicación en algo que no se puede cerrar; es, con diferencia, el peor error frecuente de la concurrencia en Java.
Sabes también que una excepción lanzada en un hilo no llega al que hizo start(): join() retorna con normalidad y el estado queda en TERMINATED igual que si todo hubiera ido bien. Por eso una tarea debe capturar y publicar su error, y por eso setUncaughtExceptionHandler —el ManejadorGlobal de 06-07— es la red de seguridad y no la estrategia. Y sabes pasar datos por el constructor con campos final, y por qué Runnable no puede devolver nada, con Callable y Future<Informe> esperándote en la próxima parada.
BiblioTech ya no bloquea el menú. TareaImportacion corre en bibliotech-importador, publica su progreso en campos volatile, comprueba la cancelación una vez por línea, distingue los tres finales posibles —completada, cancelada, fallida— y, lo más importante de todo, construye una lista aparte y solo la vuelca al catálogo si termina, de modo que una cancelación a mitad no deja el catálogo mitad viejo y mitad nuevo. Es la escritura atómica de 07-06 llevada a la memoria.
Pero el código deja tres cosas sin resolver, y las tres son visibles a simple vista: creas un hilo a mano por cada tarea, y con 200 avisos eso serían 200 hilos y 200 MB de pilas; no hay forma limpia de recoger un resultado, sino campos volatile y una convención implícita de "haz join() antes de leer"; y el progreso se consulta con un bucle que duerme 200 ms, que es sondeo, no coordinación.
En la próxima lección, Ciclo de Vida de un Hilo, bajas un nivel más antes de subir dos. Verás los seis estados de Thread.State y qué operación exacta provoca cada transición; la diferencia entre BLOCKED —esperando un monitor— y WAITING —esperando una señal—, que es lo que hace legible un volcado de hilos; el mecanismo de coordinación de bajo nivel wait/notify/notifyAll, con el bucle while obligatorio y por qué usar un if ahí es un error; por qué stop, suspend y resume se retiraron de la API; y —lo más útil en la práctica— cómo obtener y leer un volcado de hilos con jstack para encontrar un hilo bloqueado o un interbloqueo que la JVM ha detectado por ti.
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
