Java's built-in object serialization API still exists in Java 25, and ObjectOutputStream and ObjectInputStream remain the standard JDK classes for writing and reading Java object graphs. However, modern Java code should use serialization deliberately. It can be convenient for trusted local data, prototypes and Java-to-Java persistence, but native deserialization should not be used on arbitrary or untrusted input.
This updated Java 25 tutorial uses Path, try-with-resources, the @Serial annotation, modern instanceof pattern matching, Java 25's IO class for console output, and an ObjectInputFilter to restrict what deserialization accepts.
Create a serializable Java class
A class participates in Java's native serialization mechanism by implementing Serializable. The interface has no methods. It acts as a marker that tells the serialization runtime that instances of the class may be written to an object stream.
The serialVersionUID identifies the serialized form of the class. Modern Java also provides the @Serial annotation, which lets the compiler check that serialization-specific fields and methods are declared correctly.
import java.io.Serial;
import java.io.Serializable;
public final class Score implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private int wins;
private int losses;
private int ties;
public void increaseWins() {
wins++;
}
public void increaseLosses() {
losses++;
}
public void increaseTies() {
ties++;
}
public int wins() {
return wins;
}
public int losses() {
return losses;
}
public int ties() {
return ties;
}
@Override
public String toString() {
return "Score[wins=%d, losses=%d, ties=%d]"
.formatted(wins, losses, ties);
}
}Java 25 ObjectOutputStream example
Older serialization examples often manually create and close both a FileOutputStream and an ObjectOutputStream. Modern Java should use try-with-resources instead. The streams are then closed automatically, including when an exception occurs.
The NIO Files.newOutputStream(Path) API also avoids hard-coded Windows paths such as C:\temp\score.ser. The following example writes the serialized object to score.ser in the current directory.
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
public class SerializationExample {
private static final Path SCORE_FILE = Path.of("score.ser");
static void save(Score score) throws IOException {
try (var file = Files.newOutputStream(SCORE_FILE);
var out = new ObjectOutputStream(file)) {
out.writeObject(score);
}
}
public static void main(String[] args) throws IOException {
var score = new Score();
score.increaseWins();
score.increaseWins();
score.increaseTies();
save(score);
IO.println("Serialized: " + score);
IO.println("Saved to: " + SCORE_FILE.toAbsolutePath());
}
}The important operation is still ObjectOutputStream.writeObject(). Serialization walks the reachable object graph and writes the state required to reconstruct serializable objects later.
Java 25 ObjectInputStream example
Deserialization performs the reverse operation with ObjectInputStream.readObject(). Because readObject() returns Object, modern Java pattern matching can verify and bind the result without an unchecked-looking cast scattered through the code.
More importantly, Java 25 applications should treat deserialization as a security boundary. Oracle's Java documentation warns that deserializing untrusted data is inherently dangerous. An ObjectInputFilter can restrict the classes and object-graph sizes an input stream is allowed to deserialize.
import java.io.IOException;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
public class DeserializationExample {
private static final Path SCORE_FILE = Path.of("score.ser");
static Score load()
throws IOException, ClassNotFoundException {
try (var file = Files.newInputStream(SCORE_FILE);
var in = new ObjectInputStream(file)) {
var filter = ObjectInputFilter.Config.createFilter(
"Score;java.base/*;maxdepth=10;maxrefs=100;!*"
);
in.setObjectInputFilter(filter);
var object = in.readObject();
if (object instanceof Score score) {
return score;
}
throw new IOException(
"Unexpected serialized type: "
+ object.getClass().getName()
);
}
}
public static void main(String[] args) throws Exception {
var score = load();
IO.println("Deserialized: " + score);
IO.println("Wins: " + score.wins());
IO.println("Losses: " + score.losses());
IO.println("Ties: " + score.ties());
}
}Complete Java 25 serialization example
For a small example, the save and load operations can live in one class. This version demonstrates the complete round trip from a Java object to a .ser file and back again.
import java.io.IOException;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serial;
import java.io.Serializable;
import java.nio.file.Files;
import java.nio.file.Path;
public class Java25SerializationDemo {
private static final Path FILE = Path.of("score.ser");
static void save(Score score) throws IOException {
try (var output = Files.newOutputStream(FILE);
var objects = new ObjectOutputStream(output)) {
objects.writeObject(score);
}
}
static Score load()
throws IOException, ClassNotFoundException {
try (var input = Files.newInputStream(FILE);
var objects = new ObjectInputStream(input)) {
var filter = ObjectInputFilter.Config.createFilter(
"Java25SerializationDemo$Score;"
+ "java.base/*;"
+ "maxdepth=10;"
+ "maxrefs=100;"
+ "!*"
);
objects.setObjectInputFilter(filter);
var object = objects.readObject();
if (object instanceof Score score) {
return score;
}
throw new IOException("Serialized object was not a Score");
}
}
public static void main(String[] args) throws Exception {
var original = new Score();
original.increaseWins();
original.increaseWins();
original.increaseTies();
save(original);
var restored = load();
IO.println("Before serialization: " + original);
IO.println("After deserialization: " + restored);
}
static final class Score implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private int wins;
private int losses;
private int ties;
void increaseWins() {
wins++;
}
void increaseLosses() {
losses++;
}
void increaseTies() {
ties++;
}
@Override
public String toString() {
return "Score[wins=%d, losses=%d, ties=%d]"
.formatted(wins, losses, ties);
}
}
}Why use ObjectInputFilter?
The most important modernization to an old Java serialization tutorial is not syntactic. It is security. A serialized stream can describe an object graph containing classes the application never intended to construct. For that reason, applications should never deserialize arbitrary data received from an untrusted source.
Java's serialization filtering API can restrict accepted classes and impose limits such as maximum graph depth, reference count, array size and stream size. A filter can be attached to a specific ObjectInputStream, as in this tutorial, or configured more broadly for the JVM.
The filter in the example ends with !*, which rejects classes that were not explicitly allowed by an earlier rule. In a real application, build the allow-list around the exact object graph your application expects.
Should new applications use Java native serialization?
Serializable, ObjectOutputStream and ObjectInputStream are still part of Java 25, so this API is useful to understand and remains appropriate in controlled Java-to-Java scenarios. But it should not automatically be the persistence or interchange format for a new application.
For data that crosses trust boundaries, must be consumed by other languages, or needs a long-lived and independently versioned schema, a deliberately defined data format is usually easier to inspect, validate and evolve. Native Java serialization is most attractive when both ends are trusted Java code and preserving an object graph is genuinely useful.
Java 25 serialization best practices
- Use try-with-resources for object and file streams.
- Use
PathandFilesinstead of hard-coded platform-specific file paths. - Declare an explicit
serialVersionUIDand annotate it with@Serial. - Never deserialize arbitrary untrusted input.
- Use
ObjectInputFilterto constrain expected classes and object-graph sizes. - Mark sensitive or derived fields
transientwhen they should not be persisted. - Treat changes to serializable classes as changes to a persistent data format.
Java object serialization itself has changed much less than the way modern Java applications should use it. The basic writeObject() and readObject() calls remain simple. In Java 25, the bigger improvements come from safer resource management, clearer APIs, serialization annotations, modern language syntax and defensive deserialization filters.