Saltar al contenido

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.

java
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.

java
// 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 deExceptionRuntimeException
¿Obliga el compilador?Sí: hay que capturarla o declarar throwsNo
Efecto en el códigoLos métodos intermedios se llenan de throws y de try-catchLos métodos intermedios quedan limpios
Cuándo encajaCuando quien llama puede hacer algo con el errorCuando 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.

java
// 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.

ConceptoQué es
LoggerEl emisor. Va asociado a una clase o a un paquete, de modo que se puede afinar por zonas de la aplicación
AppenderEl destino: consola, fichero, base de datos, un servicio remoto
Layout (o encoder)El formato de cada mensaje: fecha, hilo, nivel, clase, texto
LevelLa 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.

BibliotecaPapel
Log4jMotor clásico de la Apache Software Foundation, hoy en su segunda generación
LogbackMotor escrito por el propio autor de SLF4J, con el que encaja de forma natural
TinyLogMotor minimalista, pensado para proyectos pequeños
Apache Commons LoggingNo 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.
java
// 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