Java 中的深複製和淺複製你瞭解嗎?

fuxing.發表於2024-05-21

前言

Java 開發中,物件複製是常有的事,很多人可能搞不清到底是複製了引用還是複製了物件。本文將詳細介紹相關知識,讓你充分理解 Java 複製。


一、物件是如何儲存的?

方法執行過程中,方法體中的資料型別主要分兩種,它們的儲存方式是不同的(如下圖):

  1. 基本資料型別: 直接儲存在棧幀的區域性變數表中;
  2. 引用資料型別: 物件的引用儲存在棧幀的區域性變數表中,而對例項本身及其所有成員變數存放在堆記憶體中。

詳情可見JVM基礎

image.png

二、前置準備

建立兩個實體類方便後續的程式碼示例

@Data
@AllArgsConstructor
public class Animal{
    private int id;
    private String type;

    @Override
    public String toString () {
        return "Animal{" +
                "id=" + id +
                ", type='" + type + '\'' +
                '}';
    }
}

@Data
@AllArgsConstructor
public class Dog {
    private int age;
    private String name;
    private Animal animal;

    @Override
    public String toString () {
        return "Dog{" +
                "age=" + age +
                ", name='" + name + '\'' +
                ", animal=" + animal +
                '}';
    }
}

三、直接賦值

直接賦值是我們最常用的方式,它只是複製了物件引用地址,並沒有在記憶體中生成新的物件

下面我們進行程式碼驗證:

public class FuXing {
    public static void main (String[] args) {
        Animal animal = new Animal(1, "dog");
        Dog dog = new Dog(18, "husky", animal);
        Dog dog2 = dog;
        System.out.println("兩個物件是否相等:" + (dog2 == dog));

        System.out.println("----------------------------");
        dog.setAge(3);
        System.out.println("變化後兩個物件是否相等:" + (dog2 == dog));
    }
}
兩個物件是否相等:true
----------------------------
變化後兩個物件是否相等:true

透過執行結果可知,dog類的age已經發生變化,但重新列印兩個類依然相等。所以它只是複製了物件引用地址,並沒有在記憶體中生成新的物件

直接賦值的 JVM 的記憶體結構大致如下:

image.png

四、淺複製

淺複製後會建立一個新的物件,且新物件的屬性和原物件相同。但是,複製時針對原物件的屬性的資料型別的不同,有兩種不同的情況:

  1. 屬性的資料型別基本型別,複製的就是基本型別的值;
  2. 屬性的資料型別引用型別,複製的就是物件的引用地址,意思就是複製物件與原物件引用同一個物件

要實現物件淺複製還是比較簡單的,只需要被複製的類實現Cloneable介面,重寫clone方法即可。下面我們對Dog進行改動:

@Data
@AllArgsConstructor
public class Dog implements Cloneable{
    private int age;
    private String name;
    private Animal animal;

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

    @Override
    public String toString () {
        return "Dog{" +
                "age=" + age +
                ", name='" + name + '\'' +
                ", animal=" + animal +
                '}';
    }
}

接下來我們執行下面的程式碼,看一下執行結果:

public class FuXing {
    public static void main (String[] args) throws Exception {
        Animal animal = new Animal(1, "dog");
        Dog dog = new Dog(18, "husky", animal);

        // 克隆物件
        Dog cloneDog = dog.clone();

        System.out.println("dog:" + dog);
        System.out.println("cloneDog:" + cloneDog);
        System.out.println("兩個物件是否相等:" + (cloneDog == dog));
        System.out.println("兩個name是否相等:" + (cloneDog.getName() == dog.getName()));
        System.out.println("兩個animal是否相等:" + (cloneDog.getAnimal() == dog.getAnimal()));

        System.out.println("----------------------------------------");

        // 更改原物件的屬性值
        dog.setAge(3);
        dog.setName("corgi");
        dog.getAnimal().setId(2);

        System.out.println("dog:" + dog);
        System.out.println("cloneDog:" + cloneDog);
        System.out.println("兩個物件是否相等:" + (cloneDog == dog));
        System.out.println("兩個name是否相等:" + (cloneDog.getName() == dog.getName()));
        System.out.println("兩個animal是否相等:" + (cloneDog.getAnimal() == dog.getAnimal()));
    }
dog:Dog{age=18, name='husky', animal=Animal{id=1, type='dog'}}
cloneDog:Dog{age=18, name='husky', animal=Animal{id=1, type='dog'}}
兩個物件是否相等:false
兩個name是否相等:true
兩個animal是否相等:true
----------------------------------------
dog:Dog{age=3, name='corgi', animal=Animal{id=2, type='dog'}}
cloneDog:Dog{age=18, name='husky', animal=Animal{id=2, type='dog'}}
兩個物件是否相等:false
兩個name是否相等:false
兩個animal是否相等:true

我們分析下執行結果,重點看一下 “兩個name是否相等”,改動後變成 false.

這是因為StringInteger等包裝類都是不可變的物件,當需要修改不可變物件的值時,需要在記憶體中生成一個新的物件來存放新的值,然後將原來的引用指向新的地址

這裡dog物件的name屬性已經指向一個新的物件,而cloneDogname屬性仍然指向原來的物件,所以就不同了。

然後我們看下兩個物件的animal屬性,原物件屬性值變動後,複製物件也跟著變動,這就是因為複製物件與原物件引用同一個物件

淺複製的 JVM 的記憶體結構大致如下:

image.png

五、深複製

與淺複製不同之處,深複製在對引用資料型別進行複製的時候,建立了一個新的物件,並且複製其成員變數。也就是說,深複製出來的物件,與原物件沒有任何關聯,是一個新的物件。

實現深複製有兩種方式

1. 讓每個引用型別屬性都重寫clone()方法

注意: 這裡如果引用型別的屬性或者層數太多了,程式碼量會變很大,所以一般不建議使用

@Data
@AllArgsConstructor
public class Animal implements Cloneable{
    private int id;
    private String type;

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

    @Override
    public String toString () {
        return "Animal{" +
                "id=" + id +
                ", type='" + type + '\'' +
                '}';
    }
}
@Data
@AllArgsConstructor
public class Dog implements Cloneable{
    private int age;
    private String name;
    private Animal animal;

    @Override
    public Dog clone () throws CloneNotSupportedException {
        Dog clone = (Dog) super.clone();
        clone.animal = animal.clone();
        return clone;
    }

    @Override
    public String toString () {
        return "Dog{" +
                "age=" + age +
                ", name='" + name + '\'' +
                ", animal=" + animal +
                '}';
    }
}

我們再次執行淺複製部分的main方法,結果如下。

dog:Dog{age=18, name='husky', animal=Animal{id=1, type='dog'}}
cloneDog:Dog{age=18, name='husky', animal=Animal{id=1, type='dog'}}
兩個物件是否相等:false
兩個name是否相等:true
兩個animal是否相等:false # 變為false
----------------------------------------
dog:Dog{age=3, name='corgi', animal=Animal{id=2, type='dog'}}
cloneDog:Dog{age=18, name='husky', animal=Animal{id=1, type='dog'}}
兩個物件是否相等:false
兩個name是否相等:false
兩個animal是否相等:false # 變為false

2.序列化

序列化是將物件寫到流中便於傳輸,而反序列化則是把物件從流中讀取出來。我們可以利用物件的序列化產生克隆物件,然後透過反序列化獲取這個物件。

@Data
@AllArgsConstructor
public class Animal implements Serializable {
    private int id;
    private String type;

    @Override
    public String toString () {
        return "Animal{" +
                "id=" + id +
                ", type='" + type + '\'' +
                '}';
    }
}
@Data
@AllArgsConstructor
public class Dog implements Serializable {
    private int age;
    private String name;
    private Animal animal;

    @SneakyThrows
    @Override
    public Dog clone () {
        // 序列化
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(bos);
        oos.writeObject(this);

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

    @Override
    public String toString () {
        return "Dog{" +
                "age=" + age +
                ", name='" + name + '\'' +
                ", animal=" + animal +
                '}';
    }
}

我們再次執行淺複製部分的main方法,結果如下。

dog:Dog{age=18, name='husky', animal=Animal{id=1, type='dog'}}
cloneDog:Dog{age=18, name='husky', animal=Animal{id=1, type='dog'}}
兩個物件是否相等:false
兩個name是否相等:false # 變為false
兩個animal是否相等:false # 變為false
----------------------------------------
dog:Dog{age=3, name='corgi', animal=Animal{id=2, type='dog'}}
cloneDog:Dog{age=18, name='husky', animal=Animal{id=1, type='dog'}}
兩個物件是否相等:false
兩個name是否相等:false
兩個animal是否相等:false # 變為false

深複製的 JVM 的記憶體結構大致如下:

image.png

相關文章