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/

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容

  • 从三月份找实习到现在,面了一些公司,挂了不少,但最终还是拿到小米、百度、阿里、京东、新浪、CVTE、乐视家的研发岗...
    时芥蓝阅读 42,174评论 11 349
  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 11,084评论 1 32
  • 1.ios高性能编程 (1).内层 最小的内层平均值和峰值(2).耗电量 高效的算法和数据结构(3).初始化时...
    欧辰_OSR阅读 29,299评论 8 265
  • 我们在编码过程经常会碰到将一个对象传递给另一个对象,java中对于基本型变量采用的是值传递,而对于对象比如bean...
    peteLee阅读 682评论 1 2
  • 姑娘如今豆蔻年华, 正巧是尚好的年纪, 不知姑娘可否愿意, 在下八抬大轿迎娶, 做我唯一的枕边人, 一生一世一双人。
    欢喜年少阅读 180评论 0 0