Back to Blog
Java

Understanding Java Serialization

java serialization: Learn how Java serialization works, how to control it with serialVersionUID and transient fields, and when to choose native serialization over mode...

Java SerializationSerializableserialVersionUIDDeserializationJava I/O
A Java object being converted into a byte stream and then reconstructed, illustrating the serialization and deserialization process.

Java serialization converts an object's state into a byte stream that can be written to disk, sent over a network, or stored in a database. The reverse process, deserialization, reconstructs the object from that byte stream. The mechanism is built into the JDK and requires no external libraries, but it carries assumptions about class versioning, security, and performance that you need to understand before relying on it in production.

How Java Serialization Works

For an object to be serialized, its class must implement the java.io.Serializable marker interface. The interface declares no methods; it simply signals to the JVM that the class is eligible for serialization. When you pass an object to ObjectOutputStream.writeObject(), the JVM inspects the object's class and serializes its non-transient, non-static fields. It also records metadata about the class, including the class name and a serialVersionUID if one is declared.

import java.io.*; public class User implements Serializable { private String name; private int age; // constructors, getters, setters }

Deserialization is the mirror operation. ObjectInputStream.readObject() reads the byte stream, reconstructs the object, and populates its fields. The JVM does not call the class's constructor during deserialization; it allocates memory without invoking any constructor, which is a critical difference from normal object creation. This behavior is why serialization can bypass validation logic placed in constructors.

The default serialization mechanism writes all fields except those marked transient or static. static fields belong to the class, not the instance, so they are not serialized. transient fields are explicitly excluded from the serialized representation.

Why serialVersionUID Matters

Every serializable class has a serialVersionUID. If you do not declare it explicitly, the JVM computes one from the class structure using a deterministic algorithm. This computed value changes whenever the class's fields, methods, or inheritance hierarchy change. When deserializing, the JVM compares the serialVersionUID in the byte stream with the one in the loaded class. If they differ, it throws an InvalidClassException.

public class User implements Serializable { private static final long serialVersionUID = 1L; private String name; private int age; }

Declaring an explicit serialVersionUID gives you control over compatibility. If you keep it the same while adding new fields, deserialization of old streams will succeed, and the new fields will be set to their default values (null for objects, 0 for primitives). If you remove or rename a field, the stream may fail unless you handle it with custom serialization. The rule is: change the serialVersionUID only when you intentionally break compatibility, and keep it constant when you want to support older serialized data.

Excluding Fields with transient

Marking a field transient prevents it from being serialized. This is useful for fields that are derived from other data, contain sensitive information, or reference objects that are not themselves serializable. For example, a password field or a database connection should not be written to disk.

public class Session implements Serializable { private String username; private transient String password; private transient Connection dbConnection; }

When deserializing, transient fields are left at their default values. You must reinitialize them after deserialization, often in a custom readObject method or by using a factory method. The same applies to static fields; they are never serialized, so they will hold the value set in the current class loader.

Custom Serialization with writeObject and readObject

Sometimes the default serialization behavior is not enough. You may need to encrypt sensitive fields, validate data on deserialization, or handle version changes gracefully. You can implement private methods writeObject(ObjectOutputStream out) and readObject(ObjectInputStream in) in your class. The JVM invokes these methods instead of the default mechanism.

private void writeObject(ObjectOutputStream out) throws IOException { out.defaultWriteObject(); // custom logic, e.g., encrypt password out.writeObject(encrypt(password)); } private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); // custom logic, e.g., decrypt password password = decrypt((String) in.readObject()); // validate fields if (age < 0) { throw new InvalidObjectException("Age cannot be negative"); } }

defaultWriteObject() and defaultReadObject() handle the normal fields. You then write or read additional data manually. This pattern is also used to evolve a class: you can read old data, apply defaults for missing fields, and then write new data in the new format.

Security Concerns with Native Java Serialization

Native Java serialization has a well-known security weakness: deserializing untrusted data can lead to remote code execution. The readObject() method can trigger arbitrary code if the stream contains a crafted object that exploits a gadget chain in the classpath. Even without a full exploit, deserialization can cause denial-of-service via excessive memory allocation or CPU usage.

The JDK provides a filter mechanism, ObjectInputFilter, that lets you restrict which classes can be deserialized. You can set a global filter or a per-stream filter. For example, you can allow only classes from a specific package or reject classes that are known to be dangerous.

ObjectInputFilter filter = ObjectInputFilter.Config.createFilter("java.base/*;!*"); ObjectInputStream in = new ObjectInputStream(inputStream); in.setObjectInputFilter(filter);

The filter pattern syntax is documented in the JDK. However, filtering is a mitigation, not a cure. The safest approach is to avoid native serialization for untrusted input altogether. Consider using structured formats like JSON, Protocol Buffers, or Avro, which do not automatically instantiate arbitrary types.

Performance and Operational Considerations

Native Java serialization is convenient but not efficient. The byte stream includes class metadata, field names, and type information, making it larger than a compact binary format. The serialization process also uses reflection, which adds CPU overhead. For high-throughput systems, this can become a bottleneck.

Another operational issue is compatibility across versions. If you change a class's fields, you must manage serialVersionUID and potentially custom serialization to avoid breaking existing data. This becomes harder as your application evolves. Many teams prefer a schema-based format like Protocol Buffers, which has explicit versioning rules and generates compact, language-neutral data.

That said, native serialization is still useful in certain scenarios. If you are writing a short-lived object to a local cache, or if you need deep copies of objects within the same JVM, the simplicity of Serializable may outweigh the drawbacks. For any data that crosses a trust boundary, choose a safer format.

Choosing Between Native Serialization and Alternatives

The decision comes down to your requirements for security, performance, and maintainability. Use native Java serialization when:

  • You are working entirely within a trusted JVM environment.
  • The object graph is complex and would be tedious to map to a schema.
  • You need a quick way to deep-copy an object.

Use a structured format like JSON or Protocol Buffers when:

  • Data is sent over a network or stored long-term.
  • The receiving system may be written in a different language.
  • You need schema evolution with explicit compatibility rules.
  • You cannot trust the source of the data.

JSON is human-readable and widely supported, but it does not preserve type information by default. Protocol Buffers and Avro are binary formats with strict schemas, making them faster and more compact than native serialization. They also avoid the security risks of deserializing arbitrary Java classes.

If you must use native serialization, always apply an ObjectInputFilter, validate all fields after deserialization, and keep serialVersionUID explicit. For new projects, prefer a modern serialization framework that gives you control over the data format and its security properties.

java serialization: Practical Usage and Code Examples | RYUSLOG DEV