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
- El punto de partida: el
falsemudo de BiblioTech - Qué es una excepción
- Por qué los códigos de retorno son peores
- Qué ocurre cuando nadie captura: propagación por la pila
- Anatomía de un stack trace
Caused by:: la cadena de causas- La jerarquía de
Throwable Error: lo que no debes intentar manejarRuntimeException: errores de programación- Las excepciones comprobadas
- Comprobadas frente a no comprobadas: el criterio de diseño
- El debate real sobre las excepciones comprobadas
- Los mensajes de
NullPointerExceptionde Java 14+ - Qué no debes hacer nunca
- Errores Comunes y Consejos
- Ejercicios
- El punto de partida: el
false mudo de BiblioTech
false mudo de BiblioTechEste 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 origenLa 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.
- 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:
- 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. - 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. - 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.
- 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 ejecutaCon 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.
- 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:
Integer.parseIntdetecta que"doce"no es un número y lanza un objetoNumberFormatException.- La JVM busca en el método actual un manejador (
catch) capaz de tratar ese tipo. No lo hay. Descarta el marco deparseInt. - Sube al llamador,
calcularDias. Tampoco haycatch. Descarta su marco. Elreturnnunca se completa. - Sube a
procesarSolicitud. Tampoco. Descarta su marco. La línea delprintln("4. ...")queda sin ejecutar. - Sube a
main. Tampoco. Descarta su marco. - 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 enSystem.err(no enSystem.out). - El hilo
maintermina. 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 comoSystem.outySystem.errse 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.setDefaultUncaughtExceptionHandlerpermite 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.
- 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
ates 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:
- Lee la clase y el mensaje (línea 1). Muchas veces con eso basta:
NumberFormatException: For input string: "doce"es un diagnóstico completo. - Baja hasta la primera línea que contenga tu paquete (
com.nexussoftware.bibliotech). Las líneas dejava.base/..., de Spring, de Hibernate o de cualquier librería suelen ser solo el camino; el error casi siempre está en cómo tú las llamaste. En el ejemplo,DemoPropagacion.calcularDias(DemoPropagacion.java:17)es la primera línea "tuya": ahí está el problema. - 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.
- 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 moresignifica "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
NullPointerExceptionlanzadas 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:-OmitStackTraceInFastThrowy volverá a aparecer completo.
Caused by:: la cadena de causas
Caused by:: la cadena de causasEn 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.
- La jerarquía de
Throwable
ThrowableTodo 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:
- Solo se pueden lanzar y capturar objetos que desciendan de
Throwable. No puedes lanzar unStringni unint. - La rama determina si el compilador te obliga a algo.
Exception(excluyendoRuntimeException) es comprobada: el compilador exige capturarla o declararla.ErroryRuntimeExceptionson no comprobadas: el compilador no dice nada. - Capturar una superclase captura a todos sus descendientes.
catch (Exception e)captura tambiénNumberFormatException, porque desciende de ella. Esto es lo que da sentido —y peligro— a las capturas anchas, y lo que hace obligatorio el orden de loscatchque 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.
Error: lo que no debes intentar manejar
Error: lo que no debes intentar manejarLa 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.
RuntimeException: errores de programación
RuntimeException: errores de programaciónRuntimeException 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
RuntimeExceptionseñ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.
- 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.
- 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?
- Sí → 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 |
- 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
catchvacío o elcatch (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.functionque conociste en 04-06 no declaran excepciones comprobadas, así que no puedes lanzar unaIOExceptiondesde dentro de unFunctiono unConsumersin 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
catchvací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.
- Los mensajes de
NullPointerException de Java 14+
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:
...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 nullEl 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.
- 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é:
NullPointerExceptional pedir el título de un material quebuscarPorReferenciadevolvió comonull.ArrayIndexOutOfBoundsExceptional recorrer un array de ISBN con<=en vez de<.NumberFormatExceptional convertir la entrada"quince dias"conInteger.parseInt.ArithmeticExceptional calcular la media de días de préstamo con cero préstamos.ClassCastExceptional convertir unObjectque contiene unStringaInteger.UnsupportedOperationExceptional añadir un material a una lista creada conList.of.ConcurrentModificationExceptional eliminar de una lista dentro de unfor-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:
- ¿Qué hilo ha muerto?
- ¿Qué operación de negocio ha fallado, en lenguaje llano?
- ¿Cuál es la causa raíz exacta?
- ¿En qué fichero y línea empezarías a investigar? ¿Por qué esa y no la primera línea
at? - ¿Qué significa
... 3 more? - ¿Cuántos marcos tenía la pila en total en el momento del fallo original?
- ¿Es este un error de programación o una condición del entorno? ¿Qué rama de la jerarquía lo confirma?
- 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 anull. - 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: main → opcionDevolver → devolver → emitir. 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:
- El orden de los
instanceofencategoriaes obligatorio. Si preguntaras primerot instanceof Exception, todaRuntimeExceptioncaería ahí y se clasificaría mal, porqueRuntimeExceptiones unaException. Esa misma regla —de lo específico a lo general— será la regla de orden de los bloquescatchen 06-02, solo que allí el compilador te obligará a respetarla. - La cadena de causas se recorre con
getCause()hastanull, y la última de la cadena es la causa raíz. Es exactamente lo que hace la JVM al imprimir los bloquesCaused 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 manejar —StackOverflowError, 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
- Introducción a Java
- Configuración del Entorno de Desarrollo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Operadores
- Entrada y Salida por Consola
- Tu Primer Programa Completo: BiblioTech
Módulo 2: Flujo de Control
- Sentencias Condicionales
- Bucles
- Sentencias Switch
- Break y Continue
- Depuración y Trazas de Ejecución
- Proyecto: Menú Interactivo de BiblioTech
Módulo 3: Programación Orientada a Objetos
- Introducción a la POO
- Clases y Objetos
- Métodos
- Constructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- La Clase Object: equals, hashCode y toString
Módulo 4: Programación Orientada a Objetos Avanzada
- Interfaces
- Clases Abstractas
- Clases Internas
- Clases Anónimas
- Expresiones Lambda
- Interfaces Funcionales y Referencias a Métodos
- Enumeraciones y Registros
Módulo 5: Estructuras de Datos y Colecciones
- Arreglos
- El Framework de Colecciones
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cola y Deque
- Pila
- Ordenación y Búsqueda en Colecciones
Módulo 6: Manejo de Excepciones
- Introducción a las Excepciones
- Bloque Try-Catch
- Throw y Throws
- Excepciones Personalizadas
- Bloque Finally
- Try-with-resources y AutoCloseable
- Estrategias de Manejo de Errores y Logging
Módulo 7: Entrada/Salida de Archivos
- Lectura de Archivos
- Escritura de Archivos
- Flujos de Archivos
- BufferedReader y BufferedWriter
- Serialización
- La API NIO.2: Path y Files
- Formatos de Intercambio: CSV y Properties
Módulo 8: Multihilo y Concurrencia
- Introducción al Multihilo
- Creación de Hilos
- Ciclo de Vida de un Hilo
- Sincronización
- Utilidades de Concurrencia
- Colecciones Concurrentes y Variables Atómicas
- Tareas Asíncronas con CompletableFuture
Módulo 9: Redes
- Introducción a las Redes
- Sockets
- ServerSocket
- DatagramSocket y DatagramPacket
- URL y HttpURLConnection
- El Cliente HTTP Moderno
Módulo 10: Temas Avanzados
- Genéricos
- Anotaciones
- Reflexión
- Características de Java 8: Streams y Optional
- Fechas y Horas con java.time
- Java 9 y Más Allá
- Memoria, Recolección de Basura y Rendimiento
Módulo 11: Frameworks y Librerías de Java
- Introducción a los Frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Pruebas Avanzadas con Mockito
- Librerías Esenciales del Ecosistema
