A deep copy using in-memory serialization writes an object and everything it references into a byte array with ObjectOutputStream, and reads the bytes back with ObjectInputStream, which builds a brand-new object graph that shares no mutable object with the original. The bytes stay in memory, so no file or network is involved, and the only requirement is that every class in the graph implements Serializable.
We use it to copy deep or unfamiliar object graphs without writing copy code for every class, for example to take a snapshot of an editor state for undo, to clone a test fixture or to hand a private copy of a cached object to a caller.
The following example copies a recipe with a list of ingredients and changes the copy.
@SuppressWarnings("unchecked")
static <T extends Serializable> T deepCopy(T original) throws IOException, ClassNotFoundException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try (ObjectOutputStream out = new ObjectOutputStream(buffer)) {
out.writeObject(original);
}
try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(buffer.toByteArray()))) {
return (T) in.readObject();
}
}
Recipe pancakes = new Recipe("Pancakes", new Ingredient("flour", 200), new Ingredient("milk", 300));
Recipe copy = deepCopy(pancakes);
copy.ingredients().get(0).setGrams(250);
int originalFlour = pancakes.ingredients().get(0).getGrams(); // 200
int copiedFlour = copy.ingredients().get(0).getGrams(); // 250
boolean sameList = copy.ingredients() == pancakes.ingredients(); // false
Notice that changing an ingredient inside the copy left the original recipe untouched, because the list and every Ingredient in it are new objects. We start with the difference between shallow and deep copies, build the example step by step, look at what serialization does not copy, and compare the cost with copy constructors and other options.
1. Shallow Copy vs Deep Copy
A shallow copy creates a new outer object but copies only the references in its fields, so the copy and the original point to the same lists and nested objects. A change made through one of them shows up in the other. A deep copy also copies every object those fields reference, all the way down, so the two graphs are independent.
Java gives us shallow copies in several places, such as Object.clone(), ArrayList.clone(), array clone() and the copy constructors of collections. A deep copy is something we write ourselves, or get from serialization, which already knows how to walk every reference of an object graph.

The post on shallow and deep copies of a HashMap shows the same difference with a map, and the guide to object cloning covers Cloneable in detail.
2. Deep Copy Using In-Memory Serialization, Step by Step
The trick has two halves. First, ObjectOutputStream writes the object into a ByteArrayOutputStream, which collects the bytes in a growing array instead of a file. After that, ObjectInputStream reads those bytes from a ByteArrayInputStream and creates new objects for everything it finds. Both streams are closed with try-with-resources, and the generic method deepCopy() from the intro wraps the two halves.
2.1. The Recipe Classes
The domain has two classes. A Recipe has a title and a mutable list of Ingredient objects, and each Ingredient has a name and a weight in grams that the user can change. Both classes implement Serializable and declare a serialVersionUID.
class Ingredient implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final String name;
private int grams;
Ingredient(String name, int grams) {
this.name = name;
this.grams = grams;
}
String getName() { return name; }
int getGrams() { return grams; }
void setGrams(int grams) { this.grams = grams; }
}
class Recipe implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final String title;
private final List<Ingredient> ingredients = new ArrayList<>();
Recipe(String title, Ingredient... items) {
this.title = title;
this.ingredients.addAll(List.of(items));
}
String title() { return title; }
List<Ingredient> ingredients() { return ingredients; }
}
The field ingredients uses an ArrayList, which is Serializable. The declared type is List, which is not, and that is fine, because serialization checks the runtime class of each object.
2.2. Copying and Changing the Copy
A recipe editor lets the user try a variation, such as more flour, and discard it later. Before the user starts editing, the app stores a deep copy as the undo snapshot. Each edit changes only the working recipe.
Recipe working = new Recipe("Pancakes", new Ingredient("flour", 200), new Ingredient("milk", 300));
Recipe snapshot = deepCopy(working);
working.ingredients().get(0).setGrams(250);
working.ingredients().add(new Ingredient("sugar", 20));
int snapshotFlour = snapshot.ingredients().get(0).getGrams(); // 200
int snapshotSize = snapshot.ingredients().size(); // 2
int workingSize = working.ingredients().size(); // 3
A shallow copy would fail here in two ways. The changed flour weight would appear in the snapshot, and the added sugar would too, because both recipes would share one list.
2.3. Shared References and Cycles Survive the Copy
Serialization writes each object only once per stream and uses a back reference for every later occurrence. A deep copy through serialization keeps the shape of the graph, so two fields that point to the same object in the original point to one new object in the copy, and cycles do not cause endless recursion. Hand-written copy methods often get this wrong.
class MealPlan implements Serializable {
Recipe monday;
Recipe friday;
}
MealPlan plan = new MealPlan();
plan.monday = new Recipe("Pasta", new Ingredient("tomato", 400));
plan.friday = plan.monday;
MealPlan planCopy = deepCopy(plan);
boolean stillShared = planCopy.monday == planCopy.friday; // true
boolean newObject = planCopy.monday != plan.monday; // true
3. What Serialization Does Not Copy
The copy contains only what the serialization rules write. Most surprises come from four cases, and each one behaves the same way as it does when objects go to a file.
- A transient field is not written, so the copy holds the default value, such as null, 0 or false.
- A static field belongs to the class, not to the object, so the copy and the original see the same value.
- A field whose object does not implement Serializable makes the whole copy fail with a NotSerializableException.
- A non-serializable superclass is not copied either, and its no-arg constructor runs and sets its fields again.
The class CachedRecipe keeps a cached nutrition summary in a transient field and and TimedRecipe holds a Thread reference that is not serializable. The first copy loses the cache, and the second copy fails.
class CachedRecipe implements Serializable {
String title = "Soup";
transient String nutritionCache = "320 kcal";
}
class TimedRecipe implements Serializable {
String title = "Bread";
Thread timer = new Thread(() -> { });
}
CachedRecipe cachedCopy = deepCopy(new CachedRecipe());
String cache = cachedCopy.nutritionCache; // null, transient
TimedRecipe timedCopy = deepCopy(new TimedRecipe()); // NotSerializableException: java.lang.Thread
The exception message names the class that is not serializable, which helps when the field sits deep in a third-party object. We fix it by making that class Serializable, marking the field transient and rebuilding it after the copy, or by choosing a copy method from section 5.
3.1. Singletons and Enums
Enum constants keep their identity, because the stream stores only the constant name and readObject() returns the existing constant. A singleton class that implements Serializable is different, because a deep copy creates a second instance. A readResolve() method fixes that by replacing the new object with the existing one.
class Units implements Serializable {
static final Units INSTANCE = new Units();
private Units() { }
@Serial
private Object readResolve() {
return INSTANCE;
}
}
Units unitsCopy = deepCopy(Units.INSTANCE);
boolean sameUnits = unitsCopy == Units.INSTANCE; // true
DayOfWeek day = deepCopy(DayOfWeek.MONDAY);
boolean sameDay = day == DayOfWeek.MONDAY; // true
The post on enums as singletons explains why an enum is the simplest singleton that survives serialization.
4. Deep Copy With SerializationUtils.clone()
Apache Commons Lang wraps the same two streams in SerializationUtils.clone(), so we do not need our own helper. It throws the unchecked SerializationException instead of IOException and ClassNotFoundException, which is handy inside lambdas. The dependency is org.apache.commons:commons-lang3:3.21.0.
Recipe soup = new Recipe("Soup", new Ingredient("carrot", 150));
Recipe soupCopy = SerializationUtils.clone(soup);
soupCopy.ingredients().get(0).setGrams(180);
int soupCarrot = soup.ingredients().get(0).getGrams(); // 150
The method is many times slower than hand-written clone methods, as our own measurement in the next section confirms.
5. Alternatives to Serialization for Deep Copies
Serialization trades speed for convenience. On JDK 25, after a warm-up loop on a 2-core cloud VM, copying our recipe with 10 ingredients took about 14 microseconds through serialization and about 0.08 microseconds with a copy constructor, more than 100 times slower. For an undo snapshot taken on a button click that cost does not matter. For a copy inside a loop that runs a million times, it does.
A copy constructor creates new nested objects field by field. It is the fastest option and needs no Serializable, but every new field must be added to it by hand.
class Spice {
String name;
int grams;
Spice(String name, int grams) {
this.name = name;
this.grams = grams;
}
Spice(Spice other) {
this(other.name, other.grams);
}
}
List<Spice> rack = new ArrayList<>(List.of(new Spice("salt", 5), new Spice("pepper", 2)));
List<Spice> rackCopy = rack.stream().map(Spice::new).collect(Collectors.toCollection(ArrayList::new));
rackCopy.get(0).grams = 8;
int originalSalt = rack.get(0).grams; // 5
The options differ mostly in speed, setup and how they treat graphs with shared objects.
| Approach | Needs | Speed | Shared references and cycles |
|---|---|---|---|
| In-memory serialization | Every class Serializable | Slow | Kept |
| SerializationUtils.clone() | Same, plus commons-lang3 | Slow | Kept |
| Copy constructor or copy method | Code in every class | Fastest | Only if we handle them |
| Cloneable with deep clone() | clone() overrides in every class | Fast | Only if we handle them |
| JSON round trip with Jackson | Getters or annotations | Slow | Duplicated, cycles fail |
| Immutable classes and records | Immutable fields | No copy needed | Not applicable |
In many cases, the last row is the best answer. A record with List.copyOf() in its constructor holds no mutable state, so sharing it between callers is safe and nothing needs copying. The post on Java records covers that pattern.
6. Is Deserializing Our Own Bytes Safe?
Yes, as long as the bytes come from our own writeObject() call in the same method, as in deepCopy(). The risk of Java deserialization comes from bytes that an attacker controls, such as a request body or a file upload, because the stream can name any serializable class on the classpath. Never reuse the reading half of deepCopy() for data that arrives from outside, unless an ObjectInputFilter restricts the allowed classes.
7. Deep Copy Using Serialization FAQs
Copying a large object graph with serialization works on the first try more often than not, and the questions start when it hits a field or a class that serialization treats in its own way.
7.1. Is Serialization a Good Way to Deep Copy an Object?
Serialization is a good way when the graph is large or unfamiliar and the copy is not on a hot path. It is a poor way inside tight loops, because it is about 100 times slower than a copy constructor in our measurement, and it fails as soon as one class in the graph is not Serializable.
7.2. Does a Serialization Deep Copy Include transient Fields?
No. A transient field is not written, so the copy holds null, 0 or false. A private readObject() method can rebuild the value after the copy, as the post on custom serialization shows.
7.3. Why Do I Get NotSerializableException During a Deep Copy?
Some object in the graph, not necessarily the one we passed, belongs to a class that does not implement Serializable. The exception message names that class, for example java.lang.Thread, and we either make it serializable or mark the field transient.
7.4. Does Serialization Handle Circular References?
Yes. The stream remembers every object it has written and writes a back reference the second time, so a parent that points to a child that points back to the parent is copied once, with the same cycle in the copy.
7.5. How Do I Deep Copy an Object That Is Not Serializable?
Write a copy constructor or a copy method, or use a JSON round trip with a library such as Jackson for simple data classes. For lists and maps, the posts on deep copying an ArrayList and a HashMap linked in section 1 show both approaches.
8. Conclusion
In-memory serialization creates a deep copy by writing an object graph into a byte array and reading it back. It copies nested lists and objects, keeps shared references and cycles intact and needs only Serializable on every class in the graph.
The price is speed and the serialization rules. Fields marked transient come back empty, non-serializable classes throw NotSerializableException, and singletons need readResolve(). For hot code paths, copy constructors or immutable records are the better choice, and the Java serialization guide covers the rules that apply to every class we copy this way.
9. References
- ObjectOutputStream Javadoc (Java 25)
- ObjectInputStream Javadoc (Java 25)
- Java Object Serialization Specification
- SerializationUtils Javadoc (Commons Lang 3)
Happy Learning !!
Hi ,
How do we compare or Verify that object is not corrupt.
Excellent article! But, one query –
In
method, you made a comment –
I wonder, what could possibly go wrong and what can we do to verify/tackle it?
The same implementation is achieved through Apache commons utils..
https://github.com/apache/commons-lang/blob/master/src/main/java/org/apache/commons/lang3/SerializationUtils.java
can you explain the flow of the program? like what is happening first and what is happening in second?
Rohit, program is very simple. In first program, Instead of writing the byte stream to any file/network resource, I am directly using to create another instance of class. This saved me lots of I/O operations.
Second program is just an example for how to use code of first program.
thanks for getting back, as we are not using lots of file I/O operations, I guess that is an advantage of deep cloning ? and why we are using deep cloning? I know its mentioned if we dont want to persist the object for future use but here we are creating another duplicate object . didnt get this part..
It means we are creating a clone of object to further use in application. We do not intent to store in database (some applications store whole object after serializing it instead writing tabular data).
Great!! now I got it , Thanks for the explanation
Excellent article. But, one query –
You have a comment in the code //Verify that object is not corrupt
I wonder, what could possibly go wrong and how we verify/tackle it?