1. Wprowadzenie: po co optymalizować serializację?
W nowoczesnych aplikacjach serializacja występuje wszędzie — od protokołów sieciowych po rozproszone cache i wymianę danych między usługami.
Szybkość i rozmiar serializacji są tu kluczowe. Jeśli serializacja jest wolna, aplikacja zaczyna „spowalniać” przy zapisie lub odczycie danych, a sieć lub dysk pracują nieefektywnie. Jeśli obiekty są zbyt duże, zajmują dużo miejsca na dysku, dłużej przesyłają się przez sieć i generują dodatkowe obciążenie pamięci oraz przepustowości.
Typowe zadania obejmują zapis dużego grafu obiektów do pliku lub cache, przesyłanie obiektów przez sieć z minimalnymi opóźnieniami oraz szybkie serializowanie/deserializowanie danych w systemie wielowątkowym.
Wniosek jest prosty: optymalizacja serializacji to nie „funkcja premium”, lecz konieczna praktyka dla wydajnych i skalowalnych aplikacji.
2. Optymalizacja rozmiaru zserializowanych danych
Wykluczanie zbędnych danych: słowo kluczowe transient
Domyślnie serializowane są wszystkie pola obiektu poza oznaczonymi jako transient. Jeśli pola nie trzeba zapisywać (np. cache, dane tymczasowe, referencje do serwisów), oznacz je jako transient:
public class User implements Serializable {
private String name;
private transient String sessionToken; // nie będzie serializowane
}
Plusy:
- Mniejszy rozmiar zserializowanego obiektu.
- Brak zbędnych/ryzykownych danych w pliku lub w sieci.
Ręczna serializacja: interfejs Externalizable
Jeśli potrzebujesz pełnej kontroli nad tym, co i jak jest serializowane, zaimplementuj interfejs Externalizable i jawnie opisz serializację (metody 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);
// nie serializujemy secret
}
@Override
public void readExternal(ObjectInput in) throws IOException {
name = in.readUTF();
age = in.readInt();
}
}
Plusy:
- Serializowane są tylko potrzebne pola.
- Można zmieniać format serializacji bez utraty kompatybilności.
Kompresja: zmniejszanie rozmiaru zserializowanych danych
Zserializowane obiekty często zajmują dużo miejsca, zwłaszcza gdy zawierają powtarzające się łańcuchy i duże kolekcje. Można zmniejszyć rozmiar za pomocą kompresji.
Przykład z GZIPOutputStream:
try (ObjectOutputStream out = new ObjectOutputStream(
new GZIPOutputStream(new FileOutputStream("data.gz")))) {
out.writeObject(bigObject);
}
Przykład z 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();
}
Plusy:
- Rozmiar pliku może zmniejszyć się kilkukrotnie (zwłaszcza dla dużych grafów obiektów).
- Mniejszy ruch przy przesyle przez sieć.
Minusy:
- Kompresja/dekompresja wymagają dodatkowego czasu (CPU).
3. Optymalizacja szybkości serializacji
Buforowanie: po co są BufferedOutputStream i BufferedInputStream
Problem:
Bez buforowania każde wywołanie write() lub read() prowadzi do systemowego dostępu do dysku lub sieci — to bardzo wolne!
Rozwiązanie:
Używaj buforowanych strumieni:
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();
}
Plusy:
- Znacznie przyspiesza zapis/odczyt dużych obiektów.
- Zmniejsza liczbę operacji na dysku/sieci.
Jak to działa?
Bufor gromadzi dane w pamięci i zapisuje je porcjami, a nie bajt po bajcie.
Szybkie kopiowanie: FileChannel.transferTo
Gdy trzeba szybko skopiować duży zserializowany plik, użyj NIO i metody transferTo:
try (FileChannel src = new FileInputStream("data.bin").getChannel();
FileChannel dest = new FileOutputStream("copy.bin").getChannel()) {
src.transferTo(0, src.size(), dest);
}
Plusy:
- Kopiowanie odbywa się na poziomie systemu operacyjnego, z pominięciem zbędnego buforowania w Javie — bardzo szybkie dla dużych plików.
4. Profilowanie serializacji
Prosty pomiar czasu: System.nanoTime()
Do szybkiej oceny wydajności serializacji można użyć 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("Czas serializacji: " + (end - start) / 1_000_000 + " ms");
Plusy:
- Prosto i szybko.
- Można porównać różne warianty (z buforem, bez bufora, z kompresją itd.).
Minusy:
- Wyniki mogą „skakać” z powodu pracy GC i procesów w tle.
- Nie nadaje się do dokładnego porównania bardzo małych różnic.
Dokładne profilowanie: JMH (Java Microbenchmark Harness)
Do dokładniejszych pomiarów używaj JMH — specjalnej biblioteki do mikrobenchmarków.
Przykład prostego benchmarku:
@Benchmark
public void serializeWithBuffer() throws Exception {
try (ObjectOutputStream out = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream("data.bin")))) {
out.writeObject(bigObject);
}
}
Plusy:
- Uwzględnia rozgrzewanie JVM, wpływ GC oraz „szumy” systemu operacyjnego.
- Daje wiarygodne i powtarzalne wyniki.
Minusy:
- Wymaga konfiguracji i zrozumienia metodologii JMH.
- Zbyt rozbudowane do porównania „na oko”.
5. Praktyka: porównanie czasu i rozmiaru serializacji
Przeprowadźmy mini‑eksperyment: zserializujemy duży graf obiektów (np. listę z 100_000 obiektami z zagnieżdżonymi kolekcjami) różnymi sposobami i porównamy czas oraz rozmiar pliku.
Serializacja bez buforowania i kompresji
long start = System.nanoTime();
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("data1.bin"))) {
out.writeObject(bigList);
}
long end = System.nanoTime();
System.out.println("Bez bufora: " + (end - start) / 1_000_000 + " ms, rozmiar: " +
new File("data1.bin").length() + " bajtów");
Serializacja z buforowaniem
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("Z buforem: " + (end - start) / 1_000_000 + " ms, rozmiar: " +
new File("data2.bin").length() + " bajtów");
Serializacja z kompresją (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("Z kompresją: " + (end - start) / 1_000_000 + " ms, rozmiar: " +
new File("data3.gz").length() + " bajtów");
Analiza wyników
W testach serializacji wyraźnie widać, jak silnie wpływają buforowanie i kompresja. Pliki po kompresji zwykle stają się 2–10 razy mniejsze (dokładny współczynnik zależy od struktury danych). Z buforem serializacja przebiega zauważalnie szybciej, a kompresja nieco spowalnia proces, lecz oszczędność miejsca często jest tego warta.
Wniosek: przy dużych wolumenach danych koniecznie używaj buforowania, a jeśli kluczowy jest rozmiar — dołącz kompresję.
6. Typowe błędy przy optymalizacji serializacji
Błąd nr 1: Brak buforowania — serializacja dużych obiektów staje się wielokrotnie wolniejsza.
Błąd nr 2: Serializowanie zbędnych lub wrażliwych danych (np. haseł, tymczasowych tokenów) — zawsze używaj transient dla takich pól.
Błąd nr 3: Założenie, że kompresja zawsze przyspiesza serializację — w rzeczywistości kompresja zmniejsza rozmiar, ale może nieco spowolnić proces (zwłaszcza na słabszych CPU).
Błąd nr 4: Pomiar czasu bez uwzględnienia rozgrzewania JVM i wpływu GC — do dokładnych benchmarków używaj JMH.
Błąd nr 5: Porównywanie tylko czasu lub tylko rozmiaru — zawsze patrz na oba parametry, aby wybrać optymalny balans dla swojego zadania.
GO TO FULL VERSION