Interfaz Runnable de Java
Define unidades de trabajo para hilos en Java con la interfaz funcional Runnable — la forma preferida para hilos, ejecutores y hilos virtuales.
Runnable es una interfaz de un solo método — posiblemente la más importante de java.lang. Todo lo que "se ejecuta en un hilo" en Java es, en última instancia, un Runnable en algún lugar: el constructor de Thread acepta uno, ExecutorService.execute acepta uno, los hooks de apagado de la JVM aceptan uno. La razón por la que el capítulo anterior recomendó "pasar un Runnable al constructor de Thread" en lugar de "extender Thread" es que Runnable separa qué se ejecuta de qué lo ejecuta. Esa separación es lo que hace que la misma tarea funcione en un hilo de plataforma, un pool de hilos, o un hilo virtual sin cambios de código.
La forma
Toda la definición cabe en tres líneas:
@FunctionalInterface
public interface Runnable {
void run();
}Eso es todo. Dos consecuencias se derivan de esas tres líneas:
- Es una interfaz funcional. Cualquier lambda o referencia a método con una firma de void sin argumentos la implementa:
() -> System.out.println("hi"),this::flush,Foo::staticMethod. - Devuelve void y no lanza excepciones comprobadas. Ese es el límite de lo que puedes expresar. Si necesitas un resultado, o lanzar algo comprobado, necesitas
Callable(un capítulo o dos más adelante).
Tres formas de escribir uno
// 1. Lambda — the modern default
Runnable r1 = () -> System.out.println("hello");
// 2. Method reference — when an existing method has the right signature
Runnable r2 = System.out::flush;
// 3. Anonymous class — pre-Java-8 form, occasionally useful when the body needs fields
Runnable r3 = new Runnable() {
@Override public void run() {
System.out.println("hello");
}
};Los tres producen un objeto de tipo Runnable. La forma lambda es la preferida desde Java 8; la forma de clase anónima solo es útil cuando necesitas campos propios (lo cual normalmente no es el caso — captura variables locales en su lugar).
Cómo se usa Runnable
Tres de las principales APIs que aceptan Runnable:
new Thread(runnable).start(); // platform thread, dedicated
executor.execute(runnable); // thread pool or virtual thread
Runtime.getRuntime().addShutdownHook(new Thread(runnable)); // JVM shutdownLa misma instancia de Runnable funciona en los tres contextos. Ese es el punto de diseño: el qué (el trabajo) y el dónde (el hilo) son ortogonales. Puedes escribir código que haga el trabajo y otra persona puede decidir en qué ejecutarlo.
El contraste con la forma de subclase de Thread hace esto concreto:
// Coupled: this work can only run on its own dedicated platform thread.
class ImageResizer extends Thread {
@Override public void run() { resize(); }
}
new ImageResizer().start();
// Decoupled: the same body runs anywhere.
Runnable resize = this::resize;
new Thread(resize).start(); // dedicated thread
executor.execute(resize); // pool
virtualExecutor.execute(resize); // virtual threadLa forma desacoplada es la razón por la que el Java de producción está lleno de Runnable (y Callable) y casi nunca tiene una clase que extienda Thread.
Las variables capturadas deben ser efectivamente finales
Una lambda que se convierte en un Runnable puede leer variables locales del método que la envuelve, pero solo aquellas que el compilador puede demostrar que son efectivamente finales — asignadas exactamente una vez y nunca reasignadas:
String name = "alice";
int n = 3;
Runnable r = () -> {
for (int i = 0; i < n; i++) {
System.out.println(name + " " + i);
}
};
// n = 4; // would break the lambda above — compile errorSi necesitas estado mutable compartido, no puedes usar una variable local capturada — necesitas un campo, un AtomicInteger, una posición de array, u otro objeto cuyos internos sean mutables. La restricción es intencional: las lambdas capturan valores, no alias, y prohibir la reasignación es la regla más simple que hace que eso sea consistente.
La solución más común es el array de un elemento:
int[] counter = {0};
Runnable r = () -> counter[0]++; // works; the array reference is final, the int inside isn'tPero para contadores compartidos seguros para hilos, un AtomicInteger es la herramienta correcta — veremos por qué en unos capítulos más adelante.
Manejo de excepciones: nada que capturar, nada que recuperar
run() no lanza excepciones comprobadas. Si tu worker puede fallar con una excepción comprobada, tienes que capturarla dentro de run():
Runnable parseFile = () -> {
try {
Files.readAllLines(path);
} catch (IOException e) {
log.error("parse failed", e); // you HAVE to handle it here
}
};Para las excepciones no comprobadas, la situación es peor: nada en el código que llama las captura. Si tu Runnable lanza NullPointerException en un hilo separado, la excepción va al manejador de excepciones no capturadas de ese hilo y el hilo muere. El hilo principal no lo sabe.
Dos formas de lidiar con esto:
- Capturar todo dentro de
run()y registrarlo tú mismo. Crudo pero fiable. - Usar
CallableyFuture.get(). ElFuturevuelve a lanzar la excepción en el hilo que llamó aget(). Esto es lo que te da el framework de ejecutores.
Para trabajo puntual, la opción 1 está bien; para cualquier cosa que produzca un resultado que el llamante necesita, la opción 2 es la respuesta correcta.
Runnable vs. Callable
Una comparación lado a lado de las dos interfaces de tareas — conocerás Callable correctamente más adelante, pero el contraste es útil ahora:
Runnable | Callable<V> | |
|---|---|---|
| Método | void run() | V call() throws Exception |
| Valor de retorno | Ninguno | Resultado tipado V |
| Excepciones comprobadas | No puede lanzar | Puede lanzar cualquier Exception |
| Aceptado por | new Thread, Executor.execute, shutdown hooks | ExecutorService.submit |
| Manejador de resultado | Ninguno (fire and forget) | Future<V> |
Siempre que necesites cualquiera de los dos — un valor de retorno o la capacidad de lanzar excepciones comprobadas — cambia a Callable. Para trabajo de efectos secundarios puros — flushing, logging, scheduling — Runnable es la herramienta más ligera.
Un ejemplo práctico: el mismo Runnable, tres ejecutores
El programa a continuación define un Runnable que hace una pequeña pieza de trabajo, luego ejecuta la misma instancia en (a) un nuevo hilo de plataforma, (b) un ExecutorService, y (c) el hilo llamante mediante .run() directo. El mismo cuerpo se ejecuta en los tres contextos; lo único que cambia es el ejecutor.
Qué sacar de la ejecución:
- Los primeros tres bloques ejecutaron la misma instancia de
greeten tres ejecutores diferentes — llamada directa, hilo dedicado, pool de hilos. El nombre del hilo impreso porgreetcambió cada vez:main,dedicated-worker,pool-1-thread-1. Esa es la razón completa para preferirRunnablesobre heredar deThread: el trabajo es reutilizable, el ejecutor es intercambiable. - La
RuntimeExceptiondel hilocrashyno mató amain. Murió en su propio hilo y el manejador de excepciones no capturadas lo reportó. Sin un manejador, la JVM imprime un stack trace en stderr y el resto del programa sigue ejecutándose — lo cual es a menudo peor, porque el trabajo que se suponía que haría el hilo silenciosamente no ocurrió. - La lambda
shoutcapturónameynde los locales demain. Son efectivamente finales — asignados una vez, nunca reasignados. Agregan = 4;en cualquier lugar después de que se defina la lambda y el archivo deja de compilar. Esa restricción es lo que hace que la captura de lambdas sea segura entre hilos. - El ejemplo
bumpusóAtomicIntegerporque dos hilos incrementaban el mismo contador. Con un campointsimple, el valor final habría estado en algún lugar entre1000y2000— actualizaciones perdidas pori++no atómico.incrementAndGet()es la solución más simple y volveremos a ello en el capítulo de atomics. - La única instancia compartida de
Runnablese pasó anew Thread(bump, "a")ynew Thread(bump, "b")— la misma lambda se ejecutó en dos hilos simultáneamente. La lambda no tiene campos propios; todo lo que toca vive fuera de ella. Esa es la forma de cadaRunnableparalelo seguro: tener el menor estado interno posible, y empujar el estado hacia un objeto seguro para hilos que los hilos comparten.
Qué sigue
El siguiente capítulo, Java Thread Lifecycle, recorre los seis valores de Thread.State — NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED — y muestra cómo leer un volcado de hilos que los expone.