Archivos, Serialización y Empaquetado JAR

Todo lo que construiste hasta acá tiene el mismo problema: desaparece al cerrar el programa. Las listas, los árboles, los grafos, todo vive en el Heap, y el Heap se evapora cuando la JVM termina.

Esta lección cierra ese círculo con dos temas que suelen ir juntos: cómo guardar el estado en disco y cómo entregar tu aplicación para que otro la ejecute sin tener tu código.


1. Las dos familias de I/O, y el patrón decorador

Java tiene dos jerarquías paralelas para entrada/salida, y confundirlas es la primera fuente de bugs.

Las dos familias de streams de Java y el patrón decorador que las envuelve Bytes — datos binarios InputStream · OutputStream Imágenes, PDFs, audio, objetos serializados: cualquier cosa que no sea texto legible. Caracteres — texto Reader · Writer Aplican una CODIFICACIÓN al traducir bytes a caracteres. Sin ella, las tildes y las ñ se rompen. El patrón decorador: cada capa agrega una capacidad BufferedReader lee bloques de 8 KB y sirve línea por línea FileReader sabe abrir un archivo y leer caracteres "datos.txt" — el archivo real en el disco Sin el buffer, cada carácter sería una ida al disco. Con él, una cada 8 KB. new BufferedReader(new FileReader("datos.txt")) — se lee de adentro hacia afuera: el FileReader toca el disco, el BufferedReader lo amortigua. Es la misma composición de Herencia, Polimorfismo y Sobrecarga de Métodos: envolver en lugar de heredar. Leer sin buffer un archivo de 10 MB puede ser cien veces más lento. No es una optimización opcional.
Los Buffered* no cambian lo que hacés, cambian cuántas veces se toca el disco. Por eso se envuelve siempre.

2. La forma moderna: Files y Path

Desde Java 7 existe NIO.2, y para el 90 % de los casos convierte diez líneas en una.

Comparación entre la API clásica de java.io y la API moderna de Files java.io clásico — leer un archivo entero StringBuilder sb = new StringBuilder(); try (BufferedReader r = new BufferedReader(new FileReader(f))) { String l; while ((l = r.readLine()) != null) sb.append(l).append("\n"); } 4 líneas, un bucle y una asignación dentro de la condición. NIO.2 moderno — lo mismo Path ruta = Path.of("datos.txt"); String texto = Files.readString(ruta); 1 línea UTF-8 por defecto, cierra sola, sin bucle.
El API clásico sigue siendo necesario para archivos gigantes que no entran en memoria. Para todo lo demás, Files.
import java.nio.file.*;
import java.io.IOException;

Path ruta = Path.of("datos", "catalogo.txt");   // arma la ruta sin separadores a mano

// Leer todo de una (archivos chicos y medianos)
String contenido = Files.readString(ruta);
List<String> lineas = Files.readAllLines(ruta);

// Escribir
Files.writeString(ruta, "hola\n");                                  // sobrescribe
Files.writeString(ruta, "otra línea\n", StandardOpenOption.APPEND); // agrega al final

// Consultas
boolean existe   = Files.exists(ruta);
long tamanio     = Files.size(ruta);
Files.createDirectories(ruta.getParent());   // crea toda la jerarquía si falta

Para archivos grandes, Files.lines() devuelve un stream perezoso: procesa línea por línea sin cargar todo en memoria.

// Cuenta las líneas de error de un log de 2 GB sin usar 2 GB de RAM
try (Stream<String> lineas = Files.lines(Path.of("app.log"))) {
    long errores = lineas.filter(l -> l.contains("ERROR")).count();
    System.out.println("Errores: " + errores);
}

Fijate el try-with-resources de la lección Manejo de Excepciones y Robustez: Files.lines abre un archivo, así que hay que cerrarlo. readString y readAllLines no lo necesitan porque cierran solos.

La trampa de la codificación

Este es el bug clásico que aparece solo en la máquina de otra persona:

// MAL: usa la codificación por defecto del sistema operativo
new FileReader("datos.txt");
new FileWriter("salida.txt");

// BIEN: la codificación es explícita y el archivo se lee igual en todos lados
Files.readString(ruta);                                    // UTF-8 por defecto
Files.newBufferedReader(ruta, StandardCharsets.UTF_8);
new FileWriter("salida.txt", StandardCharsets.UTF_8);

Un archivo escrito con la codificación por defecto de Windows y leído con la de Linux convierte cada ñ y cada á en basura. Fijá siempre UTF-8 explícitamente.


3. Serialización: guardar objetos enteros

Escribir texto está bien para datos simples. Pero ¿cómo guardás un Producto con sus atributos, o una lista entera de ellos?

La serialización convierte un objeto del Heap en una secuencia de bytes, y viceversa.

Ciclo de serialización y deserialización de un objeto a un archivo Objeto en el Heap Producto("Yerba", 3200) vive en memoria ObjectOutputStream writeObject(producto) recorre los campos y los aplana catalogo.ser bytes en el disco sobrevive al programa ObjectInputStream readObject() → objeto NUEVO lee reconstruye transient — lo que NO se guarda Contraseñas, conexiones, cachés. Al volver quedan en null o 0. serialVersionUID — el número de versión Si cambia la clase y no lo declaraste vos, Java lo recalcula: InvalidClassException.
El objeto que sale de readObject() es nuevo: mismos datos, otra dirección de memoria. Y el constructor de la clase nunca se ejecuta.
import java.io.*;
import java.util.List;

public class Producto implements Serializable {          // marcador: "soy serializable"
    private static final long serialVersionUID = 1L;     // declaralo SIEMPRE, a mano

    private final String nombre;
    private final double precio;
    private transient String cacheDeCalculo;             // NO se guarda

    public Producto(String nombre, double precio) {
        this.nombre = nombre;
        this.precio = precio;
    }
}

// Guardar una lista entera de una sola vez
try (ObjectOutputStream out = new ObjectOutputStream(
         Files.newOutputStream(Path.of("catalogo.ser")))) {
    out.writeObject(catalogo);
}

// Recuperarla
try (ObjectInputStream in = new ObjectInputStream(
         Files.newInputStream(Path.of("catalogo.ser")))) {
    @SuppressWarnings("unchecked")
    List<Producto> catalogo = (List<Producto>) in.readObject();
}

Tres cosas que hay que saber sí o sí:

  1. serialVersionUID declaralo a mano. Si no lo hacés, Java calcula uno a partir de la estructura de la clase. Agregás un campo, ese número cambia, y todos los archivos guardados antes dejan de poder leerse con InvalidClassException.
  2. transient excluye un campo. Al deserializar queda en null o 0. Es lo correcto para contraseñas, conexiones abiertas y cachés.
  3. El constructor no corre. Java reconstruye el objeto campo por campo, salteándose tu constructor. Toda la validación que pusiste ahí no se aplica.

En 2026, la serialización de Java se usa poco en sistemas nuevos. El formato es propietario —solo lo lee otro programa Java—, es frágil ante cambios en las clases, y ha sido fuente de vulnerabilidades graves de deserialización. Para intercambiar datos hoy se usa JSON (con Jackson o Gson) o formatos binarios como Protobuf. Aprendela porque la vas a encontrar en código existente, no porque sea la primera opción.


4. Empaquetar: del código fuente al JAR ejecutable

Tu programa funciona en tu IDE. Ahora hay que entregarlo.

Del código fuente al JAR ejecutable, paso a paso Main.java código fuente que escribís vos javac Main.class bytecode que entiende la JVM jar app.jar un ZIP con todos los .class adentro java -jar corre en cualquier máquina con JVM META-INF/MANIFEST.MF — dentro del JAR Manifest-Version: 1.0 Main-Class: com.facundouferer.tienda.Main Sin esa línea, java -jar no sabe por dónde empezar y falla.
Un JAR es literalmente un archivo ZIP con una convención de nombres. Podés abrirlo con cualquier descompresor y ver qué hay adentro.
# 1. Compilar todo a la carpeta bin/
javac -d bin $(find src -name "*.java")

# 2. Empaquetar. La 'e' declara la clase principal en el manifiesto
jar cvfe app.jar com.facundouferer.tienda.Main -C bin .

# 3. Ejecutar en cualquier máquina que tenga una JVM
java -jar app.jar

Las banderas de jar: c crear, v verboso, f nombre del archivo, e entry point. La parte -C bin . significa “cambiá a la carpeta bin y meté todo lo que hay ahí”.

En un proyecto real vas a usar Maven o Gradle, que hacen esto y además descargan dependencias, corren los tests y arman un fat jar con las librerías incluidas:

mvn package        # deja el jar en target/
./gradlew build    # lo deja en build/libs/

5. Errores frecuentes

ErrorQué pasaCómo se arregla
No cerrar el archivoEl archivo queda bloqueado y, al escribir, se pierde lo que quedó en el buffer.try-with-resources, siempre.
Usar FileReader/FileWriter sin charsetEl archivo se lee bien en tu máquina y se rompe en otra. Tildes y ñ convertidas en basura.Pasar StandardCharsets.UTF_8 explícitamente, o usar Files.
Leer un archivo enorme con readAllLinesOutOfMemoryError con un log de varios GB.Files.lines() dentro de un try-with-resources.
No declarar serialVersionUIDAgregás un campo y todos los archivos guardados dejan de leerse: InvalidClassException.private static final long serialVersionUID = 1L;.
Esperar que el constructor corra al deserializarLas validaciones no se aplican y el objeto puede quedar inválido.Validar en readObject, o directamente no usar serialización nativa.
Concatenar rutas con "/" o "\\" a manoFalla al cambiar de sistema operativo.Path.of("carpeta", "archivo.txt").
jar sin Main-Class en el manifiestojava -jar responde “no main manifest attribute”.Usar jar cvfe con la clase principal, o declararla en el manifiesto.
Serializar objetos con campos no serializablesNotSerializableException en tiempo de ejecución.Marcarlos transient, o que la clase también implemente Serializable.

6. Ejercicio práctico guiado

Desafío: catálogo persistente

  1. Creá Producto serializable, con serialVersionUID declarado.
  2. Escribí Catalogo con guardar(Path) y cargar(Path) usando serialización.
  3. Agregá exportarCSV(Path) e importarCSV(Path) con la API Files.
  4. Manejá el caso “el archivo no existe” devolviendo un catálogo vacío, sin que explote.
  5. Compará los dos formatos: abrí el .ser y el .csv en un editor de texto.
Ver solución sugerida
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Stream;

public class Producto implements Serializable {
    private static final long serialVersionUID = 1L;   // declarado a mano, a propósito

    private final String nombre;
    private final double precio;
    private final int stock;

    public Producto(String nombre, double precio, int stock) {
        if (nombre == null || nombre.isBlank()) {
            throw new IllegalArgumentException("El nombre es obligatorio");
        }
        if (precio < 0) throw new IllegalArgumentException("Precio negativo");
        this.nombre = nombre;
        this.precio = precio;
        this.stock = stock;
    }

    public String getNombre() { return nombre; }
    public double getPrecio() { return precio; }
    public int getStock()     { return stock; }

    public String aLineaCSV() {
        // Escapamos las comillas del nombre para no romper el formato
        return String.format(Locale.US, "\"%s\";%.2f;%d",
                             nombre.replace("\"", "\"\""), precio, stock);
    }

    public static Producto desdeLineaCSV(String linea) {
        String[] campos = linea.split(";");
        if (campos.length != 3) {
            throw new IllegalArgumentException("Línea CSV inválida: " + linea);
        }
        String nombre = campos[0].replaceAll("^\"|\"$", "").replace("\"\"", "\"");
        return new Producto(nombre,
                            Double.parseDouble(campos[1]),
                            Integer.parseInt(campos[2]));
    }

    @Override
    public String toString() {
        return String.format(Locale.US, "%-20s $%9.2f  x%d", nombre, precio, stock);
    }
}

public class Catalogo {

    private final List<Producto> productos = new ArrayList<>();

    public void agregar(Producto p) { productos.add(p); }
    public List<Producto> getProductos() { return List.copyOf(productos); }  // copia defensiva

    // ── Serialización nativa: binaria, solo la lee Java ──────────
    public void guardar(Path ruta) throws IOException {
        Files.createDirectories(ruta.toAbsolutePath().getParent());
        try (ObjectOutputStream out = new ObjectOutputStream(
                 new BufferedOutputStream(Files.newOutputStream(ruta)))) {
            out.writeObject(productos);
        }
    }

    @SuppressWarnings("unchecked")
    public static Catalogo cargar(Path ruta) throws IOException, ClassNotFoundException {
        Catalogo catalogo = new Catalogo();
        if (!Files.exists(ruta)) {
            return catalogo;   // 4. archivo inexistente → catálogo vacío, sin excepción
        }
        try (ObjectInputStream in = new ObjectInputStream(
                 new BufferedInputStream(Files.newInputStream(ruta)))) {
            catalogo.productos.addAll((List<Producto>) in.readObject());
        }
        return catalogo;
    }

    // ── CSV: texto, lo lee cualquier programa ────────────────────
    public void exportarCSV(Path ruta) throws IOException {
        List<String> lineas = new ArrayList<>();
        lineas.add("nombre;precio;stock");                    // encabezado
        for (Producto p : productos) {
            lineas.add(p.aLineaCSV());
        }
        Files.write(ruta, lineas, StandardCharsets.UTF_8);    // charset explícito
    }

    public static Catalogo importarCSV(Path ruta) throws IOException {
        Catalogo catalogo = new Catalogo();
        if (!Files.exists(ruta)) return catalogo;

        // Stream perezoso: funciona igual con un CSV de 5 GB
        try (Stream<String> lineas = Files.lines(ruta, StandardCharsets.UTF_8)) {
            lineas.skip(1)                                    // salteamos el encabezado
                  .filter(l -> !l.isBlank())
                  .map(Producto::desdeLineaCSV)
                  .forEach(catalogo::agregar);
        }
        return catalogo;
    }

    public static void main(String[] args) throws Exception {
        Path ser = Path.of("datos", "catalogo.ser");
        Path csv = Path.of("datos", "catalogo.csv");
        Files.createDirectories(Path.of("datos"));

        Catalogo original = new Catalogo();
        original.agregar(new Producto("Yerba Playadito", 3200.00, 45));
        original.agregar(new Producto("Café molido",     5800.50, 12));
        original.agregar(new Producto("Azúcar 1kg",      1150.00, 80));

        original.guardar(ser);
        original.exportarCSV(csv);

        System.out.println("Recuperado del .ser:");
        Catalogo.cargar(ser).getProductos().forEach(p -> System.out.println("  " + p));

        System.out.println("\nRecuperado del .csv:");
        Catalogo.importarCSV(csv).getProductos().forEach(p -> System.out.println("  " + p));

        System.out.println("\nArchivo inexistente → " +
            Catalogo.cargar(Path.of("no-existe.ser")).getProductos().size() + " productos");

        System.out.println("\nTamaños:  .ser " + Files.size(ser) +
                           " bytes   ·   .csv " + Files.size(csv) + " bytes");
        System.out.println("\nContenido del CSV (legible por cualquiera):");
        Files.lines(csv).forEach(l -> System.out.println("  " + l));
    }
}

Abrí los dos archivos en un editor de texto. Esa comparación es el ejercicio de verdad.

El .csv se lee perfecto, lo abre Excel, lo puede parsear Python, y si mañana agregás un campo a Producto los archivos viejos siguen siendo legibles.

El .ser es binario ilegible, solo lo entiende otro programa Java, y si agregás un campo sin cuidar el serialVersionUID todos los archivos guardados se vuelven basura.

Por eso, salvo que necesites específicamente serialización nativa —caché entre procesos Java, HttpSession replicada—, elegí un formato de texto. Hoy sería JSON con Jackson, que es lo que vas a ver en la próxima lección con Spring Boot.

Detalle a mirar: importarCSV usa Files.lines() con try-with-resources y un stream perezoso. Ese mismo código funciona con un CSV de tres líneas o de cinco gigabytes, porque nunca carga el archivo entero en memoria.


7. Distribución nativa con jpackage

Un JAR ejecutable todavía supone que la persona instaló una JVM compatible y sabe ejecutar java -jar. jpackage, incluido en los JDK modernos completos, crea una aplicación con lanzador nativo y una imagen de runtime Java incluida. El usuario no necesita configurar Java por separado.

Prerrequisitos: comprobar, no asumir

Usá un JDK completo y verificá las herramientas antes de empaquetar:

java -version
javac -version
jpackage --version

test -f dist/tienda.jar || {
  echo "Falta dist/tienda.jar; ejecutá primero el build" >&2
  exit 1
}

En PowerShell, sin asumir una ruta ni modificar el equipo:

$tool = Get-Command jpackage -ErrorAction SilentlyContinue
if (-not $tool) { throw "jpackage no está disponible: instalá un JDK completo" }
if (-not (Test-Path -LiteralPath '.\dist\tienda.jar' -PathType Leaf)) {
    throw 'Falta dist\tienda.jar; ejecutá primero el build'
}
jpackage --version

Si jpackage no existe, no continúes con un comando descargado al azar ni supongas credenciales administrativas. Corregí JAVA_HOME/PATH o instalá un JDK aprobado por el equipo.

Primero una imagen de aplicación

Generá primero --type app-image: es más rápido de inspeccionar que un instalador y permite detectar una clase principal incorrecta, dependencias ausentes o recursos mal ubicados.

APP_VERSION='1.2.0'
printf '%s' "$APP_VERSION" | grep -Eq '^[0-9]+([.][0-9]+){0,2}$' || {
  echo 'Versión inválida: usá, por ejemplo, 1.2.0' >&2
  exit 1
}

jpackage \
  --type app-image \
  --name Tienda \
  --input dist \
  --main-jar tienda.jar \
  --main-class com.facundouferer.tienda.Main \
  --app-version "$APP_VERSION" \
  --dest packages
  • --input es el directorio de entrada: debe contener el JAR principal y sus dependencias.
  • --main-jar nombra el JAR dentro de ese directorio; --main-class identifica la clase con main.
  • Una aplicación modular reemplaza esas dos entradas por --module-path mods --module com.facundouferer.tienda/com.facundouferer.tienda.Main.
  • --dest separa la salida de los artefactos de entrada. Comprobá que sea escribible y que no mezcle versiones anteriores.
  • --app-version tiene reglas adicionales según el formato nativo. Una versión numérica simple es un valor seguro, pero el pipeline debe validarla en cada plataforma.

Por defecto, jpackage arma y incluye un runtime reducido para la aplicación. Para controlarlo explícitamente se puede crear con jlink y pasarlo mediante --runtime-image, pero esa imagen debe incluir todos los módulos requeridos: quitar uno produce un fallo en ejecución, no una aplicación más eficiente.

--icon es opcional y el formato depende de la plataforma (.ico en Windows, .icns en macOS y normalmente .png en Linux). Omitirlo es un valor predeterminado seguro; no renombres un archivo para fingir otro formato.

Probar antes de crear el instalador

Inspeccioná el directorio generado y ejecutá su lanzador con una operación inocua, como --version o --help:

test -d packages/Tienda || { echo 'No se generó packages/Tienda' >&2; exit 1; }
packages/Tienda/bin/Tienda --version
if (-not (Test-Path -LiteralPath '.\packages\Tienda\Tienda.exe')) {
    throw 'No se generó el lanzador esperado'
}
& '.\packages\Tienda\Tienda.exe' --version
if ($LASTEXITCODE -ne 0) { throw 'El smoke test del lanzador falló' }

El programa debe ofrecer una opción de diagnóstico que no escriba datos ni requiera red. Además del arranque, probá recursos, archivos de configuración, rutas con espacios, instalación, actualización y desinstalación en una máquina limpia o VM.

El paquete es específico del sistema operativo

jpackage usa herramientas nativas y no es un compilador cruzado de instaladores:

Sistema donde se construye y pruebaTipos habituales
Windowsexe, msi
macOSdmg, pkg
Linuxdeb, rpm

Después de validar app-image, ejecutá jpackage --type msi ... o el tipo correspondiente dentro del job del sistema operativo de destino. Un .exe de Windows debe construirse y probarse en Windows; no prometas que un artefacto producido desde Linux o macOS es equivalente. Un pipeline multiplataforma usa un runner separado por sistema y conserva cada artefacto con su versión y checksum.

La firma de código y la notarización son responsabilidades de publicación. Requieren certificados, secretos protegidos y, según la plataforma, servicios externos. No pongas credenciales en el comando, el repositorio ni los logs: inyectalas desde el almacén de secretos del CI y verificá la firma del artefacto final.

Comparación acotada con Launch4j

Launch4j envuelve un JAR en un lanzador .exe para Windows. Puede buscar un runtime ya instalado o apuntar a un runtime incluido que se distribuye junto al ejecutable. Es útil para mantener una integración Windows existente, un formato de configuración heredado o requisitos de launcher muy específicos.

Sin embargo, Launch4j no reemplaza por sí solo todo el instalador, el runtime ni la prueba en Windows. Para proyectos nuevos sobre un JDK moderno, preferí jpackage: es tooling estándar del JDK, genera una imagen autocontenida y conoce formatos nativos de varias plataformas. Elegir Launch4j no autoriza a construir o declarar probado un .exe fuera de Windows.


Para llevarte

  • Bytes (InputStream/OutputStream) para binario; caracteres (Reader/Writer) para texto con codificación.
  • Los Buffered* son un decorador: no cambian qué hacés, cambian cuántas veces se toca el disco.
  • Para el 90 % de los casos, Files.readString, Files.writeString y Files.lines reemplazan todo el API clásico.
  • Fijá UTF-8 explícitamente. La codificación por defecto es la causa del bug que solo aparece en otra máquina.
  • Files.lines() procesa archivos gigantes sin cargarlos en memoria, pero necesita try-with-resources.
  • Al deserializar, el constructor no se ejecuta: tus validaciones no se aplican.
  • Declará serialVersionUID a mano o vas a perder todos los archivos guardados al primer cambio de la clase.
  • Preferí formatos de texto (CSV, JSON) antes que la serialización nativa: son portables, legibles y estables.
  • Un JAR es un ZIP con un MANIFEST.MF; sin Main-Class no es ejecutable.