Excepciones, hilos y logging
Lo que ocurre mientras el programa corre: cómo se controlan los errores con el mecanismo de excepciones, cómo se reparte el trabajo en hilos, cómo se deja rastro de lo que pasa con el registro de actividad, y qué añadieron las versiones recientes del lenguaje.
El mecanismo de excepciones
Una excepción es la forma que tiene Java de avisar de que algo ha ido mal sin mezclar el control de errores con la lógica normal. No es solo para fallos: también sirve para que una parte del programa comunique a otra una situación que no puede resolver por su cuenta. Dentro del bloque try se colocan las instrucciones que pueden fallar; si alguna lanza una excepción, la ejecución salta al catch correspondiente.
- try: delimita el código vigilado.
- catch: recoge un tipo concreto de excepción y decide qué hacer con ella. Puede haber varios catch encadenados, del tipo más concreto al más general.
- finally: se ejecuta siempre, haya fallado o no, y es donde tradicionalmente se cerraban los recursos.
- throw: lanza una excepción en este preciso punto.
- throws: no lanza nada, solo declara en la firma del método que ese método puede propagar esa excepción hacia quien lo llame.
Ante una excepción hay dos actitudes posibles, y conviene tenerlas claras porque son la base de muchas preguntas. La primera es tratarla aquí mismo, envolviendo la llamada en un try-catch. La segunda es propagarla: el método declara throws y deja que el problema suba a quien lo llamó, que será quien decida. Propagar no es esconder el error, es reconocer que este método no tiene información suficiente para resolverlo.
Crear una excepción propia es un procedimiento de cuatro pasos: se define una clase que hereda de Exception o de RuntimeException, se crea el objeto cuando se detecta la situación, se lanza con throw declarando el método con throws si procede, y se ajusta el código que llama para que la recoja o la vuelva a propagar.
public class IntentoNoValidoException extends Exception {
public IntentoNoValidoException(String mensaje) { super(mensaje); }
}
public void corregir(Intento intento) throws IntentoNoValidoException {
if (intento.getRespuestas().isEmpty()) {
throw new IntentoNoValidoException("El intento no tiene respuestas");
}
...
}
// quien llama decide: tratarla aquí o volver a propagarla
try {
corregir(intento);
} catch (IntentoNoValidoException e) {
registrar(e);
} finally {
cerrarSesionDeEstudio();
}Desde Java 7 hay dos atajos que ahorran mucho ruido. El multicatch permite recoger varios tipos de excepción en un solo catch separándolos por una barra vertical. Y try-with-resources declara los recursos entre paréntesis en el propio try, de modo que se cierran solos al terminar el bloque, sin necesidad de un finally; es el equivalente de la sentencia using de .NET.
// multicatch: dos tipos, un solo bloque
catch (IOException | SQLException e) { ... }
// try-with-resources: el fichero se cierra solo al salir del try
try (FileOutputStream salida = new FileOutputStream("resultados.csv")) {
exportarIntentos(salida);
} catch (IOException e) {
registrar(e);
}Para el examen
try, catch y finally: vigila, recoge y se ejecuta siempre
throw frente a throws: lanza aquí frente a declarar que el método puede propagarla
Novedades de Java 7: multicatch y try-with-resources
Equivalente en .NET: el using
Excepciones comprobadas, no comprobadas y la jerarquía de Throwable
Java parte las excepciones en dos familias según obligue o no el compilador a hacerse cargo de ellas. Las comprobadas (checked) heredan de Exception: el compilador exige capturarlas o declararlas con throws, así que atraviesan la firma de todos los métodos por los que pasan. Las no comprobadas (unchecked) heredan de RuntimeException: nadie está obligado a nada, y solo se ocupa de ellas el método al que de verdad le interesa.
| Comprobadas (checked) | No comprobadas (unchecked) | |
|---|---|---|
| Heredan de | Exception | RuntimeException |
| ¿Obliga el compilador? | Sí: hay que capturarla o declarar throws | No |
| Efecto en el código | Los métodos intermedios se llenan de throws y de try-catch | Los métodos intermedios quedan limpios |
| Cuándo encaja | Cuando quien llama puede hacer algo con el error | Cuando el error indica un defecto de programación o algo irrecuperable |
Por encima de las dos familias está Throwable, la superclase común de errores y excepciones: solo lo que hereda de Throwable se puede lanzar y capturar. De él cuelgan dos ramas muy distintas. Exception es la rama de los problemas del programa, de los que cabe recuperarse. Error es la rama de los problemas de la propia máquina virtual, de los que no se puede uno recuperar, y por eso no se envuelven en try-catch: OutOfMemoryError cuando se agota el montón o StackOverflowError cuando una recursión sin fin llena la pila son los ejemplos típicos.
RuntimeException hereda de Exception, no de Error. Que una excepción sea no comprobada no la convierte en un error de la máquina virtual: sigue siendo una excepción, solo que el compilador no obliga a tratarla.
Para el examen
Comprobadas: heredan de Exception; el compilador obliga a capturarlas o declararlas
No comprobadas: heredan de RuntimeException; no obliga
Raíz de la jerarquía: Throwable
Sus dos ramas: Exception (recuperable) y Error (fallo de la máquina virtual)
Hilos de ejecución
En Java siempre hay hilos, aunque no se programe ninguno. Un hilo es la entidad que de verdad ejecuta el código: el método es la receta y el hilo es quien cocina. Un programa de escritorio con un método main corriente arranca con un solo hilo propio; una aplicación web, en cambio, atiende cada petición con un hilo distinto, así que es multihilo desde el primer minuto sin que su autor haya escrito una línea de concurrencia.
Además de los hilos de la aplicación, la máquina virtual mantiene los suyos propios, empezando por el del recolector de basura. Repartir el trabajo entre hilos no es cosa del programa: hay un planificador que decide a quién le toca la CPU y cuándo.
Crear un hilo propio admite dos formas equivalentes en resultado y distintas en diseño: heredar de la clase Thread y sobrescribir su método run, o implementar la interfaz Runnable y pasar ese objeto a un Thread. La segunda suele preferirse porque deja libre la única herencia de clase que Java permite.
// opción 1: heredar de Thread
public class RepasoProgramado extends Thread {
@Override public void run() { generarRepasosDelDia(); }
}
new RepasoProgramado().start();
// opción 2: implementar Runnable (deja libre la herencia)
public class RepasoProgramado2 implements Runnable {
@Override public void run() { generarRepasosDelDia(); }
}
new Thread(new RepasoProgramado2()).start();A un hilo se le llama a start(), nunca a run(). start() lo pone en la cola del planificador, y es el planificador quien invocará run() cuando le toque. Llamar a run() directamente no crea ningún hilo: ejecuta el método en el hilo actual, como una llamada normal.
Para el examen
Las dos formas de crearlo: heredar de Thread o implementar Runnable
Cuál se prefiere: Runnable, porque deja libre la herencia
Cómo se lanza: con start()
El error clásico: llamar a run() no crea ningún hilo
El registro de actividad: conceptos
Registrar la actividad (logging) es dejar por escrito qué va haciendo la aplicación, para poder averiguar después qué pasó. La plataforma trae su propio mecanismo en el paquete java.util.logging, pero se queda corto y en la práctica casi nadie lo usa solo.
Lo habitual es programar contra una fachada, es decir, contra una API que no escribe nada por sí misma y que delega en la biblioteca que se decida en el momento del despliegue. SLF4J es la fachada más extendida: el código de la aplicación solo la conoce a ella, y cambiar de motor de registro no obliga a tocar ni una línea.
| Concepto | Qué es |
|---|---|
| Logger | El emisor. Va asociado a una clase o a un paquete, de modo que se puede afinar por zonas de la aplicación |
| Appender | El destino: consola, fichero, base de datos, un servicio remoto |
| Layout (o encoder) | El formato de cada mensaje: fecha, hilo, nivel, clase, texto |
| Level | La importancia del mensaje, que decide si se escribe o se descarta |
Los niveles forman una escala, y cada logger tiene un umbral: solo se procesa lo que llega a ese umbral o lo supera. La escala habitual, de menor a mayor importancia, es ALL, TRACE, DEBUG, INFO, WARN, ERROR, FATAL y OFF. Poner un entorno de producción en WARN significa que se escriben los avisos y los errores, y que se descartan las trazas de depuración, que en producción solo servirían para llenar el disco.
El umbral no silencia las llamadas: el código sigue pidiendo que se registre en DEBUG. Lo que decide qué se escribe es la configuración, no el programa, y por eso se puede cambiar el detalle de los registros de un entorno sin recompilar nada.
Para el examen
Las cuatro piezas: logger (emisor), appender (destino), layout (formato) y level (umbral)
Escala de niveles: TRACE, DEBUG, INFO, WARN, ERROR y FATAL
Quién decide qué se escribe: la configuración, no el código
Las bibliotecas de registro concretas
Detrás de la fachada hay que colocar una implementación, y son cuatro las que aparecen una y otra vez en los proyectos Java. Conviene distinguir cuál es fachada y cuál es motor, porque es justo lo que se confunde.
| Biblioteca | Papel |
|---|---|
| Log4j | Motor clásico de la Apache Software Foundation, hoy en su segunda generación |
| Logback | Motor escrito por el propio autor de SLF4J, con el que encaja de forma natural |
| TinyLog | Motor minimalista, pensado para proyectos pequeños |
| Apache Commons Logging | No es un motor: es otra fachada, anterior a SLF4J |
La elección se hace en el momento de construir la aplicación, añadiendo al proyecto la dependencia del motor y el pequeño adaptador que lo enlaza con la fachada. Si no se añade ninguno, la aplicación compila igual pero no registra nada, y ese silencio desconcertante es un problema clásico de configuración.
Para el examen
Fachadas: SLF4J y Apache Commons Logging
Motores: Log4j, Logback y TinyLog
Sin adaptador ni motor: la aplicación compila, pero no registra nada
Interfaces funcionales, asincronía y otras incorporaciones
Java nació estrictamente orientado a objetos, y las versiones modernas le han ido añadiendo recursos de programación funcional sin romper esa base. La pieza que lo sostiene todo es la interfaz funcional: una interfaz con un único método abstracto, que puede marcarse con la anotación @FunctionalInterface para que el compilador vigile que sigue teniendo uno solo. Como tiene un único método, se puede escribir en su lugar una función y pasarla como si fuera un valor.
- Function y sus parientes: interfaces funcionales de uso general que reciben un argumento y devuelven un resultado.
- Métodos por defecto: desde Java 8 una interfaz puede traer un método ya escrito, marcándolo como default. Antes solo podía declarar. Sirve para ampliar una interfaz sin romper a quien ya la implementaba.
- Programación asíncrona: la clase CompletableFuture, del paquete java.util.concurrent, permite encadenar tareas que terminarán más adelante sin bloquear el hilo actual.
- java.time: el paquete de fechas y horas incorporado en Java 8, heredero de la biblioteca Joda-Time, con tipos como Instant, Duration, LocalDate, Period o Year. Vino a sustituir a las viejas Date y Calendar.
- instanceof: comprueba en ejecución si el objeto al que apunta una referencia es de una clase o interfaz concretas, y es lo que hace segura una conversión hacia abajo.
- Optional: un envoltorio que representa «puede que haya valor y puede que no». Sirve para que un método diga con claridad que quizá no devuelva nada, en vez de devolver null y provocar un NullPointerException más adelante.
// Optional obliga a quien llama a plantearse el caso «no hay»
public Optional<Intento> ultimoIntento(String slugSubtema) { ... }
String veredicto = ultimoIntento("jakarta-ee")
.map(Intento::getVeredicto)
.orElse("sin intentos todavía");
// interfaz funcional propia
@FunctionalInterface
public interface Baremo {
int puntuar(Intento intento);
}Para el examen
Interfaz funcional: la que tiene un único método abstracto
Métodos por defecto: código dentro de una interfaz
CompletableFuture: asincronía
java.time y Optional: desde Java 8: fechas nuevas y expresar que quizá no haya valor