1. Introduzione: perché ottimizzare la serializzazione?
Nelle applicazioni moderne la serializzazione è ovunque — dai protocolli di rete alle cache distribuite e allo scambio di dati tra servizi.
La velocità e la dimensione della serializzazione sono critiche. Se la serializzazione è lenta, l’applicazione inizia a «rallentare» durante il salvataggio o il caricamento dei dati, mentre rete o disco restano inutilizzati. Se gli oggetti risultano troppo grandi, occupano molto spazio su disco, impiegano più tempo a essere trasferiti in rete e creano ulteriore carico su memoria e banda.
Compiti tipici includono il salvataggio di un grande grafo di oggetti su file o cache, la trasmissione di oggetti in rete con latenza minima e la rapida serializzazione/deserializzazione dei dati in un sistema multithread.
La conclusione è semplice: l’ottimizzazione della serializzazione non è una «funzionalità premium», ma una pratica necessaria per applicazioni performanti e scalabili.
2. Ottimizzazione della dimensione dei dati serializzati
Esclusione dei dati non necessari: parola chiave transient
Per impostazione predefinita vengono serializzati tutti i campi dell’oggetto, tranne quelli contrassegnati come transient. Se un campo non deve essere salvato (ad esempio cache, dati temporanei, riferimenti a servizi), contrassegnalo come transient:
public class User implements Serializable {
private String name;
private transient String sessionToken; // non verrà serializzato
}
Vantaggi:
- Dimensioni minori dell'oggetto serializzato.
- Niente dati superflui/pericolosi nel file o in rete.
Serializzazione manuale: interfaccia Externalizable
Se serve il pieno controllo su cosa e come viene serializzato, implementa l’interfaccia Externalizable e descrivi esplicitamente la serializzazione (metodi writeExternal/readExternal):
public class Person implements Externalizable {
private String name;
private int age;
private transient String secret;
@Override
public void writeExternal(ObjectOutput out) throws IOException {
out.writeUTF(name);
out.writeInt(age);
// non serializziamo secret
}
@Override
public void readExternal(ObjectInput in) throws IOException {
name = in.readUTF();
age = in.readInt();
}
}
Vantaggi:
- Vengono serializzati solo i campi necessari.
- Si può cambiare il formato di serializzazione senza perdere compatibilità.
Compressione: ridurre i dati serializzati
Gli oggetti serializzati occupano spesso molto spazio, soprattutto se contengono stringhe ripetute e collezioni grandi. È possibile ridurre la dimensione con la compressione.
Esempio con GZIPOutputStream:
try (ObjectOutputStream out = new ObjectOutputStream(
new GZIPOutputStream(new FileOutputStream("data.gz")))) {
out.writeObject(bigObject);
}
Esempio con ZipOutputStream:
try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream("data.zip"))) {
zip.putNextEntry(new ZipEntry("object"));
ObjectOutputStream out = new ObjectOutputStream(zip);
out.writeObject(bigObject);
out.flush();
zip.closeEntry();
}
Vantaggi:
- La dimensione del file può ridursi di molte volte (soprattutto per grandi grafi di oggetti).
- Meno traffico durante il trasferimento in rete.
Svantaggi:
- Compressione/decompressione richiedono tempo aggiuntivo (CPU).
3. Ottimizzazione della velocità di serializzazione
Bufferizzazione: perché servono BufferedOutputStream e BufferedInputStream
Problema:
Senza buffer, ogni chiamata a write() o read() comporta un accesso di sistema a disco o rete — è molto lento!
Soluzione:
Utilizza flussi bufferizzati:
try (ObjectOutputStream out = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream("data.bin")))) {
out.writeObject(bigObject);
}
try (ObjectInputStream in = new ObjectInputStream(
new BufferedInputStream(new FileInputStream("data.bin")))) {
Object obj = in.readObject();
}
Vantaggi:
- Accelera significativamente la scrittura/lettura di oggetti grandi.
- Riduce il numero di accessi a disco/rete.
Come funziona?
Il buffer accumula i dati in memoria e li scrive a blocchi, non un byte alla volta.
Copia rapida: FileChannel.transferTo
Se devi copiare velocemente un grande file serializzato, usa NIO e il metodo transferTo:
try (FileChannel src = new FileInputStream("data.bin").getChannel();
FileChannel dest = new FileOutputStream("copy.bin").getChannel()) {
src.transferTo(0, src.size(), dest);
}
Vantaggi:
- La copia avviene a livello di OS, bypassando la bufferizzazione superflua in Java — molto veloce per file grandi.
4. Profilazione della serializzazione
Misurazione semplice del tempo: System.nanoTime()
Per una valutazione rapida delle prestazioni della serializzazione si può usare System.nanoTime():
long start = System.nanoTime();
try (ObjectOutputStream out = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream("data.bin")))) {
out.writeObject(bigObject);
}
long end = System.nanoTime();
System.out.println("Tempo di serializzazione: " + (end - start) / 1_000_000 + " ms");
Vantaggi:
- Semplice e rapido.
- Consente di confrontare varianti diverse (con buffer, senza buffer, con compressione, ecc.).
Svantaggi:
- I risultati possono «oscillare» a causa del lavoro del GC e dei processi in background.
- Non adatto a confronti precisi di differenze microscopiche.
Profilazione accurata: JMH (Java Microbenchmark Harness)
Per misurazioni più accurate utilizza JMH — una libreria specifica per microbenchmark.
Esempio di benchmark semplice:
@Benchmark
public void serializeWithBuffer() throws Exception {
try (ObjectOutputStream out = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream("data.bin")))) {
out.writeObject(bigObject);
}
}
Vantaggi:
- Tiene conto del warm-up della JVM, dell’influenza del GC e dei «rumori» dell’OS.
- Fornisce risultati affidabili e riproducibili.
Svantaggi:
- Richiede configurazione e comprensione della metodologia JMH.
- Eccessivo per confronti «a occhio».
5. Pratica: confronto di tempo e dimensione della serializzazione
Facciamo un mini‑esperimento: serializziamo un grande grafo di oggetti (ad esempio, una lista di 100_000 oggetti con collezioni annidate) in modi diversi e confrontiamo tempo e dimensione del file.
Serializzazione senza buffer e senza compressione
long start = System.nanoTime();
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("data1.bin"))) {
out.writeObject(bigList);
}
long end = System.nanoTime();
System.out.println("Senza buffer: " + (end - start) / 1_000_000 + " ms, dimensione: " +
new File("data1.bin").length() + " byte");
Serializzazione con buffer
long start = System.nanoTime();
try (ObjectOutputStream out = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream("data2.bin")))) {
out.writeObject(bigList);
}
long end = System.nanoTime();
System.out.println("Con buffer: " + (end - start) / 1_000_000 + " ms, dimensione: " +
new File("data2.bin").length() + " byte");
Serializzazione con compressione (GZIP)
long start = System.nanoTime();
try (ObjectOutputStream out = new ObjectOutputStream(
new GZIPOutputStream(new FileOutputStream("data3.gz")))) {
out.writeObject(bigList);
}
long end = System.nanoTime();
System.out.println("Con compressione: " + (end - start) / 1_000_000 + " ms, dimensione: " +
new File("data3.gz").length() + " byte");
Analisi dei risultati
Nei test di serializzazione si nota quanto incidano bufferizzazione e compressione. I file compressi in genere diventano da 2 a 10 volte più piccoli (il fattore esatto dipende dalla struttura dei dati). Con il buffer la serializzazione è sensibilmente più veloce, mentre la compressione rallenta un po’ il processo, ma il risparmio di spazio spesso vale la pena.
Conclusione: per grandi volumi di dati usa sempre la bufferizzazione e, se la dimensione è critica, aggiungi la compressione.
6. Errori tipici nell’ottimizzazione della serializzazione
Errore n. 1: Mancato uso della bufferizzazione — la serializzazione di oggetti grandi diventa molto più lenta.
Errore n. 2: Serializzare dati non necessari o sensibili (ad esempio password, token temporanei) — usa sempre transient per tali campi.
Errore n. 3: Aspettarsi che la compressione acceleri sempre la serializzazione — in realtà la compressione riduce la dimensione, ma può rallentare un po’ il processo (soprattutto su CPU deboli).
Errore n. 4: Misurare il tempo senza considerare il warm-up della JVM e l’influenza del GC — per benchmark accurati usa JMH.
Errore n. 5: Confrontare solo il tempo o solo la dimensione — guarda sempre entrambi i parametri per scegliere il bilanciamento ottimale per il tuo caso.
GO TO FULL VERSION