AlgoMaster Logo

Serialization

Medium Priority20 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

Serialization is how Java turns a live object in memory into a stream of bytes you can save to disk or send over a network. Once an object is a byte stream, it can sit in a file for hours and be brought back later as the same object. This lesson covers what serialization is, the Serializable marker, how ObjectOutputStream writes objects, the role of transient and static fields, why serialVersionUID exists, how to customize the write process, the Externalizable alternative, and the limitations that drive teams to JSON or Protocol Buffers instead. We focus only on writing here.

What Serialization Solves

Consider a program that holds a shopping cart in memory. The cart has a customer, a list of products, quantities, and a running total. The moment the program exits, every object in that cart disappears. To make the cart survive a restart, or to ship the cart from a web server to a background job on another machine, you need a way to capture the object as bytes and reconstruct it later.

Plain text formats (CSV, your own delimited file) work for flat data, but they fall apart when you have one object pointing at another, which points at a list of more objects. Walking that graph by hand and writing each field is tedious and error-prone. Serialization automates the walk: you hand it the root object, and it follows every reference and writes the whole graph as one byte stream.

The diagram shows what a typical Order graph looks like in memory. The Order holds a reference to a Customer, plus references to two LineItem objects, each of which points at a Product. Serialization starts at the root (Order) and follows every arrow, writing each object exactly once. When you read the bytes back, you get the whole shape restored, with the same references wired up.

The byte stream is binary and Java-specific. It is not designed for humans to read, and it is not designed for other languages or platforms to consume. That trade-off is the source of most of serialization's strengths (compact, automatic, preserves graphs) and most of its weaknesses (brittle, Java-only, hard to debug).

The Serializable Marker Interface

Java does not let you serialize any object you please. A class has to opt in by implementing java.io.Serializable. The interface has no methods. It is a marker, a flag that says "instances of this class are safe to write as bytes."

That implements Serializable line is the entire opt-in. There is nothing to override, nothing to annotate, nothing to register. The JVM checks the marker at write time and refuses to serialize objects whose class does not have it.

The opt-in is per class, but it cascades. If Order is Serializable and holds a Customer, the Customer class also has to be Serializable. Same for any class reachable through any field. The whole graph has to be Serializable, transitively.

In the diagram, every class except Address implements Serializable. The moment you try to serialize an Order, the writer walks down through Customer to Address and throws NotSerializableException because Address is not on the approved list. One missing marker anywhere in the reachable graph poisons the whole write.

The fix is either to make Address implement Serializable, or to mark the address field as transient so the writer skips it.

Writing Objects with ObjectOutputStream

ObjectOutputStream is the class that does the actual writing. You wrap it around any OutputStream (a FileOutputStream for disk, a ByteArrayOutputStream for an in-memory buffer, a socket's output stream for a network), and you call writeObject to push an object into it.

The byte count will vary by a few bytes depending on the field names and class name, but the shape is the same. A handful of bytes for the stream header, more bytes for the class descriptor, and the field values packed in. We print the size rather than the contents because the file is binary; opening it in a text editor would show mostly gibberish with a few readable strings.

ObjectOutputStream exposes a writeObject(Object) method for objects and a family of methods for primitives: writeInt, writeLong, writeDouble, writeBoolean, writeByte, writeShort, writeChar, writeFloat, plus writeUTF for strings. Mixing them is allowed when you want a custom binary format, but for everyday "save an object", writeObject is the standard call.

The try-with-resources block matters. ObjectOutputStream buffers data internally, so anything you write may be sitting in memory until the stream is closed or flushed. If your program crashes before close, you can end up with a truncated file that the reader cannot parse. try (...) guarantees close() runs, which in turn flushes the buffer.

A single writeObject(order) call wrote the whole Order, the Customer inside it, the List of products, and each Product. The writer figured out the graph on its own. There is no per-field code, no recursion to write, no schema to declare in advance.

The reason a list of two products serializes cleanly is that java.util.ArrayList (which List.of returns under a different backing type, also Serializable) and the built-in collection types implement Serializable. That's a hidden convenience: most of the standard collection types already opt in, so you don't have to wrap them.

The writer also detects shared references. If the same Customer object is referenced from two different Order objects in one write, the bytes for that Customer appear only once. The second reference is stored as a back-pointer. When you read the bytes back, both Order objects point at the same Customer instance, just like before.

The diagram shows the pipeline. Your code hands an object to ObjectOutputStream. The stream walks the graph, encodes each object using the class descriptor and field values, and pushes bytes into the underlying FileOutputStream. The file stream pushes bytes to disk. The split between ObjectOutputStream (logic) and FileOutputStream (destination) is what lets the same serializer write to disk, memory, or a socket without changing how you call it.

Java serialization is slow compared to JSON or Protocol Buffers, often by an order of magnitude or more. The serializer uses reflection to read fields, writes a full class descriptor (class name, field names, types, serialVersionUID) on the first occurrence of each class, and tracks every object reference to handle cycles. None of that is free.

What Gets Written: Fields, the Whole Tree, Not Methods

The serializer writes the instance state and the class identity. It does not write methods, because methods belong to the class, and the class is identified by name when the bytes are read back later. The deserializer looks the class up by its fully qualified name and uses the local .class file's methods. If the class isn't on the classpath when you read, the read fails.

Concretely, for each object the writer emits:

WhatExampleNotes
Class descriptorcom.shop.ProductName, serialVersionUID, field list
Each instance fieldname="Notebook", price=4.99All non-transient, non-static fields
Super-class stateFields inherited from a parentWalked up the chain if the parent is Serializable

The class descriptor is written once per class in the stream. If you write twenty Product objects, the descriptor for Product appears once at the top and each of the twenty bodies references it. That's a small but meaningful optimization for streams that contain many objects of the same type.

Super-class fields are included if the super-class is itself Serializable. If a Serializable class extends a non-Serializable class, the super-class fields are not written, and on deserialization that super-class is reconstructed by calling its no-arg constructor. Make the whole hierarchy Serializable when you can.

The first Product carries the full class descriptor with it. The next four reference that descriptor instead of repeating it, which is why the byte cost per product drops after the first one. The same pattern holds for any class that appears multiple times in one stream.

transient: Fields the Serializer Should Skip

Some fields shouldn't be written. Common cases:

  • Secrets: passwords, API keys, payment tokens. Writing these to disk turns one secret into two, and now you have a file to secure.
  • Derived data: a totalPrice field that can be recomputed from line items. Writing it duplicates information and lets the stored value drift out of sync with the source data.
  • External resources: a database connection, an open file handle, a thread. These can't survive being turned into bytes anyway; they'd be invalid on the way back.

The transient keyword marks a field as "skip during serialization." It is a Java language modifier, not an annotation, and it goes on the field declaration.

The paymentToken field never made it into the bytes. When Customer is read back later, that field will be reset to null (the default for a reference type). The same rule applies for primitives: a transient int reads back as 0, a transient boolean as false, and so on. That's by design. The writer skips the field, and the reader doesn't have anything to assign, so it leaves the default in place.

The diagram shows the two fields that make it to disk and the one that doesn't. The transient token branch terminates at the field; it never reaches the file. To make the token survive, store it elsewhere (a secrets manager, an encrypted vault) and re-attach it after deserialization.

static Fields Are Not Serialized

static fields belong to the class, not to any single instance. Serialization writes instance state, so static fields are not part of the picture. The serializer ignores them whether they're marked transient or not.

The current value of taxRatePercent is 8 when we write, but that value lives on the Product class object, not on the notebook instance. The file captures name and price and nothing else. After deserialization in some other program (or in a later JVM run), the static field will have whatever value that program assigns to it, which might be the class default (0) if nothing else has touched it.

For a value to round-trip through serialization, it must be an instance field. Class-level state has to be managed separately.

serialVersionUID: Versioning the Class

When ObjectOutputStream writes an object, it tags the bytes with a class identity. The identity is not just the class name; it also includes a 64-bit hash called serialVersionUID. The reader uses this hash to confirm that the class it has on disk matches the class that wrote the bytes. If the hashes don't match, the reader throws InvalidClassException and refuses to load.

If you don't declare a serialVersionUID, the compiler generates one based on the class structure (class name, modifiers, super-class, fields, methods). The generated value is sensitive to almost any change. Add a field, the UID changes. Rename a method, the UID changes. Bump the JDK version on a different machine, sometimes the UID changes.

The common IDE warning ("The serializable class Foo does not declare a static final serialVersionUID field") is asking you to take control of this number instead of leaving it to the compiler. The fix is one line:

With serialVersionUID = 1L declared explicitly, the UID is whatever you say it is, regardless of how the class evolves. When you intentionally make a backward-incompatible change (remove a field, change a field's type), you bump the UID to a new value (2L) and you've consciously broken compatibility. When you make a backward-compatible change (add a new field with a sensible default), you leave the UID alone, and old serialized files still read.

The choice of literal doesn't matter. 1L, 42L, or some auto-generated long are all fine. What matters is that the value is declared, stable, and only changed when you mean it.

The diagram describes the check the reader performs. The value you choose at write time determines what readers see later. Picking 1L and sticking with it across compatible changes means old files keep working. Forgetting to declare a UID and shipping a class change means future code, even with the same source-level intent, may refuse to read old files.

Letting the compiler auto-generate serialVersionUID is error-prone. A change as small as adding a toString() method can change the generated UID, breaking every file written by the old class. Declaring it explicitly costs one line and removes the risk entirely.

A common convention is to start at 1L for a new class and bump the value only when you make a deliberately incompatible change. Some teams prefer the IDE-generated value (a long pseudo-random number) so it's obvious the field was added on purpose. Either is fine. Don't leave the field undeclared.

Custom Serialization with writeObject

The default serializer reads every non-transient, non-static field via reflection and writes it. For most classes, that's exactly what you want. But occasionally you need to do something extra: encrypt a field on the way out, derive a value, validate before writing, or write a transient field in a controlled way.

You can override the write step by defining a private method with this exact signature on the class:

The signature is checked by name and parameter types. If it matches, the serializer calls your method instead of the default routine. Inside it, you typically call out.defaultWriteObject() first to write the standard fields, then write extra data with the writeXxx methods.

The writeObject method runs as part of the default write pipeline. out.defaultWriteObject() writes all the regular instance fields (customerId, itemNames, itemPrices). Then out.writeInt(checksum()) appends one extra int to the stream. The reader, on the deserialization side, would call in.defaultReadObject() and then in.readInt() to recover the checksum. Whatever you write in writeObject is what the reader has to mirror.

A few rules about the custom writeObject:

  • It must be `private`. The signature is part of an implicit contract with the serialization machinery, not part of your public API.
  • It must not be `static`. It's invoked on the instance being written.
  • The return type must be `void` and the throws clause must include IOException.
  • Call `defaultWriteObject` first if you also want the normal field writing. If you skip it, you're responsible for writing every field yourself.

Custom serialization is a precision tool. Use it when you need the control, not as a default. Most classes work fine with the built-in field walker.

Externalizable: Total Control, Total Responsibility

Externalizable is an alternative to Serializable that hands you complete control over the byte format. Instead of letting the serializer walk your fields, you implement two methods:

Externalizable extends Serializable, so anything Externalizable is also Serializable. The difference is that the serializer recognizes Externalizable classes and skips its own field-walking logic entirely. Whatever you write in writeExternal is the only data that goes on the wire. Whatever you read in readExternal is the only data the reader gets back.

There is no serialVersionUID requirement for Externalizable (the framework doesn't use it to validate compatibility), but the class still needs a public no-arg constructor because the framework instantiates the class via that constructor before calling readExternal. Forget the no-arg constructor and reads fail at runtime.

PropertySerializableExternalizable
Method overrides neededNone (or optional writeObject)Both writeExternal and readExternal
Field writingAutomatic via reflectionManual: you write each field by hand
No-arg constructorNot requiredRequired, must be public
transient honoredYes (those fields are skipped)Irrelevant (you write what you want)
PerformanceSlower (reflection)Faster (direct field access)
MaintenanceLower (fields just work)Higher (every field change is two edits)

Externalizable shows up rarely in modern code. The maintenance burden of keeping two methods in sync with the field list outweighs the speed boost in almost every codebase. When raw performance matters, teams usually move off Java serialization entirely (to Protocol Buffers, FlatBuffers, or a custom binary format) rather than chase milliseconds with Externalizable. Knowing it exists is mostly an interview thing.

Limitations and When Not to Use It

Java's built-in serialization has real weaknesses, and the modern advice is to avoid it for new code unless you have a specific reason to use it. The main issues:

`NotSerializableException` at runtime. The "every reachable class must be Serializable" rule is checked when you call writeObject, not at compile time. A long-running service can run fine for weeks until someone adds an Address object to a cart and the write fails. Compile-time formats (Protocol Buffers, Avro) catch this at build time.

Brittleness across class changes. Even with an explicit serialVersionUID, certain changes break old files: renaming a field, changing a field's type, removing a field, moving a class to a different package. Long-lived serialized data is a maintenance liability.

Java-only. The byte format is undocumented in detail and tied to the JVM's reflection system. A Python service can't deserialize it. A Go service can't deserialize it. A future Java version might tighten compatibility rules and break it. Cross-language data exchange needs a portable format.

Security risk on the read side. Deserialization runs constructors, executes class initializers, and can be tricked into instantiating classes the attacker chose if the attacker controls the byte stream. This has been the source of major real-world vulnerabilities. Never deserialize bytes from an untrusted source. Writing is safer (you control what goes in), but the format carries security baggage on the way back.

Performance. Reflective field access plus full class descriptors per stream plus reference tracking adds up. For high-throughput systems, JSON parsers and binary formats like Protocol Buffers are usually faster, sometimes by an order of magnitude.

Modern alternatives in one sentence each. JSON (with Jackson or Gson) is human-readable, cross-language, and widely supported, at the cost of larger byte sizes. Protocol Buffers (and similar formats like Avro and Thrift) are compact binary formats with explicit schemas, fast, cross-language, and designed for evolution.

When does Java serialization still make sense? In short-lived, in-process scenarios: caching an object graph to a temp file during a single program run, passing an object between threads via a ByteArrayOutputStream, or working in legacy code that already uses it. For anything that crosses a process boundary, lives longer than a release, or comes from outside your trust boundary, pick a different format.

A graph with deep cycles or huge collections can balloon the byte count and the time to write. The serializer has to track every object it has already seen to handle cycles, so the bookkeeping scales with the number of reachable objects. For very large graphs, write the data in chunks or pick a streaming format instead of trying to serialize everything in one call.

Putting It Together

A small program that exercises most of what this lesson covered: a class with an explicit serialVersionUID, a transient field for a secret, a static field that is silently skipped, a custom writeObject that adds a checksum, and a single call that walks the whole object graph to disk.

A few things happen in those bytes. The Order's serialVersionUID is stamped in. The static nextOrderId is ignored. The Customer's paymentToken is omitted because it's transient. Both LineItem objects are written, and the second one uses the back-reference shortcut for the class descriptor that the first one introduced. The custom writeObject appends a checksum after the default fields. All of that came from one out.writeObject(order) call.

Quiz

Serialization Quiz

9 quizzes