Java serialization is a surprisingly fragile and often insecure way to persist or transmit object state.
Let’s see it in action. Imagine we have a simple User object:
public class User {
private String username;
private int age;
public User(String username, int age) {
this.username = username;
this.age = age;
}
public String getUsername() {
return username;
}
public int getAge() {
return age;
}
@Override
public String toString() {
return "User{" +
"username='" + username + '\'' +
", age=" + age +
'}';
}
}
With standard Java serialization, you’d write it like this:
import java.io.*;
public class JavaSerializationExample {
public static void main(String[] args) throws IOException, ClassNotFoundException {
User user = new User("alice", 30);
// Serialize
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(user);
oos.flush();
byte[] serializedUser = bos.toByteArray();
System.out.println("Serialized: " + bytesToHex(serializedUser));
// Deserialize
ByteArrayInputStream bis = new ByteArrayInputStream(serializedUser);
ObjectInputStream ois = new ObjectInputStream(bis);
User deserializedUser = (User) ois.readObject();
System.out.println("Deserialized: " + deserializedUser);
ois.close();
oos.close();
}
// Helper to visualize bytes
public static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
Running this produces output like:
Serialized:aced000573720013com.example.User4a64994a5802c8230200024c0008757365726e616d657400124c6a6176612f6c616e672f537472696e673b49000361676549
Deserialized: User{username='alice', age=30}
The magic bytes aced0005 at the start are the signature of Java serialization. The rest is a binary representation of the object’s class and its fields.
The problem Java serialization solves is object persistence and transmission. You can take an object in memory, turn it into a stream of bytes, save it to disk, send it over the network, and later reconstruct the exact same object from those bytes. It handles complex object graphs, including circular references, automatically.
The system works by examining the object’s class at runtime, writing out the class’s metadata (name, serialVersionUID), and then writing out the values of all its non-transient, non-static fields. During deserialization, it reads the class metadata, finds or creates the class, and then populates the fields from the stream.
Now, let’s look at alternatives: Jackson, Kryo, and Protobuf.
Jackson (for JSON)
Jackson is primarily a JSON processor, but it can serialize Java objects into JSON. This is schema-less in the sense that you don’t pre-define a schema file, but the JSON output is a schema itself, human-readable and generally interoperable.
import com.fasterxml.jackson.databind.ObjectMapper;
public class JacksonExample {
public static void main(String[] args) throws Exception {
User user = new User("bob", 25);
ObjectMapper objectMapper = new ObjectMapper();
// Serialize to JSON string
String jsonUser = objectMapper.writeValueAsString(user);
System.out.println("JSON: " + jsonUser);
// Deserialize from JSON string
User deserializedUser = objectMapper.readValue(jsonUser, User.class);
System.out.println("Deserialized: " + deserializedUser);
}
}
Output:
JSON: {"username":"bob","age":25}
Deserialized: User{username='bob', age=25}
Jackson is great for human readability, configuration files, and web APIs. It’s less efficient in terms of space and speed compared to binary formats.
Kryo
Kryo is a fast, efficient Java serialization framework. It’s binary and aims for higher performance and smaller output size than Java serialization. It requires registration of classes for optimal performance and to handle complex types.
import com.esotericsoftware.kryo.Kryo;
import com.esotericsoftware.kryo.io.Input;
import com.esotericsoftware.kryo.io.Output;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
public class KryoExample {
public static void main(String[] args) {
Kryo kryo = new Kryo();
// Register classes for efficiency and to handle them correctly
kryo.register(User.class);
User user = new User("charlie", 40);
// Serialize
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Output output = new Output(bos);
kryo.writeObject(output, user);
output.flush();
byte[] serializedUser = bos.toByteArray();
System.out.println("Kryo Serialized (hex): " + bytesToHex(serializedUser));
// Deserialize
ByteArrayInputStream bis = new ByteArrayInputStream(serializedUser);
Input input = new Input(bis);
User deserializedUser = kryo.readObject(input, User.class);
System.out.println("Deserialized: " + deserializedUser);
input.close();
output.close();
}
// Reuse bytesToHex from JavaSerializationExample
public static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
Output (will vary slightly based on Kryo version and internal optimizations, but generally compact):
Kryo Serialized (hex): 010c636861726c696518
Deserialized: User{username='charlie', age=40}
Kryo is often used in high-performance distributed systems, caching, and RPC frameworks where speed and size matter.
Protobuf (Protocol Buffers)
Protobuf is Google’s language-neutral, platform-neutral, extensible mechanism for serializing structured data. It’s a binary format, highly efficient, and requires defining a schema in a .proto file.
First, define user.proto:
syntax = "proto3";
package com.example;
message User {
string username = 1;
int32 age = 2;
}
You then compile this .proto file using the Protobuf compiler (protoc) to generate Java classes. This compilation step is crucial.
// Assuming User.java is generated from user.proto by protoc
// import com.example.UserProto.User; // This would be the generated class
// Example usage (requires generated User class and Protobuf runtime)
// User user = User.newBuilder().setUsername("david").setAge(50).build();
// byte[] serializedUser = user.toByteArray();
// User deserializedUser = User.parseFrom(serializedUser);
// System.out.println(deserializedUser.getUsername() + ", " + deserializedUser.getAge());
Protobuf is excellent for inter-service communication, data storage, and scenarios where performance, size, and schema evolution are critical.
The one thing most people don’t know is how Java serialization’s serialVersionUID is generated and why it’s so easy to break. If you have a class MyClass and its serialVersionUID is 1L, and you later add a new field to MyClass without updating serialVersionUID to 2L (or by having the compiler generate it), deserializing an older object with the new class will fail. Conversely, deserializing a new object with an old class will also fail if the new field is essential. The serialVersionUID is a contract: it dictates compatibility between serialized data and the class definition. Mismatches are the most common reason Java serialization breaks unexpectedly.
The next concept you’ll run into is schema evolution: how to add or remove fields from your data structures over time without breaking existing data.