Java对象传递与深浅拷贝核心解析

在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;

此时user1user2保存的引用值指向同一个对象。

复制代码
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

修改副本中的地址,不会影响原对象。

深拷贝常见的实现方式包括:

  1. 手动复制每一个字段;

  2. 使用复制构造方法;

  3. 重写clone()并继续复制内部对象;

  4. 使用序列化和反序列化;

  5. 使用对象映射工具。

复制构造方法是一种比较直观的实现:

复制代码
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值传递、对象比较以及深浅拷贝都围绕同一个核心展开:引用变量保存的是引用值,而不是对象本身。只要分清变量、引用和对象之间的关系,这些知识点就会更加清晰。

相关推荐
网域小星球3 个月前
C++ 从 0 入门(四)|继承、多态、this 指针、深浅拷贝(C++ 面试终极收官)
开发语言·c++·面试·多态·继承·this指针·深浅拷贝
qq_419203238 个月前
深浅拷贝、STL迭代器失效
c++·深浅拷贝·stl迭代器失效
Mysticbinary2 年前
Java的深浅拷贝认识
值传递·引用传递·深浅拷贝
道长道长IOT2 年前
浅析前端的堆栈原理以及深浅拷贝原理
前端·javascript·堆栈·深浅拷贝