Trước tiên hãy nhớ lại kiến thức cơ bản về Object

Object là một thực thể có hành vi và trạng thái, Object được tạo ra mang những đặc điểm của Class và có những giá trị cụ thể cho các đặc điểm trạng thái đó. Object được lưu trữ trong bộ nhớ Heap

Bạn đã bao giờ gặp tình trạng giá trị của 1 biến trong chương trình của bạn bị thay đổi 1 cách khó hiểu, bị NULL mà không rõ nguyên nhân. Hoặc tinh hơn debug ra thì bạn thấy nó đang bị thay đổi giá trị theo 1 biến khác cùng kiểu dữ liệu, chính xác hơn là 2 đối tượng (object) của 1 lớp (class). Rất có thể đó là vấn đề do Shallow Copy gây ra.

Trước khi đọc giải nghĩa về Shallow Copy thì các bạn có thể xem qua ví dụ ngắn dưới đây trước

package net.tunghuynh.shallowcopy;
public class Main {
    public static void main(String[] args) {
        Obj o1 = new Obj("123");
        Obj o2 = new Obj("abc");
        System.out.println("Init: " + o1 + " | " + o2);
        o2=o1;
        System.out.println("After assign: " + o1 + " | " + o2);
        o2.setContent("456");
        System.out.println("After set o2: " + o1 + " | " + o2);
        o1.setContent("789");
        System.out.println("After set o1: " + o1 + " | " + o2);
    }
}
class Obj{
    public Obj(String content) {
        this.content = content;
    }
    private String content;
    public String getContent() {
        return content;
    }
    public void setContent(String content) {
        this.content = content;
    }
    @Override
    public String toString() {
        return content;
    }
}

Kết quả:

Init: 123 | abc
After assign: 123 | 123
After set o2: 456 | 456
After set o1: 789 | 789

Các bạn có thể thấy giá trị của thuộc tính trong cả 2 đối tượng o1 và o2 đều bị thay đổi theo nhau sau mỗi lần thay đổi giá trị của thuộc tính trong đối tượng còn lại. Chi tiết hơn ta thử debug để biết rõ nguyên nhân

Qua các step ta thấy sau khi thực hiện phép gán o2=o1 thì địa chỉ của o2 đã bị thay đổi thành địa chỉ ô nhớ của o1, điều này có nghĩa là khi thay đổi giá trị thuộc tính của 1 trong 2 đối tượng thì giá trị của ô nhớ đó đã bị thay đổi nên thuộc tính của cả 2 đối tượng đều nhận được giá trị mới. Nghe thật ngớ ngẩn tưởng như là 1 lỗi nghiêm trọng nhưng thực ra đây là cơ chế của Java giúp giảm việc cấp phát bộ nhớ mới có thể không cần thiết gây tốn bộ nhớ ảnh hưởng đến chương trình

Bắt đầu cảm thấy đoạn code trên thật nhiều rủi ro khi nó nằm trong ngóc ngách nào đó của 1 hệ thống lớn. Hỳ hục tìm cách khắc phục để giảm thiểu rủi ro. Thật may mắn là Java đã hỗ trợ method Clone cho Object để hạn chế việc này. Mình thử nâng cấp đoạn code trên 1 chút và kiểm tra lại.

Ở đây, để sử dụng được clone object thay cho phép gán thì Object đó phải implements ClonableOverride clone method của Clonable inteface. Tiện thể thêm 1 thuộc tính là đối tượng vào xem có vấn đề gì không.

package net.tunghuynh.shallowcopy;

public class Main {
    public static void main(String[] args) throws Exception{
        Obj o1 = new Obj("123", new Obj("child 123"));
        Obj o2 = new Obj("abc", new Obj("child abc"));
        System.out.println("Init: " + o1 + " | " + o2);
        o2=(Obj)o1.clone();
        System.out.println("After assign: " + o1 + " | " + o2);
        o2.setContent("456");
        o2.getChild().setContent("child 456");
        System.out.println("After set o2: " + o1 + " | " + o2);
        o1.setContent("789");
        o1.getChild().setContent("child 789");
        System.out.println("After set o1: " + o1 + " | " + o2);
    }
}
class Obj implements Cloneable{
    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
    public Obj(String content) {
        this.content=content;
    }
    public Obj(String content, Obj child) {
        this.content = content;
        this.child = child;
    }

    private Obj child;
    private String content;

    public Obj getChild() {
        return child;
    }

    public void setChild(Obj child) {
        this.child = child;
    }

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }

    @Override
    public String toString() {
        return content + ((child!=null)?("->" + child):"");
    }
}

Kết quả có vẻ khả quan với thuộc tính String không bị thay đổi nữa, nhưng lại có vấn đề với đối tượng con vẫn bị thay đổi cùng nhau.

Init: 123->child 123 | abc->child abc
After assign: 123->child 123 | 123->child 123
After set o2: 123->child 456 | 456->child 456
After set o1: 789->child 789 | 456->child 789

Debug để xem chi tiết hơn thì thấy địa chỉ của thuộc tính String content đã độc lập khác nhau, còn thuộc tính đối tượng con thì vẫn bị trỏ vào cùng 1 ô nhớ @501

Quay lại Shallow Copy là gì?
Qua ví dụ trên thì các bạn có thể dễ hình dung ra, Shallow Copy là việc copy một đối tượng hiện tại thành một đối tượng mới nhưng không copy đối tượng con của nó (hoặc giá trị của đối tượng đó) mà chỉ tham chiếu (trỏ đến địa chỉ ô nhớ) đến đối tượng có sẵn.

Như ví dụ trên thì cả 2 đối tượng o1 và o2 đã cùng trỏ đến ô nhớ @491 sau phép gán mặc dù trước đó o1 và o2 đang khác địa chỉ. Hoặc trường hợp đã 2 đối tượng đã trỏ đến 2 địa chỉ khác nhau sau khi clone (shallow copy) nhưng vẫn thuộc tính đối tượng con vẫn bị trỏ về cùng 1 địa chỉ. Thì ở đây có 1 lời giải thích cho việc này là Shallow Copy sẽ chỉ copy các thuộc tính có datatype là primitive (ví dụ int, long, boolean) và ngoài ra copy cả String, Còn các thuộc tính khác là Object thì sẽ vẫn bị tham chiếu đến địa chỉ cũ.

Vậy làm thế nào để giải quyết triệt để vấn đề này và cách tránh lỗi tiềm ẩn do Shallow Copy gây ra?
Đơn giản là bạn chỉ cần áp dụng cách shallow copy ở trên cho tất cả các object con, áp dụng cho mọi cấp object con cho đến khi gặp primitive hoặc String thì thôi. Đây chính là việc tạo ra cơ chế Deep Copy cho đối tượng.
Để hiện thực hóa việc này thì bạn chỉ cần sửa code cho method clone đã Override tương tự như sau

@Override
protected Object clone() throws CloneNotSupportedException {
    Obj objClone = (Obj)super.clone();
    if (this.getChild()!=null) {
        objClone.setChild((Obj) this.getChild().clone());
    }
    return objClone;
}

Kết quả cuối cùng sau khi sửa code đã chuẩn xác hơn nhiều

Init: 123->child 123 | abc->child abc
After assign: 123->child 123 | 123->child 123
After set o2: 123->child 123 | 456->child 456
After set o1: 789->child 789 | 456->child 456

Như vậy ta biết thêm Deep Copycopy toàn bộ giá trị, tất cả các thuộc tính trong đối tượng được copy cũng tham chiếu tới địa chỉ khác. Vì vậy nếu 1 trong 2 đối tượng bị thay đổi giá trị thì cũng không ảnh hưởng đến đối tượng còn lại.

Nhưng Deep Copy không phải lúc nào cũng là tốt, nếu bị lạm dụng quá nhiều vào cả những trường hợp không cần thiết thì sẽ gây tăng hiệu năng xử lý, tốn thêm dung lượng lưu trữ trong Heap vì phải lưu thêm đối tượng mới, hơn nữa lại tốn cả công ngồi code. Việc áp dụng Deep Copy thường phải có tính toán cụ thể cho từng nghiệp vụ để xét có nên dùng hay không, không thì mặc định không nên dùng.

Chỉ nên áp dụng Deep Copy cho nhưng trường hợp thực sự cần thiết để tránh ảnh hưởng hiệu năng hệ thống.

Các lỗi do Shallow Copy gây ra không gặp phải quá nhiều trong lập trình nhưng lại là lỗi mất nhiều thời gian xử lý do đây là lỗi logic, không có exception để dễ dàng tìm ra. Hy vọng qua bài viết này các bạn có thể tránh trước được tiềm ẩn nguy cơ lỗi, hoặc rủi có gặp phải trường hợp đó thì các bạn cũng có chút hình ảnh về Shallow Copy để phán đoán và tìm lỗi nhanh hơn 🙂

Bình luận