Java对象的引用和复制

1,609 阅读11分钟

在Java中,我们无时无刻不在接触和使用变量。从最简单的int,到复杂的自定义对象等等。

然而,像Java,以及C#、JavaScript这类面向对象的语言,也总是会出现对象的复制和引用的问题。前段时间写了JavaScript中对象引用和复制,今天就来总结一下Java中的。

众所周知,Java的变量无外乎就分为两大类:

  • 基本数据类型(也可以叫做内置数据类型):
    • byte:字节型
    • short:短整型
    • int:整型
    • long:长整型
    • float:浮点型
    • double:双精度浮点型
    • boolean:布尔型
    • char:字符型
  • 引用数据类型:其余无论是数组、还是自定义的类的对象,亦或是系统自带的类的对象,都是引用数据类型,例如我们常常用的字符串String类,实际上是引用数据类型。所有的引用数据类型的类都是Object类的子类。

大家也知道,我们的变量都是放在内存里面的,那么基础数据类型和引用数据类型,其储存的形式可能有所不同,我们一一来看。

1,基本数据类型和引用数据类型在内存中的形式简述

(1) 基本数据类型

基本数据类型,顾名思义就是最基本的数据,基本上基本数据类型只会储存它们的值。

int a = 1;
int b = 2;

image.png

可见,基本数据类型只会储存它自己的值,储存形式较为简单。

(2) 引用数据类型

我们自己创建的类的对象,或者是一些类创建的对象,都是引用数据类型。我们都知道,一个类说白了就是很多属性(成员变量)和方法的集合,那么同样地,在内存中,我们每new一个对象,就会开辟一小块内存空间,在里面存放这个类的属性及其值,也可以说引用类型在内存中就是一个引用指向一个键值对的集合。

我们先新建一个类,下面将以这个自定义类为例:

package com.example.singleinstance.model;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

/**
 * 猫类
 */
@Getter
@Setter
@NoArgsConstructor
public class Cat {

	/**
	 * 名字
	 */
	private String name;

	/**
	 * 类型
	 */
	private String type;

}

实例化一个Cat对象,并给其属性赋值:

Cat cat = new Cat();
cat.setName("大橘");
cat.setType("中华田园猫");

image.png

可见我们创建了一个名为cat的猫的对象。在这里这个cat叫做引用,这个引用指向了对应的内存,也就是指向了我们新开辟的内存空间表示这个猫对象。

可以说,这一句实例化猫对象的代码可以理解为:

  • 前半段Cat cat只是创建一个名为cat的引用,还没有创建出实际的对象(准备生一只猫并给它起了名字cat,但是还没生出来)
  • 后半段new Cat()才是真正的创建出了对象并会执行构造函数中的内容,开辟了内存空间(这时猫生出来了)
  • 整行代码,把cat这个引用指向创建的对象

一步一步地来看,是不是就很好理解了呢?其实我们平时实例化对象的过程也就是如此。

这个引用其实就很类似我们C语言或者C++的指针,指向一块内存空间,也就是说,引用事实上就是存放其指向的对象的内存地址的一个变量

2,对象引用和复制问题

(1) 基本类型

基本类型因为其只储存一个值也就是本身的值,因此赋值的时候就直接复制了,我们来看:

int a = 1;
int b = a;
b = 2;
System.out.println(a);
System.out.println(b);

image.png

可见把a赋值给b,然后改变b并没有改变a,这说明int b = a的时候,是把a的值复制给b了。

image.png

(2) 引用类型

我们来实例化上述猫类对象试试:

Cat bigOrange = new Cat();
bigOrange.setName("大橘");
bigOrange.setType("中华田园猫");
Cat threeFlower = bigOrange;
threeFlower.setName("三花");
System.out.println("第一只猫:" + bigOrange.getName());
System.out.println("第二只猫:" + threeFlower.getName());

image.png

好像出现了问题。为什么我们写法和上面一样,结果不一样了呢?

因为Cat类的对象是引用类型,直接赋值的时候不会像基本类型一样把里面的值都给赋值给另一个对象,而只是建立引用。所以说当执行Cat threeFlower = bigOrange;的时候,只是新建了个名为threeFlower的引用,并指向了bigOrange所指向的内存空间。所以说事实上,上述两个变量bigOrangethreeFlower指向的是同一个内存空间。

image.png

对比一下上面基本数据类型,大家就能够理解两者的差别。

你只生了一只猫,但是起了两个名字

(3) 再看==运算符

通常我们使用==来判断两者是否相等,但事实上,==在比较基本数据类型和引用数据类型的时候作用是不一样的

  • 基本数据类型中使用==判断两者值是否相等
  • 引用数据类型中使用==判断两者是否指向同一个内存空间

我们来试一下:

int a = 1;
int b = 2;
int c = 1;
System.out.println(a == b);
System.out.println(a == c);
Cat bigOrange = new Cat();
bigOrange.setName("大橘");
bigOrange.setType("中华田园猫");
Cat threeFlower = bigOrange;
threeFlower.setName("三花");
System.out.println(bigOrange == threeFlower);

image.png

大家一定要知道这个==运算符的实际意义。

所以说我们在判断字符串是否相等的时候,就不能使用==了而是equals方法,因为字符串是引用数据类型,即使两个String的值是一样的,但是可能不会指向同一个内存空间

3,值传递和引用传递

值传递和引用传递,事实上并非是Java语言特有的概念,而是所有的编程语言中都存在的概念。我们先来看一下它们是什么:

  • 值传递:在调用函数时,将实际参数复制一个并传递到函数中,这样在函数中对参数进行修改,不会对原来的实际参数造成影响
  • 引用传递:在调用函数时,将实际参数的地址直接传递到函数中,这样在函数中对参数进行的修改,会对原来的实际参数造成影响

同样地,我们先来看一下例子:

public class Main {

	public static void change(int a) {
		a = 10;
	}

	public static void main(String[] args) {
		int a = 1;
		change(a);
		System.out.println(a);
	}

}

结果:

image.png

这就是值传递的一个很简单的例子,我们写了个change函数“试图”修改传入的值,但事实上并没有成功。因为传入的是值类型的变量,传入后,变量的值被复制给了参数,然后在函数内修改了参数的值,并不影响原来被传入变量的值。

再来看一个例子:

public class Main {

	public static void change(Cat cat) {
		cat.setName("乳白");
		cat.setType("英短");
	}

	public static void main(String[] args) {
		Cat cat = new Cat();
		cat.setName("大橘");
		cat.setType("中华田园猫");
		change(cat);
		System.out.println("name: " + cat.getName() + " type: " + cat.getType());
	}

}

结果:

image.png

可见和上面的值传递结果不同,这里传入了猫类的引用,由于cat是自定义的类型,属于引用数据类型变量,因此传入时,仅仅是对象的地址被赋值给了参数,在函数内对这个对象的属性进行修改,也会影响到原来被传入的对象。

但是,这个示例就是引用传递吗?我们来继续看一个例子:

public class Main {

	public static void change(Cat cat) {
		Cat cat1 = new Cat();
		cat1.setName("麻花");
		cat1.setType("美短");
		cat = cat1;
	}

	public static void main(String[] args) {
		Cat cat = new Cat();
		cat.setName("大橘");
		cat.setType("中华田园猫");
		change(cat);
		System.out.println("name: " + cat.getName() + " type: " + cat.getType());
	}

}

结果和上面一样吗?然而并不是:

image-20230202214644261

这个示例也说明了,在Java中所有的参数传递都是值传递,我们知道了Java中一个引用数据类型变量实质上是存放对象地址的变量,那么上述在将引用类型作为参数传递的时候,事实上是将对象的地址复制给了函数参数,在函数内将一个新的对象赋值给这个参数,也只是使这个参数指向了新的对象,并不会使原来被传入参数改变指向。

也就是说,传递引用数据类型的时候,实质上也是值传递,即把引用数据类型变量的地址复制给了参数

在C++语言中,就存在着值传递和引用传递,直接将一个变量或者指针传入函数的时候就是值传递,而在形参名前面加上&,就会使传入的参数变成引用传递。

4,引用类型的深复制

那假设上述我就要克隆一个猫猫出来改一改(复制一个猫对象而不是建立引用),变为另一只猫怎么办呢?

很简单,我们把猫类使用Cloneable接口并重写clone方法即可,clone方法需要具体自己实现

package com.example.singleinstance.model;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

/**
 * 猫类
 */
@Getter
@Setter
@NoArgsConstructor
public class Cat implements Cloneable {

	/**
	 * 名字
	 */
	private String name;

	/**
	 * 类型
	 */
	private String type;

	@Override
	public Cat clone() {
		// 自己实现克隆逻辑
	}

}

clone方法要怎么写呢?这个我这里有两种方法。

其实,你也可以自己另写一个方法实现对象深复制,但是规范起见,建议还是在对应的类中使用Cloneable接口并实现clone方法。

(1) 反射递归复制法

我们可以用反射方法先获取要复制的类的所有字段,然后依次判断字段是基本类型还是引用类型,如果是引用类型则递归进行该字段复制,否则直接给其字段赋值即可。

我们把Cat类改装如下:

package com.example.singleinstance.model;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.lang.reflect.Field;

/**
 * 猫类
 */
@Getter
@Setter
@NoArgsConstructor
public class Cat implements Cloneable {

	/**
	 * 名字
	 */
	private String name;

	/**
	 * 类型
	 */
	private String type;

	/**
	 * 克隆对象自身-递归部分
	 *
	 * @param origin 要被克隆的对象
	 * @return 克隆之后的对象
	 */
	private Object clone(Object origin) throws Exception {
		// 首先是判断传入的是否是基本数据类型,如果是则直接返回
		// 传入null也直接返回
		// String虽然是引用类型,但是比较特殊,因为String对象基本上也只是储存字符串值,遍历其属性是没有意义的
		if (origin == null || origin instanceof Number || origin instanceof Character || origin instanceof Boolean || origin instanceof String) {
			return origin;
		}
		// 否则,获取被克隆对象的所有的字段
		Field[] fields = origin.getClass().getDeclaredFields();
		// 新建一个对象,把原对象的属性值复制给新对象的属性值(调用自身类的无参构造器创建新实例)
		Object result = origin.getClass().getDeclaredConstructor().newInstance();
		// 遍历字段,判断字段类型,如果是基本类型则直接赋值,否则进行递归
		for (Field field : fields) {
			// 使该字段可以访问
			field.setAccessible(true);
			// 获取对象的对应字段值,并把对应值赋值给新对象对应属性
			field.set(result, clone(field.get(origin)));
		}
		return result;
	}

	/**
	 * 克隆对象自身-启动部分
	 *
	 * @return 克隆后的对象
	 */
	@Override
	public Cat clone() {
		Object result = null;
		try {
			result = clone(this);
		} catch (Exception e) {
			e.printStackTrace();
		}
		return (Cat) result;
	}

}

可见增加了两个方法用于克隆(深复制)对象自身。

我们来试一下子:

Cat bigOrange = new Cat();
bigOrange.setName("大橘");
bigOrange.setType("中华田园猫");
// 克隆一个大橘赋值给三花
Cat threeFlower = bigOrange.clone();
threeFlower.setName("三花");
System.out.println(bigOrange.getName());
System.out.println(threeFlower.getName());
System.out.println(bigOrange == threeFlower);

image.png

(2)【推荐】序列化再反序列化法

上述方法大家也发现了:不仅比较复杂,而且只是适用于大多数情况,不适用于所有情况,例如存在有的类没有无参构造器的时候就出问题了。

还有一个方法就是使该类可以序列化,然后先将自己序列化为对象流,再把流反序列化为对象即可实现深复制。

首先,被复制的类也就是上述Cat类需要同时实现CloneableSerializable接口,然后重写clone方法如下:

/**
 * 克隆对象自身
 *
 * @return 克隆后的对象
 */
@Override
public Cat clone() {
	Cat result = null;
	try {
		// 实例化字节序列输出流
		ByteArrayOutputStream bos = new ByteArrayOutputStream();
		// 实例化对象写入流
		ObjectOutputStream oos = new ObjectOutputStream(bos);
		// 读取对象自身并写入字节序列输出流,这样就完成了对象自身的序列化
		oos.writeObject(this);
		// 然后再实例化字节序列输入流,将上面序列化的结果再读取,这样就把刚刚序列化的内容又反序列化为对象了,反序列化得到的对象值和原来完全相同但是却不再是同一个对象了
		ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
		ObjectInputStream ois = new ObjectInputStream(bis);
		// 读取流为对象
		result = (Cat) ois.readObject();
	} catch (Exception e) {
		e.printStackTrace();
	}
	return result;
}

我们来试一下子:

Cat bigOrange = new Cat();
bigOrange.setName("大橘");
bigOrange.setType("中华田园猫");
// 克隆一个大橘赋值给三花
Cat threeFlower = bigOrange.clone();
threeFlower.setName("三花");
System.out.println(bigOrange.getName());
System.out.println(threeFlower.getName());
System.out.println(bigOrange == threeFlower);

效果相同。

image.png

5,总结

理解Java中对象的引用和复制,我们首先要搞清楚Java中有基本数据类型和引用数据类型这两大数据类型,以及它们在内存中的形式,赋值的机制的不同等等,理解了这些,一些问题也会迎刃而解。