Everything BiblioTech has saved so far is text you formatted by hand: BOOK;isbn;title;author;year. It works for the catalogue, but try extending it to the complete state of the system. A Loan references a Material and an Employee; the same Employee appears in three different loans; each loan has a list of nested Incidents; ReservationQueue holds reservations pointing at the same materials. Writing that by hand means inventing identifiers, writing each object once, resolving the references on loading and rebuilding the graph. It is work, and it is repetitive work.
Java knows how to do it on its own. Serialisation turns a complete object graph into a sequence of bytes and rebuilds it afterwards, with every reference in place, including the shared and the circular ones. Two calls: writeObject and readObject.
And here comes the warning, which is as important as the mechanism: Java's native serialisation is one of the most problematic features of the language. It is convenient, it is powerful, and it has been for twenty years one of the most exploited attack vectors in the Java ecosystem. This lesson teaches you to use it and, with the same emphasis, when not to use it.
try-with-resourceseverywhere, as always since 06-06.
Contents
- What serialising is: the object graph
- The marker interface
Serializable ObjectOutputStreamandObjectInputStream- What is serialised and what is not:
transientandstatic NotSerializableException: the contagion effectserialVersionUID: what it is and why you must declare itInvalidClassExceptionin action- Class evolution: which changes are compatible
- Customising with private
writeObjectandreadObject Externalizableand total control- Serialisation and inheritance
- Security risks: why this is discouraged
- Deserialisation filters with
ObjectInputFilter - Alternatives and when it is still reasonable
- BiblioTech: saving and restoring a session
- Common Mistakes and Tips
- Exercises
- What serialising is: the object graph
Serialising means turning an object —and everything hanging off it— into a sequence of bytes. Deserialising is the way back.
The key word is graph. An object is not alone: it has fields pointing at other objects, which in turn point at others. The whole forms a directed graph, and serialising means traversing all of it.
Consider this BiblioTech state:
flowchart TD
S["LibrarySession"]
P1["Loan LN-0001"]
P2["Loan LN-0002"]
P3["Loan LN-0003"]
E1["Employee EMP-001<br/>Marta Ruiz"]
E2["Employee EMP-002<br/>Diego Alonso"]
M1["Book<br/>Effective Java"]
M2["Book<br/>Design Patterns"]
M3["Book<br/>Refactoring"]
I1["Incident<br/>torn page"]
S --> P1
S --> P2
S --> P3
P1 --> E1
P2 --> E1
P3 --> E2
P1 --> M1
P2 --> M2
P3 --> M3
P1 --> I1
style E1 fill:#fff3e0
style S fill:#e3f2fd
Notice Employee EMP-001: two loans point at the same object. They are not two copies of Marta Ruiz; it is the same instance, referenced twice. And that matters a great deal: if Marta returns a book, her loan counter goes down, and both loans see the same change because they share the object.
Java's serialisation preserves that shared identity. It writes the EMP-001 object once and, when it meets it again, it writes an internal reference to what has already been written. On deserialising, both loans point at the same instance again.
This is achieved with a table of already-written objects maintained by the ObjectOutputStream:
| Situation | What it writes |
|---|---|
| A new object | Its class, its fields, and it notes it in the table with an identifier |
| An already-written object | Only a reference to the table identifier |
| A circular reference (A → B → A) | Resolved with the same mechanism, with no infinite loop |
null |
A null marker |
That circular references work is no minor detail: a naive algorithm would fall into infinite recursion. Java's serialisation handles them correctly and without you doing anything.
That is its great advantage, and it is not a small one. Reproducing this by hand with a text format demands inventing identifiers, writing each object once, and making two passes on loading to resolve the references.
- The marker interface
Serializable
SerializableFor an object to be serialisable, its class must implement java.io.Serializable:
It is empty. It is a marker interface, exactly the concept you saw in 04-01: an interface with no methods whose sole purpose is to tag a class so that other code can check with instanceof whether it has a property.
package com.nexussoftware.bibliotech.domain;
import java.io.Serializable;
public class Employee implements Serializable {
private static final long serialVersionUID = 1L; // section 6
private final String name;
private final String identifier;
private int activeLoans;
// ... the rest as always
}Without implements Serializable, trying to serialise throws NotSerializableException.
Why an empty interface and not an automatic mechanism? Because serialising must be a conscious decision. By implementing Serializable you are declaring a serious commitment, and it is worth knowing:
The serialised form of a class is part of its public API. All its private fields end up exposed in the file, and any change to them can break compatibility with the files already written. A private field you can rename freely in an ordinary class becomes, in a serialisable class, a permanent commitment.
That is why Joshua Bloch, in Effective Java —one of the books in the BiblioTech catalogue—, devotes a whole chapter to serialisation and his central recommendation is: implement Serializable with great caution.
What is serialisable out of the box in the JDK:
| Serialisable | Not serialisable |
|---|---|
String, all the wrappers (Integer, Double...) |
Thread |
ArrayList, LinkedList, HashMap, HashSet |
Socket, InputStream, OutputStream |
| Arrays of serialisable types | FileReader, FileWriter |
LocalDate and all of java.time (10-05) |
Database Connection |
records whose components are |
Most classes wrapping system resources |
The rule explaining the right-hand column: an object representing a live operating system resource cannot be serialised, because the bytes of a file descriptor or a socket mean nothing tomorrow nor on another machine.
ObjectOutputStream and ObjectInputStream
ObjectOutputStream and ObjectInputStreamThe two classes doing the work are filters from the byte family (07-03):
package com.nexussoftware.bibliotech.demo;
import java.io.*;
public class BasicSerialisation {
public static void save(Employee employee, String path) throws IOException {
try (ObjectOutputStream output = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream(path)))) {
output.writeObject(employee); // writes the object and its whole graph
}
}
public static Employee load(String path) throws IOException, ClassNotFoundException {
try (ObjectInputStream input = new ObjectInputStream(
new BufferedInputStream(new FileInputStream(path)))) {
// readObject returns Object: a cast is needed
return (Employee) input.readObject();
}
}
public static void main(String[] args) throws Exception {
Employee marta = new Employee("Marta Ruiz", "EMP-001");
marta.registerLoan();
marta.registerLoan();
save(marta, "data/marta.ser");
Employee restored = load("data/marta.ser");
System.out.println(restored.getName()); // Marta Ruiz
System.out.println(restored.getActiveLoans()); // 2
// It is ANOTHER object, with the SAME state
System.out.println(marta == restored); // false
System.out.println(marta.equals(restored)); // true, if equals is right (03-09)
}
}The main methods:
| Method | What it does |
|---|---|
writeObject(Object) |
Serialises the object and its whole graph |
readObject() |
Deserialises. Returns Object: a cast is needed |
writeInt, writeDouble, writeUTF... |
Inherited from DataOutputStream (07-03) |
defaultWriteObject() |
Writes the default fields. Only inside your own writeObject |
defaultReadObject() |
Reads the default fields. Only inside your own readObject |
And the exceptions to handle:
| Exception | When | Type |
|---|---|---|
NotSerializableException |
An object in the graph is not Serializable |
IOException |
InvalidClassException |
The class changed incompatibly | IOException |
ClassNotFoundException |
The class is not on the classpath when deserialising | Exception, not IOException |
StreamCorruptedException |
The file is not a valid object stream | IOException |
EOFException |
More objects are read than were written | IOException |
Note ClassNotFoundException: it does not extend IOException, so it needs its own catch or a multi-catch (06-02). It is the one that appears when you read a file written by another version of the application that had classes yours does not.
How to write and read several objects:
/** Several objects: they are written and read IN THE SAME ORDER. */
public static void saveAll(List<Employee> employees, String path) throws IOException {
try (ObjectOutputStream output = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream(path)))) {
output.writeInt(employees.size()); // first, how many there are
for (Employee e : employees) {
output.writeObject(e);
}
}
}
public static List<Employee> loadAll(String path)
throws IOException, ClassNotFoundException {
List<Employee> employees = new ArrayList<>();
try (ObjectInputStream input = new ObjectInputStream(
new BufferedInputStream(new FileInputStream(path)))) {
int howMany = input.readInt();
for (int i = 0; i < howMany; i++) {
employees.add((Employee) input.readObject());
}
}
return employees;
}Simpler still: serialise the whole collection, because ArrayList is serialisable:
output.writeObject(new ArrayList<>(employees)); // a single writeObject
// ...
List<Employee> employees = (List<Employee>) input.readObject();This second form is better: less code and no chance of the counter going out of step. But be careful which implementation of List you store: List.of(...) and Collections.unmodifiableList(...) produce JDK internal classes that are serialisable but whose concrete identity can change between versions. If you are going to serialise a collection, store an explicit ArrayList or HashMap.
What the bytes it produces look like:
AC ED 00 05 73 72 00 2C 63 6F 6D 2E 6E 65 78 75 |....sr.,com.nexu|
73 73 6F 66 74 77 61 72 65 2E 62 69 62 6C 69 6F |ssoftware.biblio|
74 65 63 68 2E 64 6F 6D 61 69 6E 2E 45 6D 70 6C |tech.domain.Empl|
6F 79 65 65 00 00 00 00 00 00 00 01 02 00 03 78 |oyee...........x|AC ED is the magic number of Java serialisation —the same concept as exercise 3 of 07-03—, followed by the protocol version and, in readable text, the full class name. This reveals two things: the format is not opaque, and whoever has the file knows exactly which classes your application uses. Keep that observation for section 12.
- What is serialised and what is not:
transient and static
transient and staticBy default all instance fields are serialised. With two exceptions:
| Modifier | Serialised? | Why |
|---|---|---|
| (none) | Yes | It is the object state |
private |
Yes | Privacy does not protect against serialisation |
final |
Yes | It is restored without going through the constructor |
static |
No | It belongs to the class, not to the object |
transient |
No | Explicitly excluded |
Two surprising details:
private fields are serialised. Module 3's encapsulation protects nothing here: every private field is written into the file, with its name. It is the direct consequence of the serialised form being part of the API.
final fields are restored without running the constructor. Deserialisation calls no constructor of the serialisable class: it creates the object directly and assigns it the fields it reads. This has a serious implication developed in section 12: deserialisation is a route to creating objects that bypasses all your validations.
static fields are not serialised because they belong to the class. If Material.MAX_FINE is 20.0 when saving and somebody changes it to 25.0 before loading, the restored object will use 25.0. That is correct: the constant belongs to the class, not to the object.
What transient is really for
transient marks a field as not serialisable. It has three legitimate uses:
1. Derived data that can be recomputed. There is no point in saving what is deduced from something else:
public class Loan implements Serializable {
private static final long serialVersionUID = 1L;
private final String reference;
private final Material material;
private final int startDay;
private int returnDay = -1;
// DERIVED: computed from the days and the material rate.
// Saving it would duplicate information, and worse: if the rate changes,
// the saved value would be stale and contradict the computed one.
private transient double calculatedFine;
private transient boolean fineCached = false;
public double calculateFine(int currentDay) {
if (!fineCached) {
calculatedFine = material.calculateFine(currentDay - startDay);
fineCached = true;
}
return calculatedFine;
}
}2. Secrets that must not end up written on disk. Passwords, tokens, keys:
public class UserSession implements Serializable {
private static final long serialVersionUID = 1L;
private final String identifier;
// NEVER on disk. Everything 06-07 said about what not to log applies
// equally, or more, to what is persisted: the file stays.
private transient char[] credential;
private transient String sessionToken;
}3. Non-serialisable resources. Connections, streams, threads. That is the case of the next section, and transient is the solution to the contagion problem.
What value transient fields have when deserialised: the default value of their type. 0 for numerics, false for boolean, null for references. No field initialiser and no constructor is run.
That is a very specific source of failures:
public class CardCache implements Serializable {
private static final long serialVersionUID = 1L;
// WRONG: on deserialising it will be null, not an empty HashMap.
// The initialiser is NOT run.
private transient Map<String, Card> cache = new HashMap<>();
public void store(String key, Card c) {
cache.put(key, c); // NullPointerException after deserialising
}
}The solution is a private readObject that reinitialises, and it is section 9.
NotSerializableException: the contagion effect
NotSerializableException: the contagion effectHere is the trap that baffles people most:
An object is serialisable only if ITS WHOLE GRAPH is. One non-serialisable field, at any depth, is enough to make the whole operation fail.
import java.io.Serializable;
import java.util.logging.Logger;
public class LoanManager implements Serializable {
private static final long serialVersionUID = 1L;
private final Catalog catalog; // serialisable
private final LoanRegistry registry; // serialisable
// PROBLEM: Logger is NOT Serializable.
// This single field makes the WHOLE LoanManager fail.
private final Logger log = Logger.getLogger(LoanManager.class.getName());
}When you try to save it:
Exception in thread "main" java.io.NotSerializableException: java.util.logging.Logger
at java.base/java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1197)
at java.base/java.io.ObjectOutputStream.defaultWriteFields(...)
at ...The message says which class failed, but not where that field was, which is what you need to know. In a graph of twenty nested classes, finding it is a manhunt. The trick: read the stack trace from the bottom up following the defaultWriteFields, as you learned in 06-01.
The solution is transient, nearly always:
public class LoanManager implements Serializable {
private static final long serialVersionUID = 1L;
private final Catalog catalog;
private final LoanRegistry registry;
// transient: it is not saved. And it does not need to be, because a
// Logger is recovered with a static call at any moment.
private transient Logger log = Logger.getLogger(LoanManager.class.getName());
/**
* Reinitialises the transient fields after deserialisation (section 9).
* Without this, 'log' would be null and the first use would fail.
*/
private void readObject(java.io.ObjectInputStream input)
throws java.io.IOException, ClassNotFoundException {
input.defaultReadObject();
log = Logger.getLogger(LoanManager.class.getName());
}
}And a practical rule that saves a lot of pain:
Loggerfields must always beprivate static final. Beingstaticthey are not serialised and the problem does not exist. It is exactly the convention you established in 06-07 for performance reasons; it turns out it solves this too.
The three solutions to contagion, depending on the case:
| Situation | Solution |
|---|---|
| The field can be recreated (logger, cache, connection) | transient + a readObject that reinitialises it |
| The field is a domain object of your own | Make it implement Serializable |
| The field comes from a library you do not control | transient + save the data needed to rebuild it |
serialVersionUID: what it is and why you must declare it
serialVersionUID: what it is and why you must declare itThis is the section that picks up what you did in module 6. In 06-04 you wrote, in every exception:
public class MaterialNotFoundException extends CatalogException {
private static final long serialVersionUID = 1L;
// ...
}And the explanation was brief: the compiler asks for it because Throwable is Serializable. Now for the full explanation.
What it is
serialVersionUID is a number that identifies the version of the serialised form of a class. It is written into the file when serialising, and on deserialising it is compared with the one of the loaded class. If they do not match, InvalidClassException is thrown.
It is a compatibility check: "this file was written by version 1 of the class; are you that version?".
How it is computed by default
If you do not declare it, the compiler generates one automatically from a cryptographic digest of:
- The class name.
- The class modifiers.
- The interfaces it implements, in order.
- The names, types and modifiers of all the fields.
- The names, types and modifiers of all the methods, including the private ones.
- The constructors.
And there lies the problem:
Almost any change to the class changes the generated
serialVersionUID. Adding a private method. Changing aprivatetoprotected. Reordering interfaces. Even compiling with another compiler version can produce a different value.
Practical consequence: without declaring it, adding a private helper method —which does not change the state at all— breaks every saved file. And the error message does not say "you have added a method"; it says the identifiers do not match, with two long numbers.
Why you must declare it
By declaring it yourself, you take control:
public class Employee implements Serializable {
/**
* Version of the serialised form.
*
* It is ONLY incremented when an INCOMPATIBLE change is made (section 8).
* Compatible changes (adding fields, methods, changing code) keep the
* same value.
*/
private static final long serialVersionUID = 1L;
// ...
}Now the old files stay readable until you decide otherwise, and you decide when to break compatibility.
How to write it, exactly:
private: it is not part of the public API.static: it belongs to the class.final: it does not change.long: it is the required type.- The
Lsuffix is mandatory.
All four modifiers matter: if you leave one out, the mechanism ignores it silently and goes back to the automatic computation. A serialVersionUID without static is useless and gives no warning at all.
How to enable the compiler warning:
Enable it in the project. It is the warning that tells you "you are going to have a problem in six months' time".
InvalidClassException in action
InvalidClassException in actionThe demonstration is worth more than the explanation. Version 1 of the class:
// VERSION 1 - it is compiled, run, and a file is saved
public class Employee implements Serializable {
// NO serialVersionUID declared: it is computed automatically
private final String name;
private final String identifier;
private int activeLoans;
public Employee(String name, String identifier) {
this.name = name;
this.identifier = identifier;
}
public String getName() { return name; }
}Now an innocent change is made: adding a method. Not a new field, not a type change. A method.
// VERSION 2 - only a method is added
public class Employee implements Serializable {
private final String name;
private final String identifier;
private int activeLoans;
public Employee(String name, String identifier) {
this.name = name;
this.identifier = identifier;
}
public String getName() { return name; }
/** NEW method. It does not touch the state. */
public String getInitials() {
String[] parts = name.split(" ");
return "" + parts[0].charAt(0) + parts[1].charAt(0);
}
}When reading the file saved with version 1:
Exception in thread "main" java.io.InvalidClassException:
com.nexussoftware.bibliotech.domain.Employee;
local class incompatible:
stream classdesc serialVersionUID = -4738291056473829104,
local class serialVersionUID = 8273645019283746152The file is unreadable for ever. And the change did not affect the state at all.
With serialVersionUID = 1L declared in both versions, the file reads with no trouble whatsoever. The new method simply exists on the restored objects.
This is the reason declaring it is compulsory. Without it, the compatibility of your files depends on compiler details you neither control nor know about. With it, you decide.
And this is exactly what your module 6 exceptions do: BiblioTechException and all its subclasses declare it, because exceptions are serialised when they travel between machines —a remote call, an application server— and an exception that cannot be deserialised at the other end turns a comprehensible business error into an incomprehensible infrastructure failure.
- Class evolution: which changes are compatible
With serialVersionUID declared, what can you change without breaking the files?
| Change | Compatible? | What happens when deserialising an old file |
|---|---|---|
| Adding a field | Yes | The new field takes its default value (0, null, false) |
| Removing a field | Yes | The value in the file is discarded |
| Adding, removing or changing methods | Yes | Nothing: methods are not serialised |
| Changing a method body | Yes | Nothing |
| Adding constructors | Yes | Nothing: they are not run when deserialising |
Changing transient to normal |
Yes | Like adding a field |
Changing normal to transient |
Yes | Like removing a field |
Changing static to non-static |
Yes, with care | Like adding a field |
| Changing the TYPE of a field | NO | InvalidClassException |
| Renaming a field | NO | Equivalent to removing one and adding another: the data is lost |
| Renaming or moving the class | NO | ClassNotFoundException |
| Changing the inheritance hierarchy | NO | InvalidClassException |
Removing implements Serializable |
NO | Unreadable |
Changing from class to enum or record |
NO | Incompatible |
The two dangerous cases deserve special attention:
Adding a field is compatible, but the default value may be invalid. If you add private int maxDays; and all your code assumes it is at least 1, the objects restored from old files will have 0 and will break your invariants silently. The solution is a readObject that checks and corrects, and it is the next section.
Renaming a field loses the data with no warning. Changing name to fullName means, to the mechanism, that the name field no longer exists (it is discarded) and that there is a new one called fullName (set to null). There is no error at all: you get objects with the name set to null. It is the kind of failure that shows up in production three weeks later.
The practical rule:
Increment
serialVersionUIDonly when you make an incompatible change, and accept that from that moment on the old files are unreadable. If you need to be able to read them, do not increment: write areadObjectthat knows how to handle both forms.
- Customising with private
writeObject and readObject
writeObject and readObjectThe default mechanism can be intervened in with two methods of exact signature:
private void writeObject(ObjectOutputStream output) throws IOException;
private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException;They are private and the mechanism still finds and calls them —it does so by reflection, which you will see in 10-03. If the signature is not exactly that, they are ignored silently, which is one of the most frustrating traps in this API.
The four legitimate uses:
package com.nexussoftware.bibliotech.service;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
import java.util.logging.Logger;
import com.nexussoftware.bibliotech.domain.Card;
/**
* Card cache with customised serialisation.
*
* The cache is NOT saved (it is derived), but it must be rebuilt empty on
* loading: otherwise it would be null and the first use would fail.
*/
public class CardCache implements Serializable {
private static final long serialVersionUID = 1L;
/** static: not serialised. The correct convention for a Logger. */
private static final Logger LOG = Logger.getLogger(CardCache.class.getName());
private final String name;
private int maxCapacity;
/** transient: on deserialising it will be null if we do not fix it. */
private transient Map<String, Card> cache = new HashMap<>();
private transient int hits = 0;
private transient int misses = 0;
public CardCache(String name, int maxCapacity) {
this.name = java.util.Objects.requireNonNull(name);
if (maxCapacity < 1) {
throw new IllegalArgumentException(
"The capacity must be at least 1, and it was: " + maxCapacity);
}
this.maxCapacity = maxCapacity;
}
/**
* Customised serialisation.
*
* USE 1: log the operation.
* USE 2: write extra data after the default fields.
*/
private void writeObject(ObjectOutputStream output) throws IOException {
LOG.fine(() -> "Serialising cache '" + name + "' with "
+ cache.size() + " entries (which are NOT saved)");
output.defaultWriteObject(); // first, the normal fields
// Extra data can be written. It will be read in the SAME order.
output.writeInt(cache.size()); // informative only
}
/**
* Customised deserialisation.
*
* USE 3: reinitialise the transient fields.
* USE 4: VALIDATE the state read.
*/
private void readObject(ObjectInputStream input)
throws IOException, ClassNotFoundException {
input.defaultReadObject(); // first, the normal fields
int previousSize = input.readInt(); // the extra, in the same order
// USE 3: reinitialise. Without this, cache would be null.
this.cache = new HashMap<>();
this.hits = 0;
this.misses = 0;
// USE 4: VALIDATE. Deserialisation does NOT call the constructor, so
// its checks have not been run. A tampered file or one from an old
// version can bring impossible values.
if (maxCapacity < 1) {
LOG.warning(() -> "Invalid capacity on deserialising ("
+ maxCapacity + "); corrected to 100");
this.maxCapacity = 100;
}
if (name == null) {
throw new java.io.InvalidObjectException(
"Cache deserialised with no name: file corrupt or tampered with");
}
LOG.fine(() -> "Cache '" + name + "' restored empty (it had "
+ previousSize + " entries)");
}
public java.util.Optional<Card> find(String key) {
Card c = cache.get(key);
if (c != null) { hits++; } else { misses++; }
return java.util.Optional.ofNullable(c);
}
public void store(String key, Card card) {
if (cache.size() >= maxCapacity) {
cache.clear(); // a simple eviction policy
}
cache.put(key, card);
}
public int getHits() { return hits; }
public int getMisses() { return misses; }
}Use 4 is the most important of the four, and it is worth underlining:
Deserialisation calls no constructor. All the validations you wrote in 06-03 —
Objects.requireNonNull, range checks, formats— are skipped entirely. A tampered file can produce objects your code considers impossible: aLoanwith anullreference, anEmployeewith −5 active loans, aMaterialwith a negative fine.
That is why readObject must validate as if it were a constructor, and throw InvalidObjectException if the state is not acceptable. It is the back door into your domain, and it must be shut.
A related method, readResolve, allows the deserialised object to be replaced by another:
/** Preserves the singleton pattern when deserialising. */
private Object readResolve() {
return INSTANCE; // the unique instance is returned, not the deserialised one
}Without it, deserialising a singleton creates a second instance, breaking the uniqueness guarantee. enums do not have this problem —the language guarantees it—, which is one of the reasons 04-07 recommended enum for singletons.
Externalizable and total control
Externalizable and total controlExternalizable extends Serializable and replaces the automatic mechanism with complete control:
public interface Externalizable extends Serializable {
void writeExternal(ObjectOutput output) throws IOException;
void readExternal(ObjectInput input) throws IOException, ClassNotFoundException;
}Differences:
Serializable |
Externalizable |
|
|---|---|---|
| Fields saved | All, automatically | Only the ones you write |
| Methods | Optional and private | Mandatory and public |
| Constructor when deserialising | Not called | The public no-argument one is called |
| Inheritance of fields | Automatic | You manage it |
| File size | Larger: it includes metadata | Smaller |
| Risk of error | Low | High: a forgotten field is lost silently |
public class Location implements Externalizable {
private String room;
private int bookcase;
private int shelf;
/** MANDATORY and PUBLIC: Externalizable calls it when deserialising. */
public Location() { }
public Location(String room, int bookcase, int shelf) {
this.room = room;
this.bookcase = bookcase;
this.shelf = shelf;
}
@Override
public void writeExternal(ObjectOutput output) throws IOException {
output.writeUTF(room);
output.writeInt(bookcase);
output.writeInt(shelf);
}
@Override
public void readExternal(ObjectInput input) throws IOException {
room = input.readUTF(); // the SAME order
bookcase = input.readInt();
shelf = input.readInt();
}
}The three reasons it is almost never used:
finalfields are impossible. The no-argument constructor cannot initialise them andreadExternalcannot assign them. Goodbye to the immutability of 03-07.- A forgotten field is lost silently. You add a field, you forget to update the two methods, and that data disappears with no error at all.
- The saving rarely pays off.
Serializable's metadata is a small percentage in most cases.
Know it so you can read other people's code. If you need that level of control, it is nearly always better to write your own format, as you did with DataOutputStream in 07-03 or as you will do with CSV in 07-07.
- Serialisation and inheritance
The rules when there is a hierarchy:
If the superclass is Serializable, the subclasses are automatically. Serializable is inherited like any interface. If Material implements it, Book, Magazine and Dvd are serialisable without declaring anything.
If the superclass is NOT Serializable, there is a strict condition:
The non-serialisable superclass must have an accessible no-argument constructor. When deserialising, its fields are not read from the file: they are initialised by calling that constructor.
/** NON-serialisable superclass. */
public abstract class InventoryItem {
private final String internalCode;
private int revisions;
/**
* MANDATORY for the serialisable subclasses to work.
* Without it: InvalidClassException "no valid constructor".
*/
protected InventoryItem() {
this.internalCode = "NO-CODE"; // default value
this.revisions = 0;
}
protected InventoryItem(String internalCode) {
this.internalCode = internalCode;
this.revisions = 0;
}
public String getInternalCode() { return internalCode; }
}
/** Serialisable subclass. */
public class Material extends InventoryItem implements Serializable {
private static final long serialVersionUID = 1L;
private final String title;
private final String reference;
private boolean available;
// ...
}When deserialising a Material:
- The no-argument constructor of
InventoryItemis called, sointernalCodeis"NO-CODE"and the original value has been lost. title,referenceandavailableare read from the file.
The fields of the non-serialisable superclass are not saved. If that is not acceptable —and with a real internalCode it would not be—, there are two ways out: make the superclass serialisable, or save those fields by hand in a writeObject/readObject of the subclass.
And the error if the constructor is missing:
A terse message that means exactly: "your non-serialisable superclass has no accessible no-argument constructor".
- Security risks: why this is discouraged
This is the most important section of the lesson. Up to here you have seen a convenient tool. Now for why the Java community has been recommending avoiding it for years.
The underlying problem
Deserialising untrusted data allows, in the general case, arbitrary code execution on your machine.
This is not an exaggeration. It is the cause of some of the most serious vulnerabilities in the history of the Java ecosystem.
The mechanism, explained without giving recipes:
readObject()instantiates classes whose name comes written in the file. Remember the dump in section 3: the class name is right there, in text.- When deserialising, methods defined by those classes are run:
readObject,readResolve,validateObject, and indirectlyequals,hashCodeorcompareTowhen inserting into collections. - An attacker who controls the bytes can compose a graph of objects of classes that are already on your classpath —yours, the JDK's, your libraries'— chaining their effects until something harmful is achieved.
- All of this happens BEFORE your code sees the object. There is no point at which you can check anything: by the time
readObject()returns, the damage is already done.
Point 4 is what makes this problem different from all the others:
// This code IS ALREADY VULNERABLE if 'input' comes from outside.
// The check arrives TOO LATE: the damage happened inside readObject().
Object o = input.readObject();
if (o instanceof Loan l) { // <-- too late
process(l);
}There is no way of validating beforehand, because the validation would have to happen during the deserialisation itself. That is why the only real defence is not to deserialise untrusted data, and secondly the filters of section 13.
What counts as "untrusted"
Practically everything you have not written yourself into a file only you can touch:
| Origin | Trusted? |
|---|---|
| A file written by your own application, in a protected directory | Reasonably |
| A file the user can upload or replace | No |
| Data received over the network | No |
| The content of a cookie or a form field | No |
| A message from a queue or an external system | No |
| A file in a shared or temporary directory | No |
| A backup of unknown origin | No |
And the uncomfortable observation: even a file that is "yours" stops being so if somebody can write in that directory. A session file in /tmp with broad permissions is not trusted.
The official position
This is not a blog opinion. The JDK's own documentation, in java.io.ObjectInputStream, warns that deserialising untrusted data is inherently dangerous. And OpenJDK's Amber project has been working for years on replacing native serialisation, precisely because of this.
The established professional recommendations:
- Do not deserialise anything you do not fully control.
- For data interchange, use text formats: CSV (07-07), JSON (11-07), XML. Those formats do not instantiate arbitrary classes: they produce strings and numbers that you turn into objects with your code and your validations.
- If there is no alternative, use a deserialisation filter (section 13).
- Reduce the surface: do not make
Serializablewhat does not need to be. - Keep dependencies up to date. Many attacks use classes from well-known libraries.
AN EXPLICIT AND NECESSARY WARNING: everything in this section is an introduction, not a security guide. In a real system, any use of serialisation crossing a trust boundary must be reviewed by your organisation's security officer, and the decisions must be documented. This is not something a developer decides on their own on a Tuesday afternoon. Application security is covered, as far as this course goes, in 12-07.
- Deserialisation filters with
ObjectInputFilter
ObjectInputFilterJava 9 introduced a defence mechanism: filters applied during deserialisation, before each class is instantiated.
package com.nexussoftware.bibliotech.infrastructure;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
/**
* Deserialisation filters for BiblioTech.
*
* WARNING: a filter REDUCES the risk, it does not eliminate it. The only
* complete defence is not deserialising untrusted data (section 12).
*/
public final class DeserialisationFilter {
private DeserialisationFilter() { }
/**
* Allow-list filter: only BiblioTech classes and a minimal set from the
* JDK are permitted. Everything else is REJECTED.
*
* The allow list is the only correct approach: a deny list always falls
* short, because you cannot enumerate everything dangerous.
*/
public static ObjectInputFilter forBiblioTech() {
String pattern = String.join(";",
"com.nexussoftware.bibliotech.**", // our classes
"java.util.ArrayList",
"java.util.LinkedList",
"java.util.HashMap",
"java.util.HashSet",
"java.lang.String",
"java.lang.Number",
"java.lang.Integer",
"java.lang.Double",
"java.lang.Boolean",
"java.lang.Enum",
"maxdepth=20", // maximum graph depth
"maxrefs=10000", // maximum references
"maxbytes=10485760", // 10 MB
"maxarray=100000", // maximum elements in an array
"!*"); // REJECT EVERYTHING ELSE
return ObjectInputFilter.Config.createFilter(pattern);
}
/** Applies the filter to a particular stream. */
public static void applyTo(ObjectInputStream input) {
input.setObjectInputFilter(forBiblioTech());
}
}Use:
try (ObjectInputStream input = new ObjectInputStream(
new BufferedInputStream(new FileInputStream(path)))) {
// BEFORE any readObject
DeserialisationFilter.applyTo(input);
LibrarySession session = (LibrarySession) input.readObject();
}If a non-permitted class arrives:
Pattern syntax:
| Pattern | Meaning |
|---|---|
com.example.Class |
That exact class |
com.example.* |
The classes of that package, without subpackages |
com.example.** |
That package and all its subpackages |
!com.bad.** |
Rejects that package |
!* |
Rejects everything not permitted earlier. It must go last |
maxdepth=N |
Maximum graph depth |
maxrefs=N |
Maximum number of references |
maxbytes=N |
Maximum stream size |
maxarray=N |
Maximum elements in an array |
The four numeric limits are not decorative: they protect against so-called deserialisation bombs, small files that on deserialising produce enormous structures or fall into deep recursion and exhaust the memory or the stack. A file of a few KB can bring down a server without them.
A global filter can also be applied to the whole JVM:
Repeat after me: the filter reduces the risk, it does not eliminate it. There are still permitted classes whose
readObjectdoes things, and it is still true that the code runs before you see anything. A filter is a layer of defence in depth, not an authorisation to deserialise whatever arrives.
- Alternatives and when it is still reasonable
An honest comparison of the options:
| Format | Readable | Portable across languages | Safe with foreign data | Size | Speed | Covered in |
|---|---|---|---|---|---|---|
| Java serialisation | No | No | No | Medium | Fast | This lesson |
| CSV | Yes | Yes | Yes | Small | Fast | 07-07 |
| Properties | Yes | Yes | Yes | Small | Fast | 07-07 |
| JSON | Yes | Yes | Yes (with a correct parser) | Medium | Medium | 11-07 |
| XML | Yes | Yes | Yes, with precautions | Large | Slow | Mentioned in 07-07 |
Your own binary (DataStream) |
No | With effort | Yes | Small | Very fast | 07-03 |
| Database | — | Yes | Yes | — | — | 11-03 |
The crucial difference in the security column: text formats do not instantiate classes. A CSV file produces strings; you decide which object to build from them, going through your constructors and your validations from 06-03. Serialisation, by contrast, builds the objects for you from class names that come in the file.
When native serialisation is still reasonable:
| Case | Why |
|---|---|
| Local cache of data you can recompute | If it gets corrupted, it is deleted and recomputed. It crosses no boundary |
| Internal state between runs of a desktop application | The file is in the user's directory and does not travel |
| Deep copy of an object in memory | Serialise to a ByteArrayOutputStream and deserialise. It is a well-known trick |
| Communication between trusted processes of the same application | With filters and controlling both ends |
| Legacy code already using it | Changing format has its own cost |
When not to use it, no exceptions:
- Data coming from outside, in any form.
- An interchange format with other systems.
- Long-term storage: the files stop being readable as soon as the classes change.
- Anything crossing a network.
- Anything a user can replace.
- BiblioTech: saving and restoring a session
With all the caveats in place, this is a case where serialisation fits: a local file, written and read by the application itself, in a directory it controls, with data that can be regenerated.
package com.nexussoftware.bibliotech.service;
import java.io.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.nexussoftware.bibliotech.infrastructure.DeserialisationFilter;
/**
* Saves and restores the state of a BiblioTech working session.
*
* WHY NATIVE SERIALISATION IS ACCEPTABLE HERE:
* - The file is written and read by the SAME application.
* - It lives in the local data directory; it does not travel nor get uploaded.
* - Its content can be REGENERATED: if it gets corrupted, it is deleted and a
* new session is started. It is not the source of truth; the CSV catalogue is.
*
* WHY THE CATALOGUE DOES NOT USE THIS: the catalogue is the valuable data, it
* must be readable, versionable and importable from a spreadsheet. That is 07-07.
*
* Even so a deserialisation FILTER is applied: defence in depth.
*/
public class SessionManager {
private static final Logger LOG = Logger.getLogger(SessionManager.class.getName());
private static final String EXTENSION = ".session";
private final File directory;
public SessionManager(String dataDirectory) {
this.directory = new File(
Objects.requireNonNull(dataDirectory, "The directory cannot be null"));
if (!directory.exists() && !directory.mkdirs()) {
LOG.warning("Could not create " + directory.getAbsolutePath());
}
}
// ------------------------- THE STATE -------------------------
/**
* Serialisable snapshot of the state of a session.
*
* It is a SEPARATE class from LibrarySession on purpose: the session
* manages resources (locks, logger) that must not be serialised.
* This class contains only DATA.
*/
public static class SessionState implements Serializable {
/**
* DECLARED from day one. Without it, adding a private method in six
* months' time would make every saved session unreadable
* (section 7).
*/
private static final long serialVersionUID = 1L;
private final String employeeId;
private final int openingDay;
private final List<String> operations;
private final int loansMade;
private final int returnsMade;
private final double finesCollected;
/** Derived: it is recomputed. There is no point in saving it. */
private transient String formattedSummary;
public SessionState(String employeeId, int openingDay, List<String> operations,
int loansMade, int returnsMade,
double finesCollected) {
this.employeeId = Objects.requireNonNull(employeeId, "The employee is mandatory");
if (openingDay < 1) {
throw new IllegalArgumentException("Invalid day: " + openingDay);
}
if (finesCollected < 0) {
throw new IllegalArgumentException("Negative fines: " + finesCollected);
}
this.openingDay = openingDay;
// Explicit ArrayList: it is serialisable and its form is stable
this.operations = new ArrayList<>(operations);
this.loansMade = loansMade;
this.returnsMade = returnsMade;
this.finesCollected = finesCollected;
}
/**
* VALIDATION on deserialising.
*
* Essential: deserialisation does NOT call the constructor, so the
* checks above have NOT been run. A corrupt or tampered file could
* bring a negative day or impossible fines, and the rest of the
* system would take them as good.
*/
private void readObject(ObjectInputStream input)
throws IOException, ClassNotFoundException {
input.defaultReadObject();
if (employeeId == null || employeeId.isBlank()) {
throw new InvalidObjectException(
"Session with no employee: file corrupt or tampered with");
}
if (openingDay < 1) {
throw new InvalidObjectException(
"Invalid opening day: " + openingDay);
}
if (finesCollected < 0) {
throw new InvalidObjectException(
"Negative fines: " + finesCollected);
}
if (operations == null) {
throw new InvalidObjectException("Null operations list");
}
// The transient field is recomputed on demand: nothing to do here
}
public String getEmployeeId() { return employeeId; }
public int getOpeningDay() { return openingDay; }
public List<String> getOperations() { return List.copyOf(operations); }
public int getLoansMade() { return loansMade; }
public int getReturnsMade() { return returnsMade; }
public double getFinesCollected() { return finesCollected; }
/** Derived field: computed the first time and cached. */
public String getSummary() {
if (formattedSummary == null) {
formattedSummary = String.format(
"Session of %s (day %d): %d loans, %d returns, %.2f EUR",
employeeId, openingDay, loansMade,
returnsMade, finesCollected);
}
return formattedSummary;
}
}
// ------------------------- SAVING -------------------------
/**
* Saves the state ATOMICALLY (07-02): temporary file and rename.
*
* A half-saved session would be worse than none: on restoring it would
* give StreamCorruptedException or, worse, an incomplete object.
*/
public void save(SessionState state) throws IOException {
Objects.requireNonNull(state, "The state cannot be null");
File target = new File(directory, state.getEmployeeId() + EXTENSION);
File temp = new File(target.getAbsolutePath() + ".tmp");
boolean completed = false;
try {
try (ObjectOutputStream output = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream(temp)))) {
output.writeObject(state);
} // close() -> flush guaranteed
if (target.exists() && !target.delete()) {
throw new IOException("Could not replace " + target.getName());
}
if (!temp.renameTo(target)) {
throw new IOException("Could not rename " + temp.getName());
}
completed = true;
LOG.info(() -> String.format("Session of %s saved to %s (%d bytes)",
state.getEmployeeId(), target.getName(), target.length()));
} finally {
if (!completed && temp.exists() && !temp.delete()) {
LOG.warning(() -> "Temporary file left undeleted: " + temp.getAbsolutePath());
}
}
}
// ------------------------- RESTORING -------------------------
/**
* Restores an employee's session.
*
* GRACEFUL DEGRADATION (06-07): if there is no saved session, or it is
* corrupt, or the class changed, an empty result is returned and we
* start from scratch. Losing a session is not a serious failure: the
* valuable data is in the catalogue.
*
* @return an empty Optional if it could not be restored
*/
public java.util.Optional<SessionState> restore(String employeeId) {
File file = new File(directory, employeeId + EXTENSION);
if (!file.exists()) {
LOG.fine(() -> "There is no saved session for " + employeeId);
return java.util.Optional.empty();
}
try (ObjectInputStream input = new ObjectInputStream(
new BufferedInputStream(new FileInputStream(file)))) {
// FILTER before reading anything. Defence in depth (section 13).
DeserialisationFilter.applyTo(input);
SessionState state = (SessionState) input.readObject();
LOG.info(() -> "Session restored: " + state.getSummary());
return java.util.Optional.of(state);
} catch (InvalidClassException e) {
// The class changed incompatibly (section 8).
LOG.log(Level.WARNING, "The session of " + employeeId
+ " was saved with another version of the application and cannot be read", e);
setAside(file);
return java.util.Optional.empty();
} catch (InvalidObjectException e) {
// Our readObject rejected the content: corrupt or tampered with.
LOG.log(Level.SEVERE, "Session of " + employeeId + " rejected by validation", e);
setAside(file);
return java.util.Optional.empty();
} catch (StreamCorruptedException | EOFException e) {
// Truncated file: a write was cut short. The atomic writing of
// save() prevents that, but a disk failure does not.
LOG.log(Level.WARNING, "Session of " + employeeId + " corrupt", e);
setAside(file);
return java.util.Optional.empty();
} catch (ClassNotFoundException e) {
// It does not extend IOException: it needs its own catch (section 3)
LOG.log(Level.SEVERE, "Class not found while restoring the session of "
+ employeeId, e);
return java.util.Optional.empty();
} catch (IOException e) {
LOG.log(Level.SEVERE, "I/O failure restoring the session of " + employeeId, e);
return java.util.Optional.empty();
}
}
/** Sets an unreadable file aside instead of deleting it: it may be needed for diagnosis. */
private void setAside(File file) {
File aside = new File(file.getAbsolutePath() + ".invalid");
if (aside.exists()) {
aside.delete();
}
if (file.renameTo(aside)) {
LOG.info(() -> "Unreadable session set aside as " + aside.getName());
}
}
}Complete use:
public class SessionDemo {
public static void main(String[] args) throws IOException {
SessionManager manager = new SessionManager("data/sessions");
// --- On start-up: try to restore ---
manager.restore("EMP-001").ifPresentOrElse(
state -> System.out.println("Continuing: " + state.getSummary()),
() -> System.out.println("New session for EMP-001"));
// --- The shift's work ---
List<String> operations = new ArrayList<>();
operations.add("LOAN LN-0001");
operations.add("LOAN LN-0002");
operations.add("RETURN LN-0001 (fine 1.75)");
// --- On exit: save ---
manager.save(new SessionManager.SessionState(
"EMP-001", 15, operations, 2, 1, 1.75));
System.out.println("Session saved. Run it again to see the restoration.");
}
}First run:
Second run:
Continuing: Session of EMP-001 (day 15): 2 loans, 1 returns, 1.75 EUR
Session saved. Run it again to see the restoration.The seven design decisions to understand:
SessionStateis a separate class fromLibrarySession. The session manages resources —locks, logger, catalogue— that must not be serialised. The state contains only data. Separating the live object from the persistable object is a design decision that avoids the contagion of section 5.serialVersionUIDdeclared from day one. Adding a private method in six months' time would make every session unreadable without it.readObjectvalidates as if it were a constructor. It is essential, because the constructor is not run. Without that validation, a corrupt file would produce objects the rest of the system considers impossible.- The filter is applied before reading anything. Defence in depth, even though the file is local.
- Atomic writing. A half-saved session would be worse than none.
- Five different
catchblocks, each with its own treatment.InvalidClassExceptionis a version change;InvalidObjectExceptionis rejected content;StreamCorruptedExceptionis a truncated file;ClassNotFoundExceptionis not anIOExceptionand needs its own block. It is 06-02 applied precisely. - The unreadable file is set aside, not deleted. Renaming it to
.invalidallows a later diagnosis of what happened, without blocking the user.
And the decision that sums up the lesson: the session uses serialisation; the catalogue does not. The catalogue is the valuable data, it must be readable, versionable, importable from a spreadsheet and stable against code changes. That is CSV, and it is lesson 07-07.
Common Mistakes and Tips
- Not declaring
serialVersionUID. Mistake number one. A private method added six months later breaks every saved file, with a message that does not explain the cause. - Declaring it wrongly. Without
static, withoutfinalor without theLsuffix, it is ignored silently and the automatic computation comes back. - Incrementing it out of habit. It is only incremented for an incompatible change. Incrementing it routinely invalidates the files needlessly.
- Renaming a field. Equivalent to deleting one and creating another: the data is lost and there is no error at all. The failure shows up weeks later.
- Believing
privateprotects. Every private field is written into the file, with its name. - Forgetting that deserialisation does not call the constructor. All your validations are skipped. Validate in
readObjector your domain has a back door. - Not reinitialising the
transientfields. They are leftnullor0, and the field initialiser is not run.NullPointerExceptionon the first use. - A non-
staticLogger. It spreads theNotSerializableExceptionto the whole class.private static final, always. - Serialising objects with live resources. A socket or a stream makes no sense outside its process.
- Serialising
List.of(...)or unmodifiable JDK collections. They are serialisable, but their concrete class is an internal detail. Store an explicitArrayListorHashMap. - A
writeObject/readObjectwith the wrong signature. It must be exactlyprivate void, with that parameter and those exceptions. Otherwise, it is ignored with no warning at all. - Forgetting
readResolvein a singleton. Deserialising creates a second instance and breaks uniqueness.enums do not have this problem. - Deserialising data you do not control. The most serious mistake possible. It allows code execution and the damage happens before your code sees anything.
- Trusting a later
instanceof. It arrives too late: the class was already instantiated duringreadObject(). - Using a deny list in the filter. It always falls short. Allow list and
!*at the end. - Believing a filter is enough. It reduces the risk; it does not authorise deserialising whatever arrives.
- Using serialisation as an interchange format. Only Java reads it, it is not readable, and it breaks with every class change.
- Using it for long-term storage. Files from two years ago stop being readable as soon as the code evolves.
- Tip: separate the live object from the persistable object.
LibrarySessionmanages resources;SessionStateonly holds data. That separation eliminates the contagion problem at the root. - Tip: enable
-Xlint:serial. The warning that avoids the problem before you have it. - Tip: write a compatibility test. Save a reference file with the current version, put it in the repository and have a test that reads it. The day somebody breaks compatibility, the test fails instead of the customer.
- Tip: if the data has value, do not save it only like this. Serialisation is acceptable for caches and transient state. For what matters, a readable format.
Exercises
Exercise 1: compatibility laboratory
Write CompatibilityLab to demonstrate empirically the effect of serialVersionUID:
- An inner class
DataV1withoutserialVersionUID, with three fields. - A method that serialises it to a file and another that deserialises it.
- Document the experiment in comments: compile, save, add a private method, recompile, and try to read. Include the expected error message.
- A class
DataV2withserialVersionUID = 1Land an added field, and demonstrate that it can indeed read a file written byDataV1B(same class, same UID, without that field), showing what value the new field takes. - A final report with the table of which changes are compatible.
Exercise 2: deep copy by serialisation
Write a utility class DeepCopy with a method that creates a deep copy of any serialisable object using ByteArrayOutputStream and ByteArrayInputStream (07-03), without touching the disk.
- The method must work with any serialisable object (use
Object; do not define your own generics, which is 10-01). - Demonstrate the difference between a shallow and a deep copy with a class
LoanBasketcontaining aList<String>: modify the copy's list and check that the original does not change. - Measure the time and compare it with a hand-written copy.
- Explain in comments three limitations of this technique.
Exercise 3: session manager with versioning
Extend SessionManager so that it supports two versions of the saved state:
SessionStateversion 1 with the current fields.- Add a field
int materialsViewedkeepingserialVersionUID = 1L. - In
readObject, detect that the file is an old one —the new field is0— and apply a reasonable default with a warning in the logger. - Add a method
listSessions()returning a report of all the sessions saved in the directory, indicating which are readable and which are not. - Add
cleanInvalidSessions()to set the unreadable files aside. - A
mainthat demonstrates the complete cycle.
Solutions
Solution 1
package com.nexussoftware.bibliotech.demo;
import java.io.*;
/**
* serialVersionUID laboratory.
*
* HOW TO REPRODUCE THE MAIN EXPERIMENT:
*
* 1. Compile and run with the getLabel() method COMMENTED OUT in DataV1.
* data/v1.ser is generated
*
* 2. UNCOMMENT getLabel() (a PRIVATE method, which does not touch the state).
*
* 3. Recompile and run again.
*
* RESULT: reading data/v1.ser gives
*
* java.io.InvalidClassException: ...DataV1; local class incompatible:
* stream classdesc serialVersionUID = -4738291056473829104,
* local class serialVersionUID = 8273645019283746152
*
* The file is left UNREADABLE FOR EVER because a private method was added
* that does not affect the state in any way.
*/
public class CompatibilityLab {
// ---------------- WITHOUT serialVersionUID: fragile ----------------
static class DataV1 implements Serializable {
// WITHOUT serialVersionUID: it is computed automatically from the
// name, fields, METHODS (including private ones), constructors...
private final String reference;
private final String title;
private final int year;
DataV1(String reference, String title, int year) {
this.reference = reference;
this.title = title;
this.year = year;
}
// STEP 2 OF THE EXPERIMENT: uncomment this and recompile.
// private String getLabel() { return reference + " - " + title; }
@Override
public String toString() {
return String.format("DataV1[%s, %s, %d]", reference, title, year);
}
}
// ---------------- WITH serialVersionUID: stable ----------------
/** Version B: three fields, UID declared. */
static class DataV2 implements Serializable {
private static final long serialVersionUID = 1L;
private final String reference;
private final String title;
private final int year;
// FIELD ADDED in version 2. Compatible: the files written without it
// read just the same, and this field takes its default value.
private final String author;
DataV2(String reference, String title, int year, String author) {
this.reference = reference;
this.title = title;
this.year = year;
this.author = author;
}
@Override
public String toString() {
return String.format("DataV2[%s, %s, %d, author=%s]",
reference, title, year,
author == null ? "(null: old file)" : author);
}
}
// ---------------- Utilities ----------------
static void save(Object o, String path) throws IOException {
try (ObjectOutputStream output = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream(path)))) {
output.writeObject(o);
}
}
static Object load(String path) throws IOException, ClassNotFoundException {
try (ObjectInputStream input = new ObjectInputStream(
new BufferedInputStream(new FileInputStream(path)))) {
return input.readObject();
}
}
/** Shows the UID the mechanism assigns to a class, declared or computed. */
static long uidOf(Class<?> type) {
ObjectStreamClass osc = ObjectStreamClass.lookup(type);
return (osc == null) ? 0L : osc.getSerialVersionUID();
}
public static void main(String[] args) throws Exception {
new File("data").mkdirs();
System.out.println("=== UID OF EACH CLASS ===");
System.out.printf(" DataV1 (not declared): %d%n", uidOf(DataV1.class));
System.out.printf(" DataV2 (declared) : %d%n", uidOf(DataV2.class));
System.out.println(" The first changes when you touch the class; the second, never.");
System.out.println();
// --- Experiment 1: without a declared UID ---
System.out.println("=== WITHOUT serialVersionUID ===");
File v1 = new File("data/v1.ser");
if (!v1.exists()) {
save(new DataV1("978-0000000001", "Effective Java", 2018), v1.getPath());
System.out.println(" File created. NOW: uncomment getLabel(),");
System.out.println(" recompile and run again.");
} else {
try {
System.out.println(" Read: " + load(v1.getPath()));
System.out.println(" (the class has not changed since it was saved)");
} catch (InvalidClassException e) {
System.out.println(" INCOMPATIBLE, as expected:");
System.out.println(" " + e.getMessage());
System.out.println(" Cause: a method was added. The file is unreadable.");
}
}
// --- Experiment 2: with a declared UID and an added field ---
System.out.println();
System.out.println("=== WITH serialVersionUID ===");
File v2 = new File("data/v2.ser");
save(new DataV2("978-0000000002", "Design Patterns", 1994, "Gamma"),
v2.getPath());
System.out.println(" Saved and read: " + load(v2.getPath()));
System.out.println();
System.out.println("=== COMPATIBLE CHANGES (with the UID declared) ===");
System.out.println(" COMPATIBLE:");
System.out.println(" - Adding a field -> it is null / 0 / false");
System.out.println(" - Removing a field -> the value in the file is discarded");
System.out.println(" - Adding or removing methods -> no effect");
System.out.println(" - Changing a body -> no effect");
System.out.println(" - normal <-> transient -> like adding or removing a field");
System.out.println(" INCOMPATIBLE:");
System.out.println(" - Changing the TYPE of a field -> InvalidClassException");
System.out.println(" - RENAMING a field -> the data is LOST, no error");
System.out.println(" - Renaming or moving the class -> ClassNotFoundException");
System.out.println(" - Changing the inheritance -> InvalidClassException");
}
}The critical point of the exercise is in the header comment: a private method that does not touch the state invalidates every file. It is the most compelling demonstration of why serialVersionUID is not optional. And look at the DataV2 line printing author=(null: old file): that is exactly what happens when a field is added, and why you have to decide on a sensible default instead of accepting the null.
Solution 2
package com.nexussoftware.bibliotech.util;
import java.io.*;
import java.util.ArrayList;
import java.util.List;
/**
* Deep copy by means of in-memory serialisation.
*
* It serialises to an array of bytes and deserialises from it: the result is
* a completely new graph, with no reference shared with the original.
* Without touching the disk: ByteArrayOutputStream / ByteArrayInputStream
* (07-03).
*
* THREE REAL LIMITATIONS:
*
* 1. THE WHOLE graph must be Serializable. A single non-serialisable field
* at any depth makes the entire copy fail (NotSerializableException).
*
* 2. IT IS SLOW. Serialising and deserialising involve reflection, metadata
* construction and byte copying. A hand-written copy method is between
* ten and a hundred times faster.
*
* 3. transient FIELDS ARE NOT COPIED. They are left null or 0 in the copy,
* which is correct for a cache but a silent bug if the field was marked
* transient for another reason.
*
* And a fourth worth keeping in mind: if the object came from an untrusted
* source, this technique inherits ALL the risks of section 12. Use it only
* with objects you already have in memory and control.
*/
public final class DeepCopy {
private DeepCopy() { }
/**
* Deep copy of a serialisable object.
*
* It returns Object: casting is the caller's business. (With generics it
* would be nicer, but that is 10-01.)
*/
public static Object copy(Serializable original) throws IOException {
if (original == null) {
return null;
}
ByteArrayOutputStream memory = new ByteArrayOutputStream();
try (ObjectOutputStream output = new ObjectOutputStream(memory)) {
output.writeObject(original);
}
// ByteArrayOutputStream does not need closing: its close() does nothing,
// and closing it before toByteArray() would be a conceptual error (07-03).
try (ObjectInputStream input = new ObjectInputStream(
new ByteArrayInputStream(memory.toByteArray()))) {
return input.readObject();
} catch (ClassNotFoundException e) {
// Impossible in practice: the class is loaded, we have just
// serialised it. It is translated into an IOException so as not to
// force the caller to catch something that cannot happen (06-03).
throw new IOException("Impossible failure while copying: class not found", e);
}
}
// ------------------------- DEMONSTRATION -------------------------
/** Test object with a mutable list inside. */
static class LoanBasket implements Serializable {
private static final long serialVersionUID = 1L;
private final String employeeId;
private final List<String> references;
private double estimatedTotal;
/** transient: it will NOT be copied. It is left null in the copy. */
private transient String internalNote;
LoanBasket(String employeeId) {
this.employeeId = employeeId;
this.references = new ArrayList<>();
this.internalNote = "working note";
}
void add(String reference, double cost) {
references.add(reference);
estimatedTotal += cost;
}
List<String> getReferences() { return references; } // mutable reference
String getInternalNote() { return internalNote; }
@Override
public String toString() {
return String.format("Basket[%s, %s, %.2f, note=%s]",
employeeId, references, estimatedTotal,
internalNote == null ? "(null)" : internalNote);
}
}
/** Hand-written copy, for a performance comparison. */
static LoanBasket copyByHand(LoanBasket original) {
LoanBasket copy = new LoanBasket(original.employeeId);
copy.references.addAll(original.references); // a new list
copy.estimatedTotal = original.estimatedTotal;
copy.internalNote = original.internalNote; // this one IS copied
return copy;
}
public static void main(String[] args) throws IOException {
LoanBasket original = new LoanBasket("EMP-001");
original.add("978-0000000001", 0.25);
original.add("978-0000000002", 0.25);
System.out.println("=== SHALLOW COPY (reference assignment) ===");
LoanBasket shallow = original;
shallow.getReferences().add("978-0000000003");
System.out.println(" Original: " + original.getReferences());
System.out.println(" 'Copy' : " + shallow.getReferences());
System.out.println(" They are the SAME object: " + (original == shallow));
System.out.println();
System.out.println("=== DEEP COPY (serialisation) ===");
LoanBasket deep = (LoanBasket) copy(original);
deep.getReferences().add("978-0000000004");
System.out.println(" Original: " + original.getReferences());
System.out.println(" Copy : " + deep.getReferences());
System.out.println(" Different objects : " + (original != deep));
System.out.println(" Different lists : "
+ (original.getReferences() != deep.getReferences()));
System.out.println(" Note (transient) copied : " + deep.getInternalNote());
System.out.println();
System.out.println("=== PERFORMANCE (10,000 copies) ===");
long start = System.nanoTime();
for (int i = 0; i < 10_000; i++) {
copy(original);
}
long msSerialisation = (System.nanoTime() - start) / 1_000_000;
start = System.nanoTime();
for (int i = 0; i < 10_000; i++) {
copyByHand(original);
}
long msByHand = (System.nanoTime() - start) / 1_000_000;
System.out.printf(" By serialisation : %5d ms%n", msSerialisation);
System.out.printf(" By hand : %5d ms%n", msByHand);
System.out.printf(" Factor : %.1fx slower%n",
(double) msSerialisation / Math.max(1, msByHand));
}
}Output:
=== SHALLOW COPY (reference assignment) ===
Original: [978-0000000001, 978-0000000002, 978-0000000003]
'Copy' : [978-0000000001, 978-0000000002, 978-0000000003]
They are the SAME object: true
=== DEEP COPY (serialisation) ===
Original: [978-0000000001, 978-0000000002, 978-0000000003]
Copy : [978-0000000001, 978-0000000002, 978-0000000003, 978-0000000004]
Different objects : true
Different lists : true
Note (transient) copied : null
=== PERFORMANCE (10,000 copies) ===
By serialisation : 412 ms
By hand : 4 ms
Factor : 103.0x slowerThe three points to see in that output:
- A shallow copy is not a copy.
shallow = originalcopies the reference: adding to one adds to the other, because they are the same object. It is the classic mistake this technique solves. transientis not copied. The internal note comes outnull. It is correct for a cache and a silent bug if the field was markedtransientfor another reason, such as a password the copy did need.- A hundred times slower. To copy an object in a loop, write the copy method by hand. This technique is useful when the graph is deep, changes often and performance is not critical: then it saves writing and maintaining dozens of lines of copying.
Solution 3
package com.nexussoftware.bibliotech.service;
import java.io.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.nexussoftware.bibliotech.infrastructure.DeserialisationFilter;
/**
* Session manager supporting TWO versions of the saved state.
*
* Version 2 adds the 'materialsViewed' field KEEPING the same
* serialVersionUID, because adding a field is a COMPATIBLE change: the
* version 1 files are still read and the new field arrives as 0.
*
* The trick is in readObject: telling "0 really were viewed" from "this file
* is old and does not know" is impossible with the default value, so an
* explicit SENTINEL is used.
*/
public class VersionedSessionManager {
private static final Logger LOG =
Logger.getLogger(VersionedSessionManager.class.getName());
private static final String EXTENSION = ".session";
private static final String INVALID_EXTENSION = ".session.invalid";
private final File directory;
public VersionedSessionManager(String dataDirectory) {
this.directory = new File(Objects.requireNonNull(dataDirectory));
if (!directory.exists() && !directory.mkdirs()) {
LOG.warning("Could not create " + directory.getAbsolutePath());
}
}
// ------------------------- THE STATE, VERSION 2 -------------------------
public static class SessionState implements Serializable {
/** SAME value as in version 1: the change is compatible. */
private static final long serialVersionUID = 1L;
/** "Not reported" marker, to tell it from a real 0. */
private static final int NOT_REPORTED = -1;
private final String employeeId;
private final int openingDay;
private final List<String> operations;
private final int loansMade;
private final int returnsMade;
private final double finesCollected;
/**
* FIELD ADDED IN VERSION 2.
*
* It is not final: readObject needs to be able to correct it when the
* file comes from version 1.
*/
private int materialsViewed;
/** Format version it was created with. transient: it is deduced. */
private transient int detectedVersion = 2;
public SessionState(String employeeId, int openingDay, List<String> operations,
int loansMade, int returnsMade,
double finesCollected, int materialsViewed) {
this.employeeId = Objects.requireNonNull(employeeId, "The employee is mandatory");
if (openingDay < 1) {
throw new IllegalArgumentException("Invalid day: " + openingDay);
}
if (finesCollected < 0) {
throw new IllegalArgumentException("Negative fines: " + finesCollected);
}
this.openingDay = openingDay;
this.operations = new ArrayList<>(operations);
this.loansMade = loansMade;
this.returnsMade = returnsMade;
this.finesCollected = finesCollected;
this.materialsViewed = Math.max(0, materialsViewed);
}
/**
* Migration and validation.
*
* A version 1 file does not contain 'materialsViewed', so it arrives
* as 0. Since we cannot tell that 0 from a real 0, we apply a
* reasonable HEURISTIC and LEAVE A RECORD in the log: whoever viewed
* materials at least viewed the ones they lent.
*/
private void readObject(ObjectInputStream input)
throws IOException, ClassNotFoundException {
input.defaultReadObject();
// --- VALIDATION (the constructor has not been run) ---
if (employeeId == null || employeeId.isBlank()) {
throw new InvalidObjectException("Session with no employee: file corrupt");
}
if (openingDay < 1) {
throw new InvalidObjectException("Invalid day: " + openingDay);
}
if (finesCollected < 0) {
throw new InvalidObjectException("Negative fines: " + finesCollected);
}
if (operations == null) {
throw new InvalidObjectException("Null operations list");
}
// --- VERSION MIGRATION ---
if (materialsViewed == 0 && loansMade > 0) {
detectedVersion = 1;
materialsViewed = loansMade;
LOG.info(() -> String.format(
"Session of %s saved with version 1 of the format: "
+ "materialsViewed = %d is estimated from the loans",
employeeId, loansMade));
} else {
detectedVersion = 2;
}
if (materialsViewed < 0) {
materialsViewed = 0;
}
}
public String getEmployeeId() { return employeeId; }
public int getOpeningDay() { return openingDay; }
public List<String> getOperations() { return List.copyOf(operations); }
public int getLoansMade() { return loansMade; }
public int getReturnsMade() { return returnsMade; }
public double getFinesCollected() { return finesCollected; }
public int getMaterialsViewed() { return materialsViewed; }
public int getDetectedVersion() { return detectedVersion; }
public String getSummary() {
return String.format(
"Session of %s (day %d, format v%d): %d loans, %d returns, "
+ "%d views, %.2f EUR",
employeeId, openingDay, detectedVersion, loansMade,
returnsMade, materialsViewed, finesCollected);
}
}
// ------------------------- OPERATIONS -------------------------
public void save(SessionState state) throws IOException {
Objects.requireNonNull(state, "The state cannot be null");
File target = new File(directory, state.getEmployeeId() + EXTENSION);
File temp = new File(target.getAbsolutePath() + ".tmp");
boolean completed = false;
try {
try (ObjectOutputStream output = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream(temp)))) {
output.writeObject(state);
}
if (target.exists() && !target.delete()) {
throw new IOException("Could not replace " + target.getName());
}
if (!temp.renameTo(target)) {
throw new IOException("Could not rename " + temp.getName());
}
completed = true;
LOG.info(() -> "Session saved: " + target.getName());
} finally {
if (!completed && temp.exists() && !temp.delete()) {
LOG.warning(() -> "Temporary file left undeleted: " + temp.getAbsolutePath());
}
}
}
public java.util.Optional<SessionState> restore(String employeeId) {
File file = new File(directory, employeeId + EXTENSION);
if (!file.exists()) {
return java.util.Optional.empty();
}
try {
return java.util.Optional.of(read(file));
} catch (Exception e) {
LOG.log(Level.WARNING, "Could not restore " + file.getName(), e);
return java.util.Optional.empty();
}
}
/** Reading with a filter. It throws so the caller can decide. */
private SessionState read(File file) throws IOException, ClassNotFoundException {
try (ObjectInputStream input = new ObjectInputStream(
new BufferedInputStream(new FileInputStream(file)))) {
DeserialisationFilter.applyTo(input);
return (SessionState) input.readObject();
}
}
// ------------------------- REPORT AND CLEAN-UP -------------------------
/** State of a session file in the directory. */
public record SessionInfo(String name, long size, boolean readable,
String detail) { }
/**
* Report of all the saved sessions.
*
* It tries to read each one and classifies the result. It does NOT
* propagate: the report must be producible even when there are broken
* files, which is precisely when it is most needed.
*/
public List<SessionInfo> listSessions() {
List<SessionInfo> report = new ArrayList<>();
File[] files = directory.listFiles(
(dir, name) -> name.endsWith(EXTENSION));
if (files == null) { // it can be null (07-01)
LOG.warning("Could not list " + directory.getAbsolutePath());
return report;
}
Arrays.sort(files); // stable order (05-09)
for (File f : files) {
try {
SessionState state = read(f);
report.add(new SessionInfo(f.getName(), f.length(), true,
state.getSummary()));
} catch (InvalidClassException e) {
report.add(new SessionInfo(f.getName(), f.length(), false,
"Incompatible class version"));
} catch (InvalidObjectException e) {
report.add(new SessionInfo(f.getName(), f.length(), false,
"Rejected by validation: " + e.getMessage()));
} catch (StreamCorruptedException | EOFException e) {
report.add(new SessionInfo(f.getName(), f.length(), false,
"Corrupt or truncated file"));
} catch (ClassNotFoundException e) {
report.add(new SessionInfo(f.getName(), f.length(), false,
"Class not found: " + e.getMessage()));
} catch (IOException e) {
report.add(new SessionInfo(f.getName(), f.length(), false,
"I/O failure: " + e.getMessage()));
}
}
return report;
}
/**
* Sets the unreadable files aside by renaming them.
*
* It does NOT delete them: an unreadable file may be needed to diagnose
* why it stopped being readable.
*
* @return how many were set aside
*/
public int cleanInvalidSessions() {
int setAside = 0;
for (SessionInfo info : listSessions()) {
if (info.readable()) {
continue;
}
File source = new File(directory, info.name());
File target = new File(directory,
info.name().replace(EXTENSION, INVALID_EXTENSION));
if (target.exists()) {
target.delete();
}
if (source.renameTo(target)) {
setAside++;
LOG.info(() -> "Unreadable session set aside: " + target.getName()
+ " (" + info.detail() + ")");
} else {
LOG.warning(() -> "Could not set aside " + source.getName());
}
}
return setAside;
}
// ------------------------- DEMONSTRATION -------------------------
public static void main(String[] args) throws IOException {
VersionedSessionManager manager = new VersionedSessionManager("data/sessions");
// 1. Save three sessions
manager.save(new SessionState("EMP-001", 15,
List.of("LOAN LN-0001", "RETURN LN-0001"), 1, 1, 1.75, 4));
manager.save(new SessionState("EMP-002", 15,
List.of("LOAN LN-0002"), 1, 0, 0.0, 2));
manager.save(new SessionState("EMP-003", 16,
List.of(), 0, 0, 0.0, 0));
// 2. Create a corrupt file on purpose
File corrupt = new File("data/sessions/EMP-999.session");
try (FileOutputStream fos = new FileOutputStream(corrupt)) {
fos.write("this is not an object stream".getBytes());
}
// 3. Report
System.out.println("=== SAVED SESSIONS ===");
for (SessionInfo info : manager.listSessions()) {
System.out.printf(" %-18s %6d bytes %-10s %s%n",
info.name(), info.size(),
info.readable() ? "[OK]" : "[ERROR]", info.detail());
}
// 4. A particular restoration
System.out.println();
System.out.println("=== RESTORATION ===");
manager.restore("EMP-001").ifPresentOrElse(
s -> System.out.println(" " + s.getSummary()),
() -> System.out.println(" Could not restore EMP-001"));
// 5. Clean-up
System.out.println();
System.out.printf("=== CLEAN-UP: %d files set aside ===%n",
manager.cleanInvalidSessions());
}
}Output:
=== SAVED SESSIONS ===
EMP-001.session 301 bytes [OK] Session of EMP-001 (day 15, format v2): 1 loans, 1 returns, 4 views, 1.75 EUR
EMP-002.session 268 bytes [OK] Session of EMP-002 (day 15, format v2): 1 loans, 0 returns, 2 views, 0.00 EUR
EMP-003.session 243 bytes [OK] Session of EMP-003 (day 16, format v2): 0 loans, 0 returns, 0 views, 0.00 EUR
EMP-999.session 28 bytes [ERROR] Corrupt or truncated file
=== RESTORATION ===
Session of EMP-001 (day 15, format v2): 1 loans, 1 returns, 4 views, 1.75 EUR
=== CLEAN-UP: 1 files set aside ===The four teaching points:
- The
serialVersionUIDdoes not change when the field is added. Adding a field is compatible, so keeping it is the right thing. Incrementing it would have needlessly invalidated every existing session. - The migration uses an explicit heuristic and logs it. A version 1 file arrives with
materialsViewed = 0, and there is no way of telling it from a real 0. The honest solution is to apply a reasonable estimate, record it in the logger and exposedetectedVersionso that everybody knows the value is estimated. Pretending the data is exact would be worse. listSessions()does not propagate. A diagnostic report has to be producible precisely when there are broken files. If the first corrupt file aborted the listing, the tool would be useless just when it is needed.- The unreadable files are set aside, not deleted. Renaming them allows a later investigation of what happened without blocking the user. Deleting data because it is not understood is almost never the right decision.
Conclusion
You know how to serialise and —more importantly— you know when not to.
You understand that serialising means turning an object graph into bytes, not a lone object: with its shared references preserved by means of an internal table, so that two loans pointing at the same Employee point at the same instance again, and with circular references resolved without infinite recursion. Reproducing that by hand demands inventing identifiers and making two passes on loading; that is the real advantage of the mechanism, and it is not a small one.
You know Serializable as a marker interface —the concept from 04-01— and you know that implementing it is a serious commitment: the serialised form of a class becomes part of its public API, with all its private fields exposed and subject to compatibility. You know how to use ObjectOutputStream/ObjectInputStream with writeObject/readObject, how to write and read several objects, and that serialising the whole collection is better than keeping a counter by hand. And you know that ClassNotFoundException does not extend IOException and needs its own catch.
You know what is saved and what is not: everything instance-level, including the private and the final ones, and nothing that is static or transient. You know the three legitimate uses of transient —derived data, secrets and non-serialisable resources— and the detail that causes a NullPointerException the first time a restored object is used: field initialisers are not run, so a transient Map initialised in its declaration arrives as null. You know the contagion effect of NotSerializableException, how to read its stack trace and the three solutions, with the rule that nearly always avoids it: the Logger goes private static final.
You have mastered serialVersionUID: what it is, how it is computed by default from names, fields, methods —including private ones— and constructors, and why that means adding a helper method makes every saved file unreadable. You know how to declare it with its four exact modifiers, you know that if you leave one out it is ignored silently, and you know that it is only incremented for an incompatible change. You now fully understand why your module 6 exceptions carry it: an exception that cannot be deserialised turns a comprehensible business error into an incomprehensible infrastructure failure. And you have the complete table of which changes are compatible, with the two traps: adding a field is compatible but its default value may violate your invariants, and renaming a field loses the data with no error at all.
You know how to customise with private writeObject/readObject, whose signature must be exact or they are ignored with no warning, and you know their most important use: validating. Because deserialisation calls no constructor, all the checks you wrote in 06-03 are skipped, and a tampered file can produce objects your code considers impossible. readObject is the back door into your domain and it must be shut with InvalidObjectException. You know readResolve for preserving singletons, Externalizable and its three reasons not to use it, and the inheritance rules, with the no-argument constructor every non-serialisable superclass needs.
And you have the section that really matters: deserialising untrusted data allows arbitrary code execution, because readObject instantiates classes whose name comes in the file and runs their methods before your code sees anything, which makes any later check useless. You know what counts as untrusted —basically everything you have not written yourself into a directory you control—, you know the ObjectInputFilters with an allow list and !* at the end, and their four numeric limits against deserialisation bombs. And you know that the filter reduces the risk but does not eliminate it, with the formal warning that any use crossing a trust boundary must be reviewed by your organisation's security officer, not by you on a Tuesday afternoon.
You have the table of alternatives with the crucial difference: text formats do not instantiate classes. A CSV produces strings, and you decide what to build from them going through your validations. And you know when native serialisation is still reasonable: local caches, internal state, deep copying in memory, trusted processes you control at both ends.
BiblioTech saves and restores a complete session between runs. SessionManager separates the live object (LibrarySession, with its locks and its logger) from the persistable object (SessionState, data only), declares serialVersionUID from day one, validates in readObject as if it were a constructor, applies a filter before reading anything, writes atomically, distinguishes five kinds of failure with their own treatment, and sets unreadable files aside instead of deleting them. And the decision that sums up the lesson is taken explicitly: the session uses serialisation; the catalogue does not, because the catalogue is the valuable data and must be readable, versionable, importable and stable against code changes.
There remains the most uncomfortable part of everything you have done so far. You are still using java.io.File —with its mute booleans that say something failed without saying why, its listFiles() that returns null, and its renameTo that behaves differently on every operating system. You have hand-written directory creation, atomic writing and file rotation, and in each one you have had to put a comment saying that in 07-06 it is done properly.
In lesson 07-06, The NIO.2 API: Path and Files, it is done properly. You will see why NIO.2 exists and which concrete File problems it solves; Path with resolve, normalize, relativize and all its path arithmetic; the utility class Files with createDirectories, copy, move with ATOMIC_MOVE —which turns your atomic writing into a single correct call—, delete versus deleteIfExists, and specific exceptions instead of boolean; high-level reading and writing with readString, writeString and newBufferedReader, with the StandardOpenOptions that make it impossible to confuse APPEND with a charset; walking directory trees; and file attributes. And BiblioTech will at last migrate its whole persistence layer, will create its data/ directory if it does not exist, will make genuine rotating backups and will locate all its reports in a folder tree.
Java Programming Course
Module 1: Introduction to Java
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
