Java中的深複製和淺複製

複製和粘貼

約在7萬多年前,我們的智人祖先經歷了一場所謂的”認知革命”。這場革命就像是一把鑰匙,打開了潘多拉的魔盒,人類的對於虛構世界的腦洞從此一開不可收拾。同人類其他衆多的幻想一樣,對人事物的“複製“的這一虛構臆想,推進了文明的演進,直接或間接地催促了藝術這種文化形態的繁榮。

而現今,隨着各種終端的普及,”複製“這個詞也隨着互聯網一起傳播出去。無論是你每天在電腦裏使用ctrl+cctrl+v快捷鍵,還是各種網站對數字資源的二次分發,都屬於“複製”這一範疇。而這一切的基礎,無外乎計算機對信息載體的編碼和解碼,然後就被電信號傳播。

你會不會和我一樣,忍不住地要去幻想,若未來人類複雜的思想也能被編碼成一串串字節碼,那時候的世界又將會是怎樣呢?
然而正文內容和這個引子並沒太大的關係

JVM在等號賦值的時候都幹了些什麼?

定義一個Parent類和Child


     private class Parent {

        public Parent() {

        }

        protected void test() {
           // do sth ...
        }

        static {
            // do sth ...
        }

    }

    private class Child extends Parent {

        public Child() {
            // do sth ...
        }

        @Override
        protected void test() {
            super.test();
            // do sth ...
        }

        static {
            // do sth ,,,
        }

    }

靜被變量和常量先行

在類在容器初始化時,JVM會按照順序自上而下運行類中的靜態語句/塊或常量,如果有父類,則首先按照順序運行靜態語句/塊或常量。初始化類的行爲有且僅有一次。

這一過程中,JVM會在堆內存中創建一個Class對象的實例,指向我們初始化後的這個類。這個也被稱作爲方法區。
此時並沒有實例化該對象。

在堆內存創建實例


    public static void main(String args[]) {
        Child child = new Child();
    }

main(String args[])標誌着這是一個主方法入口

main方法中,類又會按照這個順序執行全局變量的賦值,然後執行父類的無參構造函數和子類的構造函數。

在棧幀中,JVM會提前分配內存地址用以儲存方法參數與局部變量。在這個例子中,儲存的是args(如果有的話),和child在堆上的引用。
child對象會在堆內存中被實例化,其中包含它(及它父類)的成員變量(名稱和具體值或指針)和方法(名稱和具體實現)的索引。
靜態成員變量會保存一個引用地址

入棧和出棧


   public static void main(String args[]) {
        Child child = new Child();
        child.test();
    }

執行test()方法時,會執行父類的同名方法,再執行子類的邏輯。
因爲此方法執行了super.test(),而不是如隱形調用

而在內存操作裏,此時會有一個新的棧幀被壓入棧中,同樣的,該棧幀保存了方法中傳入的參數和局部變量。

由於該方法被其他方法調用(這裏是main()方法),棧幀中還有一個區域會保存main()方法的返回地址,這個區域被稱作VM元數據區。在test()方法結束時,它將被推出棧。並且根據元數據區的返回地址,正確地跳回到main()方法中。
在拋出異常時,可以看到一層層的Stack Trace

而如果該方法有一個返回值,這個又該如何傳遞給調用方呢?


    private class Parent {

        ...

        protected String test() {
           return "EvinK " + "is Awesome!";
        }

        ...
    }

    private class Child extends Parent {

        ...

        @Override
        protected String test() {
            String str = super.test();
            return str;
        }

        ...

    }

操作數棧在這個步驟中,發揮了重要的作用。它屬於棧幀的一個組成部分,JVM臨時用它來存放需要計算的變量,然後將計算的結果推出到棧幀的局部變量區。

區域/棧幀 return語句 super.test() str = super.test() return語句
局部變量區 str = “EvinK is Awesome!”
操作數棧 EvinK EvinK is Awesome! 指向局部變量str
- is Awesome!

使用等號複製時,發生了什麼


    private class Child extends Parent {

        public String name;

        public Child(String name) {
            this.name = name;
        }

        ...
    }

    public static void main(String args[]) {
        Child child = new Child("小明");
        Child child2 = child;
    }

前面已經說了,使用new關鍵字時,會在堆內存中存放該類的實例。而棧中,會儲存這個在堆內存中這個實例的引用。

而child2這個對象之間由child賦值,也會在棧幀中的變量區,創建一個指向這個實例在堆內存地址的引用。


    child2.name = "EvinK"; // -> child.name = "EvinK"

    // == 比較的是對象間的引用
    System.out.print(child2 == child); // always true

正是因爲這兩個變量指向了同一個內存地址,所以只要修改這兩者中的任何一個引用,都會導致另外一個局部變量被動改變。

而作爲程序開發者的我們,對此居然一無所知。

字符串也是對象

照這種說法,字符串操作豈不是很危險,稍不留神,就會得出完全不一樣的結果。


    String a = "a";
    String b = a;
    b = "b";

    // a是什麼?
操作 常量池 指向地址
a = “a” “a” a -> “a”
b = a “a” b -> “a”
b = “b” “a”, “b” b -> “b”

字符串也的確遵守這種“指向複製”規則。

b在重新被賦值後,並沒有在常量池中發現該字符串對象,於是JVM在常量池中創建了新的字符串對象”b”。

讓情況再複雜點


    String java1 = "java";
    String java2 = "java";
    String java3 = java;
    String java4 = new String(java);

    String jav = "jav";
    String a = "a";
    String java5 = jav + a;

    System.out.println(java1 == java2);
    System.out.println(java1 == java3);
    System.out.println(java1 == java4);
    System.out.println(java1 == java5);

字符串java1,java2和java3相等,因爲它們指向了同一塊內存地址。對於java2和java3而言,它們聲明時內存地址時,發現了已存在的字符串對象”java”,於是直接將引用指向這塊地址。

java4和java1的引用不相等。使用new關鍵字時,會強制在常量池重新生成一個同值但不同地址的字符串對象。

java5和java1的引用不相等。java5的引用指向操作數幀的一個臨時地址,將在出棧時被銷燬。

複製

說了這麼多,是不是有點跑題了?

    太長不看

Java裏的所有類都隱式地繼承了Object類,而在 Object 上,存在一個 clone() 方法,它被聲明爲了protected ,所以我們可以在其子類中,使用它。


    // Object Class

    protected Object clone() throws CloneNotSupportedException {
        if(!(this instanceof Cloneable)) {
            throw new CloneNotSupportedException("Class" + getClass().getName() +
            " doesn`t implement Cloneable");
        }

        return internalClone();
    }

    private native Object internalClone();

可以看到,它的實現非常的簡單,它限制所有調用 clone() 方法的對象,都必須實現 Cloneable 接口,否者將拋出 CloneNotSupportedException 這個異常。最終會調用 internalClone() 方法來完成具體的操作。而 internalClone() 方法,實則是一個 native 的方法。對此我們就沒必要深究了,只需要知道它可以 clone() 一個對象得到一個新的對象實例即可。

克隆


        public class Person implements Cloneable {

            public String name;

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

            @Override
            protected Object clone() {
                try {
                    return super.clone();
                } catch (CloneNotSupportedException e) {
                    e.printStackTrace();
                }
                return null;
            }

        }

        public static void main(String args[]) {
            Person ming = new Person("小明");
            Person evink = (Person) ming.clone();
            evink.name = "EvinK";
        }

當一個類的成員變量都是簡單的基礎類型時,淺複製就可以解決我們的問題。

讓情況變得複雜一點


        public class Person implements Cloneable {

            public String name;

            public int[] scores;

            ...

        }

        public static void main(String args[]) {
            Person ming = new Person("小明");
            ming.scores = new int[]{
                86
            };
            Person evink = (Person) ming.clone();
            evink.name = "EvinK";
            evink.scores[0] = 89; // -> ming.scores[0] = 89;

            System.out.println(evink.scores); // [I@246b179d
            System.out.println(ming.scores); // [I@246b179d

        }

經過了克隆( clone() )方法的洗禮後,我們聲明的兩個對象終於不再指向同一個內存地址了。可是,爲什麼還會發生上面一段代碼的問題。

簡單描述一下就是,爲什麼複製這個行爲,會和我們預期的不一致?

在堆內存中,進行復制操作時,會再在堆內分配一個地址用來存放Person對象,然後將原來Person中的成員變量的引用複製一份到新的對象中。而在棧幀中,ming和evink指向的Person對象地址不同,在代碼上表現爲這兩者不相等。而由於其成員變量中可能含有其他對象的引用,所以,即使經過了複製操作,被克隆出的對象中的成員變量仍然指向相同的內存地址。
使用淺複製時,會跳過構造方法的實現。

深度複製

基於clone()方法的改進方案

clone()方法的最大弊端是其無法複製對象內部的對象,所以,只要使對象內部的對象實現Cloneable接口,再在具體實現裏使用構造函數生成新的對象,這樣就能確保使用clone()方法生成的對象一定是全新的。

基於序列化(serialization)的改進方案


        public class Person implements Cloneable, Serializable {

            public String name;

            public int[] scores;

            ...

            public Object deepCopy() {
                Object obj = null;
                try {
                    // 將對象寫成 Byte Array
                    ByteArrayOutputStream bos = new ByteArrayOutputStream();
                    ObjectOutputStream out = new ObjectOutputStream(bos);
                    out.writeObject(this);
                    out.flush();
                    out.close();

                    // 從流中讀出 byte array,調用readObject函數反序列化出對象
                    ObjectInputStream in = new ObjectInputStream(
                        new ByteArrayInputStream(bos.toByteArray()));
                    obj = in.readObject();
                } catch (IOException | ClassNotFoundException e) {
                    e.printStackTrace();
                }
                return obj;
            }

        }

         public static void main(String args[]) {
            Person ming = new Person("小明");
            ming.scores = new int[]{
                86
            };
            Person evink = (Person) ming.deepCopy();
            evink.name = "EvinK";
            evink.scores[0] = 89; // -> ming.scores = 86;

            System.out.println(evink.scores); // [I@504bae78
            System.out.println(ming.scores); // [I@246b179d

        }

原文地址:https://code.evink.me/2018/07/post/java-object-copy/

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