Skip to main content

Deep Copy vs Shallow Copy in Java: clone(), Copy Constructors, Serialization and Records

Copying an object does not copy what it points at. A run-it-yourself walk through shallow vs deep copy in Java: why clone() is broken, copy constructors, serialization copies, records and defensive copies, with real output from a 24-test companion module.

You write Person backup = person;, change person, and — surprise — backup changed too. So you reach for clone(), or new ArrayList<>(list), and the bug is still there, one level down. Almost everyone who learns Java meets this, and almost everyone fixes it by trial and error. The cause is a single idea: copying an object does not copy the objects it points at. Once that is clear, “shallow vs deep” stops being interview vocabulary and becomes a thing you can see in a picture. This article starts there, then walks through every real way to copy in Java — clone(), copy constructors, serialization, records, defensive copies, the built-in collection copies — and shows what each one actually does. Everything here was run: the companion module is asmhatre/java-core-examples/copying, whose 24 tests pin every claim below as an assertion, and every console block is quoted from a committed file in its output/ folder. The deeper material is inside this page, folded into expandable sections like the one below. Open them when you want the next layer; the page reads fine in order without them.
How to read this page (and what the expandable sections are) Every section has the same shape: a plain explanation, a diagram, real code with a link to its file, real output with a link to its transcript, and then one or two folded sections with the intermediate and expert detail. If you are new to this topic, you can skip every folded section on a first read and lose nothing you need. If you already know the basics, the folded sections are where the traps are.
Versions. JDK 25.0.5+8 (Temurin, the current LTS), JUnit Jupiter 5.11.0 (the same as the sibling modules in this repository; the newest GA on Maven Central is 6.1.3), Maven 3.9.11. Nothing here needs a framework: it is plain Java, so the behaviour is the same on any recent JDK. The one non-deterministic file is 10-cost.txt, a timing loop labelled indicative where it is used.

Assigning an object does not copy it

In Java, a variable that holds an object does not hold the object. It holds a reference — an arrow to the object, which lives elsewhere. So Person alias = original; copies the arrow, and now two names point at one object. Nothing was duplicated, and no copying technique is involved at all.
original alias one Person objectname, address, tags Two names, one object. Change it through either name and both names see the change.
The picture shows the whole problem in miniature: a change made through original is visible through alias, because there is only one object to change. Every copying technique below exists to make a second object. The interesting question is how much of the first object’s surroundings the second one also takes.

A shallow copy shares everything the object points at

Suppose a Person holds a name, an Address and a list of tags. A shallow copy makes a new Person and copies each field across as it stands — which, for the Address and the list, means copying the arrows. The new person and the old person now point at the same address object and the same list. A deep copy keeps going: it makes new Address and list objects too, so the two people share nothing mutable.
Shallow copy original shallow Address List of tags Four arrows, two shared objects Deep copy original deep Address List Address (copy) List (copy) Nothing mutable is shared The only difference between the two halves is how many objects exist. Everything that goes wrong with copying in Java comes from not knowing which half you built.
On the left, both people reach the same Address and the same list, so editing either one edits both. On the right, each person has a private set. Here is the smallest code that builds both halves; the full class is ShallowVsDeepDemo.java.
static Person shallowCopy(Person p) {
    return new Person(p.name, p.address, p.tags);
}

static Person deepCopy(Person p) {
    return new Person(p.name, new Address(p.address), new ArrayList<>(p.tags));
}
The demo builds a person, makes an alias, a shallow copy and a deep copy, and asks the one question that matters — do they point at the same object? — before changing anything. The transcript is 01-shallow-vs-deep.txt.
alias   == original                 : true
shallow == original                 : false
shallow.address == original.address : true
shallow.tags    == original.tags    : true
deep.address    == original.address : false
deep.tags       == original.tags    : false
Then it changes the original’s name, city and tags and prints all four people:
original : Asha K | Pune | 
alias    : Asha K | Pune | 
shallow  : Asha | Pune | 
deep     : Asha | Mumbai | 
Look at the shallow line, because it is the most instructive one. The name stayed Asha but the city and the tags changed. The name is a String, and a String cannot be changed in place — assigning original.name = "Asha K" points the original’s field at a different string and leaves the copy’s field alone. The city and the list can be changed in place, so those edits show through the shared object. Keep that distinction in mind: it is the reason some shared fields are harmless and others are bugs, and the whole article is about telling them apart.
The rule of thumb. Sharing an immutable object (String, Integer, a LocalDate, a record of immutables) is safe, so a shallow copy of it is a perfectly good deep copy. Sharing a mutable object (ArrayList, Date, an Address with a setter, any array) is where a shallow copy becomes a bug. “Deep” only has to go as deep as the mutable things.
Going further: “how deep is deep enough?” (intermediate) There is no universal depth. A deep copy is correct when no mutable object is reachable from both the original and the copy. A Person whose fields are all Strings and primitives needs no deep copy at all — a shallow copy is already complete. A Person holding a List<Address> needs the list copied and each address copied, because the list’s elements are mutable too. This is why a one-line “deep copy” helper for arbitrary objects does not exist in the JDK: depth depends on which of your types are mutable, and only you know that. The practical question to ask of any class is therefore not “shallow or deep?” but “which of my fields are mutable, and does the copy need its own version of each?” The cheapest way to make the question go away is to make the fields immutable, which is where records and List.copyOf come in later in this article.

clone() looks like the answer and mostly is not

Object.clone() is the method the language ships for copying, so it is the first thing people try. It works like this: you implement the marker interface Cloneable, override clone() to call super.clone(), and the JVM allocates a new object and copies every field across — as a shallow copy, and without running any constructor.
super.clone()allocate + copy fields constructornever runs your fix-upscopy each mutable field The constructor box is dashed because it does not execute. Anything a constructor normally guarantees (validation, defensive copies, final field assignment) is skipped for a cloned object.
The dashed middle box is the one to remember: a cloned object is built without its constructor, so anything the constructor normally guarantees does not happen. Here is the textbook version and the one-line fix, from CloneDemo.java:
@Override public Team clone() {
    try {
        return (Team) super.clone();
    } catch (CloneNotSupportedException e) {
        throw new AssertionError(e);   // cannot happen: we implement Cloneable
    }
}
@Override public DeepTeam clone() {
    try {
        DeepTeam copy = (DeepTeam) super.clone();
        copy.members = new ArrayList<>(members);   // the step people forget
        return copy;
    } catch (CloneNotSupportedException e) {
        throw new AssertionError(e);
    }
}
Running both, from 03-clone.txt:
  Team constructor ran for 'platform'
  b == a                 : false
  b.members == a.members : true
  after a.members.add("meera"), b.members = [asha, ravi, meera]
  c.members = [asha, ravi, meera]
  d.members = [asha, ravi]
The first block shows two facts at once. The constructor message printed once, for the original — clone() made a second object without calling it. And the member list is shared. The second block shows the fix working: the one line that copies members is all that separates a shared list from a private one, and nothing in the language reminds you to write it.
The reputation is earned. Joshua Bloch’s Effective Java advises against Cloneable for new classes and recommends a copy constructor or copy factory instead, mainly because of the traps in the folded section below. The one place clone() is still the idiomatic choice is copying a one-dimensional array — and even there, Arrays.copyOf says the same thing more plainly.
Going further: the five clone() traps, each reproduced (intermediate) 1. Forgetting Cloneable. super.clone() throws CloneNotSupportedException unless the class implements the empty marker interface. Nothing at compile time tells you:
  CloneNotSupportedException: com.ankurm.copying.CloneDemo$ForgotCloneable
2. Arrays are shallow too. grid.clone() on an int[][] copies the outer array of row references, not the rows:
  gridCopy[0] == grid[0] : true
  grid     = [[99, 2], [3, 4]]
  gridCopy = [[99, 2], [3, 4]]
3. ArrayList.clone() is shallow. Its documentation says so, and the demo confirms the elements are the same objects:
  original.get(0) = abc-changed
  same element object: true
4. A parent that uses new instead of super.clone() breaks every subclass. If Animal.clone() returns new Animal(name), then Dog.clone() — which casts super.clone() to Dog — compiles and then fails at run time:
  ClassCastException: class com.ankurm.copying.CloneDemo$Animal cannot be cast to class com.ankurm.copying.CloneDemo$Dog (com.ankurm.copying.CloneDemo$Animal and com.ankurm.copying.CloneDemo$Dog are in unnamed module of loader 'app')
5. final fields cannot be deep-copied. Because clone() is not a constructor, a final field cannot be reassigned inside it. That is a compile error, so the source lives outside the Maven build in src/broken/FinalFieldClone.java, and scripts/broken.sh commits the compiler’s own message to 02-final-field-clone.txt:
$ javac src/broken/FinalFieldClone.java
src/broken/FinalFieldClone.java:17: error: cannot assign a value to final variable items
            copy.items = new ArrayList<>(items);   // error: cannot assign a value to final variable
                ^
1 error
Trap 5 is the decisive one in practice: immutability (final fields) and clone() do not mix, so a class cannot have both. The sections that follow show techniques that do not have this limit.
Going further: Cloneable without a public clone(), and the error that tells you (intermediate) Implementing Cloneable is not enough on its own, because the interface declares no methods at all: it is only a flag that tells Object.clone() it may copy this class. The method that does the copying is still protected in Object, so a class that adds implements Cloneable but forgets to override clone() as public cannot be cloned by any other class. The compiler refuses, and the message is worth recognising because it reads like a complaint about the JDK rather than about your class. From ForgotToOverride.java, a source kept outside the Maven build on purpose:
static class Foo implements Cloneable {
    int x = 1;
}

public static void main(String[] args) {
    Foo foo = new Foo();
    Object copy = foo.clone();
    System.out.println(copy);
}
The compiler’s own message, captured by scripts/broken.sh into 16-forgot-override.txt:
$ javac src/broken/ForgotToOverride.java
src/broken/ForgotToOverride.java:10: error: clone() has protected access in Object
        Object copy = foo.clone();
                         ^
1 error
The fix is the one already shown in the clone() section: override clone() as public, call super.clone() inside it, and use a covariant return type so callers do not need a cast.

A copy constructor does what clone() cannot

A copy constructor is an ordinary constructor that takes another object of the same class and builds a new one from it. It has none of the clone problems: it really is a constructor, so it can assign final fields, run validation, and decide field by field how deep to go. A copy factory is the same idea as a static method, Animal.copyOf(other). From CopyConstructorDemo.java, note that the field is final and the copy still gets its own list:
final String name;
final List<String> tricks;          // final is fine: constructors may assign it

Animal(String name, List<String> tricks) {
    this.name = name;
    this.tricks = new ArrayList<>(tricks);
}

/** Copy constructor. */
Animal(Animal other) {
    this(other.name, other.tricks);
}
  rex  = Dog(Rex, [sit, roll]) bark=7
  rex2 = Dog(Rex, [sit, roll]) bark=7
  rex2.tricks == rex.tricks : false
The output, from 04-copy-constructor.txt, shows an equal but independent copy. The copy constructor has one real weakness, and it is worth seeing because it is the reason a second technique exists: the constructor is chosen by the declared type, not the real one.
Going further: the subtype-slicing problem and the virtual copy() fix (intermediate) If a variable is declared as Animal but actually holds a Dog, then new Animal(pet) builds a plain Animal: the Dog part — here, the bark volume — is silently dropped. A constructor cannot be virtual, so it cannot ask the object what it really is. The fix is a small virtual method that each subclass overrides to call its own copy constructor:
/** Virtual copy method: each subclass overrides it, so the runtime type survives. */
Animal copy() {
    return new Animal(this);
}
  new Animal(pet) = Animal(Rex, [sit, roll])
  pet.copy()      = Dog(Rex, [sit, roll]) bark=7
The first line is the sliced copy and the second is the correct one, both from the same 04-copy-constructor.txt transcript. Note what this pattern is: it is what clone() was trying to be, with an ordinary constructor underneath instead of the magic allocation, so every normal Java rule (final, validation, access control) still applies.
Going further: a copy constructor on a graph: shared nodes and cycles (intermediate) A copy constructor that calls the copy constructor of every field gives a correct deep copy of a tree, where each object is reachable by exactly one path. Real data is often not a tree. Two fields can point at the same object (a diamond), or a chain can lead back to where it started (a cycle). The constructor cannot tell that it has already copied something, so it copies it again. From GraphCopyDemo.java:
Node(Node other) {
    this.name = other.name;
    this.left = other.left == null ? null : new Node(other.left);
    this.right = other.right == null ? null : new Node(other.right);
}
The transcript, 12-graph-copy.txt, starts with the diamond, one shared node reached twice:
  original                : left == right : true
  copy constructor        : left == right : false
  identity-map copy       : left == right : true
  serialization copy      : left == right : true
The original has one shared node; the copy constructor’s result has two separate ones. Nothing fails, but a change made later through root.left no longer shows up through root.right, which silently changes what the object means. A cycle is worse (second block of the same transcript):
  original                : a.left.left == a : true
  copy constructor        : StackOverflowError
  identity-map copy       : copy.left.left == copy : true
  identity-map copy       : copy != original       : true
The fix is to remember what has already been copied, keyed by identity, and hand back the earlier copy when the same object turns up again. The one detail that matters is registering the new node before recursing into its children, so that a cycle finds the copy already in the map and stops. Also from GraphCopyDemo.java:
static Node copyWithMemo(Node n, Map<Node, Node> seen) {
    if (n == null) return null;
    Node done = seen.get(n);
    if (done != null) return done;
    Node c = new Node(n.name);
    seen.put(n, c);                 // register BEFORE recursing, so a cycle finds it and stops
    c.left = copyWithMemo(n.left, seen);
    c.right = copyWithMemo(n.right, seen);
    return c;
}
Use an IdentityHashMap, not a HashMap: a normal map treats two nodes as the same when equals() says so, and two different nodes that merely compare equal must stay two nodes. The identity-map row and the serialization row in the first block above show the same diamond kept intact; the serialization copy in the next section does this bookkeeping for you, at the price of the rules listed there.

Serialization gives a deep copy for free, until it does not

If your classes already implement Serializable, there is a trick that needs no per-field code: write the object to a byte array and read it back. The reading side builds a brand-new object graph, so the result is a deep copy of everything reachable, however nested. From SerializationCopyDemo.java:
static <T extends Serializable> T deepCopy(T obj) throws IOException, ClassNotFoundException {
    ByteArrayOutputStream bytes = new ByteArrayOutputStream();
    try (ObjectOutputStream out = new ObjectOutputStream(bytes)) {
        out.writeObject(obj);
    }
    try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) {
        return (T) in.readObject();
    }
}
The transcript, 05-serialization.txt, first shows the headline result — nothing is shared, and a later change to the original does not reach the copy — and then the two surprises:
  copy.dept == asha.dept     : false
  copy.skills == asha.skills : false
  asha.skills = , asha.dept = Core
  copy.skills = , copy.dept = Platform
  asha.sessionToken = secret-token
  copy.sessionToken = null
The second block is the first surprise: a transient field is skipped by serialization, so the copy has null where the original had a value. Serialization copies exactly what serialization would save, no more.
Use this only on data your own program just produced. ObjectInputStream on bytes that came from outside is a well-known remote-code-execution risk, and the pattern above is safe only because the bytes never leave the method. If you would be tempted to reuse deepCopy on anything received over a network, read Java Serialization in 2026: Why It’s Dangerous and What Replaced It first.
Going further: shared references, cycles and the non-serializable field (intermediate) Serialization has two properties that a hand-written copy constructor usually does not, and both are useful. First, it preserves sharing inside the graph. If two employees in a list point at the same Dept object, the copy has two employees pointing at one new Dept — not two separate ones:
  original: same Dept object  : true
  copy    : same Dept object  : true
  copy dept is a new object   : true
Second, it handles cycles. If asha.mentor is ravi and ravi.mentor is asha, a naive recursive copy would loop forever; serialization records each object once and wires the references back:
  cyc.mentor.mentor == cyc : true
  cyc != asha              : true
The failure mode is a field that is not serializable. java.lang.Object itself is not, so a class holding a plain lock object fails at run time, not compile time:
  NotSerializableException: java.lang.Object
This is the practical limit of the technique: every class in the graph needs Serializable, every non-serializable field needs transient and a plan for being null in the copy, and the failure appears only when that exact path runs.
Going further: which constructors run during a serialization copy, and an allow-list filter (expert) Reading an object back does not call its constructor the way new does. For a Serializable class the JVM skips that class’s constructors and instead runs the no-argument constructor of the nearest superclass that is not serializable (Object‘s, when there is none). Counting constructor runs shows it. From SerializationRulesDemo.java:
static class Parent {
    static int runs = 0;
    Parent() { runs++; }
}

static class Child extends Parent implements Serializable {
    static int runs = 0;
    Child() { runs++; }
}
The transcript is 13-serialization-rules.txt:
  after new Child()   : Child runs = 1, Parent runs = 1
  after the copy      : Child runs = 1, Parent runs = 2
Child‘s constructor ran once, for the original; Parent‘s ran twice, for the original and for the copy. Any setup or validation you put in a serializable class’s constructor therefore does not protect the copy. Records are the exception: reading a record back goes through its canonical constructor, so a compact constructor that validates or copies, like the ones in the records section above, runs on every serialization copy too. The record, from the same SerializationRulesDemo.java:
record Money(long paise) implements Serializable {
    static int runs = 0;
    Money { runs++; }                   // compact canonical constructor
}
And its count, second block of the transcript:
  after new Money(..) : canonical runs = 1
  after the copy      : canonical runs = 2
Because the reading side is the dangerous half of serialization, give it an allow-list. A pattern filter admits the packages you expect and rejects everything else with !*; it is attached to the stream with setObjectInputFilter. The pattern used in SerializationRulesDemo.java:
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter("com.ankurm.copying.*;java.util.*;java.lang.*;!*");
What it did, third block of the transcript:
  list of String and Money     : [text, Money[paise=1]]
  list holding an AtomicInteger : InvalidClassException: filter status: REJECTED
The first copy contains a String and a record from allowed packages and goes through; the second contains an AtomicInteger, which is outside the list, and the read fails with InvalidClassException.

Records stop the problem at the door, but only half of it

A record (Java 16+) is a class whose fields are all final, declared in one line. Many people read that as “records are immutable, so copying is never a problem.” Remember the picture from earlier: final makes the arrow permanent, not the object it points at. A record holding a list is “shallowly immutable” — nobody can swap the list, but anybody holding the list can still change it.
/** Looks immutable. The list inside is whatever the caller handed in. */
record LeakyOrder(String id, List<String> items) { }

/** The compact constructor takes a private, unmodifiable snapshot. */
record SafeOrder(String id, List<String> items) {
    SafeOrder {
        items = List.copyOf(items);              // also rejects null elements
    }
}
Both records are in RecordCopyDemo.java. The transcript, 06-records.txt, first runs the leaky one: the caller adds to its own list afterwards, and the “immutable” record changes. Writing through the accessor changes the caller’s list as well.
  leaky.items()         = [pen, book, laptop]
  after leaky.items().add("phone"), basket = [pen, book, laptop, phone]
With List.copyOf in the compact constructor, the record takes its own snapshot, so later changes by the caller do not reach it, and the accessor hands out an unmodifiable list:
  safe.items()          = [pen, book]
  safe.items().add(...) -> UnsupportedOperationException
That one line — copy on the way in — is the single most valuable habit in this article, and it has a name: a defensive copy.
Going further: “changing” a record, and records holding legacy mutable types (intermediate) Records have no setters, so “changing” one means building a new one. A small method does it, and the original is untouched:
SafeOrder withItem(String item) {
    List<String> next = new ArrayList<>(items);
    next.add(item);
    return new SafeOrder(id, next);
}
  safe   = SafeOrder[id=A-2, items=[pen, book]]
  bigger = SafeOrder[id=A-2, items=[pen, book, charger]]
A record that holds a mutable legacy type like java.util.Date needs the copy twice: once in the constructor and once in the accessor, because the accessor would otherwise hand out the internal object. The demo mutates the caller’s date and then the returned date; only the defensive version keeps its value:
  LeakyEvent.when().getTime() after caller mutates d1 = 86400000
  SafeEvent.when().getTime()  after both mutations    = 0
The better fix is not to hold a Date at all — Instant and LocalDate are immutable and need no copying. The Date case is in the demo because older code and older libraries still hand them to you.
Going further: array components, and the with expression JDK 25 does not have (intermediate) An array is the record component that is easiest to forget: it is mutable, a record will not copy it, and it leaks twice. Once through the constructor, because the caller keeps the array it passed in; and once through the accessor, because callers get the record’s own array back. From RecordArrayDemo.java:
record RawPacket(byte[] data) {}

/** Copies on the way in and on the way out. */
record Packet(byte[] data) {
    Packet { data = data.clone(); }

    @Override
    public byte[] data() { return data.clone(); }
}
The transcript is 14-record-arrays.txt. First the leaky record, hit from both ends:
  after raw[0] = 99        : [99, 2, 3]
  after rp.data()[1] = 88  : [99, 88, 3]
Then the one that copies in the compact constructor and in the accessor, given the same two writes (second block of the same transcript):
  after the same two writes : [1, 2, 3]
Cloning is safe here because an array type cannot be subclassed; the same trick on a non-final class has a trap, covered in the defensive-copies section below. One more consequence: the equals() a record generates compares an array component by reference, so two packets holding the same bytes are still not equal (third block of the same transcript):
  new Packet({1}).equals(new Packet({1})) : false
If a record needs content equality for an array, override equals() and hashCode() with Arrays.equals and Arrays.hashCode. If you have seen p with { x = 10; } in a conference talk, that is JEP 468, derived record creation. It does not compile on JDK 25. From WithExpression.java, javac’s own output, captured into 17-with-expression.txt:
$ javac src/broken/WithExpression.java
src/broken/WithExpression.java:8: error: ';' expected
        Point q = p with { x = 10; };
                   ^
src/broken/WithExpression.java:8: error: not a statement
        Point q = p with { x = 10; };
                    ^
src/broken/WithExpression.java:8: error: ';' expected
        Point q = p with { x = 10; };
                        ^
3 errors
The JEP is still listed with the status Candidate on openjdk.org (its page was last updated in April 2024), so for now the hand-written wither from the section above, such as withName(...), is how you “change” a record.

Defensive copies: copy on the way in and on the way out

A class that stores a reference the caller still holds, or hands its own internal reference to the caller, has given away control of its own state. Both leaks are easy to demonstrate. In DefensiveCopyDemo.java the safe version differs from the leaky one in a single line:
SafeRoster(List<String> names) { this.names = List.copyOf(names); }

List<String> getNames() { return names; }      // already unmodifiable, so no second copy
The corresponding transcript, 07-defensive.txt, shows the safe version surviving the caller clearing its own list, and refusing a write through the getter:
  safe.size() after the caller clears its list : 2
  safe.getNames().add(...) -> UnsupportedOperationException
source [a, b, c] view (unmodifiableList) snapshot [a, b] (copyOf) Dashed: the view reads through to the source, so it shows c. Solid box: the snapshot is its own list.
The distinction in the diagram is the one that surprises people. Collections.unmodifiableList(source) only stops you writing through the wrapper — it is a read-only window onto the same list, so whoever owns source can still change what you see. List.copyOf(source) is a separate list. Run it, from the same transcript:
  source   = [a, b, c]
  view     = [a, b, c]
  snapshot = [a, b]
Going further: List.copyOf is still shallow, and it can skip the copy (intermediate) List.copyOf protects the list, not what is in it. If the elements are mutable, they are still shared — which is the whole article in one line:
  frozen.get(0) = x-mutated
Two more facts, both run rather than read from the docs. List.copyOf returns the same instance when it is given a list that is already an immutable one from List.of or List.copyOf (so calling it defensively on every getter is cheap) — but it does not do that for a Collections.unmodifiableList view, which it copies. And it throws NullPointerException on a null element, which is why the record comment above says it “also rejects null elements”. From 11-copyof-identity.txt, produced by CopyOfIdentityDemo.java:
copyOf(immutable list) is the same instance      : true
copyOf(unmodifiableList view) is the same instance : false
copyOf with a null element throws                : NullPointerException
Going further: why a defensive copy must not use clone() on a non-final type (expert) It is tempting to make the defensive copy of a parameter with the parameter’s own clone(). That is safe only when the parameter’s type cannot be subclassed. java.util.Date can be, so a caller can pass a subclass whose clone() returns itself, and your “copy” is then the caller’s own object, still under the caller’s control. From DefensiveCloneDemo.java:
static class EvilDate extends Date {
    EvilDate(long t) { super(t); }

    @Override
    public Object clone() { return this; }
}
The transcript is 15-defensive-clone.txt. The copy made with clone() is the attacker’s object:
  clone() result is the attacker's object : true
  clone() result class                    : EvilDate
The copy made with the constructor is a genuine Date, whatever the argument’s class (second block of the same transcript):
  new Date(getTime()) is the attacker's object : false
  new Date(getTime()) result class             : Date
This is the advice in Effective Java (3rd edition, Item 50): for a parameter whose type is not final, copy with a constructor or factory, not with clone(). The better fix, as the closing box of this article says, is a type that needs no copy at all, such as java.time.Instant.

Every built-in collection and array copy is shallow

The standard library’s copy tools — new ArrayList<>(list), List.copyOf, new HashMap<>(map), Arrays.copyOf, array.clone() — all copy the container and reuse the contents. That is exactly right when the contents are immutable (a list of strings), and exactly the bug when they are not. From CollectionCopyDemo.java, the transcript 08-collections.txt shows a new list that still shares its boxes:
  boxes     = [Box(100), Box(2)]
  viaCtor   = [Box(100), Box(2)]
  viaCopyOf = [Box(100), Box(2)]
  after boxes.add(Box(3)): viaCtor.size() = 2
Reading that block: the value change (Box(100)) shows in all three lists because the Box object is shared; but adding a new box to boxes does not reach viaCtor, because the container is separate. Containers are copied; contents are not. Primitive arrays are the one comfortable case, since there is nothing to share:
  a = [99, 2, 3], clone = [1, 2, 3], copyOf = [1, 2, 3]
Going further: the deep versions for lists and 2-D arrays (intermediate) For a list, copy each element through its copy constructor with a stream. For a 2-D array, clone each row:
List<Box> deep = boxes.stream().map(Box::new).toList();
int[][] deepGrid = Arrays.stream(grid).map(int[]::clone).toArray(int[][]::new);
  boxes = [Box(-1), Box(2), Box(3)]
  deep  = [Box(100), Box(2), Box(3)]
  grid     = [[99, 2], [3, 4]]
  shallow  = [[99, 2], [3, 4]]
  deepGrid = [[1, 2], [3, 4]]
The list pair shows the deep copy keeping 100 after the original moved on to -1; the grid triple shows Arrays.copyOf still sharing rows while the clone-per-row version is independent. HashMap‘s copy constructor behaves like the list case: a new map, the same value objects.
  mCopy.get("k") = Box(55)

What each technique costs

Which one should you use? Mostly the one that is correct for your classes, because the cost of copying is rarely the bottleneck. CopyCostDemo.java times a three-field object by each method. It is a plain System.nanoTime loop, not JMH, and the numbers drift between machines and runs, so read only the gap:
Indicative only: plain nanoTime loop, best of 7 rounds, ns per deep copy.
Absolute numbers drift per machine and run; read the ratios.

copy constructor :      14 ns
deep clone()     :      13 ns
serialization    :    9765 ns
serialization is about 698x the copy constructor
That block is 10-cost.txt, one run on one machine. The copy constructor and the hand-written clone() are indistinguishable at this resolution — and because allocation of short-lived objects is something the JIT can sometimes optimise away, I would not read anything into 14 vs 13. The gap to serialization, hundreds of times, is the only thing I would trust from this run. It is a loop of tiny objects, so for a rigorous number use the harness described in the JMH tutorial.
TechniqueDepthWorks with final fieldsKeeps runtime subtypeUse when
Assignmentnone (same object)n/ayesyou really want one object
clone()shallow unless you fix itnoyes (with super.clone())copying a 1-D array; legacy code
Copy constructor / factoryexactly as deep as you writeyesno, unless you add copy()the default for your own classes
Serializationfull graphyesyeslarge graphs with cycles, own data only
Records + List.copyOfimmutable by constructionyesn/a (records are final)new value types: avoid needing copies
Going further: how the timing was taken, and what it does not prove (expert) Each technique is timed in seven rounds of 200,000 copies (20,000 for serialization, which is slower), and the best round is kept; the first rounds double as warm-up. A copy is stored in a variable that is checked at the end, so the JIT cannot treat the whole loop as dead code. That is the minimum discipline for a hand-rolled benchmark, and it is not enough for a publishable number: there are no forks, no control for GC or frequency scaling, and no Blackhole. The module’s test suite does not assert any figure from this file for exactly that reason — it asserts every other claim in the article, which is why 10-cost.txt is labelled the only non-deterministic transcript.

Should you be copying at all?

Often the better answer is to make copying unnecessary. If a class’s fields are immutable — a record, List.copyOf in the constructor, java.time types instead of Date — then sharing is safe and there is nothing to copy; a shallow copy is already a deep one. Reach for a copy constructor when a class genuinely needs to be mutable, clone() only for a one-dimensional array, and serialization only for a large, cyclic graph of your own data where writing the copy by hand would cost more than the roughly ten microseconds serialization takes per object.

The honest limit of this article: it covers copying objects you design. For third-party types you cannot change, a deep copy is only as good as what those classes expose, and a JSON round-trip or a mapping library may be the practical tool — but those bring their own rules about what survives the trip, which I have not covered here.

Further reading

No Comments yet!

This site uses Akismet to reduce spam. Learn how your comment data is processed.