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/

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