Java 輕鬆理解深拷貝與淺拷貝

阿dun發表於2021-04-27

前言

本文程式碼中有用到一些註解,主要是Lombok與junit用於簡化程式碼。

主要是看到一堆程式碼會很亂,這樣理解更清晰。如果沒用過不用太過糾結。

物件的拷貝(克隆)是一個非常高頻的操作,主要有以下三種方式:

  • 直接賦值
  • 拷貝:
    • 淺拷貝
    • 深拷貝

因為Java沒有指標的概念,或者說是不需要我們去操心,這讓我們省去了很多麻煩,但相應的,對於物件的引用、拷貝有時候就會有些懵逼,藏下一些很難發現的bug。

為了避免這些bug,理解這三種操作的作用與區別就是關鍵。

直接賦值

用等於號直接賦值是我們平時最常用的一種方式。

它的特點就是直接引用等號右邊的物件

先來看下面的例子

先建立一個Person

@Data
@AllArgsConstructor 
@ToString
public class Person{
    private String name;
    private int age;
    private Person friend;
}

測試

@Test
public void test() {
  Person friend =new Person("老王",30,null);
  Person person1 = new Person("張三", 20, null);
  Person person2 = person1;
  System.out.println("person1: " + person1);
  System.out.println("person2: " + person2 + "\n");
  person1.setName("張四");
  person1.setAge(25);
  person1.setFriend(friend);
  System.out.println("person1: " + person1);
  System.out.println("person2: " + person2);
}

結果

person1: Person(name=張三, age=20, friend=null)
person2: Person(name=張三, age=20, friend=null)

person1: Person(name=張四, age=25, friend=Person(name=老王, age=30, friend=null))
person2: Person(name=張四, age=25, friend=Person(name=老王, age=30, friend=null))

分析:

可以看到通過直接賦值進行拷貝,其實就只是單純的對前物件進行引用。

如果這些物件都是基礎物件當然沒什麼問題,但是如果物件進行操作,相當於兩個物件同屬一個例項

image-20210427101247870

拷貝

直接賦值雖然方便,但是很多時候並不是我們想要的結果,很多時候我們需要的是兩個看似一樣但是完全獨立的兩個物件。

這種時候我們就需要用到一個方法clone()

clone()並不是一個可以直接使用的方法,需要先實現Cloneable介面,然後重寫它才能使用。

protected native Object clone() throws CloneNotSupportedException;

clone()方法被native關鍵字修飾,native關鍵字說明其修飾的方法是一個原生態方法,方法對應的實現不是在當前檔案,而是系統或者其他語言來實現。

淺拷貝

淺拷貝可以實現物件克隆,但是存在一些缺陷。

定義:

  • 如果原型物件的成員變數是值型別,將複製一份給克隆物件,也就是在堆中擁有獨立的空間;
  • 如果原型物件的成員變數是引用型別,則將引用物件的地址複製一份給克隆物件,指向相同的記憶體地址。

舉例

光看定義不太好一下子理解,上程式碼看例子。

我們先來修改一下Person類,實現Cloneable介面,重寫clone()方法,其實很簡單,只需要用super呼叫一下即可

@Data
@AllArgsConstructor
@ToString
public class Person implements Cloneable {
    private String name;
    private int age;
    private Friend friend;
    @Override
    public Object clone() {
        try {
            return super.clone();
        } catch (CloneNotSupportedException e) {
            e.printStackTrace();
        }
        return null;
    }
}

-------
  
@Data
@AllArgsConstructor
public class Friend {
    private String Name;
}

測試

@Test
public void test() {
  Person person1 = new Person("張三", 20, "老王");
  Person person2 = (Person) person1.clone();

  System.out.println("person1: " + person1);
  System.out.println("person2: " + person2 + "\n");
  person1.setName("張四");
  person1.setAge(25);
  person1.setFriend("小王");
  System.out.println("person1: " + person1);
  System.out.println("person2: " + person2);
}

結果

person1: Person(name=張三, age=20, friend=Friend(Name=老王))
person2: Person(name=張三, age=20, friend=Friend(Name=老王))

person1: Person(name=張四, age=25, friend=Friend(Name=小王))
person2: Person(name=張三, age=20, friend=Friend(Name=小王))

可以看到,name age基本物件屬性並沒改變,而friend引用物件熟悉變了。

原理

Java淺拷貝的原理其實是把原物件的各個屬性的地址拷貝給新物件。

注意我說的是各個屬性就算是基礎物件屬性其實也是拷貝的地址

你可能有點暈了,都是拷貝了地址,為什麼修改了 person1 物件的 name age 屬性值,person2 物件的 name age 屬性值沒有改變呢?

我們一步步來,拿name屬性來說明:

  1. String、Integer 等包裝類都是不可變的物件
  2. 當需要修改不可變物件的值時,需要在記憶體中生成一個新的物件來存放新的值
  3. 然後將原來的引用指向新的地址
  4. 我們修改了 person1 物件的 name 屬性值,person1 物件的 name 欄位指向了記憶體中新的 String 物件
  5. 我們並沒有改變 person2 物件的 name 欄位的指向,所以 person2 物件的 name 還是指向記憶體中原來的 String 地址

看圖

image-20210426233911883

這個圖已經很清晰的展示了其中的過程,因為person1 物件改變friend時是改變的引用物件的屬性,並不是新建立了一個物件進行替換,原本老王的消失了,變成了小王。所以person2也跟著改變了。

深拷貝

深拷貝就是我們拷貝的初衷了,無論是值型別還是引用型別都會完完全全的拷貝一份,在記憶體中生成一個新的物件。

拷貝物件和被拷貝物件沒有任何關係,互不影響。

深拷貝相比於淺拷貝速度較慢並且花銷較大。

簡而言之,深拷貝把要複製的物件所引用的物件都複製了一遍。

image-20210427102157299

因為Java本身的特性,對於不可變的基本值型別,無論如何在記憶體中都是隻有一份的。

所以對於不可變的基本值型別,深拷貝跟淺拷貝一樣,不過並不影響什麼。

實現:

想要實現深拷貝並不難,只需要在淺拷貝的基礎上進行一點修改即可。

  • 給friend新增一個clone()方法。
  • Person類的clone()方法呼叫friendclone()方法,將friend也複製一份即可。
@Data
@ToString
public class Person implements Cloneable {
    private String name;
    private int age;
    private Friend friend;

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

    public void setFriend(String friend) {
        this.friend.setName(friend);
    }

    @Override
    public Object clone() {
        try {
            Person person = (Person)super.clone();
            person.friend = (Friend) friend.clone();
            return person;
        } catch (CloneNotSupportedException e) {
            e.printStackTrace();
        }
        return null;
    }
}

------
  
@Data
@AllArgsConstructor
public class Friend implements Cloneable{
    private String Name;

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

測試

@Test
public void test() {
Person person1 = new Person("張三", 20, "老王");
Person person2 = (Person) person1.clone();

System.out.println("person1: " + person1);
System.out.println("person2: " + person2 + "\n");
person1.setName("張四");
person1.setAge(25);
person1.setFriend("小王");
System.out.println("person1: " + person1);
System.out.println("person2: " + person2);
}

結果

person1: Person(name=張三, age=20, friend=Friend(Name=老王))
person2: Person(name=張三, age=20, friend=Friend(Name=老王))

person1: Person(name=張四, age=25, friend=Friend(Name=小王))
person2: Person(name=張三, age=20, friend=Friend(Name=老王))

分析:

可以看到這次是真正的完全獨立了起來。

需要注意的是,如果Friend類本身也存在引用型別,則需要在Friend類中的clone(),也去呼叫其引用型別的clone()方法,就如是Person類中那樣,對!就是套娃!

所以對於存在多層依賴關係的物件,實現Cloneable介面重寫clone()方法就顯得有些笨拙了。

這裡我們在介紹一種方法:利用序列化實現深拷貝

Serializable 實現深拷貝

修改PersonFriend,實現Serializable介面

@Data
@ToString
public class Person implements Serializable {
    // ......同之前
    public Object deepClone() throws Exception {
        // 序列化
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(bos);

        oos.writeObject(this);

        // 反序列化
        ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
        ObjectInputStream ois = new ObjectInputStream(bis);

        return ois.readObject();
    }
}

---
  
@Data
@AllArgsConstructor
public class Friend implements Serializable {
    private String Name;
}

測試

@Test
public void test() {
    Person person1 = new Person("張三", 20, "老王");
    Person person2 = null;
    try {
        person2 = (Person) person1.deepClone();
    } catch (Exception e) {
        e.printStackTrace();
    }

    System.out.println("person1: " + person1);
    System.out.println("person2: " + person2 + "\n");
    person1.setName("張四");
    person1.setAge(25);
    person1.setFriend("小王");
    System.out.println("person1: " + person1);
    System.out.println("person2: " + person2);
}

結果

person1: Person(name=張三, age=20, friend=Friend(Name=老王))
person2: Person(name=張三, age=20, friend=Friend(Name=老王))

person1: Person(name=張四, age=25, friend=Friend(Name=小王))
person2: Person(name=張三, age=20, friend=Friend(Name=老王))

只要將會被複制到的引用物件標記Serializable介面,通過序列化到方式即可實現深拷貝。

原理:

物件被序列化成流後,因為寫在流裡的是物件的一個拷貝,而原物件仍然存在於虛擬機器裡面

通過反序列化就可以獲得一個完全相同的拷貝。

利用這個特性就實現了物件的深拷貝。

總結

  • 直接賦值是將新的物件指向原物件所指向的例項,所以一旦有所修改,兩個物件會一起變。
  • 淺拷貝是把原物件屬性的地址傳給新物件,對於不可變的基礎型別,實現了二者的分離,但對於引用物件,二者還是會一起改變。
  • 深拷貝是真正的完全拷貝,二者沒有關係。實現深拷貝時如果存在多層依賴關係,可以採用序列化的方式來進行實現。

對於Serializable介面、Cloneable介面,其實都是相當於一個標記,點進去看原始碼,其實他們是一個空介面。

相關文章