Skip to content

35. API di I/O Java (Legacy e NIO)

Indice


35.1 Legacy java.io — Design, comportamento e sottigliezze

L’API legacy java.io è l’astrazione I/O originale introdotta in Java 1.0.

Essa è orientata agli stream, bloccante, e mappata strettamente sui concetti I/O del sistema operativo.

Anche se esistono API più recenti, java.io resta fondamentale: molte API di livello superiore ci si appoggiano, ed è ancora molto usata.

35.1.1 L’astrazione di stream

Uno stream rappresenta un flusso continuo di dati tra una sorgente e una destinazione.

In java.io, gli stream sono unidirezionali: sono o di input o di output.

Stream Direzione Unità di dati Categoria
InputStream Input Byte (8-bit) Stream di byte
OutputStream Output Byte (8-bit) Stream di byte
Reader Input Caratteri Stream di caratteri
Writer Output Caratteri Stream di caratteri

Gli stream nascondono l’origine concreta dei dati (file, rete, memoria) ed espongono un’interfaccia uniforme di lettura/scrittura.

35.1.2 Chaining degli stream e pattern Decorator

La maggior parte degli stream java.io è progettata per essere combinata.

Ogni wrapper aggiunge comportamento senza cambiare la sorgente dati sottostante.

InputStream in =
    new BufferedInputStream(
        new FileInputStream("data.bin"));

In questo esempio:

  • FileInputStream esegue l’accesso reale al file
  • BufferedInputStream aggiunge un buffer in memoria

Note

Questo design è noto come Decorator Pattern.

Permette di stratificare funzionalità in modo dinamico.

35.1.3 I/O bloccante: cosa significa

Tutti gli stream legacy java.io sono bloccanti.

Ciò significa che un thread che esegue I/O può essere sospeso dal sistema operativo.

Per esempio, quando chiami read():

  • se i dati sono disponibili, vengono restituiti subito
  • se non ci sono dati, il thread attende
  • se si raggiunge la fine dello stream, viene restituito -1

Note

Il comportamento bloccante semplifica la programmazione, ma limita la scalabilità.

35.1.4 Gestione risorse: close(), flush() e perché esistono

Gli stream spesso incapsulano risorse native del sistema operativo come file descriptor o handle di socket.

Queste risorse sono limitate e devono essere rilasciate esplicitamente.

Metodo Scopo
flush() Scrive i dati bufferizzati verso la destinazione
close() Esegue flush e rilascia la risorsa
try (OutputStream out = new FileOutputStream("file.bin")) {
    out.write(42);
} // close() chiamato automaticamente

Note

Non chiudere gli stream può causare perdita di dati o esaurimento delle risorse.

35.1.5 finalize(): perché esiste e perché fallisce

Le prime versioni di Java tentarono di automatizzare il cleanup delle risorse usando la finalizzazione.

Il metodo finalize() veniva chiamato dal garbage collector prima di recuperare la memoria.

Tuttavia, i tempi del GC sono imprevedibili.

Aspetto finalize()
Tempo di esecuzione Non specificato
Affidabilità Bassa
Stato attuale Deprecato

Note

finalize() non va mai usato per pulizia I/O; è deprecato e non sicuro.

35.1.6 available(): scopo e abuso

available() stima quanti byte possono essere letti senza bloccare.

Non indica la quantità totale di dati rimanenti.

Casi d’uso tipici:

  • evitare blocchi in UI o parsing di protocolli
  • dimensionare buffer temporanei
if (in.available() > 0) {
    in.read(buffer);
}

Note

available() non deve essere usato per rilevare EOF. Solo read(), che ritorna -1, segnala la fine dello stream.

35.1.7 mark() e reset(): backtracking controllato

Alcuni input stream consentono di marcare una posizione e tornarci in seguito.

BufferedInputStream in = new BufferedInputStream(...);
in.mark(1024);
// read ahead
in.reset();
Stream markSupported()
FileInputStream No
BufferedInputStream
ByteArrayInputStream

35.1.8 Reader, Writer e codifica dei caratteri

Reader e Writer operano su caratteri, non su byte.

Questo richiede una codifica dei caratteri (charset).

Se non specifichi un charset, viene usato quello di default della piattaforma.

new FileReader("file.txt"); // encoding di default della piattaforma

Note

  • Affidarsi al charset di default porta a bug di non portabilità.
  • Specifica sempre un charset esplicitamente.

35.1.9 File vs FileDescriptor

File rappresenta un percorso nel filesystem.

Non rappresenta una risorsa aperta.

FileDescriptor rappresenta un handle nativo del SO verso un file o stream aperto.

Classe Rappresenta Possiede handle OS?
File Percorso filesystem No
FileDescriptor Handle file nativo OS

Note

Più stream possono condividere lo stesso FileDescriptor.

Chiudendone uno, si chiude la risorsa sottostante per tutti.


35.2 java.nio — Buffer, Channel e I/O non bloccante

L’API java.nio (New I/O) è stata introdotta per risolvere i limiti di java.io.

Offre un modello I/O di più basso livello e più esplicito, che mappa bene sui sistemi operativi moderni.

Alla base, java.nio ruota attorno a tre concetti:

  • Buffer — contenitori di memoria espliciti
  • Channel — connessioni dati bidirezionali
  • Selector — multiplexing dell’I/O non bloccante

35.2.1 Dagli stream ai buffer: un cambio concettuale

Gli stream legacy nascondono la gestione della memoria al programmatore.

Al contrario, NIO rende esplicita la memoria tramite i buffer.

Aspetto java.io java.nio
Modello dati Basato su stream (push) Basato su buffer (pull dai buffer)
Memoria Nascosta negli stream Esplicita via buffer
Controllo Semplice, poco granulare Più granulare e configurabile

Con NIO, l’applicazione controlla quando i dati vengono letti in memoria e come vengono consumati.

35.2.2 Buffer: scopo e struttura

Un buffer è un contenitore tipizzato a dimensione fissa.

Tutte le operazioni I/O NIO leggono da o scrivono su buffer.

Il buffer più comune è ByteBuffer.

ByteBuffer buffer = ByteBuffer.allocate(1024);
Proprietà Significato
capacity Dimensione totale del buffer
position Indice corrente di lettura/scrittura
limit Limite dei dati leggibili o scrivibili

35.2.3 Ciclo di vita del buffer: Write → Flip → Read

I buffer hanno un ciclo d’uso rigoroso.

Capirlo male è una fonte comune di bug.

Sequenza tipica:

  • scrivi i dati nel buffer
  • flip() per passare in modalità lettura
  • leggi i dati dal buffer
  • clear() o compact() per riutilizzarlo
ByteBuffer buffer = ByteBuffer.allocate(16);

buffer.put((byte) 1);
buffer.put((byte) 2);

buffer.flip(); // passa in modalità lettura

while (buffer.hasRemaining()) {
    byte b = buffer.get();
}

buffer.clear(); // pronto a scrivere di nuovo

Note

flip() non cancella i dati: regola position e limit.

35.2.4 clear() vs compact()

Dopo la lettura, un buffer può essere riutilizzato in due modi.

Metodo Comportamento
clear() Scarta i dati non letti
compact() Preserva i dati non letti

compact() è utile nei protocolli streaming dove nel buffer possono restare messaggi parziali.

35.2.5 Heap buffers vs Direct buffers

I buffer possono essere allocati in due regioni di memoria diverse.

ByteBuffer heap = ByteBuffer.allocate(1024);
ByteBuffer direct = ByteBuffer.allocateDirect(1024);
Tipo Posizione memoria Caratteristiche
Heap Heap JVM GC, economico da allocare
Direct Memoria nativa Miglior throughput I/O, più costoso da allocare

Note

I direct buffer riducono le copie tra JVM e OS, ma vanno usati con attenzione per evitare pressione di memoria.

35.2.6 Channel: cosa sono

Un channel rappresenta una connessione verso un’entità I/O come file, socket o device.

A differenza degli stream, i channel sono bidirezionali.

Channel Tipo Scopo
FileChannel File I/O su file
SocketChannel TCP Networking stream (TCP)
DatagramChannel UDP Networking datagram (UDP)
try (FileChannel channel =
    FileChannel.open(Path.of("file.txt"))) {

    ByteBuffer buffer = ByteBuffer.allocate(128);
    channel.read(buffer);
}

35.2.7 Channel bloccanti vs non bloccanti

I channel possono operare in modalità bloccante o non bloccante.

SocketChannel channel = SocketChannel.open();
channel.configureBlocking(false);

In modalità non bloccante:

  • read() può ritornare subito con 0 byte
  • write() può scrivere solo una parte dei dati

Note

L’I/O non bloccante sposta complessità dal SO all’applicazione.

35.2.8 Scatter/Gather I/O

NIO supporta lettura/scrittura da/verso più buffer con una singola operazione.

ByteBuffer header = ByteBuffer.allocate(128);
ByteBuffer body = ByteBuffer.allocate(1024);

ByteBuffer[] buffers = { header, body };
channel.read(buffers);

Utile per protocolli strutturati (header + payload).

35.2.9 Selector: multiplexing dell’I/O non bloccante

I Selector permettono a un singolo thread di monitorare più channel.

Sono la base dei server scalabili.

Componente Ruolo
Selector Monitora più channel
SelectionKey Rappresenta registrazione e stato del channel
Interest set Operazioni osservate (read, write, ecc.)

35.2.10 Quando usare java.nio

NIO è adatto quando:

  • serve alta concorrenza
  • ti serve controllo fine sulla memoria
  • stai implementando protocolli o server

Per operazioni semplici su file, spesso basta java.nio.file.Files.


35.3 java.nio.file (NIO.2) — Operazioni su file e directory (Legacy vs Modern)

Questa sezione si concentra sulle operazioni pratiche su file e directory.

Confrontiamo gli approcci legacy (java.io.File + stream java.io) con quelli moderni NIO.2 (Path + Files).

L’obiettivo non è solo conoscere i nomi dei metodi, ma capire:

  • cosa fa davvero ogni metodo
  • cosa ritorna e come segnala gli errori
  • quali trappole esistono (race condition, link, permessi, portabilità)
  • quando un metodo di Files è un miglioramento sicuro rispetto al vecchio approccio

35.3.1 Verifiche di esistenza e accessibilità

Un’operazione molto comune è verificare se un file esiste e se è accessibile (lettura, scrittura, esecuzione).

Sia l’API legacy (java.io.File) che NIO.2 (java.nio.file.Files) forniscono metodi per queste verifiche.

È però importante capire che queste verifiche sono volutamente imprecise in entrambe le API.

Sono indizi best-effort, non garanzie affidabili.

35.3.1.1 API legacy (File)

File f = new File("data.txt");

boolean exists = f.exists();
boolean canRead = f.canRead();
boolean canWrite = f.canWrite();
boolean canExec = f.canExecute();

Questi metodi ritornano boolean e non spiegano perché un’operazione è fallita.

Per esempio, exists() può ritornare false quando:

  • il file non esiste davvero
  • il file esiste ma l’accesso è negato
  • un link simbolico è rotto
  • si verifica un errore I/O

L’API non consente di distinguere i casi.

35.3.1.2 API moderna (Files)

Path p = Path.of("data.txt");

boolean exists = Files.exists(p);
boolean readable = Files.isReadable(p);
boolean writable = Files.isWritable(p);
boolean executable = Files.isExecutable(p);

Anche questi metodi ritornano boolean e nascondono la ragione dell'eventuale insuccesso.

NIO.2 aggiunge un metodo esplicito per esprimere incertezza:

boolean notExists = Files.notExists(p);

Note

exists() e notExists() possono essere entrambi false quando lo stato non è determinabile (per esempio per permessi).

Questo non rende la verifica più accurata: rende solo l’incertezza esplicita.

Un vero miglioramento di NIO.2 è il controllo su come gestire i link simbolici:

Files.exists(p, LinkOption.NOFOLLOW_LINKS);

La classe File legacy non distingue in modo affidabile:

  • file mancante
  • link simbolico rotto
  • link verso target inaccessibile

NIO.2 permette check link-aware e ispezione esplicita dei link.

35.3.1.2.2 Pattern d’uso corretto (critico)

Nessuna delle due API dà diagnosi affidabili tramite boolean “di check”.

Il codice NIO.2 corretto non “controlla prima”.

Invece tenta l’operazione e gestisce l’eccezione:

try {
    Files.delete(p);
} catch (NoSuchFileException e) {
    // il file non esiste davvero
} catch (AccessDeniedException e) {
    // problema di permessi
} catch (IOException e) {
    // altro errore I/O
}

Note

Il vero vantaggio di NIO.2 è la diagnostica tramite eccezioni durante le azioni, non check di esistenza più “accurati”.

35.3.1.2.3 Tabella riassuntiva
Obiettivo Legacy (File) Moderno (Files) Dettaglio chiave
Verificare esistenza exists() exists() / notExists() notExists() può essere false se lo stato non è determinabile
Verificare read/write canRead() / canWrite() isReadable() / isWritable() Files può usare LinkOption.NOFOLLOW_LINKS quando supportato
Dettagli errore Non disponibili Disponibili via eccezioni sulle azioni I check boolean non spiegano il motivo del fallimento

35.3.2 Creazione di file e directory

La creazione è una grande debolezza del File legacy.

Nel legacy si usano spesso createNewFile() e mkdir/mkdirs(), che ritornano boolean e danno poche info diagnostiche.

35.3.2.1 API legacy (File)

File f = new File("a.txt");
boolean created = f.createNewFile(); // può lanciare IOException

File dir = new File("dir");
boolean ok1 = dir.mkdir();
boolean ok2 = new File("a/b/c").mkdirs();

mkdir() crea un solo livello; mkdirs() crea anche i parent.

Entrambi ritornano false in caso di fallimento ma senza dire il perché.

35.3.2.2 API moderna (Files)

Path file = Path.of("a.txt");
Files.createFile(file);

Path dir1 = Path.of("dir");
Files.createDirectory(dir1);

Path dirDeep = Path.of("a/b/c");
Files.createDirectories(dirDeep);

Note

Files.createFile lancia FileAlreadyExistsException se il file esiste.

Spesso è preferibile ai check boolean perché è race-safe.

Obiettivo Legacy (File) Moderno (Files) Dettaglio chiave
Creare file createNewFile() createFile() NIO lancia FileAlreadyExistsException se esiste
Creare directory mkdir() createDirectory() NIO lancia eccezioni dettagliate
Creare parent mkdirs() createDirectories() Atomicità non garantita per directory profonde

35.3.3 Eliminazione di file e directory

La semantica di delete differisce molto tra legacy e NIO.2.

Il legacy delete() ritorna boolean; NIO.2 offre metodi che lanciano eccezioni significative.

35.3.3.1 API legacy (File)

File f = new File("a.txt");
boolean deleted = f.delete();

Se fallisce (permessi, file mancante, directory non vuota), delete() spesso ritorna false senza dettagli.

35.3.3.2 API moderna (Files)

Files.delete(Path.of("a.txt"));

Per “cancella se presente”, usa deleteIfExists().

Files.deleteIfExists(Path.of("a.txt"));
Obiettivo Legacy (File) Moderno (Files) Dettaglio chiave
Eliminare delete() delete() Files.delete() lancia eccezione con la causa del fallimento
Eliminare se esiste exists() + delete() deleteIfExists() Evita race TOCTOU (check-then-act)

35.3.4 Copia di file e directory

Nel legacy, copiare richiede tipicamente lettura/scrittura manuale via stream.

NIO.2 fornisce operazioni di copia di alto livello con opzioni.

35.3.4.1 Tecnica legacy (stream manuali)

try (InputStream in = new FileInputStream("src.bin"); OutputStream out = new FileOutputStream("dst.bin")) {

    byte[] buf = new byte[8192];
    int n;
    while ((n = in.read(buf)) != -1) {
        out.write(buf, 0, n);
    }
}

È verboso ed è facile sbagliare (mancanza di buffering, chiusura, ecc.).

35.3.4.2 API moderna (Files.copy)

Files.copy(Path.of("src.bin"), Path.of("dst.bin"));

Il comportamento è controllabile con opzioni.

Files.copy(
    Path.of("src.bin"),
    Path.of("dst.bin"),
    StandardCopyOption.REPLACE_EXISTING,
    StandardCopyOption.COPY_ATTRIBUTES
);

Note

Files.copy lancia FileAlreadyExistsException per default.

Usa REPLACE_EXISTING quando l’overwrite è intenzionale.

Obiettivo Approccio legacy Moderno (Files) Dettaglio chiave
Copiare file Loop stream manuale Files.copy(Path, Path, …) Opzioni: REPLACE_EXISTING, COPY_ATTRIBUTES
Copiare stream InputStream/OutputStream Files.copy(InputStream, Path, …) Utile per upload/download e piping
Copiare directory Ricorsione manuale walkFileTree + Files.copy Nessun one-liner per copy completa di albero

35.3.5 Spostamento e rinomina

La rinomina legacy usa spesso File.renameTo(), notoriamente inaffidabile e dipendente dalla piattaforma.

NIO.2 fornisce Files.move() con semantica precisa e opzioni.

35.3.5.1 API legacy

boolean ok = new File("a.txt").renameTo(new File("b.txt"));

renameTo() ritorna false senza spiegare, e può fallire tra filesystem.

35.3.5.2 API moderna

Files.move(Path.of("a.txt"), Path.of("b.txt"));

Le opzioni rendono il comportamento esplicito.

Files.move(
    Path.of("a.txt"),
    Path.of("b.txt"),
    StandardCopyOption.REPLACE_EXISTING,
    StandardCopyOption.ATOMIC_MOVE
);

Note

ATOMIC_MOVE è garantito solo se lo spostamento avviene nello stesso filesystem. Altrimenti viene lanciata un’eccezione.

Obiettivo Legacy (File) Moderno (Files) Dettaglio chiave
Rinomina / move renameTo() move() Exceptions + opzioni esplicite
Move atomico Non supportato move(…, ATOMIC_MOVE) Garantito solo stesso filesystem
Replace existing Non esplicito REPLACE_EXISTING Intenzione di overwrite esplicita

35.3.6 Lettura e scrittura di testo e byte (miglioramenti di Files)

Un grande miglioramento di NIO.2 è la classe utility Files, con metodi di alto livello per lettura/scrittura comuni.

Riduce boilerplate e migliora la correttezza.

35.3.6.1 Lettura/scrittura testo legacy

try (BufferedReader r = new BufferedReader(new FileReader("file.txt"))) {
    String line = r.readLine();
}
try (BufferedWriter w = new BufferedWriter(new FileWriter("file.txt"))) {
    w.write("hello");
}

Queste classi legacy usano spesso il charset di default se non si utilizza un bridge esplicito.

35.3.6.2 Lettura/scrittura testo moderna

List<String> lines = Files.readAllLines(Path.of("file.txt"), StandardCharsets.UTF_8);
Files.write(Path.of("file.txt"), lines, StandardCharsets.UTF_8);

Files.lines(Path.of("file.txt")).forEach(System.out::println);

String string = Files.readString(Path.of("file.txt"));
Files.writeString(Path.of("file.txt"), string);

35.3.6.3 Lettura/scrittura binaria moderna

byte[] data = Files.readAllBytes(Path.of("data.bin"));
Files.write(Path.of("out.bin"), data);

Important

readAllBytes e readAllLines caricano tutto in memoria.

Usa Files.lines() (lazy) o, per file grandi, preferisci API streaming come newBufferedReader/newInputStream.

Task Metodo legacy Metodo NIO.2 Files Dettaglio chiave
Leggere tutti i byte Loop InputStream manuale readAllBytes() Carica tutto in memoria
Leggere tutte le righe Loop BufferedReader readAllLines() Carica tutto in memoria
Leggere righe lazy Loop BufferedReader lines() Lazy, stream da chiudere
Scrivere byte OutputStream write(Path, byte[]) Conciso
Scrivere righe Loop BufferedWriter write(Path, Iterable, …) Charset specificabile
Append testo FileWriter(true) write(…, APPEND) Opzioni esplicite

35.3.7 newInputStream/newOutputStream e newBufferedReader/newBufferedWriter

Queste factory method creano stream/reader a partire da un Path.

Sono il bridge consigliato tra streaming classico e gestione Path NIO.2.

try (InputStream in = Files.newInputStream(Path.of("a.bin"))) { }
try (OutputStream out = Files.newOutputStream(Path.of("b.bin"))) { }
try (BufferedReader r = Files.newBufferedReader(Path.of("t.txt"), StandardCharsets.UTF_8)) { }
try (BufferedWriter w = Files.newBufferedWriter(Path.of("t.txt"), StandardCharsets.UTF_8)) { }

35.3.8 Listing directory e attraversamento di alberi

Nel legacy, il listing directory si basa su File.list() e File.listFiles().

Questi metodi ritornano array e offrono poca diagnostica.

35.3.8.1 Listing legacy

File dir = new File(".");
File[] children = dir.listFiles();

NIO.2 offre più approcci a seconda del bisogno.

35.3.8.2 Listing moderno (DirectoryStream)

try (DirectoryStream<Path> ds = Files.newDirectoryStream(Path.of("."))) {
    for (Path p : ds) {
        System.out.println(p);
    }
}

35.3.8.3 Walking moderno (Files.walk)

Files.walk(Path.of("."))
    .filter(Files::isRegularFile)
    .forEach(System.out::println);

Note

Files.walk restituisce uno Stream che va chiuso. Usa try-with-resources.

try (Stream<Path> s = Files.walk(Path.of("."))) {
    s.forEach(System.out::println);
}

35.3.8.4 Traversal con FileVisitor

Per controllo completo (skip subtree, gestione errori, follow link), usa walkFileTree + FileVisitor.

Files.walkFileTree(Path.of("."), new SimpleFileVisitor<>() {
    @Override
    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
        System.out.println(file);
        return FileVisitResult.CONTINUE;
    }
});
Obiettivo Legacy Moderno Dettaglio chiave
Listing dir list() / listFiles() newDirectoryStream() Lazy, va chiuso
Walk tree (semplice) Ricorsione manuale walk() (Stream) Stream va chiuso
Walk tree (controllo) Ricorsione manuale walkFileTree() Controllo fine e gestione errori

35.3.9 Ricerca e filtro

La ricerca è tipicamente traversal + filtro.

NIO.2 offre building block: glob pattern, stream, visitor.

try (DirectoryStream<Path> ds =
    Files.newDirectoryStream(Path.of("."), "*.txt")) {
    for (Path p : ds) {
        System.out.println(p);
    }
}
try (Stream<Path> s = Files.find(Path.of("."), 10,
    (p, a) -> a.isRegularFile() && p.toString().endsWith(".log"))) {
    s.forEach(System.out::println);
}

35.3.10 Attributi: lettura, scrittura e view

Il File legacy espone pochi attributi (size, lastModified).

NIO.2 supporta metadata ricchi tramite attribute view.

35.3.10.1 Attributi legacy

long size = new File("a.txt").length();
long lm = new File("a.txt").lastModified();

35.3.10.2 Attributi moderni

BasicFileAttributes a =
    Files.readAttributes(Path.of("a.txt"), BasicFileAttributes.class);

long size = a.size();
FileTime modified = a.lastModifiedTime();

Accesso tramite nomi string-based:

Object v = Files.getAttribute(Path.of("a.txt"), "basic:size");
Files.setAttribute(Path.of("a.txt"), "basic:lastModifiedTime", FileTime.fromMillis(0));

Note

Le attribute view dipendono dal filesystem.

Attributi non supportati generano eccezioni.

NIO.2 può rilevare e leggere link simbolici in modo esplicito.

Path link = Path.of("mylink");
boolean isLink = Files.isSymbolicLink(link);

if (isLink) {
    Path target = Files.readSymbolicLink(link);
}

Molti metodi seguono i link di default.

Per evitarlo, passa LinkOption.NOFOLLOW_LINKS quando supportato.

35.3.12 Sintesi: perché Files è un miglioramento

La classe utility Files migliora la programmazione filesystem perché:

  • riduce boilerplate (copy/move/read/write)
  • fornisce opzioni esplicite (overwrite, atomic move, follow links)
  • offre metadata più ricchi (attributes/views)
  • supporta traversal e ricerca scalabili

Le API legacy restano soprattutto per compatibilità o quando richieste da librerie legacy.


35.4 Serializzazione — Object stream, compatibilità e trappole

La serializzazione è il processo di convertire un grafo di oggetti in uno stream di byte per memorizzarlo o trasmetterlo, e ricostruirlo successivamente.

In Java, la serializzazione classica è implementata da java.io.ObjectOutputStream e java.io.ObjectInputStream.

Questo argomento è importante perché combina:

  • stream I/O e grafi di oggetti
  • versioning e backward compatibility
  • considerazioni di sicurezza e pattern d’uso sicuri
  • regole del linguaggio (transient, static, serialVersionUID)

35.4.1 Cosa fa la serializzazione (e cosa non fa)

Quando un oggetto è serializzato, Java scrive informazioni sufficienti a ricostruirlo:

  • nome della classe
  • serialVersionUID
  • valori dei campi di istanza serializzabili
  • riferimenti tra oggetti (identità)

La serializzazione non include automaticamente:

  • campi static (stato di classe)
  • campi transient (esclusi esplicitamente)
  • oggetti referenziati non serializzabili (a meno di gestione speciale)

35.4.2 Le due principali marker interface

La serializzazione Java è abilitata implementando una di queste interfacce.

Interfaccia Significato Livello di controllo
Serializable Marker opt-in, meccanismo di default Medio (hook possibili)
Externalizable Richiede implementazione manuale read/write Alto (controllo totale sul formato)

Note

Serializable non ha metodi: è una marker interface.

Externalizable estende Serializable e aggiunge readExternal/writeExternal.

35.4.3 Esempio base: scrivere e leggere un oggetto

Pattern minimo usato in pratica.

import java.io.*;

class Person implements Serializable {

    private String name;
    private int age;

    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

}

public class Demo {

    public static void main(String[] args) throws Exception {

        Person p = new Person("Alice", 30);

        try (ObjectOutputStream out =
                 new ObjectOutputStream(new FileOutputStream("p.bin"))) {
            out.writeObject(p);
        }

        try (ObjectInputStream in =
                 new ObjectInputStream(new FileInputStream("p.bin"))) {
            Person copy = (Person) in.readObject();
        }
    }

}

Note

readObject() ritorna Object: serve cast. readObject() può lanciare ClassNotFoundException.

35.4.4 Grafi di oggetti, riferimenti e identità

La serializzazione preserva l’identità degli oggetti all’interno dello stesso stream.

Se lo stesso riferimento compare più volte, Java lo scrive una sola volta e poi scrive back-reference.

Person p = new Person("Bob", 40);
Object[] arr = { p, p }; // stesso riferimento due volte

out.writeObject(arr);
Object[] restored = (Object[]) in.readObject();

// restored[0] e restored[1] puntano allo stesso oggetto

Note

Questo previene ricorsione infinita in grafi ciclici.

35.4.5 serialVersionUID: la chiave di versioning

serialVersionUID è un identificatore long usato per verificare la compatibilità tra stream serializzato e definizione della classe.

Se l’UID differisce, la deserializzazione tipicamente fallisce con InvalidClassException.

Se non dichiari serialVersionUID, la JVM ne calcola uno dalla struttura della classe: piccole modifiche possono comprometterlo.

class Person implements Serializable {

    private static final long serialVersionUID = 1L;

    private String name;
    private int age;
}
Tipo di modifica Impatto compatibilità (default)
Aggiungere un campo Spesso compatibile (campo nuovo con default)
Rimuovere un campo Spesso compatibile (campo mancante ignorato)
Cambiare tipo campo Spesso incompatibile
Cambiare nome/pacchetto Incompatibile
Cambiare serialVersionUID Incompatibile

Note

Dichiarare un serialVersionUID stabile è il modo standard per controllare la compatibilità.

35.4.6 Campi transient e static

I campi transient sono esclusi dalla serializzazione.

Alla deserializzazione, i campi transient assumono valori di default (0, false, null) salvo ripristino manuale.

I campi static appartengono alla classe, non all’istanza, quindi non vengono serializzati.

class Session implements Serializable {

    private static final long serialVersionUID = 1L;

    static int counter = 0;      // non serializzato
    transient String token;      // non serializzato
    String user;                 // serializzato
}

Note

Se un transient serve dopo la deserializzazione, va ricalcolato o ripristinato manualmente.

35.4.7 Campi non serializzabili e NotSerializableException

Se un oggetto contiene un campo il cui tipo non è serializzabile, la serializzazione fallisce con NotSerializableException.

class Holder implements Serializable {

    private static final long serialVersionUID = 1L;

    private Thread t; // Thread non è serializzabile
}

Soluzioni tipiche:

  • marcare il campo transient
  • sostituirlo con una rappresentazione serializzabile
  • usare hook di serializzazione custom

35.4.8 Costruttori e serializzazione

Il comportamento dei costruttori in deserializzazione è fonte frequente di confusione.

Java ripristina lo stato principalmente dal byte stream, non eseguendo i costruttori.

35.4.8.1 Regola: i costruttori delle classi Serializable NON vengono chiamati

Durante la deserializzazione di una classe Serializable, i suoi costruttori, o gli eventuali blocchi d'inizializzazione statici o d'istanza, NON vengono eseguiti.

L’istanza viene creata senza chiamare quei costruttori (o i blocchi d'inizializzazione statici o d'istanza) e i campi vengono iniettati dallo stream.

Note

Per questo i costruttori delle classi Serializable non devono contenere logica di inizializzazione essenziale: non verrebbe eseguita in deserializzazione.

35.4.8.2 Regola di ereditarietà: viene chiamata la prima superclass non-Serializable

Se una classe Serializable ha una superclasse non Serializable, la deserializzazione deve inizializzare quella parte.

Quindi Java chiama il costruttore no-arg della prima superclasse non-Serializable.

Implicazioni:

  • la superclasse non Serializable deve avere un no-arg accessibile
  • le sottoclassi Serializable saltano i costruttori, le superclassi non Serializable no

35.4.8.3 Tabella: quali costruttori vengono eseguiti

Tipo di classe Costruttore chiamato in deserializzazione
Classe Serializable No
Sottoclasse Serializable No
Prima superclasse non Serializable Sì (no-arg)
Classe Externalizable Sì (richiesto public no-arg)

35.4.8.4 Esempio: quali costruttori vengono chiamati

import java.io.*;

class A {
    A() {
        System.out.println("A constructor");
    }
}

class B extends A implements Serializable {
    private static final long serialVersionUID = 1L;
    B() {
        System.out.println("B constructor");
    }
}

class C extends B {
    private static final long serialVersionUID = 1L;
    C() {
        System.out.println("C constructor");
    }
}

public class Demo {
    public static void main(String[] args) throws Exception {

        C obj = new C();

        try (ObjectOutputStream out =
                 new ObjectOutputStream(new FileOutputStream("c.bin"))) {
            out.writeObject(obj);
        }

        try (ObjectInputStream in =
                 new ObjectInputStream(new FileInputStream("c.bin"))) {
            Object restored = in.readObject();
        }
    }
}

Output atteso e spiegazione
Durante la costruzione normale (new C()):

A constructor
B constructor
C constructor

Durante la deserializzazione (readObject):

A constructor

Spiegazione:

  • C è Serializable → C() non viene chiamato
  • B è Serializable → B() non viene chiamato
  • A non è Serializable → A() viene chiamato (no-arg)
  • I campi di B e C vengono ripristinati dallo stream

Note

Se la prima superclasse non-Serializable non ha un no-arg accessibile, la deserializzazione fallisce.

35.4.9 Hook di serializzazione custom: writeObject e readObject

Gli hook custom servono quando la serializzazione di default non basta (stato transient, campi derivati, cifratura, validazione, compatibilità).

Sono avanzati ma importanti per una deserializzazione corretta.

35.4.9.1 Perché esiste la serializzazione custom

Di default, Java serializza automaticamente tutti i campi di istanza non static e non transient.

È comodo, ma non copre esigenze frequenti.

Motivi tipici:

  • un campo non va salvato direttamente (dati sensibili)
  • un campo è derivato/cache e va ricalcolato
  • serve validazione in lettura (rifiutare stato invalido)
  • serve logica di backward/forward compatibility
  • un oggetto referenziato non è Serializable e va gestito

35.4.9.2 Cosa sono davvero writeObject e readObject

Per personalizzare serializzazione/deserializzazione, una classe può definire due metodi privati speciali chiamati writeObject e readObject.

Non sono override di metodi di interfacce o superclassi: non fanno parte del normale flusso del programma.

Non li chiami mai tu.

Il framework di serializzazione (ObjectOutputStream/ObjectInputStream) li individua tramite reflection, solo se nome e firma sono esatti, e li invoca automaticamente.

Se non esistono (o la firma è sbagliata), viene usata la serializzazione di default.

Note

Se la firma è errata (visibilità, parametri, return type, eccezioni), il framework non la riconosce e torna silenziosamente al default.

35.4.9.3 Firme richieste (esatte)

private void writeObject(ObjectOutputStream out) throws IOException

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException

Vincoli:

  • devono essere private
  • devono ritornare void
  • i tipi dei parametri devono combaciare esattamente
  • le eccezioni devono essere compatibili

35.4.9.4 Cosa succede in serializzazione: step-by-step

Quando serializzi:

out.writeObject(obj);

Meccanismo:

  • verifica Serializable
  • cerca un private writeObject(ObjectOutputStream)
  • se assente → default serialization
  • se presente → viene chiamato il tuo writeObject

Punto chiave: dentro writeObject, Java non scrive automaticamente i campi “normali” se non lo chiedi. Per questo esiste:

out.defaultWriteObject();

defaultWriteObject() significa: “serializza i campi serializzabili normali col meccanismo standard”.

Poi puoi scrivere dati extra come vuoi.

35.4.9.5 Pattern tipico e regola dell’ordine write/read

Pattern tipico: usare default e poi estendere.

L’ordine di lettura deve coincidere con l’ordine di scrittura.

private void writeObject(ObjectOutputStream out) throws IOException {
    out.defaultWriteObject(); // scrive i campi normali
    out.writeInt(42);         // scrive dati extra
}

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
    in.defaultReadObject();   // legge i campi normali
    int x = in.readInt();     // legge i dati extra nello stesso ordine
}

Note

Se scrivi valori extra (int/string/etc.), devi leggerli nella stessa sequenza, altrimenti la deserializzazione fallisce o corrompe lo stato.

35.4.10 Esempio d’uso: ripristinare un campo derivato transient

Caso tipico: ricalcolare un valore cached transient dopo deserializzazione.

class User implements Serializable {

    private static final long serialVersionUID = 1L;

    private String firstName;
    private String lastName;

    private transient String fullName;

    User(String firstName, String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.fullName = firstName + " " + lastName;
    }

    private void readObject(ObjectInputStream in)
            throws IOException, ClassNotFoundException {

        in.defaultReadObject();              // ripristina firstName e lastName
        fullName = firstName + " " + lastName; // ricalcola il transient
    }
}

35.4.11 Externalizable: controllo totale (e responsabilità totale)

Externalizable richiede di definire manualmente come scrivere e leggere l’oggetto.

Richiede anche un costruttore pubblico no-arg, perché la deserializzazione istanzia prima l’oggetto.

import java.io.*;

class Point implements Externalizable {
    int x;
    int y;

    public Point() { } // richiesto

    public Point(int x, int y) { this.x = x; this.y = y; }

    @Override
    public void writeExternal(ObjectOutput out) throws IOException {
        out.writeInt(x);
        out.writeInt(y);
    }

    @Override
    public void readExternal(ObjectInput in) throws IOException {
        x = in.readInt();
        y = in.readInt();
    }
}

Note

Con Externalizable controlli il formato. Se lo cambi, devi gestire tu la backward compatibility.

35.4.12 Considerazioni di sicurezza su readObject()

La deserializzazione di dati non fidati è pericolosa perché può eseguire codice indirettamente tramite:

  • hook readObject
  • logica di inizializzazione
  • gadget chain in librerie

Linee guida:

  • non deserializzare mai byte non fidati senza un motivo forte
  • preferire formati sicuri (JSON, protobuf) per input esterni
  • se obbligato, usare object filter e validazione rigorosa

35.4.13 Trappole comuni e consigli pratici

  • Serializable è solo marker: non richiede metodi
  • readObject ritorna Object e può lanciare ClassNotFoundException
  • i campi static non vengono mai serializzati
  • i campi transient tornano a default salvo ripristino
  • senza serialVersionUID la compatibilità può rompersi “a sorpresa”
  • Externalizable richiede public no-arg constructor
  • NotSerializableException quando un campo referenziato non è serializzabile

35.4.14 Quando usare (o evitare) la serializzazione Java

Usa la serializzazione classica soprattutto per:

  • persistenza locale di breve durata con versioni controllate
  • caching in memoria quando entrambe le estremità sono fidate
  • sistemi legacy che già la usano

Evitala per:

  • protocolli di rete pubblici
  • storage a lungo termine con schema evolutivo
  • input non fidati