Java Deep Copy Using In-Memory Serialization (With Examples)

Java deep copy using in-memory serialization with a generic helper, shared references, transient fields, SerializationUtils, real cost and alternatives.

Deep copy using in-memory serialization in Java, an object graph written by ObjectOutputStream into a ByteArrayOutputStream and read back by ObjectInputStream as a new, independent graph

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.

Deep copy using in-memory serialization in Java, an object graph written by ObjectOutputStream into a ByteArrayOutputStream and read back by ObjectInputStream as a new, independent graph
The bytes never leave memory, and readObject() creates a new object for every Serializable object in the original 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.

ApproachNeedsSpeedShared references and cycles
In-memory serializationEvery class SerializableSlowKept
SerializationUtils.clone()Same, plus commons-lang3SlowKept
Copy constructor or copy methodCode in every classFastestOnly if we handle them
Cloneable with deep clone()clone() overrides in every classFastOnly if we handle them
JSON round trip with JacksonGetters or annotationsSlowDuplicated, cycles fail
Immutable classes and recordsImmutable fieldsNo copy neededNot 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

Happy Learning !!

Source Code on Github

Leave a Comment

  1. Excellent article! But, one query –
    In

     deepCopy() 

    method, you made a comment –

     //Verify that object is not corrupt 

    I wonder, what could possibly go wrong and what can we do to verify/tackle it?

  2. 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..

      • 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?

Comments are closed.

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.