沒看這篇文章之前,我以為真的懂深克隆和淺克隆。

跟著Mic學架構發表於2021-10-31

面試題:深克隆和淺克隆的實現方式

面試官考察點

考察目的: 深克隆和淺克隆,考察的是Java基礎知識的理解。

考察人群: 2到5年開發經驗。

背景知識詳解

先了解下淺克隆和深克隆的定義:

  1. 淺克隆:被複制物件的所有變數都含有與原來的物件相同的值,而所有的對其他物件的引用仍然指向原來的物件。
  2. 深克隆:除去那些引用其他物件的變數,被複制物件的所有變數都含有與原來的物件相同的值。那些引用其他物件的變數將指向被複制過的新物件,而不再是原有的那些被引用的物件。換言之,深複製把要複製的物件所引用的物件都複製了一遍。

如何實現克隆

我麼先不管深克隆、還是淺克隆。首先,要先了解如何實現克隆,實現克隆需要滿足以下三個步驟

  1. 物件的類實現Cloneable介面;
  2. 覆蓋Object類的clone()方法(覆蓋clone()方法,訪問修飾符設為public,預設是protected,但是如果所有類都在同一個包下protected是可以訪問的);
  3. 在clone()方法中呼叫super.clone();

實現一個克隆

先定義一個score類,表示分數資訊。

public class Score {
    private String category;
    private double fraction;

    public Score() {
    }

    public Score(String category, double fraction) {
        this.category = category;
        this.fraction = fraction;
    }

    //getter/setter省略

    @Override
    public String toString() {
        return "Score{" +
                "category='" + category + '\'' +
                ", fraction=" + fraction +
                '}';
    }
}

定義一個Person,其中包含Score屬性,來表示這個人的考試分數。

需要注意,Person類是實現了Cloneable介面的,並且重寫了clone()這個方法。

public class Person implements Cloneable{
    private String name;
    private int age;
    private List<Score> score;

    public Person() {
    }
    

    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
}

克隆程式碼測試,程式碼邏輯不復雜,就是初始化一個物件mic,然後基於mic使用clone方法克隆出一個物件dylan

接著通過修改被克隆物件mic的成員屬性,列印出這兩個物件的狀態資訊。

public class CloneMain {

    public static void main(String[] args) throws CloneNotSupportedException {
        Person mic=new Person();
        Score s1=new Score();
        s1.setCategory("語文");
        s1.setFraction(90);
        Score s2=new Score();
        s2.setCategory("數學");
        s2.setFraction(100);
        mic.setAge(18);
        mic.setName("Mic");
        mic.setScore(Arrays.asList(s1,s2));
        System.out.println("person物件初始化狀態:"+mic);

        Person dylan=(Person)mic.clone(); //克隆一個物件
        System.out.println("列印克隆物件:dylan:"+dylan);
        mic.setAge(20);
        mic.getScore().get(0).setFraction(70); //修改mic語文分數為70
        System.out.println("列印mic:"+mic);
        System.out.println("列印dylan:"+dylan);
    }
}

執行結果如下:

person物件初始化狀態:Person{name='Mic', age=18, score=[Score{category='語文', fraction=90.0}, Score{category='數學', fraction=100.0}]}
列印克隆物件:dylan:Person{name='Mic', age=18, score=[Score{category='語文', fraction=90.0}, Score{category='數學', fraction=100.0}]}
列印mic:Person{name='Mic', age=20, score=[Score{category='語文', fraction=70.0}, Score{category='數學', fraction=100.0}]}
列印dylan:Person{name='Mic', age=18, score=[Score{category='語文', fraction=70.0}, Score{category='數學', fraction=100.0}]}

從結果中可以發現:

  1. 修改mic物件本身的普通屬性age,發現該屬性的修改隻影響到mic物件本身的例項。
  2. 當修改mic物件的語文成績時,dylan物件的語文成績也發生了變化。

為什麼會導致這個現象?回過頭看一下淺克隆的定義:

淺克隆:建立一個新物件,新物件的屬性和原來物件完全相同,對於非基本型別屬性,仍指向原有屬性所指向的物件的記憶體地址

需要特別強調非基本型別,對於非基本型別,傳遞的是值,所以新的dylan物件會對該屬性建立一個副本。同樣,對於final修飾的屬性,由於它的不可變性,在淺克隆時,也會在記憶體中建立副本。

如圖所示,dylan物件從mic物件克隆過來後,dylan物件的記憶體地址指向的是同一個。因此當mic這個物件中的屬性發生變化時,dylan物件的屬性也會發生變化。

image-20211030175125316

clone方法的原始碼分析

經過上述案例演示可以發現,如果物件實現Cloneable並重寫clone方法不進行任何操作時,呼叫clone是進行的淺克隆,那clone方法是如何實現的呢?它預設情況下做了什麼?

clone方法是Object中預設提供的,它的原始碼定義如下

protected native Object clone() throws CloneNotSupportedException;

從原始碼中我們可以看到幾個關鍵點:

1.clone方法是native方法,native方法的效率遠高於非native方法,因此如果我們需要拷貝一個物件,建議使用clone,而不是new。

2.該方法被protected修飾。這就意味著想要使用,則必須重寫該方法,並且設定成public。

3.返回值是一個Object物件,因此通過clone方法克隆一個物件,需要強制轉換。

4.如果在沒有實現Cloneable介面的例項上呼叫Object的clone()方法,則會導致丟擲CloneNotSupporteddException;

再來看一下Object.clone方法上的註釋,註釋的內容有點長。

    /**
     * Creates and returns a copy of this object.  The precise meaning
     * of "copy" may depend on the class of the object. The general
     * intent is that, for any object {@code x}, the expression:
     * <blockquote>
     * <pre>
     * x.clone() != x</pre></blockquote>
     * will be true, and that the expression:
     * <blockquote>
     * <pre>
     * x.clone().getClass() == x.getClass()</pre></blockquote>
     * will be {@code true}, but these are not absolute requirements.
     * While it is typically the case that:
     * <blockquote>
     * <pre>
     * x.clone().equals(x)</pre></blockquote>
     * will be {@code true}, this is not an absolute requirement.
     * <p>
     * By convention, the returned object should be obtained by calling
     * {@code super.clone}.  If a class and all of its superclasses (except
     * {@code Object}) obey this convention, it will be the case that
     * {@code x.clone().getClass() == x.getClass()}.
     * <p>
     * By convention, the object returned by this method should be independent
     * of this object (which is being cloned).  To achieve this independence,
     * it may be necessary to modify one or more fields of the object returned
     * by {@code super.clone} before returning it.  Typically, this means
     * copying any mutable objects that comprise the internal "deep structure"
     * of the object being cloned and replacing the references to these
     * objects with references to the copies.  If a class contains only
     * primitive fields or references to immutable objects, then it is usually
     * the case that no fields in the object returned by {@code super.clone}
     * need to be modified.
     * <p>
     * The method {@code clone} for class {@code Object} performs a
     * specific cloning operation. First, if the class of this object does
     * not implement the interface {@code Cloneable}, then a
     * {@code CloneNotSupportedException} is thrown. Note that all arrays
     * are considered to implement the interface {@code Cloneable} and that
     * the return type of the {@code clone} method of an array type {@code T[]}
     * is {@code T[]} where T is any reference or primitive type.
     * Otherwise, this method creates a new instance of the class of this
     * object and initializes all its fields with exactly the contents of
     * the corresponding fields of this object, as if by assignment; the
     * contents of the fields are not themselves cloned. Thus, this method
     * performs a "shallow copy" of this object, not a "deep copy" operation.
     * <p>
     * The class {@code Object} does not itself implement the interface
     * {@code Cloneable}, so calling the {@code clone} method on an object
     * whose class is {@code Object} will result in throwing an
     * exception at run time.
     *
     * @return     a clone of this instance.
     * @throws  CloneNotSupportedException  if the object's class does not
     *               support the {@code Cloneable} interface. Subclasses
     *               that override the {@code clone} method can also
     *               throw this exception to indicate that an instance cannot
     *               be cloned.
     * @see java.lang.Cloneable
     */
    protected native Object clone() throws CloneNotSupportedException;

上述方法中的註釋描述中,對於clone方法關於複製描述,提出了三個規則,也就是說,”複製“的確切定義取決於物件本身,它可以滿足以下任意一條規則:

  • 對於所有物件,x.clone () !=x 應當返回 true,因為克隆物件與原物件不是同一個物件。
  • 對於所有物件,x.clone ().getClass () == x.getClass () 應當返回 true,因為克隆物件與原物件的型別是一樣的。
  • 對於所有物件,x.clone ().equals (x) 應當返回 true,因為使用 equals 比較時,它們的值都是相同的。

因此,從clone方法的原始碼中可以得到一個結論,clone方法是深克隆還是淺克隆,取決於實現克隆方法物件的本身實現。

深克隆

理解了淺克隆,我們就不難猜測到,所謂深克隆的本質,應該是如下圖所示。

image-20211030181709156

dylan這個物件例項從mic物件克隆之後,應該要分配一塊新的記憶體地址,從而實現在記憶體地址上的隔離。

深拷貝實現的是對所有可變(沒有被final修飾的引用變數)引用型別的成員變數都開闢獨立的記憶體空間,使得拷貝物件和被拷貝物件之間彼此獨立,因此一般深拷貝對於淺拷貝來說是比較耗費時間和記憶體開銷的。

深克隆實現

修改Person類中的clone()方法,程式碼如下。

@Override
protected Object clone() throws CloneNotSupportedException {
  Person p=(Person)super.clone(); //可以直接使用clone方法克隆,因為String型別中的屬性是final修飾,而int是基本型別,都會建立副本
  if(this.score!=null&&this.score.size()>0){ //如果score不為空時,才做深度克隆
    //由於`score`是引用型別,所以需要重新分配記憶體空間
    List<Score> ls=new ArrayList<>();
    this.score.stream().forEach(score->{
      Score s=new Score();
      s.setFraction(score.getFraction());
      s.setCategory(score.getCategory());
      ls.add(s);
    });
    p.setScore(ls);
  }
  return p;
}

再次執行,執行結果如下

person物件初始化狀態:Person{name='Mic', age=18, score=[Score{category='語文', fraction=90.0}, Score{category='數學', fraction=100.0}]}
列印克隆物件:dylan:Person{name='Mic', age=18, score=[Score{category='語文', fraction=90.0}, Score{category='數學', fraction=100.0}]}
列印mic:Person{name='Mic', age=20, score=[Score{category='語文', fraction=70.0}, Score{category='數學', fraction=100.0}]}
列印dylan:Person{name='Mic', age=18, score=[Score{category='語文', fraction=90.0}, Score{category='數學', fraction=100.0}]}

Process finished with exit code 0

從結果可以看到,這兩個物件之間並沒有相互影響,因為我們在clone方法中,對於Person這個類的成員屬性Score使用new建立了一個新的物件,這樣就使得兩個物件分別指向不同的記憶體地址。

建立一個新物件,屬性中引用的其他物件也會被克隆,不再指向原有物件地址。總之深淺克隆都會在堆中新分配一塊區域,區別在於物件屬性引用的物件是否需要進行克隆(遞迴性的)

深克隆的其他實現方式

深克隆的實現方式很多,總的來說有以下幾種:

  • 所有物件都實現克隆方法。
  • 通過構造方法實現深克隆。
  • 使用 JDK 自帶的位元組流。
  • 使用第三方工具實現,比如:Apache Commons Lang。
  • 使用 JSON 工具類實現,比如:Gson,FastJSON 等等。

其實,深克隆既然是在記憶體中建立新的物件,那麼任何能夠建立新例項物件的方式都能完成這個動作,因此不侷限於這些方法。

所有物件都實現克隆方法

由於淺克隆本質上是因為引用物件指向同一塊記憶體地址,如果每個物件都實現克隆方法,意味著每個物件的最基本單位是基本資料型別或者封裝型別,而這些型別在克隆時會建立副本,從而避免了指向同一塊記憶體地址的問題。

修改程式碼如下。

public class Person implements Cloneable {
    private String name;
    private int age;
    private List<Score> score;

    public Person() {
    }

    @Override
    protected Object clone() throws CloneNotSupportedException {
        Person p=(Person)super.clone();
        if(this.score!=null&&this.score.size()>0){ //如果score不為空時,才做深度克隆
            //由於`score`是引用型別,所以需要重新分配記憶體空間
            List<Score> ls=new ArrayList<>();
            this.score.stream().forEach(score->{
                try {
                    ls.add((Score)score.clone()); //這裡用了克隆方法
                } catch (CloneNotSupportedException e) {
                    e.printStackTrace();
                }
            });
            p.setScore(ls);
        }
        return p;
    }
}

修改Score物件

public class Score implements Cloneable {
    private String category;
    private double fraction;

    public Score() {
    }

    public Score(String category, double fraction) {
        this.category = category;
        this.fraction = fraction;
    }

    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
}
Person dylan=(Person)mic.clone(); //克隆一個物件

執行結果如下

person物件初始化狀態:Person{name='Mic', age=18, score=[Score{category='語文', fraction=90.0}, Score{category='數學', fraction=100.0}]}
列印克隆物件:dylan:Person{name='Mic', age=18, score=[Score{category='語文', fraction=90.0}, Score{category='數學', fraction=100.0}]}
列印mic:Person{name='Mic', age=20, score=[Score{category='語文', fraction=70.0}, Score{category='數學', fraction=100.0}]}
列印dylan:Person{name='Mic', age=18, score=[Score{category='語文', fraction=90.0}, Score{category='數學', fraction=100.0}]}

通過構造方法實現深克隆。

構造方法實現深克隆,其實是我們經常使用的方法,就是使用new關鍵字來例項化一個新的物件,然後通過構造引數傳值來實現資料拷貝。

public class Person implements Cloneable {
    private String name;
    private int age;
    private List<Score> score;

    public Person() {
    }

    public Person(String name, int age, List<Score> score) {
        this.name = name;
        this.age = age;
        this.score = score;
    }
}

克隆的時候,我們這麼做

 Person dylan=new Person(mic.getName(),mic.getAge(),mic.getScore()); //克隆一個物件

基於ObjectStream實現深克隆

在Java中,物件流也可以實現深克隆,大家可能對物件流這個名詞有點陌生,它的定義如下:

  • ObjectOutputStream, 物件輸出流,把一個物件轉換為二進位制格式資料
  • ObjectInputStream,物件輸入流,把一個二進位制資料轉換為物件。

這兩個物件,在Java中通常用來實現物件的序列化。

建立一個工具類,使用ObjectStream來實現物件的克隆,程式碼實現邏輯不難:

  1. 使用ObjectOutputStream,把一個物件轉換為資料流儲存到物件ByteArrayOutputStream中。
  2. 再從記憶體中讀取該資料流,使用ObjectInputStream,把該資料流轉換為目標物件。
public class ObjectStreamClone {

    public static <T extends Serializable> T clone(T t){
        T cloneObj = null;
        try {
            // bo,儲存物件輸出流,寫入到記憶體
            ByteArrayOutputStream bo = new ByteArrayOutputStream();
            //物件輸出流,把物件轉換為資料流
            ObjectOutputStream oos = new ObjectOutputStream(bo);
            oos.writeObject(t);
            oos.close();
            // 分配記憶體,寫入原始物件,生成新物件
            ByteArrayInputStream bi = new ByteArrayInputStream(bo.toByteArray());
            ObjectInputStream oi = new ObjectInputStream(bi);
            // 返回生成的新物件
            cloneObj = (T) oi.readObject();
            oi.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return cloneObj;
    }

}

Person物件和Score物件均需要實現Serializable介面,

public class Person implements Serializable {
}
public class Score implements Serializable {}

修改測試類的克隆方法.

 Person dylan=(Person)ObjectStreamClone.clone(mic); //克隆一個物件

執行結果如下:

person物件初始化狀態:Person{name='Mic', age=18, score=[Score{category='語文', fraction=90.0}, Score{category='數學', fraction=100.0}]}
列印克隆物件:dylan:Person{name='Mic', age=18, score=[Score{category='語文', fraction=90.0}, Score{category='數學', fraction=100.0}]}
列印mic:Person{name='Mic', age=20, score=[Score{category='語文', fraction=70.0}, Score{category='數學', fraction=100.0}]}
列印dylan:Person{name='Mic', age=18, score=[Score{category='語文', fraction=90.0}, Score{category='數學', fraction=100.0}]}

通過物件流能夠實現深克隆,其根本原因還是在於物件的序列化之後,已經脫離了JVM記憶體物件的範疇,畢竟一個物件序列化之後,是可以通過檔案、或者網路跨JVM傳輸的,因此物件在反序列化時,必然需要基於該資料流重新反射生成新的物件。

問題解答

問題:深克隆和淺克隆的實現方式

回答:

  1. 淺克隆是指被複制物件中屬於引用型別的成員變數的記憶體地址和被克隆物件的記憶體地址相同,也就是克隆物件只實現了對被克隆物件基本型別的副本克隆。

    淺克隆的實現方式,可以實現Cloneable介面,並重寫clone方法,即可完成淺克隆。

    淺克隆的好處是,避免了引用物件的記憶體分配和回收,提高物件的複製效率。

  2. 深克隆時指實現對於基本型別和引用型別的完整克隆,克隆物件和被克隆物件中的引用物件的記憶體地址完全隔離。

    深克隆的實現方式:

    • 基於Cloneable介面重寫clone方法,但是我們需要在clone方法中,針對應用型別的成員變數,使用new關鍵字分配獨立的記憶體空間。
    • 基於Java中物件流的方式實現
    • 基於構造方法實現深度克隆
    • 被克隆的物件中所有涉及到引用型別變數的物件,全部實現克隆方法,並且在被克隆物件的clone方法中,需要呼叫所有成員物件的clone方法實現物件克隆

問題總結

深克隆的本質,其實是保證被克隆物件中所有應用物件以及引用所巢狀的引用物件,全部分配一塊獨立的記憶體空間,避免克隆物件和被克隆物件指向同一塊記憶體地址,造成資料錯誤等問題。

所以,深克隆,表示物件拷貝的深度,因為在Java中物件的巢狀是非常常見的。理解了這個知識點,才能避免在開發過程中遇到一些奇奇怪怪的問題。

關注[跟著Mic學架構]公眾號,獲取更多精品原創

相關文章