复制

作者: EvinK | 来源:发表于2018-07-23 18:47 被阅读3354次
    复制和粘贴

    约在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/

    相关文章

      网友评论

        本文标题:复制

        本文链接:https://www.haomeiwen.com/subject/onojmftx.html