Llevas cinco lecciones dejando comentarios del tipo "en 07-06 esto se hace bien". Los has puesto en la creación de directorios, en el renombrado atómico, en la rotación de copias de seguridad, en el listFiles() que puede devolver null y en cada boolean mudo que decía que algo había fallado sin decir por qué.
Esta es esa lección. NIO.2 —New I/O 2, introducida en Java 7 con la JSR 203— es la sustitución completa de java.io.File, y no es un retoque cosmético: es un rediseño que corrige errores de fondo de una API de 1996. Sus dos piezas centrales son Path, que representa una ruta con toda su aritmética, y Files, una clase de utilidad con más de cincuenta métodos estáticos que hacen todo lo que hasta ahora has escrito a mano.
Al terminar, BiblioTech tendrá su capa de persistencia migrada por completo: creará su directorio datos/ si no existe, escribirá de forma verdaderamente atómica en una línea, mantendrá copias de seguridad rotativas y localizará todos sus informes recorriendo un árbol de carpetas.
Con
try-with-resourcesen todo, como siempre. Y con una advertencia nueva y muy concreta:Files.lines(),Files.walk()yFiles.list()devuelven flujos que HAY que cerrar. Es el error más frecuente de esta API y se explica en el apartado 8.
Contenido
- Por qué existe NIO.2: los defectos de
File Path: construir y consultar rutas- Aritmética de rutas:
resolve,relativize,normalize Files: existencia, creación y borrado- Copiar y mover, con la escritura atómica de verdad
- Lectura y escritura de alto nivel
StandardOpenOption: decir lo que quieres decirFiles.lines,listywalk: flujos que se cierran- Recorrer árboles de directorios
- Atributos de fichero y permisos
- Ficheros temporales y
WatchService - Interoperabilidad con
File - BiblioTech migra su persistencia
- Errores Comunes y Consejos
- Ejercicios
- Por qué existe NIO.2: los defectos de
File
Filejava.io.File es de Java 1.0 y arrastra decisiones de diseño de una época en la que Java tenía que funcionar en sistemas muy distintos con un denominador común muy pequeño. Sus problemas no son de estilo: son funcionales.
Defecto 1: los boolean mudos.
File f = new File("/datos/catalogo.txt");
if (!f.delete()) {
// ¿Por que? ¿No existia? ¿No tengo permiso? ¿Estaba abierto?
// ¿Era un directorio no vacio? La API no lo dice.
}Es el antipatrón que el módulo 6 dedicó siete lecciones a erradicar: comunicar un fallo sin decir cuál. NIO.2 lanza excepciones específicas.
Defecto 2: null como valor de retorno.
File[] hijos = directorio.listFiles();
for (File h : hijos) { // NullPointerException si el directorio no existe
// ...
}listFiles() devuelve null si la ruta no es un directorio o si falla el acceso. Otro fallo silencioso convertido en excepción a destiempo.
Defecto 3: no conoce los enlaces simbólicos. File no distingue un fichero de un enlace que apunta a él. En sistemas Unix eso es una limitación seria, y una fuente de bucles infinitos al recorrer árboles.
Defecto 4: metadatos pobres. Solo lastModified(). No hay fecha de creación, ni de último acceso, ni propietario, ni permisos POSIX, ni atributos extendidos.
Defecto 5: renameTo no es fiable. Su comportamiento depende del sistema operativo: en Windows falla si el destino existe, en Unix no; puede fallar entre sistemas de ficheros distintos; y devuelve un boolean mudo.
Defecto 6: no es extensible. No hay forma de que File trabaje con un sistema de ficheros que no sea el del sistema operativo. NIO.2 sí: puede recorrer el interior de un .zip como si fuera un directorio.
Defecto 7: no hay recorrido eficiente de árboles. Recorrer un directorio grande con listFiles() carga el array entero en memoria.
La comparación completa:
| Aspecto | java.io.File |
Path + Files (NIO.2) |
|---|---|---|
| Errores | boolean mudo |
Excepciones específicas con mensaje |
| Listar directorio | File[], puede ser null |
Stream<Path> o DirectoryStream |
| Enlaces simbólicos | No los conoce | Los distingue y los sigue u omite a voluntad |
| Metadatos | Solo lastModified |
Creación, acceso, propietario, permisos, ACL |
| Copiar un fichero | No existe: bucle a mano | Files.copy |
| Mover atómicamente | renameTo, poco fiable |
Files.move con ATOMIC_MOVE |
| Crear directorios | mkdirs(), boolean mudo |
Files.createDirectories |
| Recorrer un árbol | Recursión a mano | Files.walk, walkFileTree |
| Leer un fichero entero | Bucle a mano | Files.readString, readAllLines |
| Vigilar cambios | No existe | WatchService |
| Sistemas de ficheros alternativos | No | Sí: ZIP, en memoria, remotos |
| Aritmética de rutas | Concatenar cadenas | resolve, relativize, normalize |
Un ejemplo comparado que resume el cambio:
// java.io: copiar un fichero
try (InputStream in = new BufferedInputStream(new FileInputStream(origen));
OutputStream out = new BufferedOutputStream(new FileOutputStream(destino))) {
byte[] bloque = new byte[8192];
int leidos;
while ((leidos = in.read(bloque)) != -1) {
out.write(bloque, 0, leidos);
}
}
// NIO.2: lo mismo
Files.copy(origen, destino, StandardCopyOption.REPLACE_EXISTING);Ocho líneas frente a una, y la de NIO.2 usa la primitiva más eficiente que ofrezca el sistema operativo, que en muchos casos ni siquiera pasa los datos por tu proceso.
Regla profesional: en código nuevo,
PathyFiles. Siempre. ConoceFilepara leer código antiguo y para las APIs que todavía lo piden; escríbelo solo cuando no haya alternativa.
Path: construir y consultar rutas
Path: construir y consultar rutasPath representa una ruta, no un fichero. Como File, puede apuntar a algo que no existe: es una secuencia de nombres, no un recurso.
import java.nio.file.Path;
import java.nio.file.Paths;
public class ConstruirRutas {
public static void main(String[] args) {
// Java 11+: la forma recomendada
Path p1 = Path.of("datos", "catalogo.txt");
// Java 7-10: equivalente, todavia muy comun en codigo existente
Path p2 = Paths.get("datos", "catalogo.txt");
// Ruta completa en una cadena
Path p3 = Path.of("datos/informes/2026/enero.txt");
// Ruta absoluta
Path p4 = Path.of("/home/marta/bibliotech/datos/catalogo.txt");
// Desde un URI
Path p5 = Path.of(java.net.URI.create("file:///home/marta/catalogo.txt"));
System.out.println(p1); // datos/catalogo.txt
System.out.println(p1.equals(p2)); // true
}
}Usa Path.of en código nuevo. Paths.get hace exactamente lo mismo y sigue funcionando; Path.of es de Java 11 y es la forma preferida.
Fíjate en una virtud del constructor de varios argumentos: Path.of("datos", "catalogo.txt") usa el separador correcto del sistema automáticamente. Se acabó el File.separator y la concatenación de cadenas de 07-01.
Los métodos de consulta:
import java.nio.file.Path;
public class ConsultarRutas {
public static void main(String[] args) {
Path p = Path.of("/home/marta/bibliotech/datos/catalogo.txt");
System.out.println("Ruta completa : " + p);
System.out.println("Nombre : " + p.getFileName()); // catalogo.txt
System.out.println("Directorio : " + p.getParent()); // .../datos
System.out.println("Raiz : " + p.getRoot()); // /
System.out.println("¿Es absoluta? : " + p.isAbsolute()); // true
System.out.println("Segmentos : " + p.getNameCount()); // 5
// Iterar los segmentos: Path es Iterable<Path>
System.out.println(" Recorrido:");
for (Path segmento : p) {
System.out.println(" " + segmento);
}
// Acceso por indice
System.out.println("Segmento 0 : " + p.getName(0)); // home
System.out.println("Segmento 3 : " + p.getName(3)); // datos
// Subruta [inicio, fin)
System.out.println("Subruta 1..3 : " + p.subpath(1, 3)); // marta/bibliotech
// Comprobaciones
System.out.println("¿Empieza por /home? " + p.startsWith("/home")); // true
System.out.println("¿Acaba en .txt? "
+ p.getFileName().toString().endsWith(".txt")); // true
}
}| Método | Devuelve | Ejemplo con /home/marta/datos/catalogo.txt |
|---|---|---|
getFileName() |
El último segmento | catalogo.txt |
getParent() |
La ruta sin el último segmento | /home/marta/datos |
getRoot() |
La raíz, o null si es relativa |
/ |
getNameCount() |
Número de segmentos | 4 |
getName(i) |
El segmento i | getName(0) → home |
subpath(a, b) |
Los segmentos de a a b−1 | subpath(0,2) → home/marta |
isAbsolute() |
Si parte de la raíz | true |
toAbsolutePath() |
La versión absoluta, contra user.dir |
— |
toRealPath() |
Absoluta, normalizada y con enlaces resueltos. Lanza IOException |
— |
startsWith / endsWith |
Comparación por segmentos, no por caracteres | — |
Un detalle importante que se falla a menudo: startsWith compara segmentos completos, no prefijos de texto:
Path p = Path.of("/home/marta/datos");
p.startsWith("/home/mar"); // false: "mar" no es un segmento completo
p.startsWith("/home/marta"); // trueEs el comportamiento correcto —evita que /home/martarodriguez parezca estar dentro de /home/marta—, y es una comprobación de seguridad que con cadenas se hace mal constantemente.
- Aritmética de rutas:
resolve, relativize, normalize
resolve, relativize, normalizeAquí está una de las mayores ganancias de Path: operaciones de rutas bien definidas, sin concatenar cadenas.
resolve: combinar rutas
Path base = Path.of("/home/marta/bibliotech");
base.resolve("datos/catalogo.txt");
// /home/marta/bibliotech/datos/catalogo.txt
base.resolve("/etc/config");
// /etc/config <-- si el argumento es ABSOLUTO, sustituye a la base
base.resolve("");
// /home/marta/bibliotechLa regla: si el argumento es absoluto, se devuelve tal cual; si es relativo, se añade. Ese comportamiento es deliberado y evita crear rutas sin sentido como /home/marta/etc/config.
resolveSibling: hermano en el mismo directorio
Path catalogo = Path.of("/home/marta/bibliotech/datos/catalogo.txt");
catalogo.resolveSibling("catalogo.txt.tmp");
// /home/marta/bibliotech/datos/catalogo.txt.tmp
catalogo.resolveSibling("catalogo.bak");
// /home/marta/bibliotech/datos/catalogo.bakEquivale a getParent().resolve(...) pero es más claro y funciona aunque no haya padre. Es la forma correcta de construir el fichero temporal de la escritura atómica que escribiste a mano en 07-02.
relativize: la ruta de A a B
Path base = Path.of("/home/marta/bibliotech");
Path informe = Path.of("/home/marta/bibliotech/datos/informes/enero.txt");
base.relativize(informe);
// datos/informes/enero.txt
informe.relativize(base);
// ../../..Útil para mostrar rutas cortas al usuario o para guardar rutas portables en un fichero de configuración. Su restricción: ambas rutas deben ser las dos absolutas o las dos relativas; si no, lanza IllegalArgumentException.
normalize: limpiar . y ..
Path fea = Path.of("/home/marta/./bibliotech/../bibliotech/datos/../datos/catalogo.txt");
fea.normalize();
// /home/marta/bibliotech/datos/catalogo.txtnormalize es puramente sintáctico: no consulta el sistema de ficheros. Si hay enlaces simbólicos por medio, el resultado puede no ser la ruta real. Para eso está toRealPath(), que sí consulta el disco y lanza IOException si la ruta no existe.
Y un uso de normalize que va más allá de la estética, y que conviene conocer:
/**
* Comprueba que una ruta pedida por el usuario queda DENTRO del directorio
* de datos.
*
* Sin esta comprobacion, un nombre como "../../etc/passwd" permitiria salir
* del directorio permitido. Es la vulnerabilidad conocida como "path
* traversal", y es de las mas frecuentes en aplicaciones que aceptan nombres
* de fichero del usuario. La seguridad de aplicaciones se trata en 12-07.
*/
public static Path resolverSeguro(Path directorioBase, String nombrePedido) {
Path base = directorioBase.toAbsolutePath().normalize();
Path candidata = base.resolve(nombrePedido).normalize();
if (!candidata.startsWith(base)) {
throw new IllegalArgumentException(
"Ruta fuera del directorio permitido: " + nombrePedido);
}
return candidata;
}Las dos piezas son normalize() —que resuelve los .. antes de comparar— y startsWith() —que compara por segmentos completos—. Con concatenación de cadenas, esta comprobación se hace mal casi siempre.
Tabla resumen:
| Operación | Qué hace | Consulta el disco |
|---|---|---|
resolve(otra) |
Combina; si otra es absoluta, la devuelve |
No |
resolveSibling(nombre) |
Hermano en el mismo directorio | No |
relativize(otra) |
Ruta relativa de esta a otra |
No |
normalize() |
Elimina . y .. sintácticamente |
No |
toAbsolutePath() |
Absoluta contra user.dir, sin normalizar |
No |
toRealPath() |
Absoluta, normalizada y con enlaces resueltos | Sí, lanza IOException |
Files: existencia, creación y borrado
Files: existencia, creación y borradoFiles es una clase de utilidad con métodos estáticos. Todo lo que File hacía con métodos de instancia, aquí se hace pasando el Path.
Existencia
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.LinkOption;
Path p = Path.of("datos/catalogo.txt");
Files.exists(p); // true si existe y se puede comprobar
Files.notExists(p); // true si NO existe y se puede comprobar
Files.isRegularFile(p); // fichero normal (no directorio, no enlace)
Files.isDirectory(p);
Files.isSymbolicLink(p);
Files.isReadable(p);
Files.isWritable(p);
Files.isExecutable(p);
Files.isHidden(p); // lanza IOException
// Sin seguir enlaces simbolicos
Files.exists(p, LinkOption.NOFOLLOW_LINKS);Y aquí está el detalle que más desconcierta de toda la API:
exists()ynotExists()NO son opuestos. Ambos pueden devolverfalsea la vez.
Cómo es posible: los dos devuelven false cuando no se puede determinar si el fichero existe, típicamente por falta de permisos sobre el directorio que lo contiene. Hay tres estados, no dos:
| Situación | exists() |
notExists() |
|---|---|---|
| El fichero existe y se puede comprobar | true |
false |
| El fichero no existe y se puede comprobar | false |
true |
| No se puede determinar (sin permiso) | false |
false |
Por eso !Files.exists(p) no significa "no existe": significa "no existe, o no puedo saberlo". Si la distinción importa —y en código de seguridad importa—, usa notExists() explícitamente.
Y la advertencia de 06-07, que aquí sigue vigente: comprobar la existencia antes de operar es una condición de carrera (TOCTOU). Entre la comprobación y la operación, otro proceso puede crear o borrar el fichero. La comprobación previa es una optimización o una forma de dar un mensaje mejor; la excepción sigue siendo obligatoria.
Creación
// Un fichero vacio. Lanza FileAlreadyExistsException si ya existe.
Files.createFile(Path.of("datos/nuevo.txt"));
// Un directorio. Lanza NoSuchFileException si falta el padre.
Files.createDirectory(Path.of("datos"));
// Un directorio Y TODOS SUS PADRES. No falla si ya existen.
Files.createDirectories(Path.of("datos/informes/2026/enero"));createDirectories es el que usarás casi siempre, y sustituye al mkdirs() con boolean mudo que escribiste en 07-02:
// java.io (07-02)
File padre = destino.getAbsoluteFile().getParentFile();
if (padre != null && !padre.exists() && !padre.mkdirs()) {
throw new IOException("No se pudo crear el directorio " + padre.getAbsolutePath());
}
// NIO.2: una linea, idempotente, y con excepcion informativa si falla
Files.createDirectories(destino.getParent());Fíjate en que es idempotente: si el directorio ya existe, no hace nada y no falla. Eso elimina la comprobación previa.
Borrado
// Borra. Lanza NoSuchFileException si no existe.
Files.delete(p);
// Borra si existe. Devuelve true si borro algo. NO lanza si no existe.
boolean borrado = Files.deleteIfExists(p);La diferencia importa:
| Método | Si no existe | Cuándo usarlo |
|---|---|---|
delete |
NoSuchFileException |
Cuando su ausencia es un error que hay que saber |
deleteIfExists |
Devuelve false, sin excepción |
Limpieza: da igual si estaba o no |
Y las excepciones que lanza delete, que es donde se ve la ganancia sobre el boolean mudo:
| Excepción | Significa |
|---|---|
NoSuchFileException |
No existe |
DirectoryNotEmptyException |
Es un directorio con contenido |
AccessDeniedException |
Sin permiso |
IOException |
Otro fallo, con mensaje del sistema |
Compara los dos códigos:
// java.io: sabes que fallo, no por que
if (!fichero.delete()) {
System.err.println("No se pudo borrar");
}
// NIO.2: sabes exactamente que paso y puedes actuar en consecuencia
try {
Files.delete(ruta);
} catch (NoSuchFileException e) {
LOG.fine(() -> "Ya no estaba: " + ruta); // no es un problema
} catch (DirectoryNotEmptyException e) {
LOG.warning(() -> "El directorio tiene contenido: " + ruta);
} catch (AccessDeniedException e) {
LOG.severe(() -> "Sin permiso para borrar " + ruta); // esto si hay que mirarlo
} catch (IOException e) {
LOG.log(Level.SEVERE, "Fallo borrando " + ruta, e);
}Esto es exactamente lo que 06-04 pedía: la excepción debe transportar lo que quien la lee necesita para decidir.
- Copiar y mover, con la escritura atómica de verdad
Files.copy
import java.nio.file.StandardCopyOption;
// Copia. Lanza FileAlreadyExistsException si el destino existe.
Files.copy(origen, destino);
// Sobrescribe el destino
Files.copy(origen, destino, StandardCopyOption.REPLACE_EXISTING);
// Conserva fecha de modificacion, propietario y permisos
Files.copy(origen, destino,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.COPY_ATTRIBUTES);
// Desde un InputStream a un fichero (07-03)
Files.copy(entrada, destino, StandardCopyOption.REPLACE_EXISTING);
// Desde un fichero a un OutputStream
Files.copy(origen, salida);Las dos últimas sobrecargas son muy útiles: conectan el mundo de los flujos de 07-03 con el de NIO.2 sin escribir ningún bucle.
Un aviso: Files.copy sobre un directorio copia solo el directorio, vacío. No es recursivo. Para copiar un árbol hay que recorrerlo, y eso es el apartado 9.
Files.move y el ATOMIC_MOVE
Aquí llega el momento prometido en 07-02:
// Mueve o renombra
Files.move(origen, destino);
// Sobrescribiendo el destino
Files.move(origen, destino, StandardCopyOption.REPLACE_EXISTING);
// ATOMICO: o esta entero o no esta. Nunca a medias.
Files.move(temporal, destino,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);ATOMIC_MOVE es lo que hacía falta. Compara con la implementación de 07-02:
// 07-02, con java.io: DOS operaciones, con un hueco peligroso entre ellas
if (destino.exists() && !destino.delete()) { // 1. borrar
return false;
}
return temporal.renameTo(destino); // 2. renombrar
// Entre 1 y 2, el fichero destino NO EXISTE. Si el proceso muere ahi,
// te quedas sin nada. Y otro proceso que lea en ese instante no encuentra
// el fichero.
// NIO.2: UNA operacion atomica. No hay hueco.
Files.move(temporal, destino, REPLACE_EXISTING, ATOMIC_MOVE);Con ATOMIC_MOVE, no existe ningún instante en el que el destino no esté. Un lector concurrente ve la versión antigua completa o la nueva completa. Eso es lo que significa "atómico" de verdad.
Sus dos límites, que hay que conocer:
- Solo funciona dentro del mismo sistema de ficheros. Entre particiones o unidades distintas lanza
AtomicMoveNotSupportedException, porque mover ahí implica copiar y borrar. Por eso el fichero temporal debe crearse en el mismo directorio que el destino, no en/tmp. REPLACE_EXISTINGconATOMIC_MOVEestá soportado en los sistemas habituales, pero la combinación exacta depende del sistema de ficheros.
Y así queda la escritura atómica completa, que sustituye a las cuarenta líneas de EscrituraAtomica de 07-02:
package com.nexussoftware.bibliotech.infraestructura;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.logging.Logger;
/**
* Escritura atomica con NIO.2.
*
* Sustituye a la version de 07-02 escrita con java.io. Misma garantia
* —el destino nunca queda a medias— en una fraccion del codigo, y con
* una atomicidad REAL en lugar de la secuencia borrar-mas-renombrar.
*/
public final class EscrituraAtomica {
private static final Logger LOG = Logger.getLogger(EscrituraAtomica.class.getName());
private EscrituraAtomica() { }
@FunctionalInterface
public interface Contenido {
void escribirEn(BufferedWriter salida) throws IOException;
}
public static void escribir(Path destino, Contenido contenido) throws IOException {
// 1. Crear el directorio si falta. Idempotente.
Path directorio = destino.toAbsolutePath().getParent();
Files.createDirectories(directorio);
// 2. El temporal, EN EL MISMO DIRECTORIO: ATOMIC_MOVE lo exige.
// resolveSibling hace exactamente esto (apartado 3).
Path temporal = destino.resolveSibling(destino.getFileName() + ".tmp");
boolean completado = false;
try {
// 3. Escribir todo en el temporal. BufferedWriter, no PrintWriter:
// sus metodos SI lanzan IOException (07-04).
try (BufferedWriter salida = Files.newBufferedWriter(
temporal, StandardCharsets.UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE)) {
contenido.escribirEn(salida);
}
// 4. Sustituir de forma ATOMICA. Una sola operacion.
Files.move(temporal, destino,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
completado = true;
LOG.fine(() -> "Escritura atomica completada: " + destino);
} catch (AtomicMoveNotSupportedException e) {
// Puede pasar si el temporal y el destino estan en sistemas de
// ficheros distintos. Se degrada a un move no atomico, avisando.
LOG.warning(() -> "El sistema de ficheros no soporta ATOMIC_MOVE; "
+ "se sustituye de forma no atomica: " + destino);
Files.move(temporal, destino, StandardCopyOption.REPLACE_EXISTING);
completado = true;
} finally {
// 5. Compensacion (06-05): sin basura si algo fallo.
if (!completado) {
Files.deleteIfExists(temporal); // no lanza si ya no esta
}
}
}
}Fíjate en el finally: deleteIfExists no lanza si el temporal ya no existe, así que la compensación es una sola línea sin comprobaciones. Es la diferencia entre una API bien diseñada y una que te obliga a defenderte de ella.
- Lectura y escritura de alto nivel
Files incluye métodos que hacen en una línea lo que en 07-01 y 07-02 costaba un bucle.
Lectura
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
Path p = Path.of("datos/catalogo.txt");
// Java 11+: el fichero entero como String
String todo = Files.readString(p, StandardCharsets.UTF_8);
// Todas las lineas en una lista
List<String> lineas = Files.readAllLines(p, StandardCharsets.UTF_8);
// Todos los bytes (para binario)
byte[] bytes = Files.readAllBytes(p);
// Un BufferedReader ya construido (07-04)
try (java.io.BufferedReader lector =
Files.newBufferedReader(p, StandardCharsets.UTF_8)) {
String linea;
while ((linea = lector.readLine()) != null) {
procesar(linea);
}
}
// Un InputStream (07-03)
try (java.io.InputStream entrada = Files.newInputStream(p)) {
// ...
}La advertencia de 07-01 y 07-04 sigue en pie, con más motivo porque ahora es más fácil equivocarse.
readString,readAllLinesyreadAllBytescargan el fichero entero en memoria. Con un fichero de configuración de 2 KB son perfectos; con el histórico de préstamos de 2 GB,OutOfMemoryError. Para eso estánewBufferedReaderoFiles.lines().
Escritura
// Java 11+: escribir una cadena
Files.writeString(p, "contenido", StandardCharsets.UTF_8);
// Anadiendo al final
Files.writeString(p, "una linea mas\n", StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
// Escribir una coleccion de lineas (anade el salto a cada una)
Files.write(p, lineas, StandardCharsets.UTF_8);
// Escribir bytes
Files.write(p, bytes);
// Un BufferedWriter ya construido
try (java.io.BufferedWriter salida =
Files.newBufferedWriter(p, StandardCharsets.UTF_8)) {
salida.write("...");
salida.newLine();
}Comparación con lo que escribías hace tres lecciones:
// 07-02, con java.io
try (PrintWriter salida = new PrintWriter(
new BufferedWriter(new FileWriter(ruta, StandardCharsets.UTF_8)))) {
for (String linea : lineas) {
salida.println(linea);
}
if (salida.checkError()) {
throw new IOException("Fallo al escribir");
}
}
// NIO.2
Files.write(Path.of(ruta), lineas, StandardCharsets.UTF_8);Una línea, con el charset explícito y con las IOException propagándose de verdad, sin ningún checkError() que olvidar.
StandardOpenOption: decir lo que quieres decir
StandardOpenOption: decir lo que quieres decirRecuerda el peor error de 07-02: el boolean append que se confunde con un Charset y que, olvidado, borra un fichero entero. NIO.2 lo sustituye por opciones con nombre:
| Opción | Qué hace |
|---|---|
CREATE |
Crea el fichero si no existe |
CREATE_NEW |
Crea el fichero y falla si ya existe |
APPEND |
Escribe al final, conservando el contenido |
TRUNCATE_EXISTING |
Vacía el fichero al abrirlo |
WRITE |
Abre para escribir |
READ |
Abre para leer |
DELETE_ON_CLOSE |
Borra el fichero al cerrarlo. Útil para temporales |
SYNC |
Cada escritura llega al disco físico (07-02, apartado 5) |
DSYNC |
Como SYNC, solo los datos, no los metadatos |
Los tres casos habituales, escritos de forma que no se pueden confundir:
// SOBRESCRIBIR: crear si falta, vaciar si existe
Files.newBufferedWriter(p, UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE);
// ANADIR: crear si falta, escribir al final
Files.newBufferedWriter(p, UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.APPEND);
// CREAR NUEVO: fallar si ya existe. Util como bloqueo (07-02)
Files.newBufferedWriter(p, UTF_8,
StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE);Esto es lo que arregla el error más caro del módulo. StandardOpenOption.APPEND es imposible de confundir con un charset, e imposible de olvidar sin que se note, porque sin él tienes que escribir TRUNCATE_EXISTING explícitamente. Y CREATE_NEW da, por fin, un bloqueo de fichero correcto: comprobar y crear en una sola operación atómica, con FileAlreadyExistsException si ya estaba.
Y una nota sobre los valores por defecto: si no pasas ninguna opción, newBufferedWriter usa CREATE, TRUNCATE_EXISTING y WRITE. Es decir, sobrescribe. Igual que FileWriter, pero al menos aquí puedes verlo escrito.
Files.lines, list y walk: flujos que se cierran
Files.lines, list y walk: flujos que se cierranEstos tres métodos devuelven un Stream, y aquí hay que ser preciso con el alcance.
Aviso de alcance.
Streames la API de Java 8 para colecciones, y se estudia entera en 10-04. Aquí solo se usa lo mínimo para recorrer ficheros:forEachpara procesar cada elemento ytry-with-resourcespara cerrarlo. No se usanfilter,map,collectni el resto de la API. Cuando llegues a 10-04, todo lo que veas aquí se te quedará muy corto, y estará bien: entonces sabrás componerlo.
Lo que sí hay que entender ahora, porque es un error real y frecuente:
Estos flujos son perezosos y mantienen el fichero abierto. Devuelven elementos a medida que se piden, leyendo del disco sobre la marcha. Por eso DEBEN cerrarse con
try-with-resources. No hacerlo deja descriptores de fichero abiertos, y con suficientes se agota el límite del sistema operativo y la aplicación deja de poder abrir nada.
Files.lines: las líneas de un fichero
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
public class LeerConLines {
public static void mostrar(Path fichero) throws IOException {
// try-with-resources OBLIGATORIO: el flujo mantiene el fichero abierto
try (Stream<String> lineas = Files.lines(fichero, StandardCharsets.UTF_8)) {
// forEach: procesa cada linea. La API completa, en 10-04.
lineas.forEach(System.out::println);
}
}
}Su ventaja sobre readAllLines: es perezoso. No carga el fichero en memoria; lee a medida que consumes. Un fichero de 10 GB se recorre con memoria constante, igual que con BufferedReader.
Comparación:
readAllLines |
Files.lines |
newBufferedReader |
|
|---|---|---|---|
| Carga en memoria | Todo el fichero | Una línea | Una línea |
| Devuelve | List<String> |
Stream<String> |
BufferedReader |
| Hay que cerrar | No | Sí | Sí |
| Se puede recorrer varias veces | Sí | No: un solo uso | No |
| Ficheros grandes | No | Sí | Sí |
Con acumuladores, que es lo que se puede hacer sin la API de 10-04:
public static void contar(Path fichero) throws IOException {
// Los contadores tienen que ser efectivamente finales para usarlos en
// una lambda (04-05). Un array de un elemento es el truco clasico;
// en 10-04 veras formas mejores.
int[] contadores = new int[2]; // [0] = lineas, [1] = con datos
try (Stream<String> lineas = Files.lines(fichero, StandardCharsets.UTF_8)) {
lineas.forEach(linea -> {
contadores[0]++;
if (!linea.isBlank() && !linea.startsWith("#")) {
contadores[1]++;
}
});
}
System.out.printf("%d lineas, %d con datos%n", contadores[0], contadores[1]);
}Recomendación honesta: mientras no hayas estudiado 10-04,
Files.newBufferedReadercon el bucle canónico de 07-04 es más claro para este tipo de tareas.Files.linesbrilla cuando lo combinas confilter,mapycollect, y eso es 10-04. Conócelo ahora para saber que existe y para no olvidarte de cerrarlo.
Files.list: el contenido de un directorio
public static void listar(Path directorio) throws IOException {
try (Stream<Path> hijos = Files.list(directorio)) {
hijos.forEach(hijo -> System.out.println(" " + hijo.getFileName()));
}
}Es el sustituto de listFiles(), y arregla sus dos defectos: no devuelve null —lanza NotDirectoryException o IOException con su motivo— y es perezoso, así que un directorio con un millón de ficheros no carga un array de un millón de elementos.
No es recursivo: solo el nivel inmediato.
Files.walk: el árbol completo
public static void recorrer(Path raiz) throws IOException {
try (Stream<Path> arbol = Files.walk(raiz)) {
arbol.forEach(p -> System.out.println(" " + p));
}
}
/** Limitando la profundidad. */
public static void recorrerHasta(Path raiz, int profundidad) throws IOException {
try (Stream<Path> arbol = Files.walk(raiz, profundidad)) {
arbol.forEach(System.out::println);
}
}Files.walk recorre en profundidad, empezando por la propia raíz. Sus detalles importantes:
- No sigue enlaces simbólicos por defecto. Con
FileVisitOption.FOLLOW_LINKSsí, pero entonces puede entrar en un bucle infinito si hay enlaces circulares —lanzaFileSystemLoopExceptioncuando lo detecta—. - Si un directorio no es accesible, lanza
IOExceptional llegar, y eso aborta el recorrido entero. Para saltarse los inaccesibles hay que usarwalkFileTree(apartado 9). - Incluye directorios y ficheros. Hay que distinguirlos con
Files.isRegularFile.
Ejemplo de uso realista para BiblioTech:
/** Localiza todos los informes de un arbol de directorios. */
public static void localizarInformes(Path raiz) throws IOException {
try (Stream<Path> arbol = Files.walk(raiz)) {
arbol.forEach(p -> {
if (Files.isRegularFile(p) && p.getFileName().toString().endsWith(".informe")) {
System.out.println(" " + raiz.relativize(p));
}
});
}
}En 10-04 esto se escribirá con .filter(...) en lugar del if dentro del forEach, y quedará mucho mejor. Aquí es correcto y se entiende.
- Recorrer árboles de directorios
Files.walk está bien para lo simple. Para control real existe walkFileTree con FileVisitor, que es el patrón Visitante aplicado al sistema de ficheros.
package com.nexussoftware.bibliotech.util;
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.logging.Logger;
/**
* Recorrido de arboles con control completo, usando walkFileTree.
*
* Ventaja decisiva sobre Files.walk: puede DECIDIR que hacer ante cada
* elemento y ante cada fallo, en lugar de abortar el recorrido entero
* cuando un solo directorio no es accesible.
*/
public class RecorridoControlado {
private static final Logger LOG = Logger.getLogger(RecorridoControlado.class.getName());
/** Calcula el tamano total de un arbol, saltandose lo inaccesible. */
public static Resultado calcularTamano(Path raiz) throws IOException {
Resultado resultado = new Resultado();
Files.walkFileTree(raiz, new SimpleFileVisitor<Path>() {
/** Antes de entrar en un directorio. */
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
// Saltar directorios ocultos y de trabajo sin entrar en ellos
String nombre = dir.getFileName().toString();
if (nombre.startsWith(".") || nombre.equals("tmp")) {
return FileVisitResult.SKIP_SUBTREE;
}
resultado.directorios++;
return FileVisitResult.CONTINUE;
}
/** Por cada fichero. */
@Override
public FileVisitResult visitFile(Path fichero, BasicFileAttributes attrs) {
resultado.ficheros++;
resultado.bytes += attrs.size();
if (attrs.size() > resultado.mayorTamano) {
resultado.mayorTamano = attrs.size();
resultado.mayorFichero = fichero;
}
return FileVisitResult.CONTINUE;
}
/**
* Cuando un fichero o directorio NO se puede visitar.
*
* ESTE es el metodo que hace superior a walkFileTree: devolviendo
* CONTINUE, el recorrido SIGUE. Con Files.walk, un solo directorio
* sin permisos aborta todo.
*/
@Override
public FileVisitResult visitFileFailed(Path fichero, IOException e) {
resultado.inaccesibles++;
LOG.fine(() -> "No accesible: " + fichero + " (" + e.getMessage() + ")");
return FileVisitResult.CONTINUE;
}
/** Al salir de un directorio, tras visitar todo su contenido. */
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException e) {
return FileVisitResult.CONTINUE;
}
});
return resultado;
}
public static class Resultado {
int directorios = 0;
int ficheros = 0;
int inaccesibles = 0;
long bytes = 0;
long mayorTamano = 0;
Path mayorFichero = null;
public String informe() {
return String.format(
"%d directorios, %d ficheros, %.2f MB, %d inaccesibles.%n"
+ " Mayor: %s (%.2f MB)",
directorios, ficheros, bytes / 1024.0 / 1024.0, inaccesibles,
mayorFichero == null ? "(ninguno)" : mayorFichero.getFileName(),
mayorTamano / 1024.0 / 1024.0);
}
}
}Los cuatro métodos de FileVisitor y los cuatro valores de retorno:
| Método | Cuándo se llama |
|---|---|
preVisitDirectory |
Antes de entrar en un directorio |
visitFile |
Por cada fichero |
visitFileFailed |
Cuando algo no se puede visitar |
postVisitDirectory |
Al salir del directorio |
| Valor de retorno | Efecto |
|---|---|
CONTINUE |
Seguir normalmente |
SKIP_SUBTREE |
No entrar en este directorio (solo en preVisitDirectory) |
SKIP_SIBLINGS |
Saltar los hermanos restantes |
TERMINATE |
Detener todo el recorrido |
SimpleFileVisitor es una implementación con todos los métodos devolviendo CONTINUE, así que solo sobrescribes los que te interesan. Es el patrón Adaptador, y es lo que evita tener que implementar los cuatro siempre.
Un uso muy práctico: borrar un árbol completo, que no existe como método porque Files.delete exige que el directorio esté vacío:
/** Borra un directorio y todo su contenido. USAR CON MUCHO CUIDADO. */
public static void borrarArbol(Path raiz) throws IOException {
if (Files.notExists(raiz)) {
return;
}
Files.walkFileTree(raiz, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path f, BasicFileAttributes attrs)
throws IOException {
Files.delete(f);
return FileVisitResult.CONTINUE;
}
/**
* El directorio se borra DESPUES de su contenido: por eso va en
* postVisitDirectory y no en pre. Al reves, Files.delete lanzaria
* DirectoryNotEmptyException.
*/
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException e)
throws IOException {
if (e != null) {
throw e; // no borrar si el recorrido fallo
}
Files.delete(dir);
return FileVisitResult.CONTINUE;
}
});
}El orden es la clave: el contenido primero, el directorio después. Es un recorrido en postorden, y es la única forma de que funcione.
- Atributos de fichero y permisos
File solo daba lastModified(). NIO.2 da un modelo completo.
Atributos básicos
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
Path p = Path.of("datos/catalogo.txt");
// De uno en uno: cada llamada consulta el sistema de ficheros
long tamano = Files.size(p);
FileTime modificado = Files.getLastModifiedTime(p);
// TODOS DE GOLPE: una sola consulta. Mas eficiente y CONSISTENTE.
BasicFileAttributes attrs = Files.readAttributes(p, BasicFileAttributes.class);
System.out.println("Tamano : " + attrs.size());
System.out.println("Creado : " + attrs.creationTime());
System.out.println("Modificado : " + attrs.lastModifiedTime());
System.out.println("Ultimo acceso : " + attrs.lastAccessTime());
System.out.println("¿Fichero? : " + attrs.isRegularFile());
System.out.println("¿Directorio? : " + attrs.isDirectory());
System.out.println("¿Enlace? : " + attrs.isSymbolicLink());Usa
readAttributescuando necesites más de un atributo. CadaFiles.size(),Files.getLastModifiedTime(),Files.isDirectory()es una consulta independiente al sistema de ficheros. Al recorrer un árbol de cien mil ficheros, la diferencia es enorme. Y ademásreadAttributesda una foto consistente: los atributos corresponden todos al mismo instante.
FileTime y las fechas
FileTime representa un instante:
FileTime momento = Files.getLastModifiedTime(p);
System.out.println(momento); // 2026-08-05T14:23:11.482Z
System.out.println(momento.toMillis()); // 1785943391482
// Comparar dos ficheros
FileTime a = Files.getLastModifiedTime(rutaA);
FileTime b = Files.getLastModifiedTime(rutaB);
if (a.compareTo(b) > 0) {
System.out.println("A es mas reciente que B");
}
// Antiguedad en dias, con aritmetica de milisegundos
long diasDeAntiguedad = (System.currentTimeMillis() - momento.toMillis())
/ (1000L * 60 * 60 * 24);
// Cambiar la fecha de modificacion
Files.setLastModifiedTime(p, FileTime.fromMillis(System.currentTimeMillis()));Aviso de alcance:
FileTimese convierte aInstanty aLocalDateTimecontoInstant(), y ahí empieza la APIjava.time, que es la lección 10-05. Aquí nos quedamos entoString()ytoMillis(), que es suficiente para comparar, ordenar y calcular antigüedades. Cuando llegues a 10-05 formatearás estas fechas como es debido.
Permisos POSIX
En Linux y macOS:
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.Set;
try {
Set<PosixFilePermission> permisos = Files.getPosixFilePermissions(p);
System.out.println(PosixFilePermissions.toString(permisos)); // rw-r--r--
// Establecer permisos: solo el propietario puede leer y escribir
Files.setPosixFilePermissions(p,
PosixFilePermissions.fromString("rw-------"));
} catch (UnsupportedOperationException e) {
// En Windows el modelo POSIX no existe: hay que usar ACL
LOG.fine("Sistema sin permisos POSIX");
}Comprueba siempre UnsupportedOperationException, porque en Windows este modelo no existe. Y ojo con un uso muy importante: crear un fichero ya con permisos restringidos, en lugar de crearlo abierto y cerrarlo después —entre las dos operaciones hay una ventana en la que cualquiera puede leerlo—:
// Crear el fichero YA con permisos restringidos, en una sola operacion
Files.createFile(p, PosixFilePermissions.asFileAttribute(
PosixFilePermissions.fromString("rw-------")));Esto importa para ficheros de configuración con datos sensibles, y enlaza con la advertencia de 07-07 sobre no guardar credenciales en el repositorio.
- Ficheros temporales y
WatchService
WatchServiceFicheros y directorios temporales
// Fichero temporal en el directorio temporal del sistema
Path temporal = Files.createTempFile("bibliotech-", ".tmp");
// En un directorio concreto: necesario si luego vas a hacer ATOMIC_MOVE
Path temporalLocal = Files.createTempFile(Path.of("datos"), "catalogo-", ".tmp");
// Directorio temporal
Path dirTemporal = Files.createTempDirectory("bibliotech-import-");El nombre lleva un componente aleatorio, así que no hay colisión entre procesos. Y una virtud de seguridad: en sistemas POSIX se crean con permisos rw-------, solo para el propietario.
Con borrado automático al cerrar:
Path temporal = Files.createTempFile("bibliotech-", ".tmp");
try (var salida = Files.newBufferedWriter(temporal, StandardCharsets.UTF_8,
StandardOpenOption.WRITE,
StandardOpenOption.DELETE_ON_CLOSE)) { // se borra al cerrar
salida.write("datos de trabajo");
}
// Aqui el fichero YA NO EXISTEDELETE_ON_CLOSE es más fiable que el deleteOnExit() de java.io, que solo actúa al terminar la JVM y no se ejecuta con kill -9.
Recordatorio del apartado 5: si vas a hacer ATOMIC_MOVE del temporal al destino, crea el temporal en el mismo directorio que el destino. El directorio temporal del sistema suele estar en otro sistema de ficheros.
WatchService: vigilar cambios
NIO.2 permite que la aplicación reaccione a cambios en un directorio sin hacer sondeo:
import java.nio.file.*;
/**
* Vigilancia de un directorio.
*
* NOTA: este bucle es BLOQUEANTE. En una aplicacion real se ejecuta en un
* hilo aparte, y eso es el modulo 8. Aqui queda como demostracion del
* mecanismo, no como codigo que meterias en tu main.
*/
public static void vigilar(Path directorio) throws IOException, InterruptedException {
try (WatchService vigilante = FileSystems.getDefault().newWatchService()) {
directorio.register(vigilante,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.ENTRY_DELETE);
System.out.println("Vigilando " + directorio + "...");
while (true) {
WatchKey clave = vigilante.take(); // BLOQUEA hasta que pase algo
for (WatchEvent<?> evento : clave.pollEvents()) {
System.out.printf(" %s: %s%n", evento.kind().name(), evento.context());
}
if (!clave.reset()) { // el directorio ya no existe
break;
}
}
}
}Usos reales: recargar la configuración cuando cambia el fichero, procesar automáticamente los ficheros que aparecen en un directorio de entrada, invalidar una caché.
Sus limitaciones: no es recursivo —hay que registrar cada subdirectorio—, en macOS la implementación usa sondeo y tiene latencia, y puede generar varios eventos por una sola modificación, porque muchos editores escriben en varias fases.
- Interoperabilidad con
File
FileLa conversión entre ambos mundos es trivial, y eso permite migrar poco a poco:
// De File a Path
File f = new File("datos/catalogo.txt");
Path p = f.toPath();
// De Path a File
Path p2 = Path.of("datos/catalogo.txt");
File f2 = p2.toFile();La estrategia de migración recomendada, que es la que sigue BiblioTech:
- Usa
Pathen todo el código nuevo. - Convierte con
toFile()solo en el punto exacto donde una API antigua lo exija. - Cambia las firmas públicas de
FileaPathcuando toques esas clases. - No reescribas código que funciona solo por modernizarlo: migra lo que toques.
Equivalencias directas:
java.io.File |
NIO.2 |
|---|---|
f.exists() |
Files.exists(p) |
f.isFile() |
Files.isRegularFile(p) |
f.isDirectory() |
Files.isDirectory(p) |
f.length() |
Files.size(p) |
f.delete() |
Files.delete(p) o Files.deleteIfExists(p) |
f.mkdirs() |
Files.createDirectories(p) |
f.renameTo(o) |
Files.move(p, o, ...) |
f.listFiles() |
Files.list(p) o Files.newDirectoryStream(p) |
f.lastModified() |
Files.getLastModifiedTime(p) |
f.getAbsolutePath() |
p.toAbsolutePath() |
f.getName() |
p.getFileName() |
f.getParent() |
p.getParent() |
f.canRead() |
Files.isReadable(p) |
- BiblioTech migra su persistencia
Hora de recoger todo. AlmacenBiblioTech sustituye a la mezcla de clases con java.io de las lecciones anteriores.
package com.nexussoftware.bibliotech.infraestructura;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Stream;
/**
* Capa de persistencia de BiblioTech sobre NIO.2.
*
* Sustituye a todo el codigo de java.io de 07-01 a 07-05:
* - creacion de directorios -> Files.createDirectories
* - escritura atomica -> Files.move con ATOMIC_MOVE
* - copias rotativas -> Files.move y Files.deleteIfExists
* - localizacion de informes -> Files.walk
* - fallos -> excepciones especificas, no boolean mudos
*
* Estructura de directorios que gestiona:
* datos/
* catalogo.txt
* auditoria.txt
* copias/
* catalogo.bak.1 (la mas reciente)
* catalogo.bak.2
* catalogo.bak.3
* informes/
* 2026/
* 01/ *.informe
* sesiones/
*/
public class AlmacenBiblioTech {
private static final Logger LOG = Logger.getLogger(AlmacenBiblioTech.class.getName());
private static final java.nio.charset.Charset CHARSET = StandardCharsets.UTF_8;
private static final String CATALOGO = "catalogo.txt";
private static final String AUDITORIA = "auditoria.txt";
private static final String EXT_INFORME = ".informe";
private static final int COPIAS_CONSERVADAS = 3;
private final Path raiz;
private final Path copias;
private final Path informes;
private final Path sesiones;
/**
* Prepara la estructura de directorios.
*
* createDirectories es IDEMPOTENTE: si ya existen, no hace nada y no
* falla. Se acabo el "if (!existe) crear" con su boolean mudo.
*/
public AlmacenBiblioTech(String directorioRaiz) throws IOException {
this.raiz = Path.of(Objects.requireNonNull(directorioRaiz,
"El directorio no puede ser nulo")).toAbsolutePath().normalize();
this.copias = raiz.resolve("copias");
this.informes = raiz.resolve("informes");
this.sesiones = raiz.resolve("sesiones");
Files.createDirectories(copias);
Files.createDirectories(informes);
Files.createDirectories(sesiones);
LOG.config(() -> "Almacen de BiblioTech preparado en " + raiz);
}
// ---------------------- ESCRITURA ATOMICA ----------------------
@FunctionalInterface
public interface Contenido {
void escribirEn(BufferedWriter salida) throws IOException;
}
/**
* Escribe un fichero de forma atomica, tras hacer copia de seguridad.
*
* Secuencia:
* 1. rotar las copias de seguridad existentes
* 2. copiar el fichero actual a copias/nombre.bak.1
* 3. escribir el nuevo contenido en un temporal HERMANO del destino
* 4. moverlo sobre el destino con ATOMIC_MOVE
*/
public void escribirConCopia(String nombre, Contenido contenido) throws IOException {
Path destino = raiz.resolve(nombre);
// 1 y 2: copia de seguridad rotativa
if (Files.exists(destino)) {
rotarCopias(nombre);
Files.copy(destino, copias.resolve(nombre + ".bak.1"),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.COPY_ATTRIBUTES);
}
// 3: temporal HERMANO. ATOMIC_MOVE exige el mismo sistema de ficheros.
Path temporal = destino.resolveSibling(destino.getFileName() + ".tmp");
boolean completado = false;
try {
try (BufferedWriter salida = Files.newBufferedWriter(temporal, CHARSET,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE)) {
contenido.escribirEn(salida);
}
// 4: sustitucion atomica de verdad
Files.move(temporal, destino,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
completado = true;
LOG.info(() -> String.format("Escrito %s (%d bytes)",
destino.getFileName(), tamanoSeguro(destino)));
} catch (AtomicMoveNotSupportedException e) {
LOG.warning(() -> "Sin soporte de ATOMIC_MOVE; sustitucion no atomica");
Files.move(temporal, destino, StandardCopyOption.REPLACE_EXISTING);
completado = true;
} finally {
if (!completado) {
Files.deleteIfExists(temporal); // compensacion (06-05)
}
}
}
/**
* Rotacion de copias: .bak.2 pasa a .bak.3, .bak.1 a .bak.2, y la mas
* antigua se pierde.
*
* De MAYOR a MENOR, como en 07-02: al reves, cada renombrado machacaria
* la copia siguiente antes de haberla desplazado.
*/
private void rotarCopias(String nombre) throws IOException {
Files.deleteIfExists(copias.resolve(nombre + ".bak." + COPIAS_CONSERVADAS));
for (int i = COPIAS_CONSERVADAS - 1; i >= 1; i--) {
Path origen = copias.resolve(nombre + ".bak." + i);
Path destino = copias.resolve(nombre + ".bak." + (i + 1));
if (Files.exists(origen)) {
Files.move(origen, destino, StandardCopyOption.REPLACE_EXISTING);
}
}
}
// ---------------------- LECTURA ----------------------
/**
* Lee el catalogo linea a linea, con memoria constante.
*
* Files.newBufferedReader devuelve el BufferedReader de 07-04, con el
* charset obligatorio y una excepcion especifica si el fichero falta.
*/
public List<String> leerCatalogo() throws IOException {
Path fichero = raiz.resolve(CATALOGO);
List<String> lineas = new ArrayList<>();
if (Files.notExists(fichero)) {
// DEGRADACION ELEGANTE (06-07, 07-01): la primera ejecucion no
// tiene catalogo, y eso es normal, no un fallo.
LOG.warning(() -> "No hay catalogo en " + fichero
+ "; se arranca con catalogo vacio");
return lineas;
}
try (BufferedReader lector = Files.newBufferedReader(fichero, CHARSET)) {
String linea;
while ((linea = lector.readLine()) != null) { // bucle canonico (07-04)
String limpia = linea.trim();
if (!limpia.isEmpty() && !limpia.startsWith("#")) {
lineas.add(limpia);
}
}
}
LOG.info(() -> "Catalogo leido: " + lineas.size() + " lineas de " + fichero);
return lineas;
}
/** Anade una linea a la auditoria. APPEND explicito: el fichero CRECE. */
public void auditar(String linea) {
Path fichero = raiz.resolve(AUDITORIA);
try (BufferedWriter salida = Files.newBufferedWriter(fichero, CHARSET,
StandardOpenOption.CREATE,
StandardOpenOption.APPEND)) { // imposible de confundir
salida.write(linea);
salida.write('\n'); // fichero de datos: '\n' fijo
} catch (IOException e) {
// La auditoria no tumba la operacion de negocio (07-02)
LOG.log(Level.WARNING, "No se pudo auditar: " + linea, e);
}
}
// ---------------------- INFORMES ----------------------
/** Guarda un informe en informes/AAAA/MM/nombre.informe. */
public Path guardarInforme(String anio, String mes, String nombre, String contenido)
throws IOException {
Path directorio = informes.resolve(anio).resolve(mes);
Files.createDirectories(directorio);
Path fichero = directorio.resolve(nombre + EXT_INFORME);
Files.writeString(fichero, contenido, CHARSET,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE);
LOG.info(() -> "Informe guardado: " + raiz.relativize(fichero));
return fichero;
}
/**
* Localiza todos los informes del arbol, ordenados del mas reciente al
* mas antiguo.
*
* Files.walk devuelve un flujo PEREZOSO que HAY QUE CERRAR: mantiene
* descriptores de directorio abiertos. De ahi el try-with-resources.
* Se usa solo forEach; la API completa de Streams es 10-04.
*/
public List<InfoInforme> localizarInformes() throws IOException {
List<InfoInforme> encontrados = new ArrayList<>();
try (Stream<Path> arbol = Files.walk(informes)) {
arbol.forEach(p -> {
if (!Files.isRegularFile(p)
|| !p.getFileName().toString().endsWith(EXT_INFORME)) {
return;
}
try {
// readAttributes: UNA consulta para todos los atributos,
// en vez de tres llamadas independientes (apartado 10)
BasicFileAttributes attrs =
Files.readAttributes(p, BasicFileAttributes.class);
encontrados.add(new InfoInforme(
informes.relativize(p).toString(),
attrs.size(),
attrs.lastModifiedTime()));
} catch (IOException e) {
LOG.fine(() -> "No se pudo leer atributos de " + p);
}
});
}
// Mas reciente primero (05-09)
encontrados.sort(Comparator.comparing(InfoInforme::modificado).reversed());
return encontrados;
}
/**
* Informacion de un informe localizado.
*
* FileTime se muestra con toString() y se compara con compareTo().
* Su conversion a fechas legibles es java.time, que es 10-05.
*/
public record InfoInforme(String rutaRelativa, long bytes, FileTime modificado) {
public String linea() {
return String.format("%-40s %8d bytes %s",
rutaRelativa, bytes, modificado);
}
/** Antiguedad en dias, con aritmetica de milisegundos (10-05 lo hara mejor). */
public long diasDeAntiguedad() {
return (System.currentTimeMillis() - modificado.toMillis())
/ (1000L * 60 * 60 * 24);
}
}
// ---------------------- MANTENIMIENTO ----------------------
/**
* Borra los informes con mas de N dias.
*
* @return cuantos se borraron
*/
public int limpiarInformesAntiguos(int diasMaximos) throws IOException {
int borrados = 0;
for (InfoInforme info : localizarInformes()) {
if (info.diasDeAntiguedad() > diasMaximos) {
Path fichero = informes.resolve(info.rutaRelativa());
if (Files.deleteIfExists(fichero)) {
borrados++;
LOG.fine(() -> "Informe antiguo borrado: " + info.rutaRelativa());
}
}
}
final int total = borrados;
LOG.info(() -> "Limpieza: " + total + " informes con mas de "
+ diasMaximos + " dias");
return borrados;
}
/** Resumen del espacio ocupado, con walkFileTree para no abortar por un fallo. */
public String informeDeEspacio() throws IOException {
long[] datos = new long[2]; // [0] bytes, [1] ficheros
Files.walkFileTree(raiz, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path f, BasicFileAttributes attrs) {
datos[0] += attrs.size();
datos[1]++;
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path f, IOException e) {
LOG.fine(() -> "No accesible: " + f);
return FileVisitResult.CONTINUE; // no aborta el recorrido
}
});
return String.format("%s: %d ficheros, %.2f MB",
raiz.getFileName(), datos[1], datos[0] / 1024.0 / 1024.0);
}
private long tamanoSeguro(Path p) {
try {
return Files.size(p);
} catch (IOException e) {
return -1;
}
}
public Path getRaiz() { return raiz; }
public Path getSesiones() { return sesiones; }
}Demostración completa:
package com.nexussoftware.bibliotech.presentacion;
import java.io.IOException;
import com.nexussoftware.bibliotech.infraestructura.AlmacenBiblioTech;
public class DemoAlmacen {
public static void main(String[] args) throws IOException {
AlmacenBiblioTech almacen = new AlmacenBiblioTech("datos");
// 1. Escribir el catalogo con copia de seguridad y atomicidad
almacen.escribirConCopia("catalogo.txt", salida -> {
salida.write("# Catalogo de BiblioTech - Nexus Software");
salida.newLine();
salida.write("LIBRO;978-0000000001;Java Efectivo;Bloch;2018");
salida.newLine();
salida.write("LIBRO;978-0000000002;Patrones de Diseno;Gamma;1994");
salida.newLine();
salida.write("LIBRO;978-0000000003;Refactorizacion;Fowler;1999");
salida.newLine();
});
// 2. Leer
System.out.println("=== CATALOGO ===");
for (String linea : almacen.leerCatalogo()) {
System.out.println(" " + linea);
}
// 3. Auditoria (modo APPEND)
almacen.auditar("PRESTAMO;PR-0001;978-0000000001;E-001");
almacen.auditar("DEVOLUCION;PR-0001;978-0000000001;E-001");
// 4. Informes en un arbol de directorios
almacen.guardarInforme("2026", "01", "multas-enero", "Total: 142,50 EUR");
almacen.guardarInforme("2026", "02", "multas-febrero", "Total: 98,25 EUR");
almacen.guardarInforme("2026", "08", "multas-agosto", "Total: 210,00 EUR");
System.out.println();
System.out.println("=== INFORMES LOCALIZADOS ===");
for (AlmacenBiblioTech.InfoInforme info : almacen.localizarInformes()) {
System.out.println(" " + info.linea());
}
// 5. Espacio ocupado
System.out.println();
System.out.println("=== ESPACIO ===");
System.out.println(" " + almacen.informeDeEspacio());
}
}Salida:
=== CATALOGO ===
LIBRO;978-0000000001;Java Efectivo;Bloch;2018
LIBRO;978-0000000002;Patrones de Diseno;Gamma;1994
LIBRO;978-0000000003;Refactorizacion;Fowler;1999
=== INFORMES LOCALIZADOS ===
2026/08/multas-agosto.informe 17 bytes 2026-08-05T09:14:22.331Z
2026/02/multas-febrero.informe 17 bytes 2026-08-05T09:14:22.329Z
2026/01/multas-enero.informe 18 bytes 2026-08-05T09:14:22.327Z
=== ESPACIO ===
datos: 9 ficheros, 0,01 MBLas ocho decisiones de diseño que resumen la migración:
createDirectoriesen el constructor. Idempotente: se acabó elif (!existe) crearcon subooleanmudo.- La raíz se normaliza y se hace absoluta una vez, en el constructor. Todas las rutas derivadas son coherentes y los mensajes de error dicen dónde están de verdad los ficheros.
resolveen lugar de concatenar cadenas. SinFile.separator, sin barras dobles, sin errores de plataforma.- El temporal es hermano del destino con
resolveSibling, porqueATOMIC_MOVEexige el mismo sistema de ficheros. Crearlo en/tmphabría hecho fallar la atomicidad justo cuando importa. ATOMIC_MOVEcon degradación explícita. Si el sistema de ficheros no lo soporta, se avisa y se sigue con unmovenormal, en lugar de fallar. Degradación elegante de 06-07.- Rotación de mayor a menor. El mismo detalle que en 07-02, y sigue siendo lo que más se falla.
readAttributesen una sola llamada dentro del recorrido, en lugar de tres consultas por fichero. Con miles de ficheros, la diferencia se nota.- Los flujos de
Files.walkvan entry-with-resources. Mantienen descriptores abiertos, y ese es el error más frecuente de esta API.
Errores Comunes y Consejos
- No cerrar el flujo de
Files.lines,listowalk. El error más frecuente de NIO.2. Deja descriptores abiertos y acaba agotando el límite del sistema.try-with-resourcessiempre. - Creer que
exists()ynotExists()son opuestos. Ambos sonfalsecuando no se puede determinar.!exists()no significa "no existe". - Comprobar la existencia y luego operar. TOCTOU: entre las dos cosas el mundo cambia. La comprobación es una cortesía; la excepción es obligatoria.
- Crear el temporal en
/tmpy hacerATOMIC_MOVEal destino.AtomicMoveNotSupportedExceptionsi están en sistemas de ficheros distintos. El temporal va al lado del destino:resolveSibling. - Usar
createDirectoryen vez decreateDirectories. El primero falla si el padre no existe. El segundo crea toda la cadena y es idempotente. - Usar
deletedonde queríasdeleteIfExists.NoSuchFileExceptionen una limpieza que debería ser inofensiva. - Llamar a
size(),isDirectory()ygetLastModifiedTime()por separado. Tres consultas al sistema de ficheros donde bastabareadAttributes. Al recorrer árboles grandes, se nota mucho. - Concatenar cadenas para componer rutas.
resolveyresolveSiblingexisten precisamente para eso, y además hacen bien los separadores. - Usar
startsWithdeStringpara comprobar contención de rutas./home/martarodriguezempieza por/home/martacomo texto, pero no está dentro.Path.startsWithcompara segmentos. - Comprobar contención sin
normalize()antes. Un../..en la ruta pedida se salta la comprobación. Es path traversal, y es una vulnerabilidad real. - Esperar que
Files.copycopie un directorio con su contenido. Copia el directorio vacío. Para el árbol,walkFileTree. - Borrar un directorio con
Files.delete.DirectoryNotEmptyExceptionsi tiene contenido. Hay que recorrerlo en postorden. - Usar
Files.walkconFOLLOW_LINKSsin pensarlo. Un enlace circular provocaFileSystemLoopException, o un recorrido interminable. - Que un directorio inaccesible aborte todo el recorrido. Es lo que hace
Files.walk. ConwalkFileTreeyvisitFileFaileddevolviendoCONTINUE, el recorrido sigue. - Cargar con
readStringoreadAllLinesun fichero de tamaño desconocido.OutOfMemoryError. Para eso estánewBufferedReader. - Usar permisos POSIX sin capturar
UnsupportedOperationException. En Windows ese modelo no existe. - Consejo:
Path.ofen código nuevo.Paths.getfunciona y es lo mismo;Path.ofes la forma actual. - Consejo:
Pathen las firmas públicas,Filesolo en los bordes. Convierte contoFile()justo donde una API antigua lo exija, y no antes. - Consejo: usa
StandardOpenOptionaunque sea más largo de escribir.APPENDes imposible de confundir con un charset, yTRUNCATE_EXISTINGes imposible de poner sin darte cuenta. - Consejo: no migres por migrar. Código con
Fileque funciona no necesita reescribirse. Migra lo que toques por otro motivo, y escribe NIO.2 lo nuevo.
Ejercicios
Ejercicio 1: explorador de rutas
Escribe ExploradorRutas con un método analizar(String ruta) que muestre un informe completo de una ruta usando solo NIO.2:
- Ruta tal cual, absoluta, normalizada y real (capturando la excepción si no existe).
- Nombre, padre, raíz, número de segmentos y la lista de segmentos.
- Si existe: tipo, tamaño, permisos legible/escribible, y los cuatro tiempos de
BasicFileAttributesobtenidos en una sola llamada. - Antigüedad en días desde la última modificación, con aritmética de milisegundos.
- Comparación de
exists()ynotExists(), explicando en la salida qué significa cada combinación.
Pruébalo con una ruta existente, una inexistente, un directorio y una ruta con .. sin normalizar.
Ejercicio 2: sincronizador de directorios
Escribe SincronizadorDirectorios que copie de un directorio origen a uno destino solo lo que haga falta:
- Copia los ficheros que no existen en el destino.
- Copia los que existen pero son más recientes en el origen (compara
FileTimeconcompareTo). - No toca los que están iguales.
- Informa de los que solo están en el destino (posibles borrados), sin borrarlos.
- Crea la estructura de directorios que falte en el destino.
- Usa
walkFileTreepara no abortar si un fichero es inaccesible, yrelativizepara calcular la ruta equivalente. - Devuelve un
recordcon los recuentos y muestra un informe.
Ejercicio 3: gestor de copias de seguridad rotativas
Escribe GestorCopias completo para BiblioTech:
crearCopia(Path fichero)que guarde una copia con marca de tiempo encopias/nombre.AAAAMMDD-HHMMSS.bak, usandoSystem.currentTimeMillis()para el nombre (sinjava.time, que es 10-05).- Conserva un máximo de
Ncopias por fichero; borra las más antiguas ordenando porFileTime. restaurar(Path fichero, int indice)que recupere la copia número indice (0 = la más reciente) de forma atómica, haciendo antes una copia del estado actual.listarCopias(Path fichero)que devuelva la lista ordenada de la más reciente a la más antigua, con tamaño y fecha.espacioOcupado()que sume el tamaño de todas las copias usandowalkFileTree.- Un
mainque demuestre el ciclo completo: crear cinco copias con un máximo de tres, listar, restaurar y comprobar.
Soluciones
Solución 1
package com.nexussoftware.bibliotech.util;
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.PosixFilePermissions;
/**
* Informe completo de una ruta usando solo NIO.2.
*
* Sustituye al DiagnosticoRuta de 07-01, que usaba java.io: aqui cada
* fallo dice POR QUE, en lugar de devolver un boolean mudo.
*/
public class ExploradorRutas {
public static void analizar(String ruta) {
Path p = Path.of(ruta);
System.out.println("========================================");
System.out.println("ANALISIS DE: " + ruta);
System.out.println("========================================");
// ---- 1. Formas de la ruta ----
System.out.println(" Tal cual : " + p);
System.out.println(" Absoluta : " + p.toAbsolutePath());
System.out.println(" Normalizada : " + p.toAbsolutePath().normalize());
try {
// toRealPath SI consulta el disco y resuelve enlaces simbolicos
System.out.println(" Real : " + p.toRealPath());
} catch (NoSuchFileException e) {
System.out.println(" Real : (no existe, no se puede resolver)");
} catch (IOException e) {
System.out.println(" Real : (error: " + e.getMessage() + ")");
}
// ---- 2. Estructura ----
System.out.println(" Nombre : " + p.getFileName());
System.out.println(" Padre : " + p.getParent());
System.out.println(" Raiz : " + p.getRoot());
System.out.println(" ¿Absoluta? : " + p.isAbsolute());
System.out.println(" Segmentos : " + p.getNameCount());
StringBuilder segmentos = new StringBuilder();
for (Path s : p) { // Path es Iterable<Path>
segmentos.append('[').append(s).append(']');
}
System.out.println(" Recorrido : " + segmentos);
// ---- 5. exists / notExists ----
boolean existe = Files.exists(p);
boolean noExiste = Files.notExists(p);
System.out.println(" ----");
System.out.println(" exists() : " + existe);
System.out.println(" notExists() : " + noExiste);
System.out.println(" Interpretacion : " + interpretar(existe, noExiste));
if (!existe) {
System.out.println();
return;
}
// ---- 3. Atributos: UNA sola consulta ----
try {
BasicFileAttributes attrs = Files.readAttributes(p, BasicFileAttributes.class);
System.out.println(" ----");
System.out.println(" Tipo : " + tipo(attrs));
System.out.println(" Tamano : " + attrs.size() + " bytes");
System.out.println(" Creado : " + attrs.creationTime());
System.out.println(" Modificado : " + attrs.lastModifiedTime());
System.out.println(" Ultimo acceso : " + attrs.lastAccessTime());
System.out.println(" ¿Legible? : " + Files.isReadable(p));
System.out.println(" ¿Escribible? : " + Files.isWritable(p));
// ---- 4. Antiguedad ----
long dias = (System.currentTimeMillis() - attrs.lastModifiedTime().toMillis())
/ (1000L * 60 * 60 * 24);
System.out.println(" Antiguedad : " + dias + " dias");
// Permisos POSIX si el sistema los tiene
try {
System.out.println(" Permisos POSIX : " + PosixFilePermissions.toString(
Files.getPosixFilePermissions(p)));
} catch (UnsupportedOperationException e) {
System.out.println(" Permisos POSIX : (sistema sin soporte, p.ej. Windows)");
}
} catch (IOException e) {
System.out.println(" ERROR leyendo atributos: " + e.getMessage());
}
System.out.println();
}
private static String tipo(BasicFileAttributes a) {
if (a.isDirectory()) { return "directorio"; }
if (a.isRegularFile()) { return "fichero"; }
if (a.isSymbolicLink()) { return "enlace simbolico"; }
return "otro";
}
/** Los TRES estados posibles, no dos. */
private static String interpretar(boolean existe, boolean noExiste) {
if (existe && !noExiste) {
return "EXISTE, y se ha podido comprobar";
}
if (!existe && noExiste) {
return "NO EXISTE, y se ha podido comprobar";
}
if (!existe && !noExiste) {
return "NO SE PUEDE DETERMINAR (probablemente sin permiso sobre "
+ "el directorio que la contiene). Por eso !exists() NO "
+ "significa 'no existe'.";
}
return "estado imposible";
}
public static void main(String[] args) {
analizar("datos/catalogo.txt");
analizar("datos");
analizar("datos/no-existe.txt");
analizar("datos/../datos/./informes/../catalogo.txt"); // sin normalizar
}
}Salida (fragmento de la última llamada):
========================================
ANALISIS DE: datos/../datos/./informes/../catalogo.txt
========================================
Tal cual : datos/../datos/./informes/../catalogo.txt
Absoluta : /home/marta/bibliotech/datos/../datos/./informes/../catalogo.txt
Normalizada : /home/marta/bibliotech/datos/catalogo.txt
Real : /home/marta/bibliotech/datos/catalogo.txt
Nombre : catalogo.txt
Segmentos : 7
Recorrido : [datos][..][datos][.][informes][..][catalogo.txt]
----
exists() : true
notExists() : false
Interpretacion : EXISTE, y se ha podido comprobarLos dos puntos clave: primero, la diferencia entre toAbsolutePath() —que solo antepone el directorio de trabajo, sin limpiar nada— y normalize()/toRealPath(), que sí resuelven los ... Esa distinción es exactamente la que hace posible la vulnerabilidad de path traversal del apartado 3. Y segundo, la interpretación de los tres estados de exists/notExists, que es la trampa más sutil de esta API.
Solución 2
package com.nexussoftware.bibliotech.util;
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.logging.Logger;
/**
* Sincroniza un directorio destino con un origen, copiando solo lo necesario.
*
* Es el nucleo de cualquier herramienta de copia incremental: no copiar lo
* que ya esta igual ahorra la mayor parte del trabajo.
*/
public class SincronizadorDirectorios {
private static final Logger LOG =
Logger.getLogger(SincronizadorDirectorios.class.getName());
public record Resultado(int copiados, int actualizados, int sinCambios,
int soloEnDestino, int errores,
List<String> detalleSoloEnDestino) {
public String informe() {
StringBuilder sb = new StringBuilder();
sb.append("=== SINCRONIZACION ===\n");
sb.append(String.format(" Copiados (nuevos) : %d%n", copiados));
sb.append(String.format(" Actualizados : %d%n", actualizados));
sb.append(String.format(" Sin cambios : %d%n", sinCambios));
sb.append(String.format(" Solo en destino : %d%n", soloEnDestino));
sb.append(String.format(" Errores : %d%n", errores));
if (!detalleSoloEnDestino.isEmpty()) {
sb.append(" --- Solo en destino (candidatos a borrar) ---\n");
int n = Math.min(10, detalleSoloEnDestino.size());
for (int i = 0; i < n; i++) {
sb.append(" ").append(detalleSoloEnDestino.get(i)).append('\n');
}
if (detalleSoloEnDestino.size() > n) {
sb.append(String.format(" ... y %d mas%n",
detalleSoloEnDestino.size() - n));
}
}
return sb.toString();
}
}
/** Sincroniza origen hacia destino. NUNCA borra nada del destino. */
public Resultado sincronizar(Path origen, Path destino) throws IOException {
Path raizOrigen = origen.toAbsolutePath().normalize();
Path raizDestino = destino.toAbsolutePath().normalize();
if (!Files.isDirectory(raizOrigen)) {
throw new NotDirectoryException(raizOrigen.toString());
}
Files.createDirectories(raizDestino);
int[] contadores = new int[4]; // copiados, actualizados, iguales, errores
Set<Path> vistosEnOrigen = new HashSet<>();
// ---- FASE 1: recorrer el origen y sincronizar ----
Files.walkFileTree(raizOrigen, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
throws IOException {
// relativize da la ruta equivalente en el destino (apartado 3)
Path equivalente = raizDestino.resolve(raizOrigen.relativize(dir));
Files.createDirectories(equivalente); // idempotente
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path fichero, BasicFileAttributes attrsOrigen) {
Path relativa = raizOrigen.relativize(fichero);
Path equivalente = raizDestino.resolve(relativa);
vistosEnOrigen.add(relativa);
try {
if (Files.notExists(equivalente)) {
// NUEVO: copiar conservando los atributos
Files.copy(fichero, equivalente,
StandardCopyOption.COPY_ATTRIBUTES);
contadores[0]++;
LOG.fine(() -> "Nuevo: " + relativa);
return FileVisitResult.CONTINUE;
}
BasicFileAttributes attrsDestino =
Files.readAttributes(equivalente, BasicFileAttributes.class);
FileTime tOrigen = attrsOrigen.lastModifiedTime();
FileTime tDestino = attrsDestino.lastModifiedTime();
// compareTo > 0 significa que el origen es MAS RECIENTE.
// Se compara tambien el tamano: un fichero con la misma
// fecha pero distinto tamano tambien hay que copiarlo.
if (tOrigen.compareTo(tDestino) > 0
|| attrsOrigen.size() != attrsDestino.size()) {
Files.copy(fichero, equivalente,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.COPY_ATTRIBUTES);
contadores[1]++;
LOG.fine(() -> "Actualizado: " + relativa);
} else {
contadores[2]++;
}
} catch (IOException e) {
contadores[3]++;
LOG.warning(() -> "Error sincronizando " + relativa
+ ": " + e.getMessage());
}
return FileVisitResult.CONTINUE;
}
/**
* CLAVE: un fichero inaccesible NO aborta la sincronizacion.
* Con Files.walk esto no seria posible.
*/
@Override
public FileVisitResult visitFileFailed(Path fichero, IOException e) {
contadores[3]++;
LOG.warning(() -> "No accesible en origen: " + fichero);
return FileVisitResult.CONTINUE;
}
});
// ---- FASE 2: detectar lo que solo esta en destino ----
List<String> soloDestino = new ArrayList<>();
Files.walkFileTree(raizDestino, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path fichero, BasicFileAttributes attrs) {
Path relativa = raizDestino.relativize(fichero);
if (!vistosEnOrigen.contains(relativa)) {
soloDestino.add(relativa.toString());
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path f, IOException e) {
return FileVisitResult.CONTINUE;
}
});
soloDestino.sort(null);
return new Resultado(contadores[0], contadores[1], contadores[2],
soloDestino.size(), contadores[3], soloDestino);
}
public static void main(String[] args) throws IOException {
Resultado r = new SincronizadorDirectorios()
.sincronizar(Path.of("datos"), Path.of("copia-seguridad/datos"));
System.out.println(r.informe());
}
}Salida de una segunda ejecución, tras haber modificado un fichero:
=== SINCRONIZACION ===
Copiados (nuevos) : 0
Actualizados : 1
Sin cambios : 11
Solo en destino : 2
Errores : 0
--- Solo en destino (candidatos a borrar) ---
informes/2025/12/multas-diciembre.informe
catalogo-antiguo.txtLos cuatro puntos didácticos:
relativize+resolvees el par que hace esto posible.raizOrigen.relativize(fichero)da la ruta relativa;raizDestino.resolve(relativa)da el equivalente. Con concatenación de cadenas esto se hace mal en cuanto hay subdirectorios.visitFileFaileddevolviendoCONTINUEes la razón de usarwalkFileTree. Un solo fichero bloqueado abortaría unFiles.walkentero, y una herramienta de copia que se rinde ante el primer problema no sirve.- Se compara fecha y tamaño. Comparar solo la fecha deja pasar ficheros modificados con la misma marca de tiempo, que ocurre más de lo que parece con algunas herramientas. Comparar solo el tamaño deja pasar cambios que no lo alteran.
- No borra nada. Informa de lo que sobra y deja la decisión a quien ejecuta. Una herramienta que borra automáticamente en un directorio de copia de seguridad es una forma eficiente de perder datos.
Solución 3
package com.nexussoftware.bibliotech.infraestructura;
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.logging.Logger;
import java.util.stream.Stream;
/**
* Copias de seguridad rotativas con marca de tiempo, sobre NIO.2.
*
* Cada copia se nombra fichero.AAAAMMDD-HHMMSS.bak. Se conservan las N mas
* recientes; las demas se borran.
*
* NOTA: el nombre con fecha se compone con SimpleDateFormat porque java.time
* es la leccion 10-05. Con LocalDateTime y DateTimeFormatter esto queda
* bastante mejor, y alli lo veras.
*/
public class GestorCopias {
private static final Logger LOG = Logger.getLogger(GestorCopias.class.getName());
private static final String SUFIJO = ".bak";
private static final int MAX_COPIAS_DEFECTO = 3;
private final Path directorioCopias;
private final int maxCopias;
public GestorCopias(Path directorioCopias) throws IOException {
this(directorioCopias, MAX_COPIAS_DEFECTO);
}
public GestorCopias(Path directorioCopias, int maxCopias) throws IOException {
this.directorioCopias = Objects.requireNonNull(directorioCopias)
.toAbsolutePath().normalize();
if (maxCopias < 1) {
throw new IllegalArgumentException(
"Hay que conservar al menos una copia, y se pidio: " + maxCopias);
}
this.maxCopias = maxCopias;
Files.createDirectories(this.directorioCopias);
}
/** Informacion de una copia existente. */
public record InfoCopia(Path ruta, String nombre, long bytes, FileTime creada) {
public String linea() {
return String.format("%-45s %8d bytes %s", nombre, bytes, creada);
}
}
// ---------------------- CREAR ----------------------
/**
* Crea una copia con marca de tiempo y aplica la rotacion.
*
* @return la ruta de la copia creada
*/
public Path crearCopia(Path fichero) throws IOException {
Objects.requireNonNull(fichero, "El fichero no puede ser nulo");
if (Files.notExists(fichero)) {
throw new NoSuchFileException(fichero.toString(),
null, "No se puede copiar un fichero que no existe");
}
String nombreBase = fichero.getFileName().toString();
String marca = new java.text.SimpleDateFormat("yyyyMMdd-HHmmss")
.format(new Date(System.currentTimeMillis()));
Path copia = directorioCopias.resolve(nombreBase + "." + marca + SUFIJO);
// Si en el mismo segundo se pide otra copia, se anade un sufijo
int intento = 1;
while (Files.exists(copia)) {
copia = directorioCopias.resolve(
nombreBase + "." + marca + "-" + intento + SUFIJO);
intento++;
}
// COPY_ATTRIBUTES conserva la fecha original del fichero copiado
Files.copy(fichero, copia, StandardCopyOption.COPY_ATTRIBUTES);
final Path creada = copia;
LOG.info(() -> "Copia creada: " + creada.getFileName());
aplicarRotacion(nombreBase);
return copia;
}
/** Borra las copias que sobran, empezando por las mas antiguas. */
private void aplicarRotacion(String nombreBase) throws IOException {
List<InfoCopia> copias = listarCopias(nombreBase);
for (int i = maxCopias; i < copias.size(); i++) {
InfoCopia sobrante = copias.get(i); // ya vienen de nueva a vieja
if (Files.deleteIfExists(sobrante.ruta())) {
LOG.fine(() -> "Copia antigua borrada: " + sobrante.nombre());
}
}
}
// ---------------------- LISTAR ----------------------
/** Copias de un fichero, de la MAS RECIENTE a la mas antigua. */
public List<InfoCopia> listarCopias(String nombreBase) throws IOException {
List<InfoCopia> encontradas = new ArrayList<>();
String prefijo = nombreBase + ".";
// Files.list devuelve un flujo PEREZOSO: try-with-resources obligatorio
try (Stream<Path> hijos = Files.list(directorioCopias)) {
hijos.forEach(p -> {
String nombre = p.getFileName().toString();
if (!nombre.startsWith(prefijo) || !nombre.endsWith(SUFIJO)) {
return;
}
try {
BasicFileAttributes attrs =
Files.readAttributes(p, BasicFileAttributes.class);
encontradas.add(new InfoCopia(p, nombre, attrs.size(),
attrs.lastModifiedTime()));
} catch (IOException e) {
LOG.fine(() -> "No se pudo leer " + p);
}
});
}
// Mas reciente primero (05-09)
encontradas.sort(Comparator.comparing(InfoCopia::creada).reversed());
return encontradas;
}
public List<InfoCopia> listarCopias(Path fichero) throws IOException {
return listarCopias(fichero.getFileName().toString());
}
// ---------------------- RESTAURAR ----------------------
/**
* Restaura la copia numero 'indice' (0 = la mas reciente).
*
* ANTES de restaurar, hace una copia del estado ACTUAL: si la
* restauracion resulta ser un error, se puede deshacer. Restaurar sin
* esa red es una forma habitual de perder el trabajo del dia.
*
* La sustitucion es ATOMICA: el fichero nunca queda a medias.
*/
public void restaurar(Path fichero, int indice) throws IOException {
List<InfoCopia> copias = listarCopias(fichero);
if (copias.isEmpty()) {
throw new NoSuchFileException(fichero.toString(),
null, "No hay ninguna copia de seguridad de este fichero");
}
if (indice < 0 || indice >= copias.size()) {
throw new IllegalArgumentException(String.format(
"Indice %d fuera de rango: hay %d copias (0..%d)",
indice, copias.size(), copias.size() - 1));
}
InfoCopia elegida = copias.get(indice);
// 1. Red de seguridad: copiar el estado actual antes de pisarlo
if (Files.exists(fichero)) {
crearCopia(fichero);
}
// 2. Temporal HERMANO del destino: ATOMIC_MOVE lo exige
Path temporal = fichero.resolveSibling(fichero.getFileName() + ".restaurando");
boolean completado = false;
try {
Files.copy(elegida.ruta(), temporal,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.COPY_ATTRIBUTES);
Files.move(temporal, fichero,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
completado = true;
LOG.info(() -> "Restaurado " + fichero.getFileName()
+ " desde " + elegida.nombre());
} catch (AtomicMoveNotSupportedException e) {
LOG.warning(() -> "Sin ATOMIC_MOVE; restauracion no atomica");
Files.move(temporal, fichero, StandardCopyOption.REPLACE_EXISTING);
completado = true;
} finally {
if (!completado) {
Files.deleteIfExists(temporal); // compensacion (06-05)
}
}
}
// ---------------------- ESPACIO ----------------------
/** Espacio total ocupado por las copias. */
public long espacioOcupado() throws IOException {
long[] total = new long[1];
Files.walkFileTree(directorioCopias, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path f, BasicFileAttributes attrs) {
if (f.getFileName().toString().endsWith(SUFIJO)) {
total[0] += attrs.size();
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path f, IOException e) {
return FileVisitResult.CONTINUE; // no abortar por un fallo
}
});
return total[0];
}
// ---------------------- DEMOSTRACION ----------------------
public static void main(String[] args) throws IOException, InterruptedException {
Path fichero = Path.of("datos/catalogo.txt");
Files.createDirectories(fichero.getParent());
GestorCopias gestor = new GestorCopias(Path.of("datos/copias"), 3);
// 1. Cinco versiones distintas, con maximo de 3 copias
System.out.println("=== CREANDO 5 COPIAS (maximo 3) ===");
for (int v = 1; v <= 5; v++) {
Files.writeString(fichero,
"# Catalogo version " + v + "\n"
+ "LIBRO;978-000000000" + v + ";Libro " + v + ";Autor;2020\n",
java.nio.charset.StandardCharsets.UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE);
Path copia = gestor.crearCopia(fichero);
System.out.println(" v" + v + " -> " + copia.getFileName());
Thread.sleep(1100); // para que la marca de tiempo cambie
}
// 2. Listar: solo deben quedar las 3 ultimas
System.out.println();
System.out.println("=== COPIAS CONSERVADAS ===");
List<InfoCopia> copias = gestor.listarCopias(fichero);
for (int i = 0; i < copias.size(); i++) {
System.out.printf(" [%d] %s%n", i, copias.get(i).linea());
}
// 3. Contenido actual
System.out.println();
System.out.println("=== CONTENIDO ACTUAL ===");
System.out.print(Files.readString(fichero));
// 4. Restaurar la copia [1] (la penultima, es decir, la version 4)
System.out.println();
System.out.println("=== RESTAURANDO LA COPIA [1] ===");
gestor.restaurar(fichero, 1);
System.out.print(Files.readString(fichero));
// 5. Espacio
System.out.println();
System.out.printf("=== ESPACIO OCUPADO: %.2f KB en %d copias ===%n",
gestor.espacioOcupado() / 1024.0,
gestor.listarCopias(fichero).size());
}
}Salida:
=== CREANDO 5 COPIAS (maximo 3) ===
v1 -> catalogo.txt.20260805-091422.bak
v2 -> catalogo.txt.20260805-091423.bak
v3 -> catalogo.txt.20260805-091424.bak
v4 -> catalogo.txt.20260805-091425.bak
v5 -> catalogo.txt.20260805-091426.bak
=== COPIAS CONSERVADAS ===
[0] catalogo.txt.20260805-091426.bak 68 bytes 2026-08-05T09:14:26.118Z
[1] catalogo.txt.20260805-091425.bak 68 bytes 2026-08-05T09:14:25.012Z
[2] catalogo.txt.20260805-091424.bak 68 bytes 2026-08-05T09:14:23.907Z
=== CONTENIDO ACTUAL ===
# Catalogo version 5
LIBRO;978-0000000005;Libro 5;Autor;2020
=== RESTAURANDO LA COPIA [1] ===
# Catalogo version 4
LIBRO;978-0000000004;Libro 4;Autor;2020
=== ESPACIO OCUPADO: 0,20 KB en 3 copias ===Los cinco puntos didácticos:
- La rotación se hace por fecha, no por nombre. Ordenar por
FileTimeconComparator.comparing(...).reversed()es más robusto que confiar en que el nombre ordene bien —que aquí funcionaría, porque el formatoAAAAMMDD-HHMMSSes ordenable alfabéticamente, pero es una coincidencia afortunada que no conviene dar por sentada—. - Restaurar hace primero una copia del estado actual. Es la diferencia entre una herramienta de copias y una trampa: si restauras la versión equivocada, puedes deshacerlo. Fíjate en que tras la restauración hay 3 copias, no 4: la rotación se aplicó también a la copia de seguridad previa.
- La restauración es atómica con el temporal hermano y
ATOMIC_MOVE, con degradación explícita si el sistema de ficheros no lo soporta. Files.listva entry-with-resources. SilistarCopiasse llama miles de veces sin cerrar el flujo, se agotan los descriptores del proceso.- El bucle de colisión de nombres. Dos copias en el mismo segundo tendrían el mismo nombre; el sufijo
-1,-2lo evita. Es un detalle pequeño que se descubre en producción cuando alguien pulsa el botón dos veces.
Conclusión
Todos aquellos comentarios de "en 07-06 esto se hace bien" están resueltos.
Sabes por qué existe NIO.2 y qué siete defectos de java.io.File corrige: los boolean mudos que dicen que algo falló sin decir por qué, el listFiles() que devuelve null, la ceguera ante los enlaces simbólicos, los metadatos pobres, el renameTo que se comporta distinto en cada sistema, la imposibilidad de extenderla y la ausencia de recorrido eficiente de árboles. Y sabes que la regla profesional no admite matices: Path y Files en código nuevo, File solo para leer código antiguo y para las APIs que todavía lo exigen, con toPath()/toFile() como puente en los bordes.
Dominas Path: Path.of como forma actual, la consulta de nombre, padre, raíz y segmentos, la iteración —porque Path es Iterable<Path>— y el startsWith que compara segmentos completos, no prefijos de texto. Y dominas su aritmética: resolve para combinar, con la regla de que un argumento absoluto sustituye a la base; resolveSibling para el hermano en el mismo directorio, que es exactamente lo que necesita un fichero temporal; relativize para la ruta de A a B; y normalize para eliminar . y .. de forma puramente sintáctica, frente a toRealPath(), que sí consulta el disco. Y conoces el uso de normalize + startsWith que impide el path traversal, una vulnerabilidad real que con concatenación de cadenas se previene mal casi siempre.
Conoces Files de arriba abajo. Sabes que exists() y notExists() no son opuestos, porque hay un tercer estado —"no se puede determinar"— y que por tanto !exists() no significa "no existe". Sabes que createDirectories es idempotente y crea toda la cadena de padres, que delete y deleteIfExists se distinguen por si la ausencia del fichero es un error, y que cada fallo llega como una excepción específica —NoSuchFileException, DirectoryNotEmptyException, AccessDeniedException— que dice exactamente qué pasó. Eso es lo que 06-04 pedía a una excepción.
Y tienes la pieza que llevabas cuatro lecciones aproximando: Files.move con ATOMIC_MOVE, que sustituye la secuencia borrar-más-renombrar por una sola operación sin hueco intermedio. Con sus dos límites bien entendidos: solo dentro del mismo sistema de ficheros —de ahí que el temporal deba ser hermano del destino— y con AtomicMoveNotSupportedException como degradación prevista. La EscrituraAtomica de 07-02, con sus cuarenta líneas de contabilidad, cabe ahora en quince y garantiza más.
Sabes leer y escribir en una línea con readString, writeString, readAllLines y write, con la advertencia de siempre —cargan el fichero entero en memoria— y con newBufferedReader/newBufferedWriter como forma correcta para lo que crece. Y tienes StandardOpenOption, que arregla el error más caro del módulo: APPEND es imposible de confundir con un charset, y TRUNCATE_EXISTING es imposible de escribir sin querer. CREATE_NEW, además, da por fin un bloqueo de fichero correcto: comprobar y crear en una operación atómica.
Conoces Files.lines, list y walk, con lo único que importa ahora: devuelven flujos perezosos que mantienen el fichero abierto y HAY que cerrar con try-with-resources, y se usan aquí con forEach porque la API completa de Streams es 10-04. Sabes que Files.list arregla los dos defectos de listFiles() y que Files.walk aborta el recorrido entero cuando encuentra algo inaccesible —motivo por el cual existe walkFileTree con SimpleFileVisitor, sus cuatro métodos y sus cuatro valores de retorno, y con el visitFileFailed devolviendo CONTINUE que hace que un recorrido siga adelante—. Y sabes borrar un árbol en postorden, que es la única forma de que funcione.
Conoces los atributos con readAttributes —una consulta para todos, en vez de una por atributo, que al recorrer árboles grandes cambia el rendimiento—, FileTime con su toString(), su toMillis() y su compareTo para ordenar y calcular antigüedades, remitiendo a 10-05 para las fechas de verdad; y los permisos POSIX con su UnsupportedOperationException en Windows y su uso importante: crear un fichero ya con permisos restringidos, sin la ventana de exposición de crearlo y protegerlo después. Y conoces los ficheros temporales con DELETE_ON_CLOSE y el WatchService con sus limitaciones.
BiblioTech tiene su capa de persistencia migrada. AlmacenBiblioTech prepara su estructura de directorios con createDirectories idempotente, normaliza su raíz una sola vez, compone todas sus rutas con resolve en lugar de concatenar, escribe de forma verdaderamente atómica con el temporal hermano y ATOMIC_MOVE, mantiene copias de seguridad rotativas de mayor a menor, añade a la auditoría con APPEND explícito, guarda informes en un árbol informes/AAAA/MM/, los localiza con Files.walk cerrado como es debido y leyendo los atributos de una sola vez, limpia los antiguos por antigüedad y calcula el espacio con walkFileTree sin abortar por un fichero inaccesible. Ni un boolean mudo, ni un null de retorno, ni una comprobación de existencia que sustituya a una excepción.
Y sin embargo, queda lo que da sentido a todo el módulo. El catálogo de BiblioTech sigue guardándose con un split(";") ingenuo y un sanear() que sustituye los puntos y coma de los títulos por comas y pierde el dato. Lo marcaste como un apaño en 07-02 y sigue siéndolo. Y las cuatro constantes de negocio —DIAS_PRESTAMO, TARIFA_DIARIA, MULTA_MAXIMA, UMBRAL_LEVE— llevan siete módulos escritas a fuego en el código: cambiar la tarifa de las multas exige recompilar y volver a desplegar la aplicación.
En la lección 07-07, Formatos de Intercambio: CSV y Properties, se cierra el módulo con las dos cosas. Verás por qué un formato de texto es superior a uno binario para intercambiar y qué se paga a cambio; el CSV de verdad, con todo lo que el split ingenuo hace mal —comas dentro de campos entrecomillados, comillas escapadas, saltos de línea dentro de un campo, campos vacíos frente a nulos, el BOM— y una implementación correcta con su escapar/desescapar y su tabla de casos límite, más el conflicto del separador decimal en español que ya asomó en 07-01; y Properties, con su formato clave-valor, su load/store, su problema histórico de codificación, y la jerarquía de configuración completa: valores por defecto en el código, fichero bibliotech.properties, propiedades del sistema con -D y variables de entorno, con su tabla de precedencia. Y entonces sí, las cuatro constantes dejarán de ser constantes: BiblioTech se configurará sin recompilar, y recordará entre ejecuciones todo lo que hace.
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
