
在Java中,对象参数传递、==、equals()、hashCode()以及深浅拷贝是非常容易混淆的几个知识点。它们看似属于不同问题,实际上都与"变量中保存的究竟是什么"有关。
理解这些内容的关键是区分三个概念:变量、引用值和对象本身。
一、Java为什么只有值传递

Java中的参数传递方式只有一种,即值传递。
值传递的含义是:调用方法时,会把实参变量中保存的值复制一份,然后交给方法中的形参。方法接收到的是实参值的副本,而不是实参变量本身。
对于基本数据类型,这一点比较容易理解。
public static void change(int number) {
number = 100;
}
public static void main(String[] args) {
int value = 10;
change(value);
System.out.println(value);
}
输出结果仍然是:
10
调用change(value)时,变量value中保存的数值10被复制给形参number。在方法内部修改的是number,不会影响外部的value。
二、对象作为参数时传递的是什么
引用类型变量中保存的不是对象本身,而是一个指向对象的引用值。
对象通常存放在堆内存中,局部引用变量则保存在相应的运行时内存区域。引用变量通过引用值找到堆中的对象。
在面试回答中,有时会把引用值简单理解为对象地址,但严格来说,Java规范并没有要求引用必须直接等于真实内存地址。更加准确的说法是:引用类型变量保存的是能够定位对象的引用值。
例如:
class User {
String name;
}
创建对象:
User user = new User();
user.name = "张三";
这里的user是引用变量,真正的User对象位于堆中,user保存了指向该对象的引用值。
当对象作为参数传入方法时,Java复制的是这个引用值。
public static void rename(User user) {
user.name = "李四";
}
调用:
User user = new User();
user.name = "张三";
rename(user);
System.out.println(user.name);
输出结果是:
李四
这是因为外部变量和方法形参中保存的引用值都指向同一个对象。通过形参修改对象内部的字段,外部自然能够看到变化。
但是,这并不代表Java使用引用传递。
观察下面的代码:
public static void replace(User user) {
user = new User();
user.name = "王五";
}
调用:
User user = new User();
user.name = "张三";
replace(user);
System.out.println(user.name);
输出结果仍然是:
张三
方法中的形参user只是外部引用值的一份副本。让形参指向一个新对象,只改变了副本的指向,不会修改外部变量保存的引用值。
因此可以总结为:
-
基本类型传递的是数值的副本;
-
引用类型传递的是引用值的副本;
-
通过引用副本可以修改同一个对象的内部状态;
-
不能通过重新给形参赋值,改变外部引用变量的指向。
这就是Java只有值传递的原因。
三、引用类型变量有什么特点
引用类型变量主要有以下几个特点。
第一,引用变量和对象不是同一个概念。
User user = new User();
user是变量,new User()创建的才是对象。
第二,多个引用变量可以指向同一个对象。
User user1 = new User();
User user2 = user1;
此时user1和user2保存的引用值指向同一个对象。
user2.name = "张三";
System.out.println(user1.name);
通过user2修改对象后,user1访问到的内容也会变化。
第三,引用变量可以不指向任何对象,此时它的值为null。
User user = null;
如果直接通过null引用访问对象成员,就会产生NullPointerException。

四、==比较的是什么
==既可以比较基本数据类型,也可以比较引用类型,但含义不同。
1. 比较基本数据类型
对于基本数据类型,==比较的是两个值是否相等。
int a = 10;
int b = 10;
System.out.println(a == b);
结果为true。
2. 比较引用类型
对于引用类型,==比较的是两个引用是否指向同一个对象。
String s1 = new String("Java");
String s2 = new String("Java");
System.out.println(s1 == s2);
结果通常为false,因为这里创建了两个不同的String对象。
即使两个对象内部保存的内容相同,只要不是同一个对象,使用==比较引用时就会返回false。
五、equals()比较的是什么
equals()是Object类定义的实例方法,因此只能由对象调用,基本数据类型不能直接调用。
Object类中默认的equals()主要判断两个引用是否指向同一个对象,其效果与引用类型的==基本相同。
但是,很多类会重写equals(),使其比较对象的逻辑内容。
例如,String重写了equals():
String s1 = new String("Java");
String s2 = new String("Java");
System.out.println(s1 == s2);
System.out.println(s1.equals(s2));
结果为:
false
true
s1 == s2比较对象身份,两个引用没有指向同一个对象,因此为false。
s1.equals(s2)比较字符串内容,二者内容相同,因此为true。
自定义类如果需要按照业务字段判断对象是否相等,也应当重写equals()。
例如,一个用户系统可能认为编号相同的用户属于同一个逻辑用户:
class User {
private Long id;
private String name;
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof User)) {
return false;
}
User other = (User) obj;
return java.util.Objects.equals(id, other.id);
}
}
六、hashCode()有什么作用

hashCode()用于返回对象的哈希值。
哈希值通常是一个整数,HashMap、HashSet等哈希容器会根据它快速确定对象可能存放在哪个桶中,然后再通过equals()做最终判断。
以HashMap查询Key为例,大致过程是:
计算Key的hashCode
↓
确定对应桶的位置
↓
在桶中使用equals比较
↓
找到目标Key
因此,hashCode()主要用于提高查找效率,equals()负责判断两个对象在逻辑上是否真正相等。
Java对二者规定了一个重要约定:
-
如果两个对象通过
equals()判断相等,那么它们的hashCode()必须相等; -
如果两个对象的
hashCode()相等,它们不一定通过equals()判断相等; -
如果两个对象的
hashCode()不同,它们一定不应该被认为相等。
不同对象得到相同哈希值的现象称为哈希冲突,这是正常情况。
七、为什么重写equals()必须重写hashCode()
假设两个用户对象的编号相同,因此重写后的equals()认为它们相等。
如果没有同时重写hashCode(),这两个对象可能继承Object默认的哈希值计算方式,从而得到不同的哈希值。
将第一个对象放入HashSet后,再查询第二个对象时,HashSet可能根据不同的哈希值把它们分配到不同的桶中。由于连桶的位置都不同,容器可能根本没有机会调用equals()进行比较。
结果就可能出现:
-
HashMap查询不到逻辑上已经存在的Key;
-
HashSet中保存多个逻辑上相同的对象;
-
对象查找、删除结果不符合预期。
因此,只要重写equals(),一般就必须根据相同的字段重写hashCode()。
可以使用Objects.hash()简化实现:
@Override
public int hashCode() {
return java.util.Objects.hash(id);
}
如果equals()根据id判断相等,那么hashCode()也应主要根据id计算,二者使用的核心字段应保持一致。
八、什么是浅拷贝

拷贝的目的是根据已有对象创建一个新对象。
浅拷贝会创建一个新的外层对象,并复制原对象中的字段值。
如果字段是基本数据类型,复制的是具体数值;如果字段是引用类型,复制的是引用值,而不是引用指向的内部对象。
例如:
class Address {
String city;
}
class Person {
String name;
Address address;
}
进行浅拷贝后,原对象和副本本身是两个不同对象,但它们的address字段仍然可能指向同一个Address对象。
原Person ──→ 同一个Address对象
副本Person ─→ 同一个Address对象
此时通过副本修改地址:
copy.address.city = "上海";
原对象访问到的城市也会变成"上海"。
因此,浅拷贝只实现了外层对象独立,内部可变对象仍然可能共享。
Object.clone()默认执行的通常就是这种按字段复制的浅拷贝。
九、什么是深拷贝

深拷贝不仅创建新的外层对象,还会继续复制对象内部的可变引用对象。
完成深拷贝后,原对象和副本拥有各自独立的内部对象:
原Person ──→ 原Address
副本Person ─→ 新Address
修改副本中的地址,不会影响原对象。
深拷贝常见的实现方式包括:
-
手动复制每一个字段;
-
使用复制构造方法;
-
重写
clone()并继续复制内部对象; -
使用序列化和反序列化;
-
使用对象映射工具。
复制构造方法是一种比较直观的实现:
class Address {
String city;
Address(Address source) {
this.city = source.city;
}
}
class Person {
String name;
Address address;
Person(Person source) {
this.name = source.name;
this.address = new Address(source.address);
}
}
这里不只是复制address引用,而是重新创建了一个Address对象,因此原对象和副本互不影响。
十、浅拷贝和深拷贝如何选择
浅拷贝实现简单、速度较快、内存开销较小,但内部对象可能被共享,容易出现意外修改。
深拷贝隔离性更强,但实现更加复杂,还可能带来较大的时间和内存成本。
如果对象内部只有基本数据类型或不可变对象,例如String、包装类等,浅拷贝通常已经足够。
如果对象内部包含集合、自定义可变对象等字段,并且副本需要完全独立,就应考虑深拷贝。
因此,深拷贝并不是任何场景下都比浅拷贝更好。实际开发中应根据对象是否可变、是否允许共享以及性能要求进行选择。
归根结底,Java值传递、对象比较以及深浅拷贝都围绕同一个核心展开:引用变量保存的是引用值,而不是对象本身。只要分清变量、引用和对象之间的关系,这些知识点就会更加清晰。