1. ExecutorService: gestionamos hilos como profesionales
Por qué no conviene crear hilos simplemente con new Thread
Al principio, la programación concurrente parece sencilla:
Thread t = new Thread(() -> {
// hacemos algo
});
t.start();
Este enfoque funciona, pero pronto se vuelve una carga cuando hay muchas tareas. Cada llamada a new Thread() crea un hilo nuevo, y decenas o cientos de hilos empiezan a sobrecargar el sistema. Además, gestionarlos es incómodo: hay que vigilar cuándo terminan, qué hacer ante errores, cómo detenerlos y reutilizarlos.
Aquí entra en escena ExecutorService, un orquestador inteligente de hilos. Tú simplemente le entregas las tareas y él decide con qué hilo y cuándo ejecutarlas. Como resultado, todo funciona más rápido, de forma más estable y sin dolores de cabeza.
Cómo funciona ExecutorService
ExecutorService funciona según un principio sencillo pero eficaz.
- Internamente tiene un pool de hilos — un conjunto de hilos de trabajo creado de antemano (fijo o dinámico).
- Las tareas van a una cola y son tomadas por los hilos libres.
- El servicio gestiona el ciclo de vida: puedes esperar a que termine, detener correctamente el pool y liberar recursos.
Creación de ExecutorService
La forma más habitual — usar los métodos de fábrica de la clase Executors:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
ExecutorService executor = Executors.newFixedThreadPool(4); // 4 hilos
- newFixedThreadPool(N) — pool de N hilos (adecuado para la mayoría de tareas).
- newCachedThreadPool() — pool dinámico, crea hilos según sea necesario (cuidado: puede agotar la memoria ante una avalancha de tareas).
- newSingleThreadExecutor() — un solo hilo (ejecución secuencial).
Ejemplo: ejecutar Runnable mediante ExecutorService
executor.submit(() -> {
System.out.println("¡Hola desde el pool de hilos!");
});
Cuando termines de trabajar con ExecutorService, debes cerrarlo correctamente:
executor.shutdown(); // Prohíbe añadir nuevas tareas y espera a que terminen las actuales
Importante: si no llamas a shutdown(), el programa podría no finalizar — los hilos del pool se quedarán esperando nuevas tareas.
2. Runnable vs Callable: las tareas pueden ser distintas
Antes de Java 5, si querías ejecutar algo en un hilo, escribías una implementación de la interfaz Runnable. Es una tarea que no devuelve nada y no lanza excepciones comprobadas.
Runnable task = () -> {
System.out.println("Solo trabajo, ¡no devuelvo nada!");
};
executor.submit(task);
Callable: una tarea con resultado (y excepciones)
A veces quieres que la tarea no solo «haga algo», sino que devuelva un resultado — por ejemplo, la suma de números, el resultado de unos cálculos o datos de un servidor. Para ello existe la interfaz Callable<T>.
import java.util.concurrent.Callable;
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;
return sum;
};
- El método call() devuelve un resultado de tipo T.
- El método call() puede lanzar una excepción comprobada.
Analogía: Runnable — «ve y lava los platos» (el resultado no importa), Callable — «ve y trae té y di a qué temperatura está» (el resultado importa).
Ejecución de Callable: para obtener el resultado, usa executor.submit(...). Devolverá un objeto Future<T>.
3. Future: una promesa de resultado
Future es una «promesa» de entregar un resultado en el futuro. Cuando envías una tarea a ExecutorService, recibes un Future, del que más tarde podrás obtener el resultado, saber si la tarea ha finalizado o cancelarla.
Métodos principales de Future
- T get() — obtener el resultado (espera hasta que la tarea termine).
- boolean isDone() — si la tarea ha finalizado.
- boolean cancel(boolean mayInterruptIfRunning) — intentar cancelar la tarea.
- boolean isCancelled() — si la tarea fue cancelada.
Ejemplo: ejecutar un Callable y obtener el resultado
import java.util.concurrent.*;
public class ParallelSumApp {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(2);
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;
return sum;
};
Future<Integer> future = executor.submit(sumTask);
System.out.println("La tarea se ha iniciado; puedes hacer algo más...");
// Obtenemos el resultado (el método bloquea el hilo si la tarea aún no ha terminado)
Integer result = future.get();
System.out.println("Resultado del cálculo: " + result);
executor.shutdown();
}
}
- La tarea se envía al pool de hilos.
- Mientras la tarea se ejecuta, el hilo principal puede hacer otras cosas.
- Cuando necesites el resultado, llama a future.get() — el hilo esperará si la tarea aún está en ejecución.
- En cuanto la tarea termine, se devolverá el resultado.
4. Práctica: varias tareas, esperando la finalización
A menudo hay que lanzar varias tareas a la vez y esperar a que todas terminen. Por ejemplo, procesas un array de datos, lo divides en partes y calculas la suma de cada parte en una tarea aparte.
Ejemplo: sumar elementos de un array por partes
import java.util.*;
import java.util.concurrent.*;
public class ParallelArraySum {
public static void main(String[] args) throws Exception {
int[] array = new int[1000];
Arrays.setAll(array, i -> i + 1); // Rellenamos con números del 1 al 1000
ExecutorService executor = Executors.newFixedThreadPool(4);
int chunkSize = array.length / 4;
List<Future<Integer>> futures = new ArrayList<>();
for (int i = 0; i < 4; i++) {
int from = i * chunkSize;
int to = (i == 3) ? array.length : (i + 1) * chunkSize;
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int j = from; j < to; j++) sum += array[j];
System.out.println("Suma de " + from + " a " + (to - 1) + " = " + sum);
return sum;
};
futures.add(executor.submit(sumTask));
}
int totalSum = 0;
for (Future<Integer> f : futures) {
totalSum += f.get(); // Esperamos cada tarea una por una
}
System.out.println("Suma total: " + totalSum);
executor.shutdown();
}
}
Aquí el array se divide en 4 partes. Para cada parte se crea una tarea (Callable) que calcula la suma. Todas las tareas se envían a ExecutorService, se devuelven Future. Al final reunimos los resultados de todas las tareas y los sumamos.
En casos reales es cómodo usar invokeAll para esperar a que todas las tareas terminen de una vez.
5. Manejo de errores al trabajar con Future
Cuando llamas a future.get(), si la tarea terminó con una excepción, esta se propagará como ExecutionException. Es importante: si en la tarea algo salió mal, te enterarás solo al invocar get().
Ejemplo: manejo de excepciones
Callable<Integer> errorTask = () -> {
throw new IllegalArgumentException("¡Algo salió mal!");
};
Future<Integer> badFuture = executor.submit(errorTask);
try {
badFuture.get();
} catch (ExecutionException e) {
System.out.println("La tarea terminó con error: " + e.getCause());
}
- Dentro de la tarea se lanza una excepción.
- Al llamar a get() se «envuelve» en ExecutionException.
- La causa real se puede obtener con getCause().
6. Detalles útiles
Cómo cancelar una tarea
Future<?> f = executor.submit(() -> {
while (true) {
// Trabajo infinito
if (Thread.currentThread().isInterrupted()) {
System.out.println("¡Me han pedido que termine!");
break;
}
}
});
Thread.sleep(100); // Esperamos un poco
f.cancel(true); // Intentamos cancelar la tarea
- cancel(true) intenta interrumpir la tarea si aún no ha finalizado.
- Dentro de la tarea conviene comprobar Thread.currentThread().isInterrupted() y terminar correctamente.
shutdown vs shutdownNow
shutdown() — parada suave: impide añadir nuevas tareas y permite que las actuales terminen con normalidad. Es la más habitual.
shutdownNow() — parada brusca: intenta interrumpir los hilos activos y devuelve la lista de tareas que no llegaron a arrancar. Úsalo con precaución.
invokeAll e invokeAny
invokeAll(Collection<Callable<T>> tasks) lanza todas las tareas pasadas y espera a que todas terminen. Devuelve una lista de Future.
invokeAny(Collection<Callable<T>> tasks) espera solo a la primera tarea que se complete con éxito, devuelve su resultado y cancela las demás. Útil cuando importa la primera respuesta válida.
7. Errores típicos al trabajar con ExecutorService, Callable y Future
Error n.º 1: no cerrar el ExecutorService. Si olvidas llamar a shutdown(), el programa puede quedarse «colgado» tras finalizar main, porque los hilos del pool esperan nuevas tareas.
Error n.º 2: esperar el resultado justo después de enviar la tarea. Si llamas a get() inmediatamente después de submit(), no obtendrás ventajas de la asincronía: el hilo seguirá esperando. Haz trabajo útil en paralelo y solicita el resultado cuando realmente lo necesites.
Error n.º 3: ignorar las excepciones en las tareas. Si no manejas ExecutionException al invocar get(), puedes pasar por alto errores importantes que ocurrieron dentro de la tarea.
Error n.º 4: usar variables compartidas mutables sin sincronización. Si varias tareas trabajan con los mismos datos, necesitas sincronización o colecciones seguras para hilos.
Error n.º 5: crear demasiados hilos. No conviene hacer un pool con un número de hilos muy superior al de núcleos del procesador: incluso puede ralentizar la ejecución.
Error n.º 6: olvidar cancelar las tareas. Si una tarea ya no es necesaria, cancélala con cancel() para no gastar recursos.
GO TO FULL VERSION