淺談Java中的淺拷貝和深拷貝

leonsir發表於2019-03-19

寫在前面的


前幾天在複習這塊內容的時候看到了幾篇不錯的部落格,文章讀起來通俗易懂,今天打算把它們整合一下記錄下來。(注:原文連結在此文章的底部)

假如你想複製一個簡單變數,很簡單:

int apples = 5;
int pears = apples; 
複製程式碼

不僅僅是int型別,其它七種基本資料型別(byte,short,long,double,float,char,boolean)同樣適用於該類情況。

但是如果你想複製一個物件,那情況就稍有些複雜了,可能你會這麼寫:

class Student { 
    private int number; 
 
    public int getNumber() { 
        return number; 
    } 
 
    public void setNumber(int number) { 
        this.number = number; 
    } 
    
} 
public class Test { 
     
    public static void main(String args[]) { 
         
        Student stu1 = new Student(); 
        stu1.setNumber(12345); 
        Student stu2 = stu1; 
         
        System.out.println("學生1:" + stu1.getNumber()); 
        System.out.println("學生2:" + stu2.getNumber()); 
    } 
} 
複製程式碼

列印結果:

學生1:12345 
學生2:12345 
複製程式碼

這裡我們定義了一個Student類,該類只有一個number欄位。然後我們new了一個Student例項,然後將該值賦值給stu2例項(Student stu2 = stu1;)。再看列印結果,作為一個新手,感覺物件複製不過如此,難道真的就是這樣的嗎?接下來我們試著改變stu2例項的number欄位,再列印結果看看:

stu2.setNumber(54321); 
 
System.out.println("學生1:" + stu1.getNumber()); 
System.out.println("學生2:" + stu2.getNumber()); 
複製程式碼

列印結果:

學生1:54321 
學生2:54321 
複製程式碼

這就奇怪了,為什麼改變stu2number值,stu1number值也發生了改變呢?

原因出在stu2 = stu1這一句,該句的作用是將stu1的引用賦值給stu2,這樣stu1stu2指向記憶體堆中的同一個物件。如圖:

淺談Java中的淺拷貝和深拷貝
那麼,怎麼能達到複製一個物件呢,對於“複製物件”,我們應該有什麼要特別注意的地方呢?接下來就引出今天討論的主角:物件拷貝。

現在才是概述


  物件拷貝(Object Copy)就是將一個物件的屬性拷貝到另一個有著相同類型別的物件中去。在程式中拷貝物件是很常見的,主要是為了在新的上下文環境中複用物件的部分或全部資料。Java中有三種型別的物件拷貝:淺拷貝(Shallow Copy)、深拷貝(Deep Copy)、延遲拷貝(Lazy Copy)。

1. 淺拷貝

  • 什麼是淺拷貝?

  淺拷貝是按位拷貝物件,它會建立一個新物件,這個物件有著原始物件屬性值的一份精確拷貝。如果屬性是基本型別,拷貝的就是基本型別的值;如果屬性是記憶體地址(引用型別),拷貝的就是記憶體地址 ,因此如果其中一個物件改變了這個地址,就會影響到另一個物件。

淺談Java中的淺拷貝和深拷貝
在上圖中,SourceObject有一個int型別的屬性 "field1"和一個引用型別屬性"refObj"(引用ContainedObject型別的物件)。當對SourceObject做淺拷貝時,建立了CopiedObject,它有一個包含"field1"拷貝值的屬性"field2"以及仍指向refObj本身的引用。由於"field1"是基本型別,所以只是將它的值拷貝給"field2",但是由於"refObj"是一個引用型別, 所以CopiedObject指向"refObj"相同的地址。因此對SourceObject中的"refObj"所做的任何改變都會影響到CopiedObject

  • 如何實現淺拷貝

是否記得萬類之王Object。它有9個方法(getClass(), hashCode(), equals(), clone(), toString(), notify(), notifyAll(), wait(), finalize()),其中一個為clone()方法。 該方法的簽名是:

protected native Object clone() throws CloneNotSupportedException;
複製程式碼

因為每個類直接或間接的父類都是Object,因此它們都含有clone()方法。 要想對一個物件進行復制,就需要對clone()方法覆蓋。

關於clone

clone顧名思義就是複製, 在Java語言中, clone方法被物件呼叫,所以會複製物件。所謂的複製物件,首先要分配一個和源物件同樣大小的空間,在這個空間中建立一個新的物件。那麼在java語言中,有幾種方式可以建立物件呢?

1. 使用new操作符建立一個物件

2. 使用clone方法複製一個物件

  那麼這兩種方式有什麼相同和不同呢? new操作符的本意是分配記憶體。程式執行到new操作符時, 首先去看new操作符後面的型別,因為知道了型別,才能知道要分配多大的記憶體空間。分配完記憶體之後,再呼叫建構函式,填充物件的各個域,這一步叫做物件的初始化,構造方法返回後,一個物件建立完畢,可以把他的引用(地址)釋出到外部,在外部就可以使用這個引用操縱這個物件。(具體細節可參考我的另一篇文章《JVM之物件的建立、記憶體佈局、訪問總結》

  而clone在第一步是和new相似的, 都是分配記憶體,呼叫clone方法時,分配的記憶體和源物件(即呼叫clone方法的物件)相同,然後再使用原物件中對應的各個域,填充新物件的域, 填充完成之後,clone方法返回,一個新的相同的物件被建立,同樣可以把這個新物件的引用釋出到外部。

下面是淺拷貝的一個例子

public class Subject {

   private String name; 

   public Subject(String s) { 
      name = s; 
   } 

   public String getName() { 
      return name; 
   } 

   public void setName(String s) { 
      name = s; 
   } 
}
複製程式碼
public class Student implements Cloneable { 

   // 物件引用 
   private Subject subj; 

   private String name; 

   public Student(String s, String sub) { 
      name = s; 
      subj = new Subject(sub); 
   } 

   public Subject getSubj() { 
      return subj; 
   } 

   public String getName() { 
      return name; 
   } 

   public void setName(String s) { 
      name = s; 
   } 

   /** 
    *  重寫clone()方法 
    * @return 
    */ 
   public Object clone() { 
      //淺拷貝 
      try { 
         // 直接呼叫父類的clone()方法
         return super.clone(); 
      } catch (CloneNotSupportedException e) { 
         return null; 
      } 
   } 
}
複製程式碼
public class CopyTest {

    public static void main(String[] args) {
        // 原始物件
        Student stud = new Student("John", "Algebra");
        System.out.println("Original Object: " + stud.getName() + " - " + stud.getSubj().getName());

        // 拷貝物件
        Student clonedStud = (Student) stud.clone();
        System.out.println("Cloned Object: " + clonedStud.getName() + " - " + clonedStud.getSubj().getName());

        // 原始物件和拷貝物件是否一樣:
        System.out.println("Is Original Object the same with Cloned Object: " + (stud == clonedStud));
        // 原始物件和拷貝物件的name屬性是否一樣
        System.out.println("Is Original Object's field name the same with Cloned Object: " + (stud.getName() == clonedStud.getName()));
        // 原始物件和拷貝物件的subj屬性是否一樣
        System.out.println("Is Original Object's field subj the same with Cloned Object: " + (stud.getSubj() == clonedStud.getSubj()));

        stud.setName("Dan");
        stud.getSubj().setName("Physics");

        System.out.println("Original Object after it is updated: " + stud.getName() + " - " + stud.getSubj().getName());
        System.out.println("Cloned Object after updating original object: " + clonedStud.getName() + " - " + clonedStud.getSubj().getName());
    }
}

複製程式碼

​ 輸出結果如下:

Original Object: John - Algebra
Cloned Object: John - Algebra
Is Original Object the same with Cloned Object: false
Is Original Object's field name the same with Cloned Object: true
Is Original Object's field subj the same with Cloned Object: true
Original Object after it is updated: Dan - Physics
Cloned Object after updating original object: John - Physics
複製程式碼

在這個例子中,我讓要拷貝的類Student實現了Clonable介面並重寫Object類的clone()方法,然後在方法內部呼叫super.clone()方法。從輸出結果中我們可以看到,對原始物件stud"name"屬性所做的改變並沒有影響到拷貝物件clonedStud,但是對引用物件subj"name"屬性所做的改變影響到了拷貝物件clonedStud

2. 深拷貝

  • 什麼是深拷貝

  深拷貝會拷貝所有的屬性,並拷貝屬性指向的動態分配的記憶體。當物件和它所引用的物件一起拷貝時即發生深拷貝。深拷貝相比於淺拷貝速度較慢並且花銷較大。

淺談Java中的淺拷貝和深拷貝
在上圖中,SourceObject有一個int型別的屬性 "field1"和一個引用型別屬性"refObj1"(引用ContainedObject型別的物件)。當對SourceObject做深拷貝時,建立了CopiedObject,它有一個包含"field1"拷貝值的屬性"field2"以及包含"refObj1"拷貝值的引用型別屬性"refObj2" 。因此對SourceObject中的"refObj"所做的任何改變都不會影響到CopiedObject

  • 如何實現深拷貝

下面是實現深拷貝的一個例子。只是在淺拷貝的例子上做了一點小改動,SubjectCopyTest 類都沒有變化。

public class Student implements Cloneable { 
   // 物件引用 
   private Subject subj; 

   private String name; 

   public Student(String s, String sub) { 
      name = s; 
      subj = new Subject(sub); 
   } 

   public Subject getSubj() { 
      return subj; 
   } 

   public String getName() { 
      return name; 
   } 

   public void setName(String s) { 
      name = s; 
   } 

   /** 
    * 重寫clone()方法 
    * 
    * @return 
    */ 
   public Object clone() { 
      // 深拷貝,建立拷貝類的一個新物件,這樣就和原始物件相互獨立
      Student s = new Student(name, subj.getName()); 
      return s; 
   } 
}
複製程式碼

輸出結果如下:

Original Object: John - Algebra
Cloned Object: John - Algebra
Is Original Object the same with Cloned Object: false
Is Original Object's field name the same with Cloned Object: true
Is Original Object's field subj the same with Cloned Object: false
Original Object after it is updated: Dan - Physics
Cloned Object after updating original object: John - Algebra
複製程式碼

很容易發現clone()方法中的一點變化。因為它是深拷貝,所以你需要建立拷貝類的一個物件。因為在Student類中有物件引用,所以需要在Student類中實現Cloneable介面並且重寫clone方法。

3. 通過序列化實現深拷貝

也可以通過序列化來實現深拷貝。序列化是幹什麼的?它將整個物件圖寫入到一個持久化儲存檔案中並且當需要的時候把它讀取回來, 這意味著當你需要把它讀取回來時你需要整個物件圖的一個拷貝。這就是當你深拷貝一個物件時真正需要的東西。請注意,當你通過序列化進行深拷貝時,必須確保物件圖中所有類都是可序列化的。

public class ColoredCircle implements Serializable { 

   private int x; 
   private int y; 

   public ColoredCircle(int x, int y) { 
      this.x = x; 
      this.y = y; 
   } 

   public int getX() { 
      return x; 
   } 

   public void setX(int x) { 
      this.x = x; 
   } 

   public int getY() { 
      return y; 
   } 

   public void setY(int y) { 
      this.y = y; 
   } 

   @Override 
   public String toString() { 
      return "x=" + x + ", y=" + y; 
   } 
}
複製程式碼
public class DeepCopy {

   public static void main(String[] args) throws IOException { 
      ObjectOutputStream oos = null; 
      ObjectInputStream ois = null; 

      try { 
         // 建立原始的可序列化物件 
         ColoredCircle c1 = new ColoredCircle(100, 100); 
         System.out.println("Original = " + c1); 

         ColoredCircle c2 = null; 

         // 通過序列化實現深拷貝 
         ByteArrayOutputStream bos = new ByteArrayOutputStream(); 
         oos = new ObjectOutputStream(bos); 
         // 序列化以及傳遞這個物件 
         oos.writeObject(c1); 
         oos.flush(); 
         ByteArrayInputStream bin = new        ByteArrayInputStream(bos.toByteArray()); 
         ois = new ObjectInputStream(bin); 
         // 返回新的物件 
         c2 = (ColoredCircle) ois.readObject(); 

         // 校驗內容是否相同 
         System.out.println("Copied   = " + c2); 
         // 改變原始物件的內容 
         c1.setX(200); 
         c1.setY(200); 
         // 檢視每一個現在的內容 
         System.out.println("Original = " + c1); 
         System.out.println("Copied   = " + c2); 
      } catch (Exception e) { 
         System.out.println("Exception in main = " + e); 
      } finally { 
         oos.close(); 
         ois.close(); 
      } 
   } 
}

複製程式碼

​ 輸出結果如下:

Original = x=100, y=100
Copied   = x=100, y=100
Original = x=200, y=200
Copied   = x=100, y=100
複製程式碼

這裡,你只需要做以下幾件事兒:

  • 確保物件圖中的所有類都是可序列化的
  • 建立輸入輸出流
  • 使用這個輸入輸出流來建立物件輸入和物件輸出流
  • 將你想要拷貝的物件傳遞給物件輸出流
  • 從物件輸入流中讀取新的物件並且轉換回你所傳送的物件的類

在這個例子中,我建立了一個ColoredCircle物件c1然後將它序列化(將它寫到ByteArrayOutputStream中). 然後我反序列化這個序列化後的物件並將它儲存到c2中。隨後我修改了原始物件c1。然後結果如你所見,c1不同於c2,對c1所做的任何修改都不會影響c2。

注意: 序列化這種方式有其自身的限制和問題:

  1. 因為無法序列化transient變數, 使用這種方法將無法拷貝transient變數。

  2. 再就是效能問題。建立一個socket, 序列化一個物件, 通過socket傳輸它,然後反序列化它,這個過程與呼叫已有物件的方法相比是很慢的。所以在效能上會有天壤之別。如果效能對你的程式碼來說是至關重要的,建議不要使用這種方式。它比通過實現Clonable介面這種方式來進行深拷貝幾乎多花100倍的時間。

4. 延遲拷貝

  延遲拷貝是淺拷貝和深拷貝的一個組合,實際上很少會使用。 當最開始拷貝一個物件時,會使用速度較快的淺拷貝,還會使用一個計數器來記錄有多少物件共享這個資料。當程式想要修改原始的物件時,它會決定資料是否被共享(通過檢查計數器)並根據需要進行深拷貝。

  延遲拷貝從外面看起來就是深拷貝,但是隻要有可能它就會利用淺拷貝的速度。當原始物件中的引用不經常改變的時候可以使用延遲拷貝。由於存在計數器,效率下降很高,但只是常量級的開銷。而且, 在某些情況下, 迴圈引用會導致一些問題。

5. 如何選擇

  如果物件的屬性全是基本型別的,那麼可以使用淺拷貝,但是如果物件有引用屬性,那就要基於具體的需求來選擇淺拷貝還是深拷貝。我的意思是如果物件引用任何時候都不會被改變,那麼沒必要使用深拷貝,只需要使用淺拷貝就行了。如果物件引用經常改變,那麼就要使用深拷貝。沒有一成不變的規則,一切都取決於具體需求。

原文出處


相關文章