Cerraste el módulo 5 con un inventario honesto: BiblioTech gestiona catálogo, préstamos, reservas, avisos y deshacer, pero es frágil de una manera que ya no se puede ignorar. Un Integer.parseInt("veinte") tumba la aplicación. Un false devuelto no dice por qué falló. Un System.out.println("AVISO: ...") no se puede filtrar, archivar ni tratar. Y una operación que falla a mitad deja el sistema incoherente.

Este módulo resuelve eso, y empieza por lo más básico y a la vez lo peor entendido: qué es realmente una excepción. No es "un error que rompe el programa". Es un objeto —con su clase, su estado y sus métodos— que representa un fallo y que activa un mecanismo de transporte muy particular: en lugar de devolverse al llamador como un valor, viaja hacia atrás por la pila de llamadas buscando a alguien que se haga cargo. Entender ese viaje es entender todo lo demás.

En esta lección no vas a escribir ni un try ni un catch. Eso es la lección siguiente. Aquí se trata de leer y comprender el fallo: por qué los códigos de retorno son una solución peor, qué ocurre exactamente cuando nadie captura una excepción, cómo se lee un stack trace de arriba abajo —incluida la sección Caused by:, que es donde suele estar la verdad—, cómo está organizada la jerarquía Throwable y qué significa cada rama, y cuál es la diferencia entre excepciones comprobadas y no comprobadas, que es la decisión de diseño más discutida del lenguaje. Cuando termines, un volcado de excepción dejará de ser un muro de texto intimidante y pasará a ser lo que realmente es: un informe detallado que te dice exactamente qué falló y dónde.

Contenido

  1. El punto de partida: el false mudo de BiblioTech
  2. Qué es una excepción
  3. Por qué los códigos de retorno son peores
  4. Qué ocurre cuando nadie captura: propagación por la pila
  5. Anatomía de un stack trace
  6. Caused by:: la cadena de causas
  7. La jerarquía de Throwable
  8. Error: lo que no debes intentar manejar
  9. RuntimeException: errores de programación
  10. Las excepciones comprobadas
  11. Comprobadas frente a no comprobadas: el criterio de diseño
  12. El debate real sobre las excepciones comprobadas
  13. Los mensajes de NullPointerException de Java 14+
  14. Qué no debes hacer nunca
  15. Errores Comunes y Consejos
  16. Ejercicios

  1. El punto de partida: el false mudo de BiblioTech

Este es un método real del BiblioTech que construiste en el módulo 5. Fíjate bien, porque contiene el problema que da sentido a todo el módulo:

package com.nexussoftware.bibliotech.servicio;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import com.nexussoftware.bibliotech.dominio.Libro;
import com.nexussoftware.bibliotech.dominio.Material;

/** Version del modulo 5: comunica los fallos con booleanos y con println. */
public class Catalogo {

    private final List<Material> materiales = new ArrayList<>();
    private final Map<String, Material> indicePorReferencia = new HashMap<>();
    private final Set<String> isbnRegistrados = new HashSet<>();

    public boolean registrar(Material material) {
        if (material == null) {
            System.out.println("AVISO: material nulo");
            return false;                                   // (1)
        }
        if (indicePorReferencia.containsKey(material.getReferencia())) {
            System.out.println("AVISO: referencia duplicada");
            return false;                                   // (2)
        }
        if (material instanceof Libro libro && !isbnRegistrados.add(libro.getIsbn())) {
            System.out.println("AVISO: ISBN duplicado");
            return false;                                   // (3)
        }
        materiales.add(material);
        indicePorReferencia.put(material.getReferencia(), material);
        return true;
    }

    public Material buscarPorReferencia(String referencia) {
        return indicePorReferencia.get(referencia);         // devuelve null si no existe
    }
}

El método funciona. Pero mira lo que le llega a quien lo llama:

boolean ok = catalogo.registrar(material);
if (!ok) {
    // ¿Y ahora que? ¿Por que ha fallado?
    // ¿Era nulo? ¿Referencia duplicada? ¿ISBN duplicado?
    // El booleano no lo dice. Los tres casos son el mismo false.
}

Ese es el false mudo: un valor que informa de que algo salió mal, pero que borra toda la información sobre qué salió mal. Tres causas radicalmente distintas —un fallo de programación (null), un conflicto de datos (referencia repetida) y una violación de una regla de negocio (ISBN ya catalogado)— colapsan en el mismo bit. Y quien llama no puede reaccionar de forma distinta a cada una, porque no sabe cuál ha ocurrido.

Peor todavía: el motivo real se ha impreso por consola, donde el programa no puede leerlo. Un System.out.println no es un canal de comunicación entre métodos; es un vertedero de texto. Si registrar se llama desde un servicio web, desde un proceso por lotes o desde un test automatizado, nadie ve ese aviso.

Y buscarPorReferencia es aún más peligroso, porque devuelve null en el caso normal de "no existe". El null viaja tan campante por el programa hasta que alguien lo usa:

Material m = catalogo.buscarPorReferencia("LIB-999");   // no existe: devuelve null
System.out.println(m.getTitulo());                      // NullPointerException, muy lejos del origen

La explosión ocurre en un sitio distinto de donde estaba el problema. El error real fue en la línea 1 (buscar algo que no existe); el síntoma aparece en la línea 2. Este desplazamiento entre causa y síntoma es una de las mayores fuentes de tiempo perdido depurando.

Las excepciones resuelven exactamente esto: permiten señalar un fallo con nombre, con datos y en el momento exacto en que se detecta.

  1. Qué es una excepción

Una excepción es un objeto Java, instancia de una clase que desciende de java.lang.Throwable, que representa una condición anómala durante la ejecución del programa.

Las tres palabras importantes de esa definición:

  • Objeto: no es una palabra clave mágica ni un código numérico. Es un objeto normal, con su tipo, sus campos y sus métodos. Se puede crear con new, guardar en una variable, pasar como parámetro e incluso almacenar en una lista. Lo que lo hace especial es cómo se transporta.
  • Representa una condición anómala: un fichero que no está, un número mal escrito, un índice fuera de rango, una regla de negocio violada. Algo que impide al método cumplir su contrato.
  • Durante la ejecución: no es un error de compilación. El código compila perfectamente; el fallo aparece cuando se ejecuta con unos datos determinados.

Mira cualquier excepción como lo que es —un objeto con estado:

public class QueEsUnaExcepcion {
    public static void main(String[] args) {
        // Una excepcion se crea con new, como cualquier otro objeto.
        // Crearla NO la lanza: aqui simplemente esta en una variable.
        IllegalArgumentException fallo =
                new IllegalArgumentException("La tarifa diaria no puede ser negativa: -0.25");

        System.out.println("Clase   : " + fallo.getClass().getName());
        System.out.println("Mensaje : " + fallo.getMessage());
        System.out.println("toString: " + fallo);
        System.out.println("Marcos  : " + fallo.getStackTrace().length);
        System.out.println("Causa   : " + fallo.getCause());

        System.out.println("El programa sigue vivo: crear una excepcion no la lanza.");
    }
}

Salida:

Clase   : java.lang.IllegalArgumentException
Mensaje : La tarifa diaria no puede ser negativa: -0.25
toString: java.lang.IllegalArgumentException: La tarifa diaria no puede ser negativa: -0.25
Marcos  : 1
Causa   : null
El programa sigue vivo: crear una excepcion no la lanza.

Tres observaciones que conviene grabar desde el principio:

  1. Crear una excepción no la lanza. El objeto existe, pero el flujo del programa no se altera. Lanzarla requiere la palabra throw, que verás en 06-03.
  2. El objeto ya contiene la pila de llamadas en el momento de su construcción (getStackTrace() devuelve un array de marcos). Esa captura ocurre en el constructor, no al lanzarla. Es la parte más costosa de crear una excepción, y volverá a aparecer en 06-02 al hablar de rendimiento.
  3. El mensaje es texto libre, y su calidad depende enteramente de quien lo escribe. "error" y "La tarifa diaria no puede ser negativa: -0.25" cuestan lo mismo; solo uno de los dos sirve para algo.

Cuando una excepción se lanza, ocurren dos cosas simultáneas:

  • La ejecución del método se interrumpe inmediatamente en ese punto. Las líneas siguientes del método no se ejecutan.
  • El objeto excepción empieza a propagarse hacia atrás por la pila de llamadas, buscando un manejador. Ese viaje es el apartado 4.

  1. Por qué los códigos de retorno son peores

Antes de las excepciones —y todavía hoy en lenguajes como C o Go— los fallos se comunican con el valor de retorno: -1, null, false, un código numérico. Java permite hacerlo, y a veces es incluso lo correcto. Pero como mecanismo general tiene cuatro defectos graves.

Problema Código de retorno Excepción
Se puede ignorar Sí: catalogo.registrar(m); compila y descarta el resultado en silencio No: si no se maneja, el programa se detiene ruidosamente
Transporta información Un false no dice por qué; un -1 tampoco Clase + mensaje + datos propios + pila completa
Ocupa el valor de retorno Si el método ya devuelve un Material, hay que inventarse un valor "imposible" (null) El valor de retorno queda libre para su propósito real
Contamina al llamador Cada llamada necesita su if de comprobación, mezclado con la lógica El camino normal queda limpio; el manejo se separa

El primer defecto es el decisivo. Compara:

// Con codigo de retorno: ignorar el fallo es TRIVIAL y ademas invisible
catalogo.registrar(materialDuplicado);      // devuelve false... y a nadie le importa
procesarComoSiEstuvieraRegistrado();        // el programa sigue con datos incorrectos

// Con excepcion: ignorar el fallo es IMPOSIBLE en silencio
catalogo.registrar(materialDuplicado);      // lanza ReferenciaDuplicadaException
procesarComoSiEstuvieraRegistrado();        // esta linea NO se ejecuta

Con el booleano, el programa continúa con una premisa falsa: cree que el material está registrado y no lo está. Los errores que se propagan silenciosamente contaminando el estado son mucho más caros de diagnosticar que los que detienen el programa, porque el síntoma aparece minutos u horas después, en otro sitio, sin relación aparente con la causa.

El cuarto defecto, la contaminación del llamador, se ve mejor con un flujo completo. Así queda un préstamo con códigos de retorno:

// ESTILO CODIGOS DE RETORNO: la logica real esta enterrada entre comprobaciones
public boolean prestar(String referencia, String idEmpleado, int dia) {
    Material m = catalogo.buscarPorReferencia(referencia);
    if (m == null) { return false; }                 // no existe... o quiza si y fallo otra cosa
    if (!m.estaDisponible()) { return false; }       // no disponible
    Empleado e = registroEmpleados.buscar(idEmpleado);
    if (e == null) { return false; }                 // empleado desconocido
    if (!e.puedeTomarPrestado()) { return false; }   // limite excedido
    boolean ok = registro.anotar(m, e, dia);
    if (!ok) { return false; }                       // ¿por que?
    return true;
}

Cinco if de guarda, cinco return false indistinguibles, y quien llame a prestar recibirá un solo booleano con cinco significados posibles. Y encima, cada uno de esos false que devuelve prestar acabará convertido en otro false en la capa superior, perdiendo aún más información en cada salto.

Al final del módulo, ese mismo método lanzará MaterialNoEncontradoException, MaterialNoDisponibleException, EmpleadoDesconocidoException o LimitePrestamosExcedidoException, cada una con los datos concretos del fallo, y la capa de presentación podrá decidir qué mensaje mostrar en cada caso.

Dicho esto, los códigos de retorno no son siempre malos. Map.get devolviendo null y Set.add devolviendo false son diseños perfectamente razonables, porque en esos casos "no está" y "ya estaba" son resultados normales, no fallos. La regla informal es: si la condición forma parte del funcionamiento esperado y el llamador la va a comprobar siempre, un valor de retorno está bien; si es un fallo que impide cumplir el contrato del método, es una excepción. En 06-07 volverás sobre este criterio con una tabla completa.

  1. Qué ocurre cuando nadie captura: propagación por la pila

Aquí está el mecanismo central del módulo. Recupera la pila de llamadas de 05-08: cada vez que un método llama a otro, la JVM apila un marco (stack frame) con las variables locales y el punto de retorno; cuando el método termina, su marco se desapila.

Cuando se lanza una excepción, ese desapilado ocurre de golpe y sin ejecutar el resto de los métodos. Es lo que se llama desenrollado de la pila (stack unwinding).

Observa un caso concreto de BiblioTech:

package com.nexussoftware.bibliotech.presentacion;

public class DemoPropagacion {

    public static void main(String[] args) {
        System.out.println("1. main empieza");
        procesarSolicitud("doce");                 // el usuario escribio "doce" en vez de 12
        System.out.println("2. main termina");     // NUNCA SE EJECUTA
    }

    static void procesarSolicitud(String entrada) {
        System.out.println("3. procesarSolicitud empieza");
        int dias = calcularDias(entrada);
        System.out.println("4. dias = " + dias);   // NUNCA SE EJECUTA
    }

    static int calcularDias(String texto) {
        System.out.println("5. calcularDias empieza");
        return Integer.parseInt(texto);            // AQUI se lanza NumberFormatException
    }
}

Salida real:

1. main empieza
3. procesarSolicitud empieza
5. calcularDias empieza
Exception in thread "main" java.lang.NumberFormatException: For input string: "doce"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:665)
	at java.base/java.lang.Integer.parseInt(Integer.java:781)
	at com.nexussoftware.bibliotech.presentacion.DemoPropagacion.calcularDias(DemoPropagacion.java:17)
	at com.nexussoftware.bibliotech.presentacion.DemoPropagacion.procesarSolicitud(DemoPropagacion.java:12)
	at com.nexussoftware.bibliotech.presentacion.DemoPropagacion.main(DemoPropagacion.java:6)

Los mensajes 2 y 4 no aparecen. No es que se hayan saltado: es que sus métodos fueron abandonados a media ejecución. Ninguna línea posterior al punto de lanzamiento llegó a ejecutarse en ninguno de los métodos de la cadena.

Este es el viaje completo:

flowchart TB
    subgraph antes["Pila justo antes del fallo"]
        direction TB
        A3["parseInt(texto) ← CIMA"]
        A2["calcularDias('doce')"]
        A1["procesarSolicitud('doce')"]
        A0["main(args) ← FONDO"]
        A3 --> A2 --> A1 --> A0
    end
    antes -->|"se lanza NumberFormatException"| B1
    subgraph desenrollado["Desenrollado: busca manejador y no lo encuentra"]
        direction TB
        B1["parseInt: sin catch, se descarta el marco"]
        B2["calcularDias: sin catch, se descarta el marco"]
        B3["procesarSolicitud: sin catch, se descarta el marco"]
        B4["main: sin catch, se descarta el marco"]
        B1 --> B2 --> B3 --> B4
    end
    B4 --> C["La excepcion llega a la JVM:<br/>manejador por defecto del hilo"]
    C --> D["Imprime el stack trace en System.err"]
    D --> E["El hilo 'main' TERMINA<br/>codigo de salida distinto de cero"]

Los pasos, en detalle:

  1. Integer.parseInt detecta que "doce" no es un número y lanza un objeto NumberFormatException.
  2. La JVM busca en el método actual un manejador (catch) capaz de tratar ese tipo. No lo hay. Descarta el marco de parseInt.
  3. Sube al llamador, calcularDias. Tampoco hay catch. Descarta su marco. El return nunca se completa.
  4. Sube a procesarSolicitud. Tampoco. Descarta su marco. La línea del println("4. ...") queda sin ejecutar.
  5. Sube a main. Tampoco. Descarta su marco.
  6. Ya no hay más marcos: la excepción llega al manejador de excepciones no capturadas por defecto del hilo, que imprime Exception in thread "main" ... seguido del stack trace en System.err (no en System.out).
  7. El hilo main termina. Si no quedan otros hilos no daemon vivos, la JVM se apaga con un código de salida distinto de cero (típicamente 1), que es lo que un script o un sistema de integración continua interpreta como "este proceso ha fallado".

Tres precisiones importantes que casi nadie explica:

  • Termina el hilo, no necesariamente la JVM. En un programa de un solo hilo, coinciden. Pero si una excepción escapa de un hilo secundario (módulo 8), ese hilo muere y el resto del programa sigue funcionando, a menudo sin que nadie se entere. Es una fuente clásica de fallos silenciosos.
  • El stack trace se imprime en System.err. Si rediriges la salida estándar a un fichero pero no la de error, o al revés, verás la mitad de la historia. Y como System.out y System.err se vacían de forma independiente, es habitual que en la consola aparezcan entremezclados y desordenados, lo que despista mucho al depurar.
  • Se puede sustituir ese manejador por defecto. Thread.setDefaultUncaughtExceptionHandler permite decidir qué ocurre con las excepciones que escapan de todo. Es una de las piezas de la "frontera de errores" que montarás en 06-07.

  1. Anatomía de un stack trace

En 02-05 aprendiste a leer un stack trace como herramienta de depuración. Ahora toca la lectura completa y precisa, porque el stack trace es el documento más valioso que tienes cuando algo falla en producción y no puedes reproducirlo.

Vuelve al volcado del apartado anterior, ahora anotado:

Exception in thread "main" java.lang.NumberFormatException: For input string: "doce"
└──── (A) ────┘ └─ (B) ─┘  └──────── (C) ───────────┘  └────────── (D) ──────────┘
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:665)
	at java.base/java.lang.Integer.parseInt(Integer.java:781)
	at com.nexussoftware.bibliotech.presentacion.DemoPropagacion.calcularDias(DemoPropagacion.java:17)
	at com.nexussoftware.bibliotech.presentacion.DemoPropagacion.procesarSolicitud(DemoPropagacion.java:12)
	at com.nexussoftware.bibliotech.presentacion.DemoPropagacion.main(DemoPropagacion.java:6)
	└─ (E) ─┘└──────────── (F) ─────────────┘└──── (G) ────┘└──────── (H) ───────┘
Marca Elemento Qué te dice
(A) Exception in thread Texto fijo del manejador por defecto: nadie capturó esto
(B) "main" Nombre del hilo que murió. Clave en programas multihilo
(C) java.lang.NumberFormatException Clase de la excepción: el qué ha fallado
(D) For input string: "doce" Mensaje: el detalle concreto, con el dato culpable
(E) at Cada línea at es un marco de la pila
(F) com...DemoPropagacion Clase con su paquete completo
(G) .calcularDias Método donde estaba la ejecución
(H) (DemoPropagacion.java:17) Fichero y número de línea exactos

Y ahora la regla de oro para leerlo:

La primera línea at es donde ocurrió el fallo. La última es donde empezó todo. Tu código suele estar en el medio.

El orden es de la cima al fondo de la pila, es decir, del punto más profundo hacia atrás. Por eso main siempre aparece al final: es el fondo de la pila.

Estrategia práctica de lectura, en este orden:

  1. Lee la clase y el mensaje (línea 1). Muchas veces con eso basta: NumberFormatException: For input string: "doce" es un diagnóstico completo.
  2. Baja hasta la primera línea que contenga tu paquete (com.nexussoftware.bibliotech). Las líneas de java.base/..., de Spring, de Hibernate o de cualquier librería suelen ser solo el camino; el error casi siempre está en cómo las llamaste. En el ejemplo, DemoPropagacion.calcularDias(DemoPropagacion.java:17) es la primera línea "tuya": ahí está el problema.
  3. Sigue bajando para reconstruir el camino: quién llamó a quién y con qué datos. Esto te dice cómo se llegó a esa situación, que es lo que suele faltar.
  4. Busca Caused by:, si lo hay. Es el apartado siguiente, y es donde está la verdad en las aplicaciones con frameworks.

Un detalle que confunde a mucha gente: en el ejemplo, Integer.parseInt aparece dos veces con líneas distintas (665 y 781). No es un error del volcado: parseInt(String) es una sobrecarga que llama internamente a parseInt(String, int radix). Cada marco es una invocación distinta, aunque el nombre del método se repita.

Otra observación: java.base/ delante del nombre de la clase es el módulo de la plataforma (JPMS, Java 9+, tema de 10-06). Indica que esa clase viene del JDK, no de tu código ni de una librería. Es un filtro visual muy útil: todo lo que empieza por java.base/ es de la biblioteca estándar.

Por último, dos casos que sorprenden:

  • Stack traces truncados con ... 23 more. Java no repite los marcos comunes entre una excepción y su causa. ... 23 more significa "los 23 marcos siguientes son idénticos a los del bloque anterior". No es información perdida.
  • Traces vacíos o sin líneas. Con la optimización de la JIT, ciertas excepciones muy repetidas (como NullPointerException lanzadas en un bucle caliente) pueden acabar lanzándose sin stack trace por una optimización llamada fast throw. Si ves una excepción sin trace, arráncala con -XX:-OmitStackTraceInFastThrow y volverá a aparecer completo.

  1. Caused by:: la cadena de causas

En una aplicación real con capas —y BiblioTech lo será al final de este módulo—, una excepción de bajo nivel se envuelve en otra de más alto nivel antes de seguir subiendo. El resultado es una cadena de causas, y el stack trace las muestra todas.

Un ejemplo con la estructura típica de tres capas:

Exception in thread "main" com.nexussoftware.bibliotech.servicio.PrestamoFallidoException: No se pudo registrar el prestamo del material LIB-0001 para el empleado EMP-004
	at com.nexussoftware.bibliotech.servicio.GestorPrestamos.prestar(GestorPrestamos.java:88)
	at com.nexussoftware.bibliotech.presentacion.MenuBiblioTech.opcionPrestar(MenuBiblioTech.java:142)
	at com.nexussoftware.bibliotech.presentacion.BiblioTechApp.main(BiblioTechApp.java:31)
Caused by: com.nexussoftware.bibliotech.dominio.MaterialNoDisponibleException: El material LIB-0001 esta prestado desde el dia 12
	at com.nexussoftware.bibliotech.dominio.Material.prestar(Material.java:64)
	at com.nexussoftware.bibliotech.servicio.GestorPrestamos.prestar(GestorPrestamos.java:84)
	... 2 more
Caused by: java.lang.IllegalStateException: El contador de prestamos acumulados es negativo: -1
	at com.nexussoftware.bibliotech.dominio.Empleado.registrarPrestamo(Empleado.java:47)
	at com.nexussoftware.bibliotech.dominio.Material.prestar(Material.java:61)
	... 3 more

Cómo se lee esto:

Bloque Qué es Utilidad
El primero La excepción de más alto nivel, la que llegó a la JVM Dice qué operación de negocio falló
Cada Caused by: La excepción que provocó la anterior Baja un nivel de abstracción
El último Caused by: La causa raíz Es donde está el problema real

Regla práctica: lee la primera línea para saber qué operación falló y baja al último Caused by: para saber por qué. El medio es el camino entre ambas.

En el ejemplo, la operación que falló es "registrar un préstamo" (bloque 1), la razón inmediata es que el material no estaba disponible (bloque 2), pero la causa raíz es un contador de préstamos que se ha vuelto negativo (bloque 3). Arreglar el nivel superior no serviría de nada: el problema está en Empleado.registrarPrestamo.

Y aquí un anticipo importante de 06-03: esta cadena solo existe si quien envuelve la excepción conserva la causa. Si en GestorPrestamos.prestar alguien hubiera escrito:

// MAL: pierde toda la informacion de lo que realmente paso
throw new PrestamoFallidoException("No se pudo registrar el prestamo");

...el stack trace se habría quedado en el primer bloque, y los dos Caused by: —los que contienen la información útil— habrían desaparecido para siempre. Este error, que en 06-03 llamarás "perder la causa", es responsable de una cantidad enorme de horas malgastadas en producción.

  1. La jerarquía de Throwable

Todo lo que se puede lanzar en Java desciende de una única clase: java.lang.Throwable. Su descendencia se organiza así:

flowchart TB
    T["Throwable<br/>(raiz: todo lo lanzable)"]
    T --> E["Error<br/>fallos graves de la JVM<br/>NO comprobadas"]
    T --> X["Exception<br/>condiciones anomalas<br/>COMPROBADAS"]
    X --> R["RuntimeException<br/>errores de programacion<br/>NO comprobadas"]

    E --> E1["StackOverflowError"]
    E --> E2["OutOfMemoryError"]
    E --> E3["NoClassDefFoundError"]

    X --> X1["IOException"]
    X --> X2["SQLException"]
    X --> X3["ClassNotFoundException"]
    X1 --> X4["FileNotFoundException"]

    R --> R1["NullPointerException"]
    R --> R2["IllegalArgumentException"]
    R --> R3["IllegalStateException"]
    R --> R4["IndexOutOfBoundsException"]
    R --> R5["ClassCastException"]
    R --> R6["ArithmeticException"]
    R2 --> R7["NumberFormatException"]
    R4 --> R8["ArrayIndexOutOfBoundsException"]
    R4 --> R9["StringIndexOutOfBoundsException"]

Esta jerarquía no es decorativa. Tiene tres consecuencias prácticas que gobiernan todo lo demás:

  1. Solo se pueden lanzar y capturar objetos que desciendan de Throwable. No puedes lanzar un String ni un int.
  2. La rama determina si el compilador te obliga a algo. Exception (excluyendo RuntimeException) es comprobada: el compilador exige capturarla o declararla. Error y RuntimeException son no comprobadas: el compilador no dice nada.
  3. Capturar una superclase captura a todos sus descendientes. catch (Exception e) captura también NumberFormatException, porque desciende de ella. Esto es lo que da sentido —y peligro— a las capturas anchas, y lo que hace obligatorio el orden de los catch que verás en 06-02.

Lo que Throwable aporta a todos sus descendientes:

Método Qué devuelve
getMessage() El mensaje de detalle que se pasó al constructor
getLocalizedMessage() Igual, pero pensado para sobrescribirse con traducción
toString() nombre.completo.de.la.Clase: mensaje
printStackTrace() Imprime el trace completo en System.err
getStackTrace() El array de StackTraceElement con los marcos
getCause() La excepción que la provocó, o null
getSuppressed() Excepciones suprimidas (06-06)

Fíjate en que Throwable tiene dos hijos directos con destinos opuestos: Error, que no debes manejar, y Exception, que sí. Esa separación es el motivo por el que nunca se captura Throwable: hacerlo mete en el mismo saco cosas que se tratan de forma completamente distinta.

  1. Error: lo que no debes intentar manejar

La rama Error representa fallos de la máquina virtual o del entorno, no de la lógica de tu programa. Son situaciones de las que, en general, una aplicación no puede recuperarse de forma sensata.

Error Causa típica
StackOverflowError Pila de llamadas agotada: recursión sin caso base o demasiado profunda (lo viste en 05-08)
OutOfMemoryError El montón se ha agotado: fuga de memoria, colección que crece sin límite, o -Xmx insuficiente
NoClassDefFoundError Una clase que estaba al compilar no aparece en el classpath al ejecutar
ExceptionInInitializerError Una excepción escapó de un inicializador estático (static { ... })
AssertionError Una aserción assert falló (o la lanza un framework de tests)

El StackOverflowError es el más fácil de provocar, y ya lo conoces del módulo 5:

public class RecursionSinFreno {
    // Sin caso base: cada llamada apila un marco mas... hasta agotar la pila
    static int contarPrestamos(int n) {
        return 1 + contarPrestamos(n - 1);
    }

    public static void main(String[] args) {
        contarPrestamos(10);   // StackOverflowError tras ~10.000-50.000 marcos
    }
}

Su stack trace es reconocible al instante: miles de líneas idénticas repetidas.

Exception in thread "main" java.lang.StackOverflowError
	at RecursionSinFreno.contarPrestamos(RecursionSinFreno.java:4)
	at RecursionSinFreno.contarPrestamos(RecursionSinFreno.java:4)
	at RecursionSinFreno.contarPrestamos(RecursionSinFreno.java:4)
	... (miles de lineas iguales)

Cuando veas eso, no busques el error en la línea que se repite: búscalo en la condición de parada que falta o que nunca se cumple.

La regla con los Error es tajante: no los captures. Si tu programa se queda sin memoria, capturar el OutOfMemoryError no arregla nada —lo más probable es que el siguiente new vuelva a fallar, y encima el propio manejador puede necesitar memoria para ejecutarse—. La respuesta correcta es dejar que el proceso muera, que un supervisor lo reinicie, y analizar la causa: un volcado de montón, una fuga, un límite mal dimensionado. Eso es materia de 10-07.

La única excepción razonable a esta regla es un servidor de aplicaciones o un framework que captura Throwable en su bucle principal para registrarlo antes de morir, nunca para continuar como si nada. Tú, escribiendo lógica de negocio, no tienes ese caso.

  1. RuntimeException: errores de programación

RuntimeException y sus descendientes son excepciones no comprobadas: el compilador no obliga a capturarlas ni a declararlas. El criterio de diseño detrás de esa decisión es claro y merece recordarse:

Una RuntimeException señala normalmente un error de programación: algo que no debería haber ocurrido si el código estuviera bien escrito. La solución no es capturarla, es corregir el código.

Estas son las que más vas a ver, con su causa típica y su remedio real:

Excepción Causa típica Cómo se arregla de verdad
NullPointerException Usar un miembro de una referencia null Comprobar antes, o no permitir el null de origen (Objects.requireNonNull, 06-03)
ArrayIndexOutOfBoundsException array[i] con i < 0 o i >= length Revisar el límite del bucle (< frente a <=)
StringIndexOutOfBoundsException charAt/substring fuera de rango Comprobar la longitud antes
IndexOutOfBoundsException list.get(i) fuera de rango Idem, con size()
NumberFormatException Integer.parseInt("doce") Validar la entrada o capturar en la frontera (06-02)
ArithmeticException División entera por cero: 5 / 0 Comprobar el divisor. Con double, 5.0/0 da Infinity, no excepción
ClassCastException Convertir a un tipo incompatible Usar instanceof con patrón, o genéricos (10-01)
IllegalArgumentException Un método recibe un argumento inválido Validar en el llamador; la lanza el método que la detecta
IllegalStateException Llamar a un método en un momento inadecuado Revisar el ciclo de vida del objeto
UnsupportedOperationException Modificar una colección inmutable (List.of, Arrays.asList) Copiar a una lista mutable
ConcurrentModificationException Modificar una colección mientras se itera (05-04) Usar Iterator.remove o removeIf
NoSuchElementException next() sin más elementos; remove() sobre cola vacía (05-07) Comprobar hasNext(), o usar poll()

Un programa corto que provoca varias de ellas a propósito, para reconocerlas por su mensaje:

package com.nexussoftware.bibliotech.presentacion;

import java.util.List;

/** Provoca excepciones tipicas de una en una. Ejecuta con el numero de caso. */
public class MuestraDeFallos {

    public static void main(String[] args) {
        int caso = args.length > 0 ? Integer.parseInt(args[0]) : 1;

        switch (caso) {
            case 1 -> {
                String titulo = null;
                System.out.println(titulo.length());
                // NullPointerException: Cannot invoke "String.length()" because "titulo" is null
            }
            case 2 -> {
                String[] isbns = { "978-0000000001", "978-0000000002" };
                System.out.println(isbns[2]);
                // ArrayIndexOutOfBoundsException: Index 2 out of bounds for length 2
            }
            case 3 -> {
                System.out.println(Integer.parseInt("quince"));
                // NumberFormatException: For input string: "quince"
            }
            case 4 -> {
                int diasTotales = 30, materiales = 0;
                System.out.println(diasTotales / materiales);
                // ArithmeticException: / by zero
            }
            case 5 -> {
                Object o = "Java Efectivo";
                Integer n = (Integer) o;
                System.out.println(n);
                // ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer
            }
            case 6 -> {
                List<String> fija = List.of("Java Efectivo", "Refactorizacion");
                fija.add("Patrones de Diseno");
                // UnsupportedOperationException (sin mensaje)
            }
            default -> System.out.println("Caso desconocido: " + caso);
        }
    }
}

Presta atención a un detalle del caso 4: la división entera por cero lanza excepción, pero la división en coma flotante no. 30 / 0 explota; 30.0 / 0.0 devuelve NaN y 30.0 / 0 devuelve Infinity, en silencio. Es una asimetría de la especificación IEEE 754 que sorprende, y una fuente real de cálculos de multas absurdos: si TARIFA_DIARIA se dividiera accidentalmente por cero, BiblioTech no fallaría, simplemente empezaría a mostrar Infinity € en los recibos.

  1. Las excepciones comprobadas

Una excepción comprobada (checked) es cualquier descendiente de Exception que no descienda de RuntimeException. Sobre ellas el compilador aplica la llamada regla de capturar o declarar (catch or specify): si un método puede lanzarlas, el llamador está obligado a hacer una de dos cosas.

Compruébalo tú mismo. Este código no compila:

import java.io.FileReader;

public class NoCompila {
    public static void main(String[] args) {
        FileReader lector = new FileReader("catalogo.txt");
        // error: unreported exception java.io.FileNotFoundException;
        //        must be caught or declared to be thrown
    }
}

El compilador se planta. Las dos salidas legales serán las de 06-02 y 06-03: capturarla con try-catch, o declararla con throws en la firma para que el problema sea del llamador.

Estas son las comprobadas más frecuentes:

Excepción comprobada Cuándo aparece Módulo donde se trabaja
IOException Cualquier operación de entrada/salida que puede fallar Módulo 7
FileNotFoundException El fichero no existe o no se puede abrir (hija de IOException) Módulo 7
InterruptedException Un hilo dormido o esperando es interrumpido Módulo 8
SQLException Fallo de base de datos Módulo 11
ClassNotFoundException Class.forName no encuentra la clase Módulo 10 (reflexión)
CloneNotSupportedException clone() sobre una clase que no implementa Cloneable Módulo 3

La intención original del diseño es esta: una excepción comprobada representa una condición anómala pero previsible y potencialmente recuperable, ajena al control del programador. Que un fichero no exista no es un error de tu código: es un hecho del mundo. Por eso el lenguaje te obliga a decidir explícitamente qué hacer al respecto, en lugar de dejar que el programa se caiga.

  1. Comprobadas frente a no comprobadas: el criterio de diseño

Esta tabla resume la diferencia completa, y conviene volver a ella cuando en 06-04 tengas que elegir de qué clase heredan tus propias excepciones:

Aspecto Comprobadas No comprobadas
Clase base Exception (no RuntimeException) RuntimeException y Error
¿Obliga el compilador? Sí: capturar o declarar No
¿Aparecen en throws? Obligatorio si se propagan Opcional (solo documenta)
Significado previsto Condición externa y recuperable Error de programación o fallo grave
Reacción esperada Manejarla: reintentar, degradar, avisar Corregir el código
Ejemplos IOException, SQLException, InterruptedException NullPointerException, IllegalArgumentException
Caso de BiblioTech "El fichero de catálogo no se puede leer" "Alguien pasó un Material nulo a registrar"
Coste Contamina las firmas de toda la cadena de llamadas Puede pasar desapercibida hasta que explota

La pregunta que te ayudará a decidir en la práctica:

¿Puede el llamador hacer algo sensato y distinto de "morir" cuando esto ocurra?

  • → una comprobada es defendible: pedir el fichero otra vez, usar valores por defecto, reintentar.
  • No, esto no debería ocurrir nunca si el código está bien → no comprobada.

Aplicado a BiblioTech, que es la decisión que tomarás formalmente en 06-04:

Situación Tipo elegido Razón
El material buscado no existe en el catálogo No comprobada Quien busca debería haber comprobado antes; y obligar a un try en cada búsqueda sería insoportable
El empleado ha alcanzado el límite de 3 préstamos No comprobada Es una regla de negocio conocida y consultable con puedeTomarPrestado()
El fichero de catálogo no se puede leer al arrancar Comprobada Es un hecho externo, y arriba se puede decidir arrancar con catálogo vacío
Se registra un material null No comprobada (NullPointerException) Error puro de programación

  1. El debate real sobre las excepciones comprobadas

Java es prácticamente el único lenguaje mayoritario con excepciones comprobadas. C#, Kotlin, Scala, Python, JavaScript y Go las descartaron deliberadamente. Conviene que conozcas el debate, porque marca el estilo del código moderno que vas a encontrar.

A favor de las comprobadas:

  • Hacen visible en la firma qué puede fallar. La API se autodocumenta.
  • Impiden olvidar un fallo previsible: el compilador no deja compilar.
  • Obligan a pensar en el camino de error en el momento de escribir el código, no cuando ya está en producción.

En contra:

  • Contaminan las firmas. Si un método profundo lanza IOException, toda la cadena hasta arriba debe declararla o capturarla. Un cambio interno se convierte en un cambio de API.
  • Empujan al anti-patrón. Ante la obligación, mucha gente escribe el catch vacío o el catch (Exception e) { e.printStackTrace(); }, que es peor que no haber capturado nada: el programa continúa con el estado roto y sin que nadie se entere.
  • Rompen con las lambdas. Las interfaces funcionales de java.util.function que conociste en 04-06 no declaran excepciones comprobadas, así que no puedes lanzar una IOException desde dentro de un Function o un Consumer sin envolverla. Esto se volvió muy incómodo a partir de Java 8, y es una de las razones de peso del giro moderno hacia las no comprobadas.
// Esto NO compila: Consumer.accept no declara IOException
List<String> rutas = List.of("catalogo.txt", "prestamos.txt");
rutas.forEach(ruta -> {
    Files.readString(Path.of(ruta));    // error: unreported exception IOException
});

Dónde está el consenso hoy: los frameworks modernos han votado con los pies. Spring convierte todas las SQLException (comprobadas) en su jerarquía DataAccessException (no comprobadas). Hibernate hace lo mismo. La mayoría de librerías nuevas usan exclusivamente no comprobadas.

La postura pragmática, que es la que seguirás en este módulo:

  • Usa no comprobadas por defecto para los errores de tu dominio.
  • Reserva las comprobadas para condiciones externas donde el llamador realmente vaya a hacer algo distinto de propagar.
  • Nunca silencies una comprobada con un catch vacío para quitártela de encima. Si de verdad no puedes hacer nada útil ahí, envuélvela en una no comprobada conservando la causa (06-03) y deja que suba.

  1. Los mensajes de NullPointerException de Java 14+

La NullPointerException ha sido históricamente la excepción más frustrante de Java, por una razón muy concreta: no decía cuál de las referencias de la línea era null. Ante esto:

int anio = catalogo.buscarPorReferencia("LIB-0001").getFicha().anio();

...un NullPointerException en Java 8 te daba esto:

Exception in thread "main" java.lang.NullPointerException
	at com.nexussoftware.bibliotech.presentacion.Informe.generar(Informe.java:42)

¿Era catalogo el nulo? ¿El resultado de buscarPorReferencia? ¿El de getFicha()? La única salida era partir la línea en varias o abrir el depurador.

Desde Java 14, con los helpful NullPointerExceptions (activados por defecto desde Java 15), el mensaje es este:

Exception in thread "main" java.lang.NullPointerException: Cannot invoke
"com.nexussoftware.bibliotech.dominio.Ficha.anio()" because the return value of
"com.nexussoftware.bibliotech.servicio.Catalogo.buscarPorReferencia(String)" is null
	at com.nexussoftware.bibliotech.presentacion.Informe.generar(Informe.java:42)

El mensaje señala con precisión quirúrgica: el valor devuelto por buscarPorReferencia era null. Diagnóstico resuelto sin depurador y sin tocar el código.

La JVM sabe construir estos mensajes para todos los casos en que un null provoca el fallo:

Situación Fragmento del mensaje
Llamada a método sobre null Cannot invoke "X.metodo()" because "variable" is null
Lectura de campo Cannot read field "campo" because "variable" is null
Longitud de array Cannot read the array length because "array" is null
Elemento de array Cannot load from object array because "array" is null
Desempaquetado de envoltorio Cannot invoke "java.lang.Integer.intValue()" because "obj" is null

Ese último es el que explica el clásico NullPointerException "imposible" que puede darte un Map<String, Integer>:

Map<String, Integer> prestamosPorEmpleado = new HashMap<>();
int total = prestamosPorEmpleado.get("EMP-999");   // la clave no existe: get devuelve null
// NullPointerException: Cannot invoke "java.lang.Integer.intValue()"
//                       because the return value of "java.util.Map.get(Object)" is null

El null devuelto por get se intenta desempaquetar a int (autounboxing, módulo 1), lo que implica llamar a intValue() sobre null. El mensaje detallado lo dice exactamente. Sin él, este fallo desconcierta durante un buen rato.

Como usas Java 17 o superior, tienes esto activado por defecto y gratis. Aprovéchalo: cuando veas una NullPointerException, lee el mensaje completo antes de tocar nada. Casi siempre te está dando la respuesta ya masticada.

  1. Qué no debes hacer nunca

Antes de escribir tu primer try en la lección siguiente, tres reglas que no admiten matices. Las verás demostradas con código en 06-02; aquí van los principios.

1. No captures Throwable.

// PROHIBIDO
try {
    gestor.prestar("LIB-0001", "EMP-001", 12);
} catch (Throwable t) {
    System.out.println("Algo fallo");
}

Capturar Throwable mete en el mismo saco tu error de negocio y un OutOfMemoryError o un StackOverflowError, de los que no puedes recuperarte. Peor aún: atrapa también ThreadDeath y errores del cargador de clases, con los que interferir puede dejar la JVM en un estado imposible de diagnosticar. Captura el tipo más específico que sepas manejar.

2. No captures y te quedes callado.

// PROHIBIDO: el "catch vacio", el peor error del manejo de excepciones
try {
    catalogo.registrar(material);
} catch (Exception e) {
    // ya lo miraremos
}

Esto no maneja el error: lo destruye. La excepción llevaba dentro la clase, el mensaje, la causa y la pila completa; todo eso se pierde para siempre y el programa continúa fingiendo que la operación tuvo éxito. Un fallo así puede tardar semanas en manifestarse, y cuando lo haga no habrá ni rastro de su origen. Si el catch está vacío, es que estaba mal capturarla.

Un catch vacío solo es admisible en un caso, y con comentario obligatorio explicando por qué el fallo es realmente irrelevante ahí.

3. No uses excepciones para el flujo normal.

// PROHIBIDO: recorrer una lista provocando la excepcion de fin
try {
    int i = 0;
    while (true) {
        System.out.println(materiales.get(i++).getTitulo());
    }
} catch (IndexOutOfBoundsException fin) {
    // "ya hemos terminado la lista"
}

Aparte de ser ilegible, esto es órdenes de magnitud más lento que un for normal, porque construir el objeto excepción implica capturar la pila de llamadas completa. Las excepciones son para lo excepcional. Verás los números en 06-02.

Errores Comunes y Consejos

Creer que una excepción "rompe el programa" y ya está. No: activa un mecanismo muy definido —desenrollado de la pila buscando un manejador— que puedes controlar por completo. Solo termina el hilo si nadie la maneja en todo el recorrido.

Leer el stack trace de abajo arriba. Se lee de arriba abajo. La primera línea at es el punto exacto del fallo; la última es main. Y si hay Caused by:, la verdad suele estar en el último de ellos.

Ignorar las líneas del stack trace que no son tuyas. Correcto como filtro inicial: busca la primera línea con tu paquete. Pero no las borres del informe de error: el camino por dentro de la librería a veces es justo lo que revela el uso incorrecto.

Pensar que Exception incluye todo lo lanzable. No incluye Error. catch (Exception e) no atrapa un OutOfMemoryError. En este caso concreto, esa limitación es una virtud.

Confundir Error con error. En Java, Error es una rama concreta de la jerarquía —fallos de la JVM—, no un sinónimo genérico de "algo salió mal". Un NullPointerException no es un Error.

Creer que una excepción no comprobada es "menos grave". Es exactamente al revés: normalmente indica un defecto en tu código, mientras que una comprobada suele indicar una circunstancia del entorno. La palabra "comprobada" describe qué hace el compilador, no la gravedad.

Escribir mensajes de excepción inútiles. "Error", "Fallo" o "No se pudo" no ayudan a nadie a las tres de la mañana. Un buen mensaje dice qué pasó, con qué dato concreto y qué se esperaba: "Referencia duplicada: LIB-0001 ya existe en el catalogo". Es la misma cantidad de trabajo.

Consejo: ante una NullPointerException, lee el mensaje entero. Desde Java 14 te dice literalmente qué referencia era nula. Es la mejora de diagnóstico más rentable de la última década de Java.

Consejo: cuando reportes un error, copia el stack trace COMPLETO. Incluidos todos los Caused by: y los ... N more. Un trace recortado a la primera línea suele eliminar precisamente la información que resolvía el caso.

Consejo: en producción, redirige System.err a un fichero. Si no, los stack traces se pierden en cuanto se cierra la terminal. En 06-07 harás esto correctamente, con un Logger y un FileHandler.

Consejo: -XX:-OmitStackTraceInFastThrow. Si en producción aparecen excepciones sin stack trace, es la optimización fast throw de la JIT. Ese parámetro la desactiva y devuelve los traces completos mientras diagnosticas.

Ejercicios

Ejercicio 1: el zoológico de excepciones de BiblioTech

Escribe la clase ZoologicoDeExcepciones en el paquete com.nexussoftware.bibliotech.presentacion con un main que, sin usar try ni catch (todavía no toca), provoque de forma controlada las siguientes excepciones, una por ejecución según un argumento numérico, y que antes de cada una imprima qué va a pasar y por qué:

  1. NullPointerException al pedir el título de un material que buscarPorReferencia devolvió como null.
  2. ArrayIndexOutOfBoundsException al recorrer un array de ISBN con <= en vez de <.
  3. NumberFormatException al convertir la entrada "quince dias" con Integer.parseInt.
  4. ArithmeticException al calcular la media de días de préstamo con cero préstamos.
  5. ClassCastException al convertir un Object que contiene un String a Integer.
  6. UnsupportedOperationException al añadir un material a una lista creada con List.of.
  7. ConcurrentModificationException al eliminar de una lista dentro de un for-each.

Para cada caso, ejecuta el programa y anota en un comentario al final del fichero el mensaje exacto que produce cada excepción en tu JDK.

Ejercicio 2: lectura forense de un stack trace

Te llega este volcado desde el entorno de producción de Nexus Software:

Exception in thread "main" java.lang.IllegalStateException: No se pudo generar el recibo del prestamo PR-0007
	at com.nexussoftware.bibliotech.presentacion.ReciboConsola.emitir(ReciboConsola.java:54)
	at com.nexussoftware.bibliotech.servicio.GestorPrestamos.devolver(GestorPrestamos.java:132)
	at com.nexussoftware.bibliotech.presentacion.MenuBiblioTech.opcionDevolver(MenuBiblioTech.java:188)
	at com.nexussoftware.bibliotech.presentacion.BiblioTechApp.main(BiblioTechApp.java:29)
Caused by: java.lang.NullPointerException: Cannot invoke "com.nexussoftware.bibliotech.dominio.Empleado.getNombre()" because "this.titular" is null
	at com.nexussoftware.bibliotech.dominio.Prestamo.descripcionTitular(Prestamo.java:97)
	at com.nexussoftware.bibliotech.presentacion.ReciboConsola.emitir(ReciboConsola.java:51)
	... 3 more

Responde por escrito, justificando cada respuesta con la línea concreta del volcado:

  1. ¿Qué hilo ha muerto?
  2. ¿Qué operación de negocio ha fallado, en lenguaje llano?
  3. ¿Cuál es la causa raíz exacta?
  4. ¿En qué fichero y línea empezarías a investigar? ¿Por qué esa y no la primera línea at?
  5. ¿Qué significa ... 3 more?
  6. ¿Cuántos marcos tenía la pila en total en el momento del fallo original?
  7. ¿Es este un error de programación o una condición del entorno? ¿Qué rama de la jerarquía lo confirma?
  8. Propón dos correcciones: una que evite el síntoma y otra que ataque la causa raíz. ¿Cuál es la buena?

Ejercicio 3: clasificador de excepciones

Escribe ClasificadorExcepciones, una utilidad que reciba un objeto Throwable ya construido (no lanzado) y produzca un informe con:

  • El nombre simple y el nombre completo de la clase.
  • Si es un Error, una excepción comprobada o una no comprobada. Pista: t instanceof Error, t instanceof RuntimeException, y en otro caso es comprobada.
  • La reacción recomendada, según una tabla que definas tú ("No manejar: dejar morir el proceso", "Corregir el codigo", "Manejar: es una condicion externa").
  • La cadena completa de causas, indentada por niveles, recorriendo con getCause() hasta llegar a null.
  • El número de marcos de su pila y el primer marco que pertenezca al paquete com.nexussoftware, si lo hay.

En el main, construye a mano una cadena de tres excepciones anidadas —una IllegalStateException causada por una RuntimeException causada por una java.io.IOException— y pásala al clasificador. Ten cuidado con las cadenas circulares: protege el recorrido con un límite de profundidad.

Soluciones

Solución 1

package com.nexussoftware.bibliotech.presentacion;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Provoca deliberadamente las excepciones mas frecuentes, SIN capturarlas,
 * para observar su mensaje y su stack trace.
 *
 * Uso: java ZoologicoDeExcepciones <1..7>
 */
public class ZoologicoDeExcepciones {

    public static void main(String[] args) {
        int caso = (args.length > 0) ? Integer.parseInt(args[0]) : 1;
        System.out.println("=== Caso " + caso + " ===");

        switch (caso) {
            case 1 -> nullPointer();
            case 2 -> indiceFueraDeRango();
            case 3 -> formatoDeNumero();
            case 4 -> divisionPorCero();
            case 5 -> conversionInvalida();
            case 6 -> listaInmutable();
            case 7 -> modificacionConcurrente();
            default -> System.out.println("Casos validos: 1 a 7");
        }

        // Esta linea SOLO se imprime en el caso 'default': en los demas,
        // la excepcion se propaga y termina el hilo main antes de llegar aqui.
        System.out.println("Fin normal del programa.");
    }

    /** 1. NullPointerException: el catalogo devuelve null cuando no encuentra nada. */
    private static void nullPointer() {
        System.out.println("Buscamos LIB-9999, que no existe. buscarPorReferencia devolvera null");
        System.out.println("y al pedirle el titulo tendremos NullPointerException.");

        Map<String, String> indice = new HashMap<>();
        indice.put("LIB-0001", "Java Efectivo");

        String titulo = indice.get("LIB-9999");     // null: la clave no existe
        System.out.println(titulo.toUpperCase());   // <-- NullPointerException
    }

    /** 2. ArrayIndexOutOfBoundsException: el clasico error del <= en el limite. */
    private static void indiceFueraDeRango() {
        System.out.println("Recorremos 3 ISBN con i <= longitud en vez de i < longitud.");

        String[] isbns = { "978-0000000001", "978-0000000002", "978-0000000003" };
        for (int i = 0; i <= isbns.length; i++) {   // <= : una vuelta de mas
            System.out.println("  ISBN[" + i + "] = " + isbns[i]);   // <-- explota con i == 3
        }
    }

    /** 3. NumberFormatException: entrada de usuario que no es un numero. */
    private static void formatoDeNumero() {
        System.out.println("El empleado escribio 'quince dias' donde se esperaba un entero.");

        String entrada = "quince dias";
        int dias = Integer.parseInt(entrada);       // <-- NumberFormatException
        System.out.println("Dias de prestamo: " + dias);
    }

    /** 4. ArithmeticException: division ENTERA por cero. */
    private static void divisionPorCero() {
        System.out.println("Media de dias con cero prestamos: division entera por cero.");

        int diasTotales = 45;
        int numeroPrestamos = 0;

        // Aviso: con double NO habria excepcion, daria Infinity.
        System.out.println("  Con double: " + (45.0 / 0));       // Infinity, sin fallo

        int media = diasTotales / numeroPrestamos;               // <-- ArithmeticException
        System.out.println("Media: " + media);
    }

    /** 5. ClassCastException: conversion incompatible en tiempo de ejecucion. */
    private static void conversionInvalida() {
        System.out.println("Un Object que contiene un String se convierte a Integer.");

        Object dato = "Java Efectivo";              // en realidad es un String
        Integer anio = (Integer) dato;              // <-- ClassCastException
        System.out.println("Anio: " + anio);
    }

    /** 6. UnsupportedOperationException: List.of devuelve una lista INMUTABLE. */
    private static void listaInmutable() {
        System.out.println("List.of crea una lista inmutable; add lanza excepcion.");

        List<String> catalogoFijo = List.of("Java Efectivo", "Patrones de Diseno");
        catalogoFijo.add("Refactorizacion");        // <-- UnsupportedOperationException

        // Nota: Arrays.asList tiene el mismo problema para add/remove,
        // aunque si permite set(i, x). Es una vista de tamano fijo sobre el array.
        List<String> vista = Arrays.asList("a", "b");
        vista.set(0, "z");                          // esto SI funciona
    }

    /** 7. ConcurrentModificationException: modificar mientras se itera (visto en 05-04). */
    private static void modificacionConcurrente() {
        System.out.println("Eliminamos dentro de un for-each: el iterador detecta el cambio.");

        List<String> materiales = new ArrayList<>(
                List.of("Java Efectivo", "Patrones de Diseno", "Refactorizacion"));

        for (String titulo : materiales) {
            if (titulo.startsWith("Patrones")) {
                materiales.remove(titulo);          // <-- ConcurrentModificationException
            }
        }
        // La forma correcta era: materiales.removeIf(t -> t.startsWith("Patrones"));
    }
}

/*
 * MENSAJES OBSERVADOS (JDK 17):
 *
 * 1. java.lang.NullPointerException: Cannot invoke "String.toUpperCase()"
 *      because the return value of "java.util.Map.get(Object)" is null
 * 2. java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3
 * 3. java.lang.NumberFormatException: For input string: "quince dias"
 * 4. java.lang.ArithmeticException: / by zero
 * 5. java.lang.ClassCastException: class java.lang.String cannot be cast to
 *      class java.lang.Integer (java.lang.String and java.lang.Integer are in
 *      module java.base of loader 'bootstrap')
 * 6. java.lang.UnsupportedOperationException      (sin mensaje)
 * 7. java.util.ConcurrentModificationException    (sin mensaje)
 *
 * Observacion: las excepciones 6 y 7 NO llevan mensaje. Su sola clase es el
 * diagnostico. Es un recordatorio de que el nombre de la excepcion es la parte
 * mas importante de la informacion, y de por que en 06-04 se crean clases
 * propias con nombres del dominio en lugar de reutilizar tipos genericos.
 */

Solución 2

1. ¿Qué hilo ha muerto? El hilo "main", según la primera línea: Exception in thread "main". Al ser el único hilo del programa, la JVM termina con código de salida distinto de cero.

2. ¿Qué operación de negocio ha fallado? Emitir el recibo de la devolución del préstamo PR-0007. Lo dicen conjuntamente el mensaje de la excepción de más alto nivel (No se pudo generar el recibo del prestamo PR-0007) y el camino de llamadas: mainopcionDevolverdevolveremitir. El usuario eligió la opción "devolver" del menú.

3. ¿Cuál es la causa raíz exacta? El campo titular del objeto Prestamo vale null. Lo dice el último Caused by:, y con la precisión de los mensajes de Java 14+: Cannot invoke "...Empleado.getNombre()" because "this.titular" is null. El this.titular indica que no es una variable local ni un parámetro, sino un campo del propio Prestamo: el objeto se construyó sin titular o alguien lo puso a null después.

4. ¿Dónde empezar a investigar? En Prestamo.java:97, dentro de descripcionTitular, que es la primera línea at del último Caused by:. Ahí es donde el null se manifiesta. No en la primera línea del volcado (ReciboConsola.java:54), porque esa es solo la capa que envolvió el fallo, no la que lo produjo.

Ahora bien, el sitio donde se manifiesta no es necesariamente donde está el defecto: lo verdaderamente sospechoso es cómo un Prestamo llegó a existir con titular a null. La investigación real es en el constructor de Prestamo y en GestorPrestamos, que es quien los crea.

5. ¿Qué significa ... 3 more? Que los 3 marcos siguientes de la causa son idénticos a los del bloque anterior y Java no los repite. Reconstruidos, son GestorPrestamos.devolver, MenuBiblioTech.opcionDevolver y BiblioTechApp.main. No hay información perdida.

6. ¿Cuántos marcos tenía la pila? Cinco. Los dos explícitos de la causa (Prestamo.descripcionTitular y ReciboConsola.emitir) más los 3 comprimidos en el ... 3 more. La pila completa era:

Prestamo.descripcionTitular  (Prestamo.java:97)      ← CIMA, aqui explota
ReciboConsola.emitir         (ReciboConsola.java:51)
GestorPrestamos.devolver     (GestorPrestamos.java:132)
MenuBiblioTech.opcionDevolver(MenuBiblioTech.java:188)
BiblioTechApp.main           (BiblioTechApp.java:29) ← FONDO

Detalle fino: ReciboConsola.emitir aparece en la línea 51 en la causa y en la 54 en la envoltura. Coherente: en la 51 se llamó a descripcionTitular y falló; en la 54 está el catch que la envolvió en la IllegalStateException.

7. ¿Error de programación o condición del entorno? Error de programación. Lo confirma la rama de la jerarquía: NullPointerException desciende de RuntimeException, es decir, es no comprobada. Nada externo ha fallado —ni disco, ni red, ni entrada de usuario—: simplemente existe un objeto de dominio en un estado inválido que nunca debió permitirse.

8. Dos correcciones.

Correción del síntoma (mala como única solución):

// En Prestamo.descripcionTitular
public String descripcionTitular() {
    if (titular == null) { return "(sin titular)"; }   // tapa el sintoma
    return titular.getNombre();
}

El recibo se emite, pero sin titular, y el préstamo sigue estando corrupto en memoria y en cualquier informe posterior. Se ha silenciado el aviso, no arreglado el problema.

Corrección de la causa (la buena): impedir que un Prestamo pueda existir sin titular, validando en el constructor —exactamente lo que harás en 06-03:

public Prestamo(String referencia, Material material, Empleado titular, int diaInicio) {
    this.referencia = Objects.requireNonNull(referencia, "La referencia no puede ser nula");
    this.material   = Objects.requireNonNull(material,   "El material no puede ser nulo");
    this.titular    = Objects.requireNonNull(titular,    "El titular no puede ser nulo");
    // ...
}

Así el fallo se detecta en el momento y lugar en que se crea el objeto inválido, con un mensaje claro, en vez de veinte minutos después al imprimir un recibo. Es el principio de fallar rápido (fail-fast) que desarrollarás en 06-03.

Solución 3

package com.nexussoftware.bibliotech.presentacion;

import java.io.IOException;

/**
 * Analiza un Throwable ya construido: rama de la jerarquia, reaccion
 * recomendada, cadena de causas y localizacion del primer marco propio.
 *
 * No lanza ni captura nada: solo INSPECCIONA el objeto excepcion,
 * demostrando que una excepcion es un objeto normal con estado.
 */
public class ClasificadorExcepciones {

    /** Limite de seguridad: protege frente a cadenas de causas circulares. */
    private static final int PROFUNDIDAD_MAXIMA = 10;

    private static final String PAQUETE_PROPIO = "com.nexussoftware";

    public static String informar(Throwable t) {
        if (t == null) {
            return "No hay excepcion que analizar.";
        }

        StringBuilder sb = new StringBuilder();
        sb.append("========================================\n");
        sb.append("INFORME DE EXCEPCION\n");
        sb.append("========================================\n");
        sb.append("Clase simple  : ").append(t.getClass().getSimpleName()).append('\n');
        sb.append("Clase completa: ").append(t.getClass().getName()).append('\n');
        sb.append("Mensaje       : ").append(t.getMessage()).append('\n');
        sb.append("Categoria     : ").append(categoria(t)).append('\n');
        sb.append("Reaccion      : ").append(reaccion(t)).append('\n');
        sb.append("Marcos de pila: ").append(t.getStackTrace().length).append('\n');
        sb.append("Primer marco propio: ").append(primerMarcoPropio(t)).append('\n');
        sb.append("----------------------------------------\n");
        sb.append("CADENA DE CAUSAS:\n");
        sb.append(cadenaDeCausas(t));
        sb.append("========================================");
        return sb.toString();
    }

    /**
     * Determina la rama de la jerarquia.
     * Ojo al ORDEN de las comprobaciones: RuntimeException es hija de Exception,
     * asi que hay que preguntar primero por la mas especifica. Es exactamente
     * la misma regla de orden que gobierna los catch multiples (06-02).
     */
    private static String categoria(Throwable t) {
        if (t instanceof Error) {
            return "ERROR de la JVM (no comprobada)";
        }
        if (t instanceof RuntimeException) {
            return "NO COMPROBADA (RuntimeException)";
        }
        if (t instanceof Exception) {
            return "COMPROBADA (Exception, no RuntimeException)";
        }
        return "Throwable directo (caso muy raro)";
    }

    private static String reaccion(Throwable t) {
        if (t instanceof Error) {
            return "No manejar. Dejar morir el proceso, registrar y analizar la causa.";
        }
        if (t instanceof RuntimeException) {
            return "Normalmente indica un defecto: corregir el codigo, no capturarla.";
        }
        if (t instanceof Exception) {
            return "Condicion externa: manejarla (reintentar, degradar o informar).";
        }
        return "Sin recomendacion.";
    }

    /** Recorre getCause() con indentacion creciente y proteccion anticircular. */
    private static String cadenaDeCausas(Throwable t) {
        StringBuilder sb = new StringBuilder();
        Throwable actual = t;
        int nivel = 0;

        while (actual != null && nivel < PROFUNDIDAD_MAXIMA) {
            String sangria = "  ".repeat(nivel);
            String prefijo = (nivel == 0) ? "" : "Caused by: ";
            sb.append(sangria)
              .append(prefijo)
              .append(actual.getClass().getSimpleName())
              .append(": ")
              .append(actual.getMessage())
              .append('\n');

            Throwable siguiente = actual.getCause();

            // Una excepcion puede ser su propia causa (Throwable lo permite si se
            // construye mal): sin esta comprobacion, el bucle seria infinito.
            if (siguiente == actual) {
                sb.append(sangria).append("  [causa circular: se detiene aqui]\n");
                break;
            }
            actual = siguiente;
            nivel++;
        }

        if (nivel >= PROFUNDIDAD_MAXIMA) {
            sb.append("  [cadena truncada a ").append(PROFUNDIDAD_MAXIMA).append(" niveles]\n");
        }
        if (nivel == 0) {
            sb.append("  (esta excepcion no tiene causa: es la raiz)\n");
        }
        return sb.toString();
    }

    /**
     * Busca el primer marco que pertenezca a nuestro codigo.
     * Es lo que haces mentalmente al leer un stack trace: saltarte las lineas
     * de java.base y de las librerias hasta encontrar la primera propia.
     */
    private static String primerMarcoPropio(Throwable t) {
        for (StackTraceElement marco : t.getStackTrace()) {
            if (marco.getClassName().startsWith(PAQUETE_PROPIO)) {
                return marco.getClassName() + "." + marco.getMethodName()
                        + " (" + marco.getFileName() + ":" + marco.getLineNumber() + ")";
            }
        }
        return "(ninguno: la excepcion se origino integramente fuera de " + PAQUETE_PROPIO + ")";
    }

    // ------------------------------------------------------------------

    public static void main(String[] args) {
        // Construimos a mano una cadena de tres niveles, de la mas profunda
        // a la mas superficial. El segundo argumento del constructor es la CAUSA.
        IOException raiz = new IOException("No se pudo leer catalogo.txt: permiso denegado");

        RuntimeException intermedia =
                new RuntimeException("Fallo la carga del catalogo desde disco", raiz);

        IllegalStateException superficial =
                new IllegalStateException("BiblioTech no puede arrancar sin catalogo", intermedia);

        System.out.println(informar(superficial));

        System.out.println();
        System.out.println(informar(raiz));

        System.out.println();
        System.out.println(informar(new StackOverflowError()));
    }
}

Salida (abreviada):

========================================
INFORME DE EXCEPCION
========================================
Clase simple  : IllegalStateException
Clase completa: java.lang.IllegalStateException
Mensaje       : BiblioTech no puede arrancar sin catalogo
Categoria     : NO COMPROBADA (RuntimeException)
Reaccion      : Normalmente indica un defecto: corregir el codigo, no capturarla.
Marcos de pila: 1
Primer marco propio: com.nexussoftware.bibliotech.presentacion.ClasificadorExcepciones.main (ClasificadorExcepciones.java:141)
----------------------------------------
CADENA DE CAUSAS:
IllegalStateException: BiblioTech no puede arrancar sin catalogo
  Caused by: RuntimeException: Fallo la carga del catalogo desde disco
    Caused by: IOException: No se pudo leer catalogo.txt: permiso denegado
========================================

Dos aprendizajes del ejercicio:

  1. El orden de los instanceof en categoria es obligatorio. Si preguntaras primero t instanceof Exception, toda RuntimeException caería ahí y se clasificaría mal, porque RuntimeException es una Exception. Esa misma regla —de lo específico a lo general— será la regla de orden de los bloques catch en 06-02, solo que allí el compilador te obligará a respetarla.
  2. La cadena de causas se recorre con getCause() hasta null, y la última de la cadena es la causa raíz. Es exactamente lo que hace la JVM al imprimir los bloques Caused by:. Al implementarlo tú, entiendes que ese formato del stack trace no es magia: es un recorrido trivial sobre una lista enlazada de excepciones.

Conclusión

Ya sabes qué es realmente una excepción: un objeto que representa un fallo, que se construye con new como cualquier otro, que lleva dentro su clase, su mensaje, su causa y una fotografía completa de la pila de llamadas, y que —cuando se lanza— interrumpe el método en seco y viaja hacia atrás por la pila buscando quien se haga cargo.

Entiendes por qué esto es mejor que los códigos de retorno: un false se puede ignorar en silencio, no dice por qué falló, ocupa el valor de retorno y obliga a comprobar en cada llamada. El false mudo de Catalogo.registrar y el null de buscarPorReferencia son, exactamente, la primera fragilidad que este módulo va a eliminar.

Conoces el mecanismo completo del desenrollado de la pila: la JVM busca un manejador marco a marco, descartando cada uno sin ejecutar el resto de su código, y si llega al fondo sin encontrarlo, entrega la excepción al manejador por defecto del hilo, que imprime el trace en System.err y termina el hilo —no necesariamente la JVM, matiz que volverá en el módulo 8—, dejando un código de salida distinto de cero.

Sabes leer un stack trace de arriba abajo, identificando el hilo, la clase de la excepción, el mensaje y cada marco con su fichero y su línea; sabes que la primera línea at es el punto del fallo, que la última es main, que conviene saltar a la primera línea de tu propio paquete, y que ante una cadena de Caused by: la verdad está en el último. También sabes que ... N more no oculta información, y que un trace ausente puede ser cosa de la optimización fast throw.

Tienes el mapa de la jerarquía: Throwable en la raíz, con Error para los fallos de la JVM que no debes manejarStackOverflowError, OutOfMemoryError—, y Exception para las condiciones anómalas, con su rama RuntimeException de errores de programación. Reconoces las excepciones más frecuentes de cada rama por su nombre y por su mensaje, sabes que la división entera por cero explota mientras que la de coma flotante devuelve Infinity en silencio, y sabes que los mensajes detallados de NullPointerException de Java 14+ te dicen literalmente qué referencia era nula.

Y tienes clara la distinción entre comprobadas y no comprobadas: qué obliga el compilador en cada caso, el criterio de diseño —condición externa recuperable frente a defecto del código—, el debate real sobre su valor, y la postura pragmática que seguirás en BiblioTech: no comprobadas por defecto para el dominio, comprobadas solo cuando el llamador vaya a hacer algo distinto de propagar. Junto con las tres prohibiciones que no admiten matices: no captures Throwable, no captures sin hacer nada, y no uses excepciones para el flujo normal.

Hasta aquí has aprendido a leer el fallo. En la lección siguiente, Bloque Try-Catch, aprenderás a manejarlo: la sintaxis y la semántica exactas de try y catch, qué se protege y qué se salta cuando salta una excepción, los métodos del objeto capturado, la regla de orden obligatoria cuando hay varios catch —de la subclase a la superclase, con el error de compilación que da si la inviertes—, el multi-catch con | de Java 7 y sus reglas, el alcance de las variables declaradas dentro del try, y las cuatro cosas sensatas que se pueden hacer dentro de un catch: recuperarse con un valor por defecto, reintentar, traducir a otra excepción o registrar y relanzar. Con la demostración de los anti-patrones y del coste real de lanzar una excepción frente a un simple if. Y con la primera victoria tangible del módulo: el menú interactivo de BiblioTech dejará de morirse cuando alguien escriba "doce" donde se esperaba un número.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados