1. 程式人生 > 實用技巧 >java的物件的拷貝和淺拷貝的異同

java的物件的拷貝和淺拷貝的異同

java的物件的拷貝和淺拷貝的異同

說在前面

​ 這幾天在看阿里的開發規範,有一條引起了我的注意,不建議使用Object類中的clone()的方法來進行物件拷貝,為了一探究竟,有了這篇文章,如有不足,歡迎留言交流。

1.Java使用關鍵字new建立物件的過程。

一般的物件拷貝有三種方式,直接賦值,淺拷貝,深拷貝。在說明這三個物件拷貝的方式之前,有必要說明以下 使用new建立物件的過程,

​ 當 虛擬機器遇到一條new指令時,首先將去檢查這個指令的引數是否能在常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已被載入、解析和初始化過。如果沒有,那必須先執行相應的類載入過程,至於類的載入,下一篇再更(在此立個flag).

​ 在類載入檢查通過後,接下來虛擬機器將為新生物件分配記憶體。物件所需記憶體的大小在類載入完成後便可完全確定,為物件分配空間的任務等同於把一塊確定大小的記憶體從Java堆中劃分出來。假設Java堆中記憶體是絕對規整的,所有用過的記憶體都放在一邊,空閒的記憶體放在另一邊,中間放著一個指標作為分界點的指示器,那所分配記憶體就僅僅是把那個指標向空閒空間那邊挪動一段與物件大小相等的距離,這種分配方式稱為“指標碰撞”(Bump the Pointer)。

​ 如果Java堆中的記憶體並不是規整的,已使用的記憶體和空閒的記憶體相互交錯,那就沒有辦法簡單地進行指標碰撞了,虛擬機器就必須維護一個列表,記錄上哪些記憶體塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給物件例項,並更新列表上的記錄,這種分配方式稱為“空閒列表”(Free List)。選擇哪種分配方式是由Java堆是 否規整決定,而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。選擇不同的垃圾收集器,分配記憶體的方式也是不同的.

​ 記憶體分配完成後,虛擬機器需要將分配到的記憶體空間都初始化為零值(不包括物件頭),然後,虛擬機器要對物件進行必要的設定,例如這個物件是哪個類的例項、如何才能找到類的元資料資訊、物件的雜湊碼、物件的GC分代年齡等資訊。這些資訊存放在物件的物件頭(Object Header)之中。根據虛擬機器當前的執行狀態的不同,如是否啟用偏向鎖等,物件頭會有不同的設定方式。在上面工作都完成之後,從虛擬機器的視角來看,一個新的物件已經產生了,但從Java程式的視角來看,物件建立才剛剛開始—方法還沒有執行,所有的欄位都還為零。所以,一般來說由位元組碼中是否跟隨invokespecial指令所決定),執行new指令之後會接著執行方法,把物件按照程式設計師的意願進行初始化,這樣一個真正可用的物件才算完全產生出來。生產的物件會有在棧中有一個引用地址。瞭解了物件的建立過程,對物件拷貝有一個更好的理解.

注:

值型別:Java 的基本資料型別,例如 int、float
引用型別:自定義類和 Java 包裝類(string、integer)

2. 直接賦值

​ 直接賦值,也就是我們平時經常用的物件的賦值,類似於 Person a = new Person() ,Person b = a. 這就是直接賦值。由於java是值傳遞,所以使用這種方式進行賦值時,並沒有生成一個新的物件,而是將物件的記憶體地址引用指向了新的物件,也就是堆中建立的實際物件沒有變,只是多了一個指向。

程式碼示例如下:

 /**
 * 一個使用者的物件
 */
public class Person {
    private String name;
    private int age;

    public Person(){}
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
    
}

public class Main {
    public static void main(String[] args) {
        Person a = new Person("chen",10);
        Person b = a;

        // 改變原物件的值
        b.setAge(20);
        System.out.println("a原型物件的年齡:"+a.getAge());
        System.out.println("b新物件的年齡:"+b.getAge());

        System.out.println("a物件的記憶體地址"+a);
        System.out.println("b物件的記憶體地址:"+b);
    }
}

程式執行結果:

我是原型物件:20
我是新物件:20
a物件的記憶體地址myclone.Person@1b6d3586
b物件的記憶體地址:myclone.Person@1b6d3586

程式執行的結果可以看到物件a,和b操作的是一個物件,當賦值物件改變了一個屬性的值,原物件的值也會隨之改變。看看直接賦值的在記憶體的分配,就會一目瞭然。

總之,直接賦值,並不會產生新的物件,只是多了一個指向。但是有時候我們想產生一個新物件。新物件的值改變不會影響到原型物件。當然也可以同時new兩個物件,然後完成相互賦值,但是當屬性非常多時,這種方式就很麻煩。這個時候我們用可以使用Objext()類中clone()方法來實現一下。

3淺拷貝的實現

3.1 什麼是淺拷貝

如果原型物件的成員變數是值型別,將複製一份給克隆物件,也就是說在堆中擁有獨立的空間;如果原型物件的成員變數是引用型別,則將引用物件的地址複製一份給克隆物件,也就是說原型物件和克隆物件的成員變數指向相同的記憶體地址。換句話說,在淺克隆中,當物件被複制時只複製它本身和其中包含的值型別的成員變數,而引用型別的成員物件並沒有複製.

3.2 實現一個淺拷貝

實現物件的拷貝的類,一般有兩個步驟,

1 實現clonable這個介面,這是個標記介面,java 類似這樣的介面還有很多,比如 序列化的介面Serializable,隨機訪問的介面RandomAccess,都是這型別的介面。這類的介面都沒有具體的方法,作用就是起標記的作用。如果沒有實現這個介面,呼叫clone()方法,就會拋異常CloneNotSupportedException.

2,重寫clone()方法。就可以實現一個淺克隆,為了便於後面的講解,定義一個Children的引用類,並和person類來進行組合。

/**
 * 一個使用者的物件,繼承cloneable,並重寫clone的方法
 */
public class Person implements Cloneable {
    
    
    private String name;
    
    private int age;
    
    private Children children;
    
     /**
     * 重新父類的方法
     * @return
     * @throws CloneNotSupportedException
     */
    @Override
    public Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
    
    .....省略get和set
    
   
// 孩子的類    
 class Children{
    private String name;
    private int age;

    public Children() {
    }

    public Children(String name, int age) {
        this.name = name;
        this.age = age;
    }
     ... 省略get和set方法


public class Main {
    public static void main(String[] args) throws CloneNotSupportedException {

        Children children = new Children("chenChildren",2);

        Person person = new Person("chen",10,children);
        Person person1 = (Person) person.clone();

        // 檢測克隆物件和原型物件的記憶體地址是否相同
        System.out.println("person物件的記憶體地址:"+person);
        System.out.println("person1克隆物件的記憶體地址:"+person1);

        // 取出物件引用屬性children,並確認淺拷貝後,是不是指向了同一個引用.
        Children chenChildren = person.getChildren();
        Children wangChildren = person1.getChildren();
        System.out.println("person孩子的記憶體地址:"+chenChildren);
        System.out.println("person1克隆對像孩子的記憶體地址:"+wangChildren);

        // 改變克隆物件的年齡和姓名
        person1.setAge(20);
        person1.setName("wang");
        System.out.println("person原型物件:"+"年齡"+person.getAge()+"姓名:"+person.getName());
        System.out.println("person1克隆物件:"+"年齡"+person1.getAge()+"姓名:"+person1.getName());


         // 改變person1孩子的姓名,確認person孩子的物件是否會改變
        wangChildren.setName("wangChildren");
        person1.setChildren(children);
        System.out.println("person原型物件的孩子的名字:"+chenChildren.getName());
        System.out.println("person1克隆物件孩子的名字:"+wangChildren.getName());
    }
}


程式執行結果分析

person物件的記憶體地址myclone.Person@1b6d3586
person1克隆物件的記憶體地址:myclone.Person@4554617c

person孩子的記憶體地址myclone.Children@74a14482
person1克隆對像孩子的記憶體地址myclone.Children@74a14482

person原型物件:年齡10姓名:chen
person1克隆物件:年齡20姓名:wang

person原型物件的孩子的名字:wangChildren
person1克隆物件孩子的名字:wangChildren

通過程式執行結果,可以有三個重要的資訊:

1,是淺拷貝後物件地址和原型對像的記憶體地址是不同的,也就是person和person1的地址是不同的。

person物件的記憶體地址myclone.Person@1b6d3586
person1克隆物件的記憶體地址:myclone.Person@4554617c

2 String也是引用地址,為什麼變了克隆物件的姓名後,原型物件沒有改變。不是引用地址指向是相同的嗎,這裡不是矛盾了嗎?

person原型物件:年齡10姓名:chen
person1克隆物件:年齡20姓名:wang

​ 這裡有一個很重要的知識點,那就是String 的不變性的特性,String、Integer 等包裝類都是不可變的物件,當需要修改不可變物件的值時,需要在記憶體中生成一個新的物件來存放新的值,然後將原來的引用指向新的地址,所以在這裡我們修改了 person1 物件的 name 屬性值,person1 物件的 name 欄位指向了記憶體中新的 name 物件,但是我們並沒有改變 person 物件的 name 欄位的指向,所以 person 物件的 name 還是指向記憶體中原來的 name 地址,也就沒有變化

3 淺拷貝後,他們的引用地址指向是相同的,所以修改任意一個,另一物件的屬性引用的值也會改變。

這個實列中,我們修改了person1的孩子wangChlidren的值, person的chenChildren的名字都跟著改變了。說明淺拷貝後,他們指向了同一個屬性引用。

person孩子的記憶體地址myclone.Children@74a14482
person1克隆對像孩子的記憶體地址myclone.Children@74a14482
    
person原型物件的孩子的名字:wangChildren
person1克隆物件孩子的名字:wangChildren

看到這裡,相信很多人都對阿里不建議使用淺拷貝有了自己的理解吧,具體的講就是不安全,如果實現了淺拷貝,兩個物件持有共同的引用的地址,不管誰修改都會影響對方的值。這樣肯定是不可取的。解決辦法就是使用深拷貝,就可以完成物件的完全拷貝。

4.深拷貝的實現

深拷貝,顧名思義,就是不管原型物件是什麼,都會複製一個全新的物件出來,產生的新物件的修改,並不會影響原型物件.

具體的實現方法一般有兩種:

4.1 重寫clone()方法。

適用於一個類中屬性引用少的情況,一般不推薦使用. 在我們的示例的程式碼中, 物件的引用屬性也需要實現cloneable這個介面, 需要將Person中的clone()方法修改一下:

public class Person implements Cloneable {


    private String name;

    private int age;

    private Children children;

    public Person(){}

    /**
     * 重寫父類的方法,並實現深克隆
     * @return
     * @throws CloneNotSupportedException
     */
    @Override
    public Object clone() throws CloneNotSupportedException {
        Person person = (Person) super.clone();
        person.children = (Children) children.clone();
        return person;
    }
   
   
   // 引用實現cloneable這個方法
class  Children implements Cloneable{
    private String name;
    private int age;

    public Children() {
    }

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

執行main函式測試一下,確認兩個物件的屬性引用的修改是否還能相互影響.

public class Main {
    public static void main(String[] args) throws CloneNotSupportedException {

        Children children = new Children("chenChildren",2);

        Person person = new Person("chen",10,children);
        Person person1 = (Person) person.clone();

        // 檢測克隆物件的記憶體地址是否相同
        System.out.println("person物件的記憶體地址:"+person);
        System.out.println("person1克隆物件的記憶體地址:"+person1);

        // 取出各物件的引用children
        Children chenChildren = person.getChildren();
        Children wangChildren = person1.getChildren();
        
        // 確認他們的記憶體地址是否相同
        System.out.println("person孩子的記憶體地址:"+chenChildren);
        System.out.println("person1克隆對像孩子的記憶體地址:"+wangChildren);


        // 修改深拷貝後的引用物件,確認是否影響原型物件person中的children引用的值。
        wangChildren.setName("wangChildren");
        person1.setChildren(children);
        System.out.println("person原型物件的孩子的名字:"+chenChildren.getName());
        System.out.println("person1克隆物件孩子的名字:"+wangChildren.getName());
    }
}

執行結果:

 
person物件的記憶體地址:myclone.Person@1b6d3586
person1克隆物件的記憶體地址:myclone.Person@4554617c

person孩子的記憶體地址:myclone.Children@74a14482
person1克隆對像孩子的記憶體地址:myclone.Children@1540e19d

person原型物件的孩子的名字:chenChildren
person1克隆物件孩子的名字:wangChildren

結果分析如下:

1.克隆後,產生了一個新物件

person物件的記憶體地址:myclone.Person@1b6d3586
person1克隆物件的記憶體地址:myclone.Person@4554617c

2.深拷貝後,引用值也拷貝了一份,且克隆物件引用屬性的改變,並不會影響原型物件屬性的改變.

person孩子的記憶體地址:myclone.Children@74a14482
person1克隆對像孩子的記憶體地址:myclone.Children@1540e19d

person原型物件的孩子的名字:chenChildren
person1克隆物件孩子的名字:wangChildren

4.2.通過序列化實現

將person類和Children都實現序列化介面

/**
 * 一個使用者的物件
 */
public class Person implements Serializable {


    private String name;

    private int age;

    private Children children;

    public Person(){}
    .....省略get和set方法
  
  
 class  Children implements Serializable {
    private String name;
    private int age;

    public Children() {
    }  
    ... 省略get和set方法

重寫一個Main 方法進行測試

/**
 * 測試一下
 */
public class MainApp {
    ObjectInputStream inputStream = null;
    ObjectOutputStream outputStream = null;
    
    public static void main(String[] args){
        // 建立可序列化的物件
     Children children = new Children("chenChildren",2);
      Person person = new Person("chen",10,children);

        Person person1 = new MainApp().getPerson(person, children);
            // 檢測克隆物件的記憶體地址是否相同
        System.out.println("person物件的記憶體地址:"+person);
        System.out.println("person1克隆物件的記憶體地址:"+person1);

            // 拷貝完成和物件原型的children
        Children chenChildren = person.getChildren();
        Children wangChildren = person1.getChildren();
        System.out.println("person孩子的記憶體地址:"+chenChildren);
        System.out.println("person1克隆對像孩子的記憶體地址:"+wangChildren);

        
         // 修改深拷貝後的引用物件,確認是否會影響原型物件的引用。
        wangChildren.setName("wangChildren");
        person1.setChildren(children);
        System.out.println("person原型物件的孩子的名字:"+chenChildren.getName());
        System.out.println("person1克隆物件孩子的名字:"+wangChildren.getName());
   }

    /**
     * 序列化的函式
     * @param person
     * @param children
     * @return
     */
    public  Person getPerson(Person person,Children children) {
        children = new Children("chenChildren", 2);
        person = new Person("chen", 10, children);
        Person person1 = null;
        ByteArrayOutputStream arrayOutputStream = new ByteArrayOutputStream();
        try {
            outputStream = new ObjectOutputStream(arrayOutputStream);

            // 序列化來傳遞這個物件
            outputStream.writeObject(person);
            outputStream.flush();

            ByteArrayInputStream arrayInputStream = new ByteArrayInputStream(arrayOutputStream.toByteArray());
            inputStream = new ObjectInputStream(arrayInputStream);

            person1 = (Person) inputStream.readObject();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } finally {
            if (outputStream != null) {
                try {
                    outputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return person1;
    }
}

執行結果

person物件的記憶體地址:myclone.Person@6acbcfc0
person1克隆物件的記憶體地址:myclone.Person@5f184fc6

person孩子的記憶體地址:myclone.Children@3feba861
person1克隆對像孩子的記憶體地址:myclone.Children@5b480cf9

person原型物件的孩子的名字:chenChildren
person1克隆物件孩子的名字:wangChildren

結果顯而易件,不在贅述

當然上面的這幾個方式都比較複雜,為了理解原理可以這樣,但是工作也是為了效率,還是需要藉助一些工具,比如apache的lang3的工具類。

import org.apache.commons.lang3.SerializationUtils;

public class Main {
    public static void main(String[] args) {
        Children children = new Children("chenChildren",2);
        
        Person person = new Person("chen",10,children);

        // 序列化的具體的實現
        byte[] serial = SerializationUtils.serialize(person);
        Person person1 = SerializationUtils.deserialize(serial);

        // 檢測克隆物件和原型物件的記憶體地址是否相同
        System.out.println("person物件的記憶體地址:"+person);
        System.out.println("person1克隆物件的記憶體地址:"+person1);

        // 將原型物件和克隆物件的屬性children的記憶體地址進行比較 
        Children chenChildren = person.getChildren();
        Children wangChildren = person1.getChildren();
        System.out.println("person孩子的記憶體地址:"+chenChildren);
        System.out.println("person1克隆對像孩子的記憶體地址:"+wangChildren);

        // 修改深拷貝後的引用物件,確認是否影響原型物件的引用。
        wangChildren.setName("wangChildren");
        person1.setChildren(children);
        System.out.println("person原型物件的孩子的名字:"+chenChildren.getName());
        System.out.println("person1克隆物件孩子的名字:"+wangChildren.getName());
       }
}

執行結果

person物件的記憶體地址:cn.chen.xuliehua.Person@24d46ca6
person1克隆物件的記憶體地址:cn.chen.xuliehua.Person@4783da3f

person孩子的記憶體地址:cn.chen.xuliehua.Children@4517d9a3
person1克隆對像孩子的記憶體地址:cn.chen.xuliehua.Children@378fd1ac

person原型物件的孩子的名字:chenChildren
person1克隆物件孩子的名字:wangChildren

5.如何選擇拷貝方式:

5.1 如果物件的屬性全是基本型別的,那麼可以使用淺拷貝。

5.2 如果物件有引用屬性,那就要基於具體的需求來選擇淺拷貝還是深拷貝。

5.3 意思是如果物件引用任何時候都不會被改變,那麼沒必要使用深拷貝,只需要使用淺拷貝就行了。如果物件引用經常改變,那麼就要使用深拷貝。沒有一成不變的規則,一切都取決於具體需求。

參考資料:

https://juejin.im/post/6844903806577164302

https://zhuanlan.zhihu.com/p/95686213

<深入理解Jvm虛擬機器> 周志明.