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

前言

本文代碼中有用到一些註解,主要是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接口,其實都是相當於一個標記,點進去看源碼,其實他們是一個空接口。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章