java的对象浅拷贝和深拷贝你真的懂了吗

java的对象浅拷贝和深拷贝你真的懂了吗

说在前面

​ 这几天在看阿里的开发规范,有一条引起了我的注意,不建议使用Object类中的clone()的方法来进行对象拷贝,为了一探究竟,有了这篇文章,如有不足,欢迎留言交流。

1.Java使用关键字new创建对象的过程。

一般的对象拷贝有三种方式,直接赋值,浅拷贝,深拷贝。在说明这三个对象拷贝的方式之前,有必要说明以下 使用new创建对象的过程,

​ 当 虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程,至于类的加载,下一篇再更(在此立个flag).

​ 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump the Pointer)。

​ 如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。选择哪种分配方式是由Java堆是 否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。选择不同的垃圾收集器,分配内存的方式也是不同的.

​ 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),然后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚刚开始—方法还没有执行,所有的字段都还为零。所以,一般来说由字节码中是否跟随invokespecial指令所决定),执行new指令之后会接着执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。生产的对象会有在栈中有一个引用地址。了解了对象的创建过程,对对象拷贝有一个更好的理解.

注:

值类型:Java 的基本数据类型,例如 int、float
引用类型:自定义类和 Java 包装类(string、integer)

2. 直接赋值

​ 直接赋值,也就是我们平时经常用的对象的赋值,类似于 Person a = new Person() ,Person b = a. 这就是直接赋值。由于java是值传递,所以使用这种方式进行赋值时,并没有生成一个新的对象,而是将对象的内存地址引用指向了新的对象,也就是堆中创建的实际对象没有变,只是多了一个指向。

代码示例如下:

 /**
 * 一个用户的对象
 */
public class Person {
    private String name;
    private int age;

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

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
    
}

public class Main {
    public static void main(String[] args) {
        Person a = new Person("chen",10);
        Person b = a;

        // 改变原对象的值
        b.setAge(20);
        System.out.println("a原型对象的年龄:"+a.getAge());
        System.out.println("b新对象的年龄:"+b.getAge());

        System.out.println("a对象的内存地址"+a);
        System.out.println("b对象的内存地址:"+b);
    }
}

程序运行结果:

我是原型对象:20
我是新对象:20
a对象的内存地址myclone.Person@1b6d3586
b对象的内存地址:myclone.Person@1b6d3586

程序运行的结果可以看到对象a,和b操作的是一个对象,当赋值对象改变了一个属性的值,原对象的值也会随之改变。看看直接赋值的在内存的分配,就会一目了然。

总之,直接赋值,并不会产生新的对象,只是多了一个指向。但是有时候我们想产生一个新对象。新对象的值改变不会影响到原型对象。当然也可以同时new两个对象,然后完成相互赋值,但是当属性非常多时,这种方式就很麻烦。这个时候我们用可以使用Objext()类中clone()方法来实现一下。

3浅拷贝的实现

3.1 什么是浅拷贝

如果原型对象的成员变量是值类型,将复制一份给克隆对象,也就是说在堆中拥有独立的空间;如果原型对象的成员变量是引用类型,则将引用对象的地址复制一份给克隆对象,也就是说原型对象和克隆对象的成员变量指向相同的内存地址。换句话说,在浅克隆中,当对象被复制时只复制它本身和其中包含的值类型的成员变量,而引用类型的成员对象并没有复制.

3.2 实现一个浅拷贝

实现对象的拷贝的类,一般有两个步骤,

1 实现clonable这个接口,这是个标记接口,java 类似这样的接口还有很多,比如 序列化的接口Serializable,随机访问的接口RandomAccess,都是这类型的接口。这类的接口都没有具体的方法,作用就是起标记的作用。如果没有实现这个接口,调用clone()方法,就会抛异常CloneNotSupportedException.

2,重写clone()方法。就可以实现一个浅克隆,为了便于后面的讲解,定义一个Children的引用类,并和person类来进行组合。

/**
 * 一个用户的对象,继承cloneable,并重写clone的方法
 */
public class Person implements Cloneable {
    
    
    private String name;
    
    private int age;
    
    private Children children;
    
     /**
     * 重新父类的方法
     * @return
     * @throws CloneNotSupportedException
     */
    @Override
    public Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
    
    .....省略get和set
    
   
// 孩子的类    
 class Children{
    private String name;
    private int age;

    public Children() {
    }

    public Children(String name, int age) {
        this.name = name;
        this.age = age;
    }
     ... 省略get和set方法


public class Main {
    public static void main(String[] args) throws CloneNotSupportedException {

        Children children = new Children("chenChildren",2);

        Person person = new Person("chen",10,children);
        Person person1 = (Person) person.clone();

        // 检测克隆对象和原型对象的内存地址是否相同
        System.out.println("person对象的内存地址:"+person);
        System.out.println("person1克隆对象的内存地址:"+person1);

        // 取出对象引用属性children,并确认浅拷贝后,是不是指向了同一个引用.
        Children chenChildren = person.getChildren();
        Children wangChildren = person1.getChildren();
        System.out.println("person孩子的内存地址:"+chenChildren);
        System.out.println("person1克隆对像孩子的内存地址:"+wangChildren);

        // 改变克隆对象的年龄和姓名
        person1.setAge(20);
        person1.setName("wang");
        System.out.println("person原型对象:"+"年龄"+person.getAge()+"姓名:"+person.getName());
        System.out.println("person1克隆对象:"+"年龄"+person1.getAge()+"姓名:"+person1.getName());


         // 改变person1孩子的姓名,确认person孩子的对象是否会改变
        wangChildren.setName("wangChildren");
        person1.setChildren(children);
        System.out.println("person原型对象的孩子的名字:"+chenChildren.getName());
        System.out.println("person1克隆对象孩子的名字:"+wangChildren.getName());
    }
}


程序运行结果分析

person对象的内存地址myclone.Person@1b6d3586
person1克隆对象的内存地址:myclone.Person@4554617c

person孩子的内存地址myclone.Children@74a14482
person1克隆对像孩子的内存地址myclone.Children@74a14482

person原型对象:年龄10姓名:chen
person1克隆对象:年龄20姓名:wang

person原型对象的孩子的名字:wangChildren
person1克隆对象孩子的名字:wangChildren

通过程序运行结果,可以有三个重要的信息:

1,是浅拷贝后对象地址和原型对像的内存地址是不同的,也就是person和person1的地址是不同的。

person对象的内存地址myclone.Person@1b6d3586
person1克隆对象的内存地址:myclone.Person@4554617c

2 String也是引用地址,为什么变了克隆对象的姓名后,原型对象没有改变。不是引用地址指向是相同的吗,这里不是矛盾了吗?

person原型对象:年龄10姓名:chen
person1克隆对象:年龄20姓名:wang

​ 这里有一个很重要的知识点,那就是String 的不变性的特性,String、Integer 等包装类都是不可变的对象,当需要修改不可变对象的值时,需要在内存中生成一个新的对象来存放新的值,然后将原来的引用指向新的地址,所以在这里我们修改了 person1 对象的 name 属性值,person1 对象的 name 字段指向了内存中新的 name 对象,但是我们并没有改变 person 对象的 name 字段的指向,所以 person 对象的 name 还是指向内存中原来的 name 地址,也就没有变化

3 浅拷贝后,他们的引用地址指向是相同的,所以修改任意一个,另一对象的属性引用的值也会改变。

这个实列中,我们修改了person1的孩子wangChlidren的值, person的chenChildren的名字都跟着改变了。说明浅拷贝后,他们指向了同一个属性引用。

person孩子的内存地址myclone.Children@74a14482
person1克隆对像孩子的内存地址myclone.Children@74a14482
    
person原型对象的孩子的名字:wangChildren
person1克隆对象孩子的名字:wangChildren

看到这里,相信很多人都对阿里不建议使用浅拷贝有了自己的理解吧,具体的讲就是不安全,如果实现了浅拷贝,两个对象持有共同的引用的地址,不管谁修改都会影响对方的值。这样肯定是不可取的。解决办法就是使用深拷贝,就可以完成对象的完全拷贝。

4.深拷贝的实现

深拷贝,顾名思义,就是不管原型对象是什么,都会复制一个全新的对象出来,产生的新对象的修改,并不会影响原型对象.

具体的实现方法一般有两种:

4.1 重写clone()方法。

适用于一个类中属性引用少的情况,一般不推荐使用. 在我们的示例的代码中, 对象的引用属性也需要实现cloneable这个接口, 需要将Person中的clone()方法修改一下:

public class Person implements Cloneable {


    private String name;

    private int age;

    private Children children;

    public Person(){}

    /**
     * 重写父类的方法,并实现深克隆
     * @return
     * @throws CloneNotSupportedException
     */
    @Override
    public Object clone() throws CloneNotSupportedException {
        Person person = (Person) super.clone();
        person.children = (Children) children.clone();
        return person;
    }
   
   
   // 引用实现cloneable这个方法
class  Children implements Cloneable{
    private String name;
    private int age;

    public Children() {
    }

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

执行main函数测试一下,确认两个对象的属性引用的修改是否还能相互影响.

public class Main {
    public static void main(String[] args) throws CloneNotSupportedException {

        Children children = new Children("chenChildren",2);

        Person person = new Person("chen",10,children);
        Person person1 = (Person) person.clone();

        // 检测克隆对象的内存地址是否相同
        System.out.println("person对象的内存地址:"+person);
        System.out.println("person1克隆对象的内存地址:"+person1);

        // 取出各对象的引用children
        Children chenChildren = person.getChildren();
        Children wangChildren = person1.getChildren();
        
        // 确认他们的内存地址是否相同
        System.out.println("person孩子的内存地址:"+chenChildren);
        System.out.println("person1克隆对像孩子的内存地址:"+wangChildren);


        // 修改深拷贝后的引用对象,确认是否影响原型对象person中的children引用的值。
        wangChildren.setName("wangChildren");
        person1.setChildren(children);
        System.out.println("person原型对象的孩子的名字:"+chenChildren.getName());
        System.out.println("person1克隆对象孩子的名字:"+wangChildren.getName());
    }
}

执行结果:

 
person对象的内存地址:myclone.Person@1b6d3586
person1克隆对象的内存地址:myclone.Person@4554617c

person孩子的内存地址:myclone.Children@74a14482
person1克隆对像孩子的内存地址:myclone.Children@1540e19d

person原型对象的孩子的名字:chenChildren
person1克隆对象孩子的名字:wangChildren

结果分析如下:

1.克隆后,产生了一个新对象

person对象的内存地址:myclone.Person@1b6d3586
person1克隆对象的内存地址:myclone.Person@4554617c

2.深拷贝后,引用值也拷贝了一份,且克隆对象引用属性的改变,并不会影响原型对象属性的改变.

person孩子的内存地址:myclone.Children@74a14482
person1克隆对像孩子的内存地址:myclone.Children@1540e19d

person原型对象的孩子的名字:chenChildren
person1克隆对象孩子的名字:wangChildren

4.2.通过序列化实现

将person类和Children都实现序列化接口

/**
 * 一个用户的对象
 */
public class Person implements Serializable {


    private String name;

    private int age;

    private Children children;

    public Person(){}
    .....省略get和set方法
  
  
 class  Children implements Serializable {
    private String name;
    private int age;

    public Children() {
    }  
    ... 省略get和set方法

重写一个Main 方法进行测试

/**
 * 测试一下
 */
public class MainApp {
    ObjectInputStream inputStream = null;
    ObjectOutputStream outputStream = null;
    
    public static void main(String[] args){
        // 创建可序列化的对象
     Children children = new Children("chenChildren",2);
      Person person = new Person("chen",10,children);

        Person person1 = new MainApp().getPerson(person, children);
            // 检测克隆对象的内存地址是否相同
        System.out.println("person对象的内存地址:"+person);
        System.out.println("person1克隆对象的内存地址:"+person1);

            // 拷贝完成和对象原型的children
        Children chenChildren = person.getChildren();
        Children wangChildren = person1.getChildren();
        System.out.println("person孩子的内存地址:"+chenChildren);
        System.out.println("person1克隆对像孩子的内存地址:"+wangChildren);

        
         // 修改深拷贝后的引用对象,确认是否会影响原型对象的引用。
        wangChildren.setName("wangChildren");
        person1.setChildren(children);
        System.out.println("person原型对象的孩子的名字:"+chenChildren.getName());
        System.out.println("person1克隆对象孩子的名字:"+wangChildren.getName());
   }

    /**
     * 序列化的函数
     * @param person
     * @param children
     * @return
     */
    public  Person getPerson(Person person,Children children) {
        children = new Children("chenChildren", 2);
        person = new Person("chen", 10, children);
        Person person1 = null;
        ByteArrayOutputStream arrayOutputStream = new ByteArrayOutputStream();
        try {
            outputStream = new ObjectOutputStream(arrayOutputStream);

            // 序列化来传递这个对象
            outputStream.writeObject(person);
            outputStream.flush();

            ByteArrayInputStream arrayInputStream = new ByteArrayInputStream(arrayOutputStream.toByteArray());
            inputStream = new ObjectInputStream(arrayInputStream);

            person1 = (Person) inputStream.readObject();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } finally {
            if (outputStream != null) {
                try {
                    outputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return person1;
    }
}

执行结果

person对象的内存地址:myclone.Person@6acbcfc0
person1克隆对象的内存地址:myclone.Person@5f184fc6

person孩子的内存地址:myclone.Children@3feba861
person1克隆对像孩子的内存地址:myclone.Children@5b480cf9

person原型对象的孩子的名字:chenChildren
person1克隆对象孩子的名字:wangChildren

结果显而易件,不在赘述

当然上面的这几个方式都比较繁琐,理解原理可以这样实现。但是在平时工作中,我们最好使用现成的工具来实现,比如apache的lang3的工具类。

 <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-lang3</artifactId>
      <version>3.8.1</version>
  </dependency>
import org.apache.commons.lang3.SerializationUtils;

public class Main {
    public static void main(String[] args) {
        Children children = new Children("chenChildren",2);
        
        Person person = new Person("chen",10,children);

        // 序列化的具体的实现
        byte[] serial = SerializationUtils.serialize(person);
        Person person1 = SerializationUtils.deserialize(serial);

        // 检测克隆对象和原型对象的内存地址是否相同
        System.out.println("person对象的内存地址:"+person);
        System.out.println("person1克隆对象的内存地址:"+person1);

        // 将原型对象和克隆对象的属性children的内存地址进行比较 
        Children chenChildren = person.getChildren();
        Children wangChildren = person1.getChildren();
        System.out.println("person孩子的内存地址:"+chenChildren);
        System.out.println("person1克隆对像孩子的内存地址:"+wangChildren);

        // 修改深拷贝后的引用对象,确认是否影响原型对象的引用。
        wangChildren.setName("wangChildren");
        person1.setChildren(children);
        System.out.println("person原型对象的孩子的名字:"+chenChildren.getName());
        System.out.println("person1克隆对象孩子的名字:"+wangChildren.getName());
       }
}

执行结果

person对象的内存地址:cn.chen.xuliehua.Person@24d46ca6
person1克隆对象的内存地址:cn.chen.xuliehua.Person@4783da3f

person孩子的内存地址:cn.chen.xuliehua.Children@4517d9a3
person1克隆对像孩子的内存地址:cn.chen.xuliehua.Children@378fd1ac

person原型对象的孩子的名字:chenChildren
person1克隆对象孩子的名字:wangChildren

5.如何选择拷贝方式:

不同的工作场景使用的拷贝方式不同,我们理解原理,就可以更好的权衡选择那种方式,没有更好,只有合适。

5.1 如果对象的属性全是基本类型的,那么可以使用浅拷贝。

5.2 如果对象有引用属性,那就要基于具体的需求来选择浅拷贝还是深拷贝。

5.3 意思是如果对象引用任何时候都不会被改变,那么没必要使用深拷贝,只需要使用浅拷贝就行了。如果对象引用经常改变,那么就要使用深拷贝。没有一成不变的规则,一切都取决于具体需求。

参考资料:

https://juejin.im/post/6844903806577164302

https://zhuanlan.zhihu.com/p/95686213

<深入理解Jvm虚拟机> 周志明.

posted @ 2020-10-09 20:39  chenweicool  阅读(535)  评论(0编辑  收藏  举报