Quedan dos fragilidades de la lista que cerró el módulo 5, y esta lección ataca la más traicionera: las operaciones que fallan a medias y dejan el estado incoherente. Si GestorPrestamos.prestar marca el material como prestado, incrementa el contador del empleado y entonces falla al anotarlo en el registro, BiblioTech queda con un material bloqueado que nadie tiene prestado y un empleado con un préstamo fantasma que le consume cupo. Ninguna excepción, por bien diseñada que esté, arregla eso: hace falta un mecanismo que garantice que cierto código se ejecuta pase lo que pase.

Ese mecanismo es finally. Es la construcción más simple del módulo —un bloque que siempre se ejecuta— y a la vez la que esconde más trampas: el return dentro de finally que descarta silenciosamente el valor y hasta la excepción pendiente, el finally que lanza y hace desaparecer el fallo original, y los dos casos en que ni siquiera finally se ejecuta.

Al final verás el patrón manual de cierre de recursos anterior a Java 7 en toda su verbosidad. No es nostalgia: es la única forma de entender de verdad qué hace por ti el try-with-resources de la lección siguiente, y por qué su existencia estaba tan justificada.

Contenido

  1. Qué garantiza finally
  2. El orden exacto de ejecución
  3. finally con return, break y continue
  4. Las combinaciones válidas
  5. try-finally sin catch
  6. El propósito clásico: liberar recursos
  7. La trampa del return en finally
  8. El finally que lanza y pierde la excepción original
  9. Los dos casos en que finally NO se ejecuta
  10. El patrón manual de cierre antes de Java 7
  11. BiblioTech: operaciones que no dejan el estado a medias
  12. Errores Comunes y Consejos
  13. Ejercicios

  1. Qué garantiza finally

finally es un bloque opcional que se añade a un try, y su garantía es esta:

El bloque finally se ejecuta siempre que se haya entrado en el try, haya o no excepción, se capture o no, y con independencia de cómo se salga del bloque.

Las cuatro salidas posibles de un try, y qué hace finally en cada una:

Cómo termina el try ¿Se ejecuta finally?
Termina normalmente
Lanza una excepción capturada por un catch , después del catch
Lanza una excepción no capturada , antes de que la excepción siga subiendo
Ejecuta return, break o continue , antes de saltar

Un primer ejemplo que muestra los tres primeros casos:

package com.nexussoftware.bibliotech.presentacion;

public class GarantiaDeFinally {

    public static void main(String[] args) {
        System.out.println("--- CASO 1: sin excepcion ---");
        procesar("15");

        System.out.println("\n--- CASO 2: excepcion capturada ---");
        procesar("doce");

        System.out.println("\n--- CASO 3: excepcion NO capturada ---");
        try {
            procesarSinRed(null);
        } catch (NullPointerException e) {
            System.out.println("  (capturada arriba: " + e.getClass().getSimpleName() + ")");
        }
    }

    static void procesar(String entrada) {
        System.out.println("  1. antes del try");
        try {
            System.out.println("  2. dentro del try");
            int dias = Integer.parseInt(entrada);
            System.out.println("  3. convertido: " + dias);
        } catch (NumberFormatException e) {
            System.out.println("  4. en el catch: " + e.getMessage());
        } finally {
            System.out.println("  5. EN EL FINALLY");
        }
        System.out.println("  6. despues del bloque");
    }

    static void procesarSinRed(String entrada) {
        try {
            System.out.println("  2. dentro del try");
            System.out.println("  3. longitud: " + entrada.length());   // NullPointerException
        } catch (NumberFormatException e) {
            System.out.println("  4. este catch NO captura NullPointerException");
        } finally {
            System.out.println("  5. EN EL FINALLY (aunque nadie capturo)");
        }
        System.out.println("  6. esta linea NO se ejecuta");
    }
}

Salida:

--- CASO 1: sin excepcion ---
  1. antes del try
  2. dentro del try
  3. convertido: 15
  5. EN EL FINALLY
  6. despues del bloque

--- CASO 2: excepcion capturada ---
  1. antes del try
  2. dentro del try
  4. en el catch: For input string: "doce"
  5. EN EL FINALLY
  6. despues del bloque

--- CASO 3: excepcion NO capturada ---
  2. dentro del try
  5. EN EL FINALLY (aunque nadie capturo)
  (capturada arriba: NullPointerException)

El caso 3 es el revelador. Ningún catch local era compatible con NullPointerException, así que la excepción siguió subiendo... pero el finally se ejecutó igual, antes de que se fuera. La línea 6 no se ejecutó, porque el método fue abandonado. Esa combinación —"el resto del método se abandona, pero el finally se ejecuta"— es exactamente lo que hace útil el bloque para liberar recursos y para reparar estado.

  1. El orden exacto de ejecución

Cuando hay excepción, el orden es: try hasta el punto de fallo → catch compatible (si lo hay) → finally → lo que venga después.

flowchart TB
    A["Entra al try"] --> B{"¿Lanza excepcion?"}

    B -->|"no"| C["El try termina"]
    C --> F["FINALLY"]
    F --> G["Continua tras el bloque"]

    B -->|"si"| D{"¿Hay catch compatible?"}
    D -->|"si"| E["Ejecuta el catch"]
    E --> F2["FINALLY"]
    F2 --> G

    D -->|"no"| H["FINALLY"]
    H --> I["La excepcion sigue subiendo<br/>por la pila de llamadas"]

Con try anidados, cada finally se ejecuta de dentro hacia fuera conforme la excepción va subiendo:

package com.nexussoftware.bibliotech.presentacion;

public class OrdenDeFinally {

    public static void main(String[] args) {
        try {
            nivel1();
        } catch (IllegalStateException e) {
            System.out.println("6. main captura: " + e.getMessage());
        }
    }

    static void nivel1() {
        try {
            System.out.println("1. nivel1: try");
            nivel2();
        } finally {
            System.out.println("5. nivel1: FINALLY");
        }
    }

    static void nivel2() {
        try {
            System.out.println("2. nivel2: try");
            nivel3();
        } finally {
            System.out.println("4. nivel2: FINALLY");
        }
    }

    static void nivel3() {
        System.out.println("3. nivel3: lanza");
        throw new IllegalStateException("fallo en el nivel mas profundo");
    }
}

Salida:

1. nivel1: try
2. nivel2: try
3. nivel3: lanza
4. nivel2: FINALLY
5. nivel1: FINALLY
6. main captura: fallo en el nivel mas profundo

Los finally se disparan en orden inverso a la profundidad, exactamente igual que se desapilan los marcos. Es la misma pila de llamadas de 05-08 y de 06-01: mientras la excepción desenrolla la pila, cada marco que se descarta ejecuta su finally antes de desaparecer. Esa es la propiedad que permite que cada nivel limpie lo suyo sin saber nada de los demás.

  1. finally con return, break y continue

Aquí está la parte que sorprende: finally se ejecuta incluso cuando el try o el catch hacen return. La JVM guarda el valor de retorno, ejecuta el finally, y solo entonces retorna.

package com.nexussoftware.bibliotech.presentacion;

public class FinallyConReturn {

    public static void main(String[] args) {
        System.out.println("Resultado A: " + conReturnEnTry());
        System.out.println("Resultado B: " + conReturnEnCatch());
        System.out.println("\n--- break y continue ---");
        conBreak();
        conContinue();
    }

    /** El return del try se "congela": el finally se ejecuta ANTES de retornar. */
    static int conReturnEnTry() {
        try {
            System.out.println("  [A] en el try, voy a hacer return 10");
            return 10;
        } finally {
            System.out.println("  [A] FINALLY (se ejecuta antes de devolver el 10)");
        }
    }

    static int conReturnEnCatch() {
        try {
            System.out.println("  [B] en el try, lanzo");
            throw new IllegalStateException("fallo");
        } catch (IllegalStateException e) {
            System.out.println("  [B] en el catch, voy a hacer return 20");
            return 20;
        } finally {
            System.out.println("  [B] FINALLY (tambien tras el return del catch)");
        }
    }

    /** Con break: el finally se ejecuta antes de salir del bucle. */
    static void conBreak() {
        for (int i = 1; i <= 3; i++) {
            try {
                System.out.println("  [break] vuelta " + i);
                if (i == 2) {
                    break;
                }
            } finally {
                System.out.println("  [break] FINALLY de la vuelta " + i);
            }
        }
        System.out.println("  [break] fuera del bucle");
    }

    /** Con continue: el finally se ejecuta antes de pasar a la siguiente vuelta. */
    static void conContinue() {
        for (int i = 1; i <= 3; i++) {
            try {
                if (i == 2) {
                    System.out.println("  [continue] salto la vuelta 2");
                    continue;
                }
                System.out.println("  [continue] proceso la vuelta " + i);
            } finally {
                System.out.println("  [continue] FINALLY de la vuelta " + i);
            }
        }
    }
}

Salida:

  [A] en el try, voy a hacer return 10
  [A] FINALLY (se ejecuta antes de devolver el 10)
Resultado A: 10
  [B] en el try, lanzo
  [B] en el catch, voy a hacer return 20
  [B] FINALLY (tambien tras el return del catch)
Resultado B: 20

--- break y continue ---
  [break] vuelta 1
  [break] FINALLY de la vuelta 1
  [break] vuelta 2
  [break] FINALLY de la vuelta 2
  [break] fuera del bucle
  [continue] proceso la vuelta 1
  [continue] FINALLY de la vuelta 1
  [continue] salto la vuelta 2
  [continue] FINALLY de la vuelta 2
  [continue] proceso la vuelta 3
  [continue] FINALLY de la vuelta 3

La mecánica exacta con el return, que conviene entender bien porque es la base de la trampa del apartado 7:

  1. Se evalúa la expresión del return. En el caso A, el valor 10 se calcula y se guarda.
  2. Se ejecuta el finally.
  3. Se retorna el valor guardado en el paso 1.

La consecuencia es sutil pero importante: si el finally modifica la variable que se iba a devolver, el cambio no afecta al valor retornado, porque el valor ya se copió.

static int demostracion() {
    int valor = 10;
    try {
        return valor;              // se guarda el 10 AHORA
    } finally {
        valor = 99;                // modifica la variable, no el valor guardado
        System.out.println("  finally puso valor = " + valor);
    }
}
// Imprime "finally puso valor = 99" y DEVUELVE 10

Ojo con la excepción a esta regla: si lo que devuelves es una referencia a un objeto mutable, el finally sí puede modificar el objeto apuntado, porque lo que se copió fue la referencia, no el contenido.

static List<String> demostracionObjeto() {
    List<String> lista = new ArrayList<>(List.of("a"));
    try {
        return lista;              // se guarda la REFERENCIA
    } finally {
        lista.add("b");            // modifica el OBJETO apuntado: si afecta
    }
}
// Devuelve [a, b]

Es el mismo paso por valor de referencias que viste en 03-03, aplicado aquí.

  1. Las combinaciones válidas

Un try admite tres formas legales:

Forma ¿Válida? Para qué sirve
try + catch Manejar el fallo
try + catch + finally Manejar el fallo y limpiar
try + finally Limpiar sin manejar: el fallo sigue subiendo
try solo No compila
try + finally + catch (en ese orden) No compila El finally siempre va el último
// error: 'try' without 'catch', 'finally' or resource declarations
try {
    hacerAlgo();
}

// error: 'catch' without 'try'   (el finally debe ir al final)
try {
    hacerAlgo();
} finally {
    limpiar();
} catch (Exception e) {         // NO COMPILA
    manejar(e);
}

También puede haber varios catch y un solo finally, siempre al final:

try {
    operacion();
} catch (MaterialNoEncontradoException e) {
    // ...
} catch (PrestamoException e) {
    // ...
} finally {
    liberar();                  // uno solo, y el ultimo
}

  1. try-finally sin catch

La forma menos conocida y una de las más útiles. Significa exactamente: "no sé manejar este fallo, pero tengo que limpiar antes de que se vaya".

package com.nexussoftware.bibliotech.servicio;

public class TryFinallySinCatch {

    private boolean catalogoBloqueado = false;

    /**
     * Reorganiza el catalogo bajo bloqueo.
     *
     * No captura NADA: si la reorganizacion falla, el fallo debe subir para
     * que arriba decidan. Pero el bloqueo hay que liberarlo SIEMPRE, o el
     * catalogo quedaria inaccesible para el resto de la aplicacion.
     */
    public void reorganizar(int criterio) {
        bloquear();
        try {
            System.out.println("  Reorganizando con criterio " + criterio);
            if (criterio < 0) {
                throw new IllegalArgumentException("Criterio invalido: " + criterio);
            }
            System.out.println("  Reorganizacion completada");
        } finally {
            desbloquear();          // se ejecuta con exito Y con fallo
        }
    }

    private void bloquear() {
        catalogoBloqueado = true;
        System.out.println("  [bloqueo adquirido]");
    }

    private void desbloquear() {
        catalogoBloqueado = false;
        System.out.println("  [bloqueo liberado]");
    }

    public boolean estaBloqueado() { return catalogoBloqueado; }

    public static void main(String[] args) {
        TryFinallySinCatch servicio = new TryFinallySinCatch();

        System.out.println("Caso correcto:");
        servicio.reorganizar(1);
        System.out.println("¿Bloqueado tras el exito? " + servicio.estaBloqueado());

        System.out.println("\nCaso con fallo:");
        try {
            servicio.reorganizar(-1);
        } catch (IllegalArgumentException e) {
            System.out.println("  main captura: " + e.getMessage());
        }
        System.out.println("¿Bloqueado tras el fallo? " + servicio.estaBloqueado());
    }
}

Salida:

Caso correcto:
  [bloqueo adquirido]
  Reorganizando con criterio 1
  Reorganizacion completada
  [bloqueo liberado]
¿Bloqueado tras el exito? false

Caso con fallo:
  [bloqueo adquirido]
  Reorganizando con criterio -1
  [bloqueo liberado]
  main captura: Criterio invalido: -1
¿Bloqueado tras el fallo? false

Lo importante: la excepción llegó a main intacta, con su tipo, su mensaje y su pila, y aun así el bloqueo se liberó. Esto es lo que hace try-finally insustituible: separa por completo "limpiar" de "manejar", que son responsabilidades distintas y que a menudo corresponden a capas distintas.

Sin finally, la única alternativa sería duplicar el desbloquear() en el camino normal y en un catch que relanzase, con el riesgo evidente de olvidar uno de los dos:

// MAL: fragil y duplicado
bloquear();
try {
    reorganizarInterno(criterio);
    desbloquear();                    // camino normal
} catch (RuntimeException e) {
    desbloquear();                    // camino de fallo... ¿y si alguien anade otro return?
    throw e;
}

  1. El propósito clásico: liberar recursos

El uso histórico de finally es cerrar lo que se ha abierto: un fichero, una conexión, un bloqueo, un socket.

Aviso de alcance: los ejemplos siguientes usan FileReader y BufferedReader de forma deliberadamente mínima. La entrada/salida de ficheros es el módulo 7 —lectura, escritura, flujos, NIO.2— y allí se explica la API a fondo. Aquí lo único que importa es el cierre: qué pasa si no cierras, y cómo garantizar que se cierra.

Por qué hay que cerrar. Un FileReader abierto consume un descriptor de fichero, un recurso del sistema operativo que es limitado (en Linux, típicamente 1024 por proceso si no se cambia el límite). Si abres ficheros en un bucle y no los cierras, el proceso agota los descriptores y todo empieza a fallar con Too many open files. Y en escritura es peor: los datos pueden quedarse en el búfer sin llegar al disco.

El intento ingenuo, que está mal:

// MAL: si readLine lanza IOException, el fichero NUNCA se cierra
BufferedReader lector = new BufferedReader(new FileReader("catalogo.txt"));
String linea = lector.readLine();
procesar(linea);                   // si esto lanza, nos saltamos el close
lector.close();                    // esta linea puede no ejecutarse nunca

La versión con finally:

package com.nexussoftware.bibliotech.servicio;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

/**
 * Lectura de un fichero con cierre garantizado por finally.
 *
 * NOTA: la API de E/S se estudia en el modulo 7. Aqui solo interesa el CIERRE.
 */
public class LectorConFinally {

    public int contarLineas(String ruta) throws IOException {
        BufferedReader lector = null;          // declarada FUERA para verla en el finally
        int lineas = 0;

        try {
            lector = new BufferedReader(new FileReader(ruta));
            String linea;
            while ((linea = lector.readLine()) != null) {
                if (!linea.isBlank()) {
                    lineas++;
                }
            }
            return lineas;

        } finally {
            // Se ejecuta con exito y con fallo
            if (lector != null) {              // pudo fallar el propio constructor
                lector.close();                // close() declara IOException: ver apartado 10
            }
            System.out.println("  [fichero cerrado]");
        }
    }
}

Tres detalles que ya asoman aquí y que se desarrollan en el apartado 10:

  • La variable se declara fuera del try, porque el finally tiene que verla (alcance de bloques, 06-02).
  • Hay que comprobar != null, porque si el constructor de FileReader falla —fichero inexistente—, lector sigue valiendo null y el close() lanzaría un NullPointerException.
  • El propio close() declara IOException, así que en muchos casos hace falta otro try dentro del finally. Ahí es donde la cosa se vuelve fea.

  1. La trampa del return en finally

Esta es la trampa que hay que conocer para no caer en ella jamás.

Un return dentro de finally descarta el valor de retorno del try, y —mucho peor— descarta también cualquier excepción pendiente.

package com.nexussoftware.bibliotech.presentacion;

public class TrampaDelReturnEnFinally {

    public static void main(String[] args) {
        System.out.println("A) " + descartaElValor());
        System.out.println("B) " + descartaLaExcepcion());
        System.out.println("C) " + versionCorrecta());
    }

    /** El return del finally GANA: el 10 se pierde. */
    static int descartaElValor() {
        try {
            return 10;
        } finally {
            return 99;              // el compilador avisa, pero compila
        }
    }

    /**
     * MUCHO PEOR: el return del finally se TRAGA la excepcion.
     * El metodo devuelve -1 como si todo hubiera ido bien.
     */
    static int descartaLaExcepcion() {
        try {
            throw new IllegalStateException("El catalogo esta corrupto");
        } finally {
            return -1;              // la excepcion DESAPARECE. Sin rastro.
        }
    }

    /** Como debe hacerse: el finally solo limpia, no decide. */
    static int versionCorrecta() {
        try {
            return 10;
        } finally {
            System.out.println("   (limpieza, sin return)");
        }
    }
}

Salida:

A) 99
B) -1
   (limpieza, sin return)
C) 10

Detente en el caso B. El método lanzó un IllegalStateException diciendo que el catálogo está corrupto, y quien llama recibió tranquilamente un -1. La excepción no subió, no se registró, no dejó ningún rastro. Es un catch vacío camuflado, con el agravante de que ni siquiera hay un catch a la vista que te ponga sobre aviso al leer el código.

Lo mismo ocurre con break, continue y con un throw dentro del finally: cualquier salida abrupta desde el finally descarta lo que estuviera en curso.

for (String referencia : referencias) {
    try {
        procesar(referencia);
        throw new IllegalStateException("fallo procesando " + referencia);
    } finally {
        continue;                   // se traga TODAS las excepciones del bucle
    }
}

Los compiladores modernos y todos los analizadores estáticos (SpotBugs, SonarQube, los propios avisos del IDE) marcan esto como error grave. El aviso de javac con -Xlint:finally es:

warning: [finally] finally clause cannot complete normally

La regla, sin matices:

Nunca pongas return, break, continue ni throw dentro de un finally. El finally limpia; no decide, no devuelve y no lanza.

  1. El finally que lanza y pierde la excepción original

Una variante del problema anterior que aparece sin querer, y que es la que motiva directamente la lección siguiente.

Cuando el try lanza una excepción y el finally lanza otra, la del finally gana y la del try se pierde por completo. Y esto ocurre en el caso más común de todos: cerrar un recurso.

package com.nexussoftware.bibliotech.presentacion;

public class FinallyQuePierdeLaExcepcion {

    /** Simula un recurso cuyo cierre tambien puede fallar. */
    static class RecursoFragil {
        private final String nombre;

        RecursoFragil(String nombre) {
            this.nombre = nombre;
            System.out.println("  [abierto " + nombre + "]");
        }

        void leer() {
            throw new IllegalStateException("FALLO REAL: el fichero " + nombre + " esta corrupto");
        }

        void cerrar() {
            throw new IllegalStateException("FALLO AL CERRAR: no se pudo liberar " + nombre);
        }
    }

    public static void main(String[] args) {
        try {
            operar();
        } catch (IllegalStateException e) {
            System.out.println("\nExcepcion recibida en main:");
            System.out.println("  " + e.getMessage());
            System.out.println("  Causa: " + e.getCause());
            System.out.println("  Suprimidas: " + e.getSuppressed().length);
        }
    }

    static void operar() {
        RecursoFragil recurso = new RecursoFragil("catalogo.txt");
        try {
            recurso.leer();                 // lanza el FALLO REAL
        } finally {
            recurso.cerrar();               // lanza OTRA, y la primera desaparece
        }
    }
}

Salida:

  [abierto catalogo.txt]

Excepcion recibida en main:
  FALLO AL CERRAR: no se pudo liberar catalogo.txt
  Causa: null
  Suprimidas: 0

El fallo real ha desaparecido. El fichero estaba corrupto —eso es lo que hay que arreglar— pero lo único que llega arriba es un mensaje sobre el cierre, que es un síntoma secundario. Y no queda ni rastro: getCause() es null y getSuppressed() está vacío.

Es un problema grave y sorprendentemente frecuente, porque el close() de casi cualquier recurso real puede lanzar: un BufferedWriter que vacía su búfer al cerrar, una conexión de red que ya se había caído, un bloqueo que otro hilo liberó.

El apaño manual existe, y es horrible:

static void operarConApano() {
    RecursoFragil recurso = new RecursoFragil("catalogo.txt");
    IllegalStateException fallidoPrincipal = null;

    try {
        recurso.leer();
    } catch (IllegalStateException e) {
        fallidoPrincipal = e;               // guardamos la original
        throw e;
    } finally {
        try {
            recurso.cerrar();
        } catch (IllegalStateException eCierre) {
            if (fallidoPrincipal != null) {
                fallidoPrincipal.addSuppressed(eCierre);   // la anadimos como suprimida
            } else {
                throw eCierre;              // no habia original: esta es la unica
            }
        }
    }
}

Trece líneas de contabilidad manual para una operación de dos. Y hay que repetirlas en cada recurso, en cada método. Nadie lo hace bien de forma consistente.

Esta es exactamente la razón por la que existe try-with-resources, la construcción de la lección siguiente. Hace automáticamente todo lo anterior: conserva la excepción original, registra la del cierre como suprimida —recuperable con getSuppressed()— y no te obliga a escribir ni una línea de esa contabilidad.

  1. Los dos casos en que finally NO se ejecuta

La garantía de finally es fuerte, pero no absoluta. Hay exactamente dos situaciones en que no se ejecuta, y ambas tienen algo en común: la JVM deja de existir o el hilo deja de ejecutar.

Caso 1: System.exit().

package com.nexussoftware.bibliotech.presentacion;

public class FinallyYSystemExit {

    public static void main(String[] args) {
        // Un hook de apagado SI se ejecuta con System.exit
        Runtime.getRuntime().addShutdownHook(new Thread(() ->
                System.out.println("HOOK DE APAGADO: esto si se ejecuta")));

        try {
            System.out.println("1. en el try");
            System.exit(0);                     // la JVM termina AQUI MISMO
            System.out.println("2. inalcanzable");
        } finally {
            System.out.println("3. ESTE FINALLY NO SE EJECUTA");
        }
    }
}

Salida:

1. en el try
HOOK DE APAGADO: esto si se ejecuta

System.exit() no lanza ninguna excepción ni retorna: le pide a la JVM que termine inmediatamente. No hay desenrollado de pila, así que no hay finally que ejecutar.

Lo que se ejecuta son los hooks de apagado, hilos registrados con Runtime.getRuntime().addShutdownHook(...) que la JVM arranca antes de morir. Son el mecanismo correcto para la limpieza global de una aplicación: cerrar el pool de conexiones, vaciar los búferes de log, guardar el estado. En BiblioTech, será el sitio natural para persistir el catálogo cuando llegue el módulo 7. Dos advertencias: los hooks tampoco se ejecutan ante un Runtime.halt() o una señal SIGKILL, y deben ser rápidos, porque muchos entornos matan el proceso si tardan.

Caso 2: la JVM o el hilo terminan de forma anómala.

  • La JVM cae por un fallo grave (kill -9, corte de corriente, error del propio proceso nativo).
  • Un Error de los que dejan la JVM inutilizable. Un StackOverflowError en un finally que necesita pila para ejecutarse, o un OutOfMemoryError cuando el propio finally necesita reservar memoria.
  • El hilo se detiene con el obsoleto Thread.stop() (eliminado en las versiones modernas de Java).

De aquí se deriva una conclusión práctica importante:

finally garantiza la coherencia dentro del proceso, no la durabilidad fuera de él. Si necesitas que algo sobreviva a una caída de la JVM, finally no basta: necesitas persistencia (módulo 7) o transacciones (módulo 11).

  1. El patrón manual de cierre antes de Java 7

Recopilemos todo lo anterior en el patrón completo y correcto de gestión de recursos tal y como había que escribirlo antes de 2011. Prepárate.

package com.nexussoftware.bibliotech.servicio;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

/**
 * Patron manual de cierre de recursos, anterior a Java 7.
 *
 * Se incluye para que veas exactamente que trabajo hace por ti el
 * try-with-resources de la leccion 06-06. En codigo nuevo, NO SE ESCRIBE ASI.
 *
 * (La API de E/S es materia del modulo 7: aqui solo interesa el cierre.)
 */
public class LectorManualPreJava7 {

    /** UN solo recurso. Ya es incomodo. */
    public List<String> leerReferencias(String ruta) throws IOException {
        List<String> referencias = new ArrayList<>();

        BufferedReader lector = null;                    // 1. declarar FUERA
        try {
            lector = new BufferedReader(new FileReader(ruta));
            String linea;
            while ((linea = lector.readLine()) != null) {
                if (!linea.isBlank()) {
                    referencias.add(linea.split(";")[0].trim());
                }
            }
            return referencias;

        } finally {
            if (lector != null) {                        // 2. comprobar null
                try {
                    lector.close();                      // 3. close() lanza IOException
                } catch (IOException eCierre) {
                    // 4. ¿que hacemos con esto? Si lo relanzamos, PERDEMOS
                    //    la excepcion original del try (apartado 8).
                    //    Lo unico razonable sin ayuda del lenguaje es registrarlo.
                    System.err.println("Aviso: no se pudo cerrar " + ruta
                            + ": " + eCierre.getMessage());
                }
            }
        }
    }

    /** DOS recursos. Aqui empieza el desastre. */
    public void copiarCatalogo(String origen, String destino) throws IOException {
        BufferedReader lector = null;
        java.io.BufferedWriter escritor = null;

        try {
            lector = new BufferedReader(new FileReader(origen));
            escritor = new java.io.BufferedWriter(new java.io.FileWriter(destino));

            String linea;
            while ((linea = lector.readLine()) != null) {
                escritor.write(linea);
                escritor.newLine();
            }

        } finally {
            // Orden INVERSO al de apertura: primero el escritor, luego el lector.
            // Y cada cierre necesita su propio try, porque si el primero lanza,
            // el segundo NO se ejecutaria.
            try {
                if (escritor != null) { escritor.close(); }
            } catch (IOException e) {
                System.err.println("Aviso: fallo al cerrar el destino: " + e.getMessage());
            } finally {
                try {
                    if (lector != null) { lector.close(); }
                } catch (IOException e) {
                    System.err.println("Aviso: fallo al cerrar el origen: " + e.getMessage());
                }
            }
        }
    }
}

Cuenta lo que hay que recordar para escribir esto bien:

# Requisito Qué pasa si se olvida
1 Declarar la variable fuera del try No compila: el finally no la ve
2 Inicializarla a null No compila: variable posiblemente no inicializada
3 Comprobar != null en el finally NullPointerException si falló la apertura
4 Envolver el close() en su propio try No compila si close() declara comprobada
5 No relanzar desde el catch del cierre Se pierde la excepción original (apartado 8)
6 Cerrar en orden inverso al de apertura El recurso dependiente se cierra sobre uno ya cerrado
7 Un try/finally anidado por cada recurso adicional Si el primer cierre falla, los demás no se cierran

Con dos recursos son veinte líneas de contabilidad para cuatro líneas de trabajo real. Con tres, es inmantenible. El resultado predecible: durante años, una fracción enorme del código Java en producción tenía fugas de descriptores, porque casi nadie escribía los siete puntos correctamente.

Java 7 resolvió esto de un plumazo. Este es el mismo copiarCatalogo con try-with-resources, para que veas adónde vas:

public void copiarCatalogo(String origen, String destino) throws IOException {
    try (BufferedReader lector = new BufferedReader(new FileReader(origen));
         BufferedWriter escritor = new BufferedWriter(new FileWriter(destino))) {

        String linea;
        while ((linea = lector.readLine()) != null) {
            escritor.write(linea);
            escritor.newLine();
        }
    }
    // Cierre automatico, en orden inverso, con las excepciones de cierre
    // registradas como SUPRIMIDAS sin perder la original.
}

Veinte líneas convertidas en cero. Eso es la lección siguiente.

  1. BiblioTech: operaciones que no dejan el estado a medias

Ahora la aplicación que resuelve la cuarta fragilidad del módulo 5. El problema, en concreto:

// VERSION FRAGIL: si algo falla en medio, el estado queda incoherente
public Prestamo prestar(String referencia, String idEmpleado, int dia) {
    Material material = catalogo.obtenerPorReferencia(referencia);
    Empleado empleado = obtenerEmpleado(idEmpleado);

    material.prestar();                     // paso 1: el material queda BLOQUEADO
    empleado.registrarPrestamo();           // paso 2: el empleado gasta CUPO
    Prestamo p = new Prestamo(siguienteReferencia(), material, empleado, dia);
    registro.anotar(p);                     // paso 3: si ESTO falla...
    colaReservas.notificarPrestamo(referencia);

    return p;
}

Si el paso 3 lanza, el material queda marcado como prestado sin ningún préstamo que lo justifique y el empleado pierde un hueco de su cupo para siempre. El material es inaccesible: nadie puede prestarlo porque figura ocupado, y nadie puede devolverlo porque no existe el préstamo.

La solución con finally: un indicador de éxito y un bloque de compensación.

package com.nexussoftware.bibliotech.servicio;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

import com.nexussoftware.bibliotech.dominio.*;

/**
 * Orquesta prestamos garantizando que, si la operacion falla a mitad,
 * el estado del Catalogo y del RegistroPrestamos queda COHERENTE.
 *
 * Tecnica: bandera de exito + bloque de compensacion en finally.
 * Es la version manual del concepto de TRANSACCION; la de verdad,
 * con base de datos, llega en el modulo 11.
 */
public class GestorPrestamos {

    private final Catalogo catalogo;
    private final Map<String, Empleado> empleados = new HashMap<>();
    private final Map<String, Prestamo> prestamos = new HashMap<>();

    private int contador = 0;

    public GestorPrestamos(Catalogo catalogo) {
        this.catalogo = Objects.requireNonNull(catalogo, "El catalogo no puede ser nulo");
    }

    public void darDeAlta(Empleado empleado) {
        Objects.requireNonNull(empleado, "El empleado no puede ser nulo");
        empleados.put(empleado.getIdentificador(), empleado);
    }

    /**
     * Presta un material dejando el sistema coherente ocurra lo que ocurra.
     *
     * Los pasos que MODIFICAN estado se marcan con banderas. Si al llegar al
     * finally la operacion no se completo, se deshacen en orden inverso.
     */
    public Prestamo prestar(String referencia, String idEmpleado, int dia) {
        Objects.requireNonNull(referencia, "La referencia no puede ser nula");
        Objects.requireNonNull(idEmpleado, "El identificador no puede ser nulo");
        if (dia < 1) {
            throw new IllegalArgumentException("El dia debe ser 1 o posterior, y era: " + dia);
        }

        // --- Fase 1: LECTURA. No modifica nada, asi que un fallo aqui es inocuo ---
        Material material = catalogo.obtenerPorReferencia(referencia);   // puede lanzar
        Empleado empleado = obtenerEmpleado(idEmpleado);                 // puede lanzar

        if (!material.estaDisponible()) {
            throw new MaterialNoDisponibleException(referencia, material.getTitulo(),
                    "desconocido", dia + Prestamo.DIAS_PRESTAMO);
        }
        if (!empleado.puedeTomarPrestado()) {
            throw new LimitePrestamosExcedidoException(empleado.getIdentificador(),
                    empleado.getNombre(), empleado.getPrestamosAcumulados(),
                    Empleado.MAX_PRESTAMOS_SIMULTANEOS);
        }

        // --- Fase 2: ESCRITURA. Cada paso modifica estado y se marca ---
        boolean materialMarcado = false;
        boolean cupoConsumido = false;
        boolean prestamoAnotado = false;
        String referenciaPrestamo = null;

        try {
            material.prestar();
            materialMarcado = true;

            empleado.registrarPrestamo();
            cupoConsumido = true;

            referenciaPrestamo = siguienteReferencia();
            Prestamo prestamo = new Prestamo(referenciaPrestamo, material, empleado, dia);

            prestamos.put(referenciaPrestamo, prestamo);
            prestamoAnotado = true;

            // Ultimo paso, tambien dentro del try protegido
            notificarColaReservas(referencia);

            return prestamo;

        } finally {
            // Si NO se completo el ultimo paso, deshacemos lo hecho, en orden inverso.
            // OJO: aqui NO hay return, ni throw, ni break. Solo compensacion.
            if (!prestamoAnotado) {
                System.out.println("  [compensacion] la operacion fallo a mitad, deshaciendo...");

                if (referenciaPrestamo != null) {
                    prestamos.remove(referenciaPrestamo);
                    System.out.println("    - prestamo " + referenciaPrestamo + " retirado");
                }
                if (cupoConsumido) {
                    empleado.registrarDevolucion();
                    System.out.println("    - cupo de " + idEmpleado + " restituido");
                }
                if (materialMarcado) {
                    material.devolver();
                    System.out.println("    - material " + referencia + " liberado");
                }
                System.out.println("  [compensacion] estado restaurado");
            }
        }
    }

    /**
     * Devuelve un material dejando el sistema coherente.
     * Mismo patron, en sentido inverso.
     */
    public void devolver(String referenciaPrestamo, int dia) {
        Objects.requireNonNull(referenciaPrestamo, "La referencia no puede ser nula");

        Prestamo prestamo = prestamos.get(referenciaPrestamo);
        if (prestamo == null) {
            throw new MaterialNoEncontradoException(referenciaPrestamo, prestamos.size());
        }
        if (prestamo.estaDevuelto()) {
            throw new PrestamoYaDevueltoException(referenciaPrestamo, prestamo.getDiaInicio());
        }

        boolean devolucionRegistrada = false;
        boolean cupoLiberado = false;
        boolean completado = false;

        try {
            prestamo.registrarDevolucion(dia);       // marca el prestamo y libera el material
            devolucionRegistrada = true;

            prestamo.getTitular().registrarDevolucion();
            cupoLiberado = true;

            notificarColaReservas(prestamo.getMaterial().getReferencia());
            completado = true;

        } finally {
            if (!completado) {
                System.out.println("  [compensacion] devolucion incompleta, deshaciendo...");
                if (cupoLiberado) {
                    prestamo.getTitular().registrarPrestamo();
                }
                if (devolucionRegistrada) {
                    prestamo.anularDevolucion();     // metodo de compensacion en Prestamo
                }
                System.out.println("  [compensacion] estado restaurado");
            }
        }
    }

    private Empleado obtenerEmpleado(String idEmpleado) {
        Empleado empleado = empleados.get(idEmpleado);
        if (empleado == null) {
            throw new MaterialNoEncontradoException(idEmpleado, empleados.size());
        }
        return empleado;
    }

    /** Simula el paso que puede fallar (avisar al siguiente de la cola de reservas). */
    private void notificarColaReservas(String referencia) {
        if ("LIB-0003".equals(referencia)) {
            throw new IllegalStateException(
                    "El servicio de avisos no responde para " + referencia);
        }
    }

    private String siguienteReferencia() {
        contador++;
        return String.format("PR-%04d", contador);
    }

    public int prestamosRegistrados() { return prestamos.size(); }
}

Y la demostración de que el estado queda coherente:

package com.nexussoftware.bibliotech.presentacion;

import com.nexussoftware.bibliotech.dominio.*;
import com.nexussoftware.bibliotech.servicio.Catalogo;
import com.nexussoftware.bibliotech.servicio.GestorPrestamos;

public class DemoCoherencia {

    public static void main(String[] args) {
        Catalogo catalogo = new Catalogo();
        catalogo.registrar(new Libro("LIB-0001", "Java Efectivo", "Bloch", 2018, "978-0000000001"));
        catalogo.registrar(new Libro("LIB-0003", "Refactorizacion", "Fowler", 1999, "978-0000000003"));

        GestorPrestamos gestor = new GestorPrestamos(catalogo);
        Empleado marta = new Empleado("Marta Ruiz", "EMP-001");
        gestor.darDeAlta(marta);

        System.out.println("=== ESTADO INICIAL ===");
        estado(catalogo, marta, gestor);

        System.out.println("\n=== Prestamo que FUNCIONA ===");
        gestor.prestar("LIB-0001", "EMP-001", 10);
        estado(catalogo, marta, gestor);

        System.out.println("\n=== Prestamo que FALLA en el ultimo paso ===");
        try {
            gestor.prestar("LIB-0003", "EMP-001", 10);
        } catch (IllegalStateException e) {
            System.out.println("  Excepcion recibida: " + e.getMessage());
        }

        System.out.println("\n=== ESTADO TRAS EL FALLO ===");
        estado(catalogo, marta, gestor);
        System.out.println("""

                COMPROBACION:
                - LIB-0003 vuelve a estar disponible (no quedo bloqueado).
                - Marta conserva 1 prestamo, no 2 (no perdio cupo).
                - No hay ningun prestamo fantasma en el registro.
                - Y la excepcion llego a main INTACTA: el finally no la oculto.""");
    }

    private static void estado(Catalogo catalogo, Empleado empleado, GestorPrestamos gestor) {
        System.out.println("  LIB-0001 disponible: "
                + catalogo.obtenerPorReferencia("LIB-0001").estaDisponible());
        System.out.println("  LIB-0003 disponible: "
                + catalogo.obtenerPorReferencia("LIB-0003").estaDisponible());
        System.out.println("  " + empleado);
        System.out.println("  Prestamos registrados: " + gestor.prestamosRegistrados());
    }
}

Salida:

=== ESTADO INICIAL ===
  LIB-0001 disponible: true
  LIB-0003 disponible: true
  EMP-001 (Marta Ruiz): 0/3 prestamos
  Prestamos registrados: 0

=== Prestamo que FUNCIONA ===
  LIB-0001 disponible: false
  LIB-0003 disponible: true
  EMP-001 (Marta Ruiz): 1/3 prestamos
  Prestamos registrados: 1

=== Prestamo que FALLA en el ultimo paso ===
  [compensacion] la operacion fallo a mitad, deshaciendo...
    - prestamo PR-0002 retirado
    - cupo de EMP-001 restituido
    - material LIB-0003 liberado
  [compensacion] estado restaurado
  Excepcion recibida: El servicio de avisos no responde para LIB-0003

=== ESTADO TRAS EL FALLO ===
  LIB-0001 disponible: false
  LIB-0003 disponible: true
  EMP-001 (Marta Ruiz): 1/3 prestamos
  Prestamos registrados: 1

COMPROBACION:
- LIB-0003 vuelve a estar disponible (no quedo bloqueado).
- Marta conserva 1 prestamo, no 2 (no perdio cupo).
- No hay ningun prestamo fantasma en el registro.
- Y la excepcion llego a main INTACTA: el finally no la oculto.

Las cuatro decisiones de diseño que hacen que este patrón funcione:

  1. Separar lectura de escritura. Toda la validación y la búsqueda ocurren antes de tocar nada. Si algo falla en la fase 1, no hay nada que deshacer. Es la aplicación directa del fail-fast de 06-03: cuanto más tarde empieces a modificar, menos tendrás que compensar.
  2. Una bandera por cada paso que modifica estado. Sin ellas, el finally no sabría cuánto avanzó la operación y deshacer de más sería tan grave como no deshacer.
  3. Deshacer en orden inverso. Igual que se cierran los recursos: el último paso completado es el primero que se revierte.
  4. El finally no lanza, no retorna y no captura. Solo compensa. Por eso la excepción original llega a main intacta —y por eso, si la compensación misma pudiera fallar, habría que envolverla en su propio try interno para no perder la excepción principal, exactamente el problema del apartado 8.

Y una honestidad necesaria sobre los límites de esta técnica: esto es una compensación manual, no una transacción real. No es atómica frente a otro hilo que mire el estado en mitad de la operación (módulo 8), y no sobrevive a una caída de la JVM (apartado 9). Las transacciones de verdad llegan en el módulo 11 con Spring y Hibernate. Pero para una aplicación de un solo hilo como BiblioTech, resuelve exactamente la fragilidad que teníamos.

Errores Comunes y Consejos

Poner un return en el finally. Descarta el valor del try y —lo grave— descarta cualquier excepción pendiente, convirtiendo un fallo en un resultado normal. Lo mismo con break, continue y throw. El finally limpia; no decide.

Dejar que el finally lance sin protegerlo. Si el try ya había lanzado, la excepción del finally la sustituye y la original desaparece sin dejar rastro. Si el código del finally puede fallar, envuélvelo en su propio try interno... o usa try-with-resources (06-06), que es para lo que existe.

Declarar el recurso dentro del try. El finally no lo ve: no compila. Va fuera, inicializado a null.

Olvidar la comprobación de null en el finally. Si la apertura del recurso falló, la variable sigue a null y el close() lanza un NullPointerException que además tapa el fallo original.

Cerrar los recursos en el orden de apertura. Hay que hacerlo al revés. Cerrar primero el FileReader y luego el BufferedReader que lo envuelve deja al segundo operando sobre un flujo ya cerrado.

Creer que finally se ejecuta siempre, sin excepciones. No lo hace con System.exit() ni cuando la JVM o el hilo mueren de forma anómala. Para la limpieza global de la aplicación están los hooks de apagado; para la durabilidad, la persistencia.

Usar finally para lógica de negocio. Si el bloque hace algo más que limpiar o compensar, es que está en el sitio equivocado. finally es para deshacer y liberar, no para "el paso final del proceso".

Compensar sin banderas. Deshacer un paso que nunca llegó a ejecutarse es tan destructivo como no deshacer el que sí. Una bandera por paso modificador.

Consejo: try-finally sin catch es una de las formas más elegantes de Java. Significa exactamente "no sé manejar esto, pero limpio antes de que se vaya", y deja la excepción intacta para quien sí sepa.

Consejo: valida todo antes de modificar nada. Cuanto más código haya entre la primera modificación y el final de la operación, más superficie de compensación tienes. El fail-fast de 06-03 reduce el problema en origen.

Consejo: activa -Xlint:finally al compilar. Avisa de los finally que no pueden terminar normalmente, que es justo el error del apartado 7.

Consejo: en código nuevo, no escribas el patrón manual del apartado 10. Está aquí para que entiendas qué automatiza la lección siguiente, no para que lo uses.

Ejercicios

Ejercicio 1: traza del orden de ejecución

Sin ejecutar el código, predice la salida exacta de cada método y el valor que devuelve. Después ejecútalo y compara. Para cada discrepancia, explica la regla que te faltaba.

public class Adivina {

    static int metodoA() {
        int x = 1;
        try {
            x = 2;
            return x;
        } finally {
            x = 3;
            System.out.println("A-finally, x=" + x);
        }
    }

    static int metodoB() {
        try {
            return 1;
        } finally {
            return 2;
        }
    }

    static String metodoC() {
        StringBuilder sb = new StringBuilder("inicio");
        try {
            return sb.toString();
        } finally {
            sb.append("-modificado");
        }
    }

    static StringBuilder metodoD() {
        StringBuilder sb = new StringBuilder("inicio");
        try {
            return sb;
        } finally {
            sb.append("-modificado");
        }
    }

    static int metodoE() {
        try {
            throw new IllegalStateException("fallo");
        } finally {
            System.out.println("E-finally");
        }
    }

    static int metodoF() {
        try {
            throw new IllegalStateException("fallo");
        } finally {
            return -1;
        }
    }

    static int metodoG() {
        int total = 0;
        for (int i = 1; i <= 3; i++) {
            try {
                if (i == 2) { continue; }
                total += i;
            } finally {
                total += 10;
                System.out.println("G-finally i=" + i + " total=" + total);
            }
        }
        return total;
    }
}

Responde además: ¿por qué metodoC y metodoD se comportan de forma distinta si el finally es idéntico?

Ejercicio 2: SesionCatalogo con bloqueo garantizado

Escribe SesionCatalogo en com.nexussoftware.bibliotech.servicio, que simule una sesión de trabajo sobre el catálogo con bloqueo exclusivo.

Requisitos:

  • Campos: bloqueado (boolean), operacionesRealizadas (int), sesionesAbiertas (contador estático).
  • void abrir(): si ya está bloqueado, lanza IllegalStateException; si no, bloquea e incrementa el contador de sesiones.
  • void cerrar(): libera el bloqueo. Debe ser idempotente: llamarlo dos veces no debe fallar.
  • T ejecutar(String nombreOperacion, Supplier<T> operacion): método genérico usando java.util.function.Supplier (04-06) que abre la sesión, ejecuta la operación y garantiza el cierre con try-finally, incluso si la operación lanza. Registra la operación en operacionesRealizadas solo si tuvo éxito.
  • void ejecutarSinResultado(String nombre, Runnable operacion): variante para operaciones sin retorno.

En el main, demuestra:

  1. Una operación correcta: la sesión se cierra y el contador sube.
  2. Una operación que lanza: la excepción llega al llamador intacta y la sesión se cierra igual.
  3. Que tras el fallo se puede abrir una sesión nueva (el bloqueo no quedó atascado).
  4. Que cerrar() dos veces seguidas no falla.
  5. Un intento de abrir dos sesiones a la vez, que debe fallar con un mensaje claro.

Ejercicio 3: transferencia de préstamo con compensación

En Nexus Software es común que un empleado ceda un material a otro sin devolverlo a la biblioteca. Implementa TransferenciaPrestamos.transferir(String referenciaPrestamo, String idDestino, int dia), que traspase un préstamo activo de un empleado a otro.

La operación tiene cinco pasos que modifican estado:

  1. Liberar el cupo del titular actual.
  2. Consumir el cupo del empleado destino.
  3. Cambiar el titular del préstamo.
  4. Anotar una Incidencia de tipo transferencia en el préstamo.
  5. Notificar a la cola de avisos (este paso puede fallar).

Requisitos:

  • Valida todo antes de modificar nada: el préstamo existe, no está devuelto, el destino existe, el destino no es el mismo titular actual y el destino tiene cupo.
  • Usa banderas y un finally de compensación que deshaga en orden inverso los pasos completados.
  • La excepción original debe llegar al llamador intacta.
  • La compensación puede fallar a su vez: protégela para no perder la excepción principal, y si la compensación falla, añádela como suprimida a la original con addSuppressed() (anticipa 06-06).
  • Escribe un main que demuestre: una transferencia correcta, una que falla en el paso 5 con el estado restaurado, y una que falla en la validación (sin compensación, porque no se llegó a modificar nada).

Verifica al final que la suma de préstamos acumulados de todos los empleados coincide con el número de préstamos activos, en todos los escenarios.

Soluciones

Solución 1

Salidas y valores:

Método Salida por consola Devuelve Regla aplicada
metodoA A-finally, x=3 2 El valor del return se copia antes del finally. Modificar la variable después no cambia lo devuelto
metodoB (nada) 2 El return del finally gana y descarta el del try
metodoC (nada) "inicio" toString() crea un String nuevo e inmutable; modificar el StringBuilder después no lo afecta
metodoD (nada) "inicio-modificado" Se devuelve la referencia; el finally modifica el objeto apuntado, y quien recibe ve el cambio
metodoE E-finally (lanza IllegalStateException) El finally se ejecuta y la excepción sigue subiendo
metodoF (nada) -1 El return del finally se traga la excepción. El fallo desaparece
metodoG tres líneas (abajo) 34 El finally se ejecuta también con continue

Traza detallada de metodoG:

i=1: no hay continue, total += 1 -> 1;  finally: total += 10 -> 11
G-finally i=1 total=11
i=2: continue, se salta total += 2;     finally: total += 10 -> 21
G-finally i=2 total=21
i=3: total += 3 -> 24;                  finally: total += 10 -> 34
G-finally i=3 total=34
Devuelve 34

Por qué metodoC y metodoD difieren con un finally idéntico:

Es la distinción entre valor y referencia, y la clave está en qué se copia al evaluar el return:

  • En metodoC, sb.toString() construye un objeto String nuevo con el contenido actual ("inicio"). Ese String es inmutable y no tiene ninguna conexión con el StringBuilder. El finally modifica el StringBuilder, que ya no le importa a nadie.
  • En metodoD se devuelve la referencia al StringBuilder. Lo que se copia es la dirección, no el contenido. El finally modifica el objeto al que apunta esa dirección, y quien recibe la referencia ve el cambio.

Es exactamente el paso por valor de referencias de 03-03: el valor de retorno se congela, pero si ese valor es una referencia, el objeto apuntado sigue siendo mutable.

Y el aviso que dan metodoB y metodoF al compilar con -Xlint:finally:

warning: [finally] finally clause cannot complete normally

Es el compilador diciéndote literalmente que ese finally no va a dejar salir lo que hubiera en curso.

Solución 2

package com.nexussoftware.bibliotech.servicio;

import java.util.function.Supplier;

/**
 * Sesion de trabajo con bloqueo exclusivo sobre el catalogo.
 *
 * El bloqueo se libera SIEMPRE gracias a try-finally, incluso cuando la
 * operacion lanza. La excepcion, en cambio, llega intacta al llamador:
 * esta clase limpia, no maneja.
 */
public class SesionCatalogo {

    private static int sesionesAbiertas = 0;

    private boolean bloqueado = false;
    private int operacionesRealizadas = 0;

    /**
     * Abre la sesion adquiriendo el bloqueo.
     *
     * @throws IllegalStateException si ya hay una sesion abierta
     */
    public void abrir() {
        if (bloqueado) {
            throw new IllegalStateException(
                    "Ya hay una sesion abierta sobre el catalogo. Cierrela antes de abrir otra.");
        }
        bloqueado = true;
        sesionesAbiertas++;
        System.out.println("    [sesion #" + sesionesAbiertas + " abierta]");
    }

    /**
     * Cierra la sesion. Es IDEMPOTENTE: llamarlo sobre una sesion ya cerrada
     * no hace nada y no falla. Es una buena practica que se retoma en 06-06
     * al hablar del contrato de close().
     */
    public void cerrar() {
        if (!bloqueado) {
            return;                      // ya estaba cerrada: no es un error
        }
        bloqueado = false;
        System.out.println("    [sesion cerrada]");
    }

    /**
     * Ejecuta una operacion con resultado dentro de una sesion.
     *
     * Se usa Supplier<T> de java.util.function (04-06). El <T> es un tipo
     * generico ya existente; los genericos propios son materia de 10-01.
     */
    public <T> T ejecutar(String nombreOperacion, Supplier<T> operacion) {
        System.out.println("  Ejecutando '" + nombreOperacion + "'");
        abrir();

        boolean completada = false;
        try {
            T resultado = operacion.get();
            completada = true;           // solo llega aqui si NO lanzo
            return resultado;

        } finally {
            // Limpieza garantizada. Sin return, sin throw: la excepcion pasa intacta.
            if (completada) {
                operacionesRealizadas++;
                System.out.println("    [operacion '" + nombreOperacion + "' contabilizada]");
            } else {
                System.out.println("    [operacion '" + nombreOperacion
                        + "' fallida: NO se contabiliza]");
            }
            cerrar();
        }
    }

    /** Variante para operaciones sin resultado. */
    public void ejecutarSinResultado(String nombreOperacion, Runnable operacion) {
        ejecutar(nombreOperacion, () -> {
            operacion.run();
            return null;                 // Supplier necesita devolver algo
        });
    }

    public boolean estaBloqueado()      { return bloqueado; }
    public int getOperacionesRealizadas(){ return operacionesRealizadas; }
    public static int getSesionesAbiertas() { return sesionesAbiertas; }

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

    public static void main(String[] args) {
        SesionCatalogo sesion = new SesionCatalogo();

        System.out.println("=== 1. Operacion correcta ===");
        int total = sesion.ejecutar("contar materiales", () -> {
            System.out.println("    contando...");
            return 3;
        });
        System.out.println("  Resultado: " + total);
        System.out.println("  Bloqueado: " + sesion.estaBloqueado()
                + " | Operaciones: " + sesion.getOperacionesRealizadas());

        System.out.println("\n=== 2. Operacion que lanza ===");
        try {
            sesion.ejecutar("reindexar", () -> {
                System.out.println("    reindexando...");
                throw new IllegalStateException("Indice corrupto en la posicion 7");
            });
        } catch (IllegalStateException e) {
            System.out.println("  Excepcion recibida INTACTA: " + e.getMessage());
        }
        System.out.println("  Bloqueado: " + sesion.estaBloqueado()
                + " | Operaciones: " + sesion.getOperacionesRealizadas());

        System.out.println("\n=== 3. Sesion nueva tras el fallo ===");
        String titulo = sesion.ejecutar("leer titulo", () -> "Java Efectivo");
        System.out.println("  Resultado: " + titulo);

        System.out.println("\n=== 4. cerrar() dos veces ===");
        sesion.cerrar();
        sesion.cerrar();
        System.out.println("  No ha fallado: cerrar() es idempotente");

        System.out.println("\n=== 5. Dos sesiones a la vez ===");
        try {
            sesion.ejecutar("externa", () -> {
                // Dentro de la operacion se intenta abrir OTRA sesion
                sesion.abrir();
                return "imposible";
            });
        } catch (IllegalStateException e) {
            System.out.println("  " + e.getMessage());
        }
        System.out.println("  Bloqueado tras el intento: " + sesion.estaBloqueado());

        System.out.println("\n=== Resumen ===");
        System.out.println("  Sesiones abiertas en total: " + getSesionesAbiertas());
        System.out.println("  Operaciones completadas   : " + sesion.getOperacionesRealizadas());
    }
}

Salida:

=== 1. Operacion correcta ===
  Ejecutando 'contar materiales'
    [sesion #1 abierta]
    contando...
    [operacion 'contar materiales' contabilizada]
    [sesion cerrada]
  Resultado: 3
  Bloqueado: false | Operaciones: 1

=== 2. Operacion que lanza ===
  Ejecutando 'reindexar'
    [sesion #2 abierta]
    reindexando...
    [operacion 'reindexar' fallida: NO se contabiliza]
    [sesion cerrada]
  Excepcion recibida INTACTA: Indice corrupto en la posicion 7
  Bloqueado: false | Operaciones: 1

=== 3. Sesion nueva tras el fallo ===
  Ejecutando 'leer titulo'
    [sesion #3 abierta]
    [operacion 'leer titulo' contabilizada]
    [sesion cerrada]
  Resultado: Java Efectivo

=== 4. cerrar() dos veces ===
  No ha fallado: cerrar() es idempotente

=== 5. Dos sesiones a la vez ===
  Ejecutando 'externa'
    [sesion #4 abierta]
    [operacion 'externa' fallida: NO se contabiliza]
    [sesion cerrada]
  Ya hay una sesion abierta sobre el catalogo. Cierrela antes de abrir otra.
  Bloqueado tras el intento: false

=== Resumen ===
  Sesiones abiertas en total: 4
  Operaciones completadas   : 2

El punto 2 es el que demuestra el valor de try-finally sin catch: la sesión se cerró, la operación no se contabilizó, y la excepción llegó a main con su mensaje original y su pila. La clase limpió sin decidir.

Y fíjate en el punto 5: aunque la operación intentó algo ilegal, el bloqueo quedó liberado. Sin el finally, el catálogo habría quedado bloqueado para siempre y la aplicación inservible.

Solución 3

package com.nexussoftware.bibliotech.servicio;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

import com.nexussoftware.bibliotech.dominio.*;

/**
 * Transferencia de un prestamo activo entre empleados, con compensacion
 * garantizada si la operacion falla a mitad.
 *
 * Demuestra:
 *  - Validar todo antes de modificar nada.
 *  - Una bandera por paso modificador.
 *  - Compensacion en orden inverso dentro del finally.
 *  - Proteccion de la propia compensacion con addSuppressed (anticipa 06-06).
 */
public class TransferenciaPrestamos {

    private final Map<String, Prestamo> prestamos;
    private final Map<String, Empleado> empleados;

    /** Referencia que hara fallar el paso 5, para la demostracion. */
    private String referenciaQueFallaAlNotificar = null;

    public TransferenciaPrestamos(Map<String, Prestamo> prestamos,
                                  Map<String, Empleado> empleados) {
        this.prestamos = Objects.requireNonNull(prestamos);
        this.empleados = Objects.requireNonNull(empleados);
    }

    public void simularFalloDeNotificacion(String referenciaPrestamo) {
        this.referenciaQueFallaAlNotificar = referenciaPrestamo;
    }

    /**
     * Transfiere un prestamo activo a otro empleado.
     *
     * @throws MaterialNoEncontradoException si el prestamo o el destino no existen
     * @throws PrestamoYaDevueltoException   si el prestamo ya se devolvio
     * @throws IllegalArgumentException      si el destino es el titular actual
     * @throws LimitePrestamosExcedidoException si el destino no tiene cupo
     * @throws IllegalStateException         si falla la notificacion (paso 5)
     */
    public void transferir(String referenciaPrestamo, String idDestino, int dia) {

        // ================== FASE 1: VALIDACION (no modifica nada) ==================
        Objects.requireNonNull(referenciaPrestamo, "La referencia del prestamo no puede ser nula");
        Objects.requireNonNull(idDestino, "El identificador de destino no puede ser nulo");
        if (dia < 1) {
            throw new IllegalArgumentException("El dia debe ser 1 o posterior, y era: " + dia);
        }

        Prestamo prestamo = prestamos.get(referenciaPrestamo);
        if (prestamo == null) {
            throw new MaterialNoEncontradoException(referenciaPrestamo, prestamos.size());
        }
        if (prestamo.estaDevuelto()) {
            throw new PrestamoYaDevueltoException(referenciaPrestamo, prestamo.getDiaInicio());
        }

        Empleado destino = empleados.get(idDestino);
        if (destino == null) {
            throw new MaterialNoEncontradoException(idDestino, empleados.size());
        }

        Empleado origen = prestamo.getTitular();
        if (origen.getIdentificador().equals(idDestino)) {
            throw new IllegalArgumentException(
                    "El prestamo " + referenciaPrestamo + " ya pertenece a " + idDestino);
        }
        if (!destino.puedeTomarPrestado()) {
            throw new LimitePrestamosExcedidoException(destino.getIdentificador(),
                    destino.getNombre(), destino.getPrestamosAcumulados(),
                    Empleado.MAX_PRESTAMOS_SIMULTANEOS);
        }

        // ================== FASE 2: MODIFICACION (con banderas) ==================
        boolean cupoOrigenLiberado = false;
        boolean cupoDestinoConsumido = false;
        boolean titularCambiado = false;
        boolean incidenciaAnotada = false;
        boolean completada = false;

        try {
            // Paso 1
            origen.registrarDevolucion();
            cupoOrigenLiberado = true;

            // Paso 2
            destino.registrarPrestamo();
            cupoDestinoConsumido = true;

            // Paso 3
            prestamo.cambiarTitular(destino);
            titularCambiado = true;

            // Paso 4
            prestamo.anotarIncidencia(new Prestamo.Incidencia(dia,
                    "Transferido de " + origen.getIdentificador() + " a " + idDestino,
                    Gravedad.SIN_RETRASO));
            incidenciaAnotada = true;

            // Paso 5: el que puede fallar
            notificarColaAvisos(referenciaPrestamo, idDestino);

            completada = true;
            System.out.println("  Transferencia " + referenciaPrestamo + ": "
                    + origen.getIdentificador() + " -> " + idDestino + " OK");

        } finally {
            if (!completada) {
                compensar(prestamo, origen, destino,
                        cupoOrigenLiberado, cupoDestinoConsumido,
                        titularCambiado, incidenciaAnotada);
            }
        }
    }

    /**
     * Deshace los pasos completados, en ORDEN INVERSO.
     *
     * Si la compensacion falla, NO se relanza: eso sustituiria la excepcion
     * original (apartado 8). Se registra como SUPRIMIDA en la excepcion en curso.
     */
    private void compensar(Prestamo prestamo, Empleado origen, Empleado destino,
                           boolean cupoOrigenLiberado, boolean cupoDestinoConsumido,
                           boolean titularCambiado, boolean incidenciaAnotada) {

        System.out.println("  [compensacion] transferencia incompleta, deshaciendo...");
        RuntimeException falloDeCompensacion = null;

        // Orden inverso: 4 -> 3 -> 2 -> 1
        if (incidenciaAnotada) {
            try {
                prestamo.retirarUltimaIncidencia();
                System.out.println("    - incidencia retirada");
            } catch (RuntimeException e) {
                falloDeCompensacion = acumular(falloDeCompensacion, e);
            }
        }
        if (titularCambiado) {
            try {
                prestamo.cambiarTitular(origen);
                System.out.println("    - titular restituido a " + origen.getIdentificador());
            } catch (RuntimeException e) {
                falloDeCompensacion = acumular(falloDeCompensacion, e);
            }
        }
        if (cupoDestinoConsumido) {
            try {
                destino.registrarDevolucion();
                System.out.println("    - cupo de " + destino.getIdentificador() + " restituido");
            } catch (RuntimeException e) {
                falloDeCompensacion = acumular(falloDeCompensacion, e);
            }
        }
        if (cupoOrigenLiberado) {
            try {
                origen.registrarPrestamo();
                System.out.println("    - cupo de " + origen.getIdentificador() + " restituido");
            } catch (RuntimeException e) {
                falloDeCompensacion = acumular(falloDeCompensacion, e);
            }
        }

        if (falloDeCompensacion != null) {
            // No podemos lanzar: perderiamos la excepcion original.
            // Lo dejamos registrado. En 06-07 esto sera un logger.log(SEVERE, ...).
            System.err.println("  [compensacion] AVISO: la compensacion fallo parcialmente: "
                    + falloDeCompensacion.getMessage());
        } else {
            System.out.println("  [compensacion] estado restaurado por completo");
        }
    }

    private RuntimeException acumular(RuntimeException acumulada, RuntimeException nueva) {
        if (acumulada == null) {
            return nueva;
        }
        acumulada.addSuppressed(nueva);      // se veran con getSuppressed() (06-06)
        return acumulada;
    }

    private void notificarColaAvisos(String referenciaPrestamo, String idDestino) {
        if (referenciaPrestamo.equals(referenciaQueFallaAlNotificar)) {
            throw new IllegalStateException(
                    "El servicio de avisos no responde al notificar " + referenciaPrestamo
                            + " a " + idDestino);
        }
        System.out.println("    (aviso enviado a " + idDestino + ")");
    }

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

    public static void main(String[] args) {
        Catalogo catalogo = new Catalogo();
        Libro l1 = new Libro("LIB-0001", "Java Efectivo", "Bloch", 2018, "978-0000000001");
        Libro l2 = new Libro("LIB-0002", "Patrones de Diseno", "GoF", 1994, "978-0000000002");
        catalogo.registrar(l1);
        catalogo.registrar(l2);

        Empleado marta = new Empleado("Marta Ruiz", "EMP-001");
        Empleado diego = new Empleado("Diego Alonso", "EMP-002");
        Empleado nuria = new Empleado("Nuria Vidal", "EMP-003");

        Map<String, Empleado> empleados = new HashMap<>();
        empleados.put("EMP-001", marta);
        empleados.put("EMP-002", diego);
        empleados.put("EMP-003", nuria);

        // El constructor de Prestamo ya llama a material.prestar() y a
        // titular.registrarPrestamo(): no hay que hacerlo a mano (06-03).
        Map<String, Prestamo> prestamos = new HashMap<>();
        prestamos.put("PR-0001", new Prestamo("PR-0001", l1, marta, 10));
        prestamos.put("PR-0002", new Prestamo("PR-0002", l2, marta, 10));

        TransferenciaPrestamos servicio = new TransferenciaPrestamos(prestamos, empleados);

        System.out.println("=== ESTADO INICIAL ===");
        estado(empleados, prestamos);

        System.out.println("\n=== 1. Transferencia correcta ===");
        servicio.transferir("PR-0001", "EMP-002", 12);
        estado(empleados, prestamos);

        System.out.println("\n=== 2. Transferencia que falla en el paso 5 ===");
        servicio.simularFalloDeNotificacion("PR-0002");
        try {
            servicio.transferir("PR-0002", "EMP-003", 13);
        } catch (IllegalStateException e) {
            System.out.println("  Excepcion recibida INTACTA: " + e.getMessage());
        }
        estado(empleados, prestamos);

        System.out.println("\n=== 3. Fallo en validacion (sin compensacion) ===");
        try {
            servicio.transferir("PR-9999", "EMP-003", 14);
        } catch (MaterialNoEncontradoException e) {
            System.out.println("  " + e.getMessage());
            System.out.println("  (no hubo compensacion: no se modifico nada)");
        }
        estado(empleados, prestamos);
    }

    /** Comprueba la invariante global del sistema. */
    private static void estado(Map<String, Empleado> empleados, Map<String, Prestamo> prestamos) {
        int sumaCupos = 0;
        for (Empleado e : empleados.values()) {
            System.out.println("  " + e);
            sumaCupos += e.getPrestamosAcumulados();
        }
        int activos = 0;
        for (Prestamo p : prestamos.values()) {
            if (!p.estaDevuelto()) { activos++; }
            System.out.println("  " + p.getReferencia() + " -> "
                    + p.getTitular().getIdentificador());
        }
        System.out.println("  INVARIANTE: suma de cupos (" + sumaCupos
                + ") == prestamos activos (" + activos + ") -> "
                + (sumaCupos == activos ? "OK" : "ROTA"));
    }
}

Salida:

=== ESTADO INICIAL ===
  EMP-001 (Marta Ruiz): 2/3 prestamos
  EMP-002 (Diego Alonso): 0/3 prestamos
  EMP-003 (Nuria Vidal): 0/3 prestamos
  PR-0001 -> EMP-001
  PR-0002 -> EMP-001
  INVARIANTE: suma de cupos (2) == prestamos activos (2) -> OK

=== 1. Transferencia correcta ===
    (aviso enviado a EMP-002)
  Transferencia PR-0001: EMP-001 -> EMP-002 OK
  EMP-001 (Marta Ruiz): 1/3 prestamos
  EMP-002 (Diego Alonso): 1/3 prestamos
  EMP-003 (Nuria Vidal): 0/3 prestamos
  PR-0001 -> EMP-002
  PR-0002 -> EMP-001
  INVARIANTE: suma de cupos (2) == prestamos activos (2) -> OK

=== 2. Transferencia que falla en el paso 5 ===
  [compensacion] transferencia incompleta, deshaciendo...
    - incidencia retirada
    - titular restituido a EMP-001
    - cupo de EMP-003 restituido
    - cupo de EMP-001 restituido
  [compensacion] estado restaurado por completo
  Excepcion recibida INTACTA: El servicio de avisos no responde al notificar PR-0002 a EMP-003
  EMP-001 (Marta Ruiz): 1/3 prestamos
  EMP-002 (Diego Alonso): 1/3 prestamos
  EMP-003 (Nuria Vidal): 0/3 prestamos
  PR-0001 -> EMP-002
  PR-0002 -> EMP-001
  INVARIANTE: suma de cupos (2) == prestamos activos (2) -> OK

=== 3. Fallo en validacion (sin compensacion) ===
  No existe ningun material con la referencia 'PR-9999' (el catalogo tiene 2 materiales)
  (no hubo compensacion: no se modifico nada)
  INVARIANTE: suma de cupos (2) == prestamos activos (2) -> OK

Las tres cosas que demuestra el ejercicio:

  1. La invariante global se mantiene en los tres escenarios. La suma de cupos consumidos coincide siempre con el número de préstamos activos, incluso después de un fallo a mitad de una operación de cinco pasos.
  2. La excepción llega intacta. El finally compensó y no ocultó nada: main recibió el IllegalStateException con su mensaje original.
  3. La compensación está protegida. Cada paso de deshacer va en su propio try, y los fallos de compensación se acumulan con addSuppressed() en lugar de relanzarse. Si se relanzaran, sustituirían la excepción original —el error del apartado 8— y perderíamos el motivo real del fallo.

Ese uso de addSuppressed() no es casual: es exactamente el mecanismo que try-with-resources aplica automáticamente, y que verás en detalle en la lección siguiente.

Conclusión

Ya conoces la garantía de finally y sus límites exactos. Sabes que se ejecuta siempre que se haya entrado en el try: sin excepción, con excepción capturada, con excepción no capturada —justo antes de que siga subiendo— y también cuando el try o el catch hacen return, break o continue. Y sabes que con try anidados los finally se disparan de dentro hacia fuera, al mismo ritmo al que se desapilan los marcos, de modo que cada nivel limpia lo suyo sin saber nada de los demás.

Entiendes la mecánica precisa del return: el valor se calcula y se guarda antes de ejecutar el finally, así que modificar la variable después no cambia lo devuelto —salvo que lo devuelto sea una referencia a un objeto mutable, en cuyo caso el objeto sí puede cambiar, como demostraron metodoC y metodoD.

Conoces las combinaciones válidas —try-catch, try-catch-finally y try-finally, siempre con el finally el último— y el valor específico de try-finally sin catch: "no sé manejar esto, pero limpio antes de que se vaya", que separa limpiar de manejar y deja la excepción intacta para quien sí sepa decidir.

Tienes las dos trampas que hay que evitar sin excepciones. La primera: un return —o break, continue, throw— dentro del finally descarta el valor del try y, mucho peor, se traga cualquier excepción pendiente, convirtiendo un catálogo corrupto en un tranquilo -1. La segunda: un finally que lanza su propia excepción sustituye a la original, que desaparece sin causa ni suprimidas —el caso realísimo del close() que falla y borra el fallo verdadero. Has visto el apaño manual de trece líneas que lo evita, y sabes que existe una construcción que hace todo eso por ti.

Sabes que la garantía no es absoluta: finally no se ejecuta con System.exit() ni cuando la JVM o el hilo mueren de forma anómala, y que para la limpieza global de la aplicación están los hooks de apagado —que sí corren con System.exit, pero tampoco con un halt() o un SIGKILL. De ahí la conclusión que conviene retener: finally garantiza coherencia dentro del proceso, no durabilidad fuera de él.

Y has visto el patrón manual de cierre anterior a Java 7 en toda su extensión: declarar fuera, inicializar a null, comprobar != null, envolver el close() en su propio try, no relanzar desde ahí, cerrar en orden inverso y anidar un try/finally por cada recurso adicional. Siete requisitos que casi nadie cumplía, y veinte líneas de contabilidad para cuatro de trabajo real.

BiblioTech ha resuelto la cuarta fragilidad del módulo 5. GestorPrestamos.prestar y devolver separan la fase de lectura de la de escritura, marcan con una bandera cada paso que modifica estado y compensan en orden inverso dentro de un finally que no lanza, no retorna y no captura. El resultado, demostrado con la invariante "suma de cupos consumidos == préstamos activos": cuando la notificación a la cola de avisos falla en el último paso, el material vuelve a estar disponible, el empleado recupera su cupo, no queda ningún préstamo fantasma y la excepción llega a main intacta. Con la honestidad de reconocer que esto es compensación manual, no una transacción real: no es atómica frente a otro hilo (módulo 8) ni sobrevive a una caída de la JVM, y las transacciones de verdad llegan con Spring y Hibernate en el módulo 11.

De la lista de fragilidades del módulo 5 solo queda la última, la de la persistencia, que es el módulo 7. Pero antes hay que cerrar la deuda que esta lección ha dejado abierta dos veces: el finally que pierde la excepción original al cerrar, y las veinte líneas del patrón manual.

Eso es la lección siguiente, Try-with-resources y AutoCloseable: la sintaxis de gestión automática de recursos de Java 7 y su equivalencia exacta con el try-finally manual, mostradas en paralelo; las interfaces AutoCloseable y Closeable y su diferencia; varios recursos en la misma declaración con orden de cierre inverso al de apertura, demostrado con trazas; la variable implícitamente final y la forma de Java 9 que admite una variable ya existente; las excepciones suprimidas, que resuelven exactamente el problema del apartado 8 conservando la original y dejando la del cierre accesible con getSuppressed(); cómo combinar try-with-resources con catch y finally; qué recursos no deben cerrarse así —empezando por System.in y el Scanner del módulo 1—; y cómo hacer que tus propias clases sean cerrables, con una SesionBiblioteca que abra un turno de trabajo y al cerrarse consolide las estadísticas y libere los bloqueos.

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