Java中equals()方法探讨

基本数据类型VS对象引用类型的相等性判断

  • 基本数据类型:bytecharshortintlongfloatdouble
    boolean
  • 对象引用类型:简单来说就是new出来的对象的名称就是一个对象引用

基本数据类型的相等性判断

特点:直接比较值是否相等,使用==运算符

1
2
3
int a = 5;
int b = 5;
System.out.println(a == b); // true(值相等)

基本数据类型的变量直接存储值,==直接比较两个变量的二进制值是否相等

对象引用类型的相等性判断

对象引用类型的变量相等于一个指针,利用==相当于比较“这个指针变量”的值(是一个“地址”)是否相同,而利用equals方法类似于对C语言中指针解引用之后判断指针所指向的地方的内容是否相等,并且可以重写equals()方法(个人理解,有错勿喷)

  1. ==运算符:比较内存地址
1
2
3
4
5
6
String s1 = new String("hello");
String s2 = new String("hello");
String s3 = s1;

System.out.println(s1 == s2); // false(不同对象地址)
System.out.println(s1 == s3); // true(同一对象地址)
  1. equals()方法:比较对象内容
    默认行为与==相同,但可重写实现内容比较(重写见后续内容)
1
System.out.println(s1.equals(s2)); // true(String 类默认重写了 equals)

查看String类的源码,可以看到重写的equals()逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public boolean equals(Object anObject) {
if (this == anObject) { // 1. 先比较地址是否相同
return true;
}
if (anObject instanceof String) { // 2. 检查类型是否为 String
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length) { // 3. 比较长度是否一致
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) { // 4. 逐个字符比较
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
  1. 常见错误
  • 未重写equals的类:对于自行创建的类的对象,默认继承原始类Object的equals方法,该方法的实现等同于==,比较地址
    Object.java中的如下方法:
1
2
3
public boolean equals(Object obj) {
return (this == obj);
}
  • 数组的相等性判断:
    • arr1 == arr2:比较两个数组对象的地址
    • arr1.equals(arr2):数组对象的equals()方法未重写,继承Object类的方法
    • Arrays.equals(arr1, arr2):利用内容比较工具方法,会逐个比较数组元素的值,以下是源码片段简化
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
     public static boolean equals(int[] a, int[] a2) {
    if (a == a2) return true; // 地址相同直接返回 true
    if (a == null || a2 == null) return false;
    if (a.length != a2.length) return false;

    for (int i = 0; i < a.length; i++) {
    if (a[i] != a2[i]) return false; // 逐个比较元素值
    }
    return true;
    }
1
2
3
4
5
int[] arr1 = {1, 2};
int[] arr2 = {1, 2};
System.out.println(arr1 == arr2); // false
System.out.println(arr1.equals(arr2)); // false(未重写 equals)
System.out.println(Arrays.equals(arr1, arr2)); // true(需使用工具类)

值得注意的是,如果数组对象是自创建对象,即使对于Arrays.equals()内容比较工具方法,其底层也任然会调用自创建对象的equals()方法进行内容比较。若为重写,依旧进行地址比较
对于其他容器,List/SetArrayList.equals()HashSet.equals()方法同样依赖元素的equals()方法。**Map**中HashMap的键比较同时依赖于equals()hashCode方法

  1. Object.equals(person a, person b)的空指针安全性

首先我们需要知道,如果不在使用a.equals(b)之前进行空指针判断而直接调用的话,会出现空指针风险。
对于a.equals(b),当a为null时,直接调用a.equals(b)会抛出NullPointerException
java.util.Objects.equals(a, b) 是 Java 7 引入的静态工具方法,专门设计用于安全处理空指针。其核心逻辑如下:

1
2
3
4
public static boolean equals(Object a, Object b) {
return (a == b) || (a != null && a.equals(b));
// 此处 a.equals(b)中的equal()方法调用重写后的equals方法
}

equals()hashCode的重写

equals()hashCode方法一般同时重写
二者之间具有极强的依赖于契约关系:

  • 哈希集合依赖:HashSet, HashMap等集合依赖hashCode定位对象,使用equals确认对象是否真正相等
  • 若x.equals(y)为true,则x.hashCode() == y.hashCode()必须为true。(若 a.equals(b)falsea.hashCode() b.hashCode() 可以相同,但应尽量避免)
    以下展示了一个不同时重写的问题:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MyClass {
private int id;
// 只重写equals
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MyClass myClass = (MyClass) o;
return id == myClass.id;
}
// 未重写hashCode , 导致二者即使equal方法返回真时,hashCode也不同
}

// 使用HashSet时出现问题
MyClass a = new MyClass(1);
MyClass b = new MyClass(1);
Set<MyClass> set = new HashSet<>();
set.add(a);
set.add(b);
System.out.println(set.size()); // 输出2,预期为1

正确重写方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
public boolean equals(Object o) {
// 1. 检查是否是同一对象
if (this == o) return true;

// 2. 检查类型是否一致(避免 ClassCastException)
if (o == null || getClass() != o.getClass()) return false;

// 3. 类型转换
MyClass obj = (MyClass) o;

// 4. 逐个比较关键字段
return Objects.equals(field1, obj.field1) &&
field2 == obj.field2;
// 如果filed是自创建对象,按需实现相应自创建对象的equals方法,实现内容比较
}

@Override
public int hashCode() {
// 使用所有参与 equals() 比较的字段
return Objects.hash(field1, field2, field3);
}

在重写时,应该尽量避免使用可变字段(无final标记的或者String等)参与equalshashCode方法比较
如果两个类相互依赖,再重写equals方法时仍互相依赖,可能会陷入无线递归导致栈溢出,可以为对象设置唯一标识符进行equals比较或者采取其他方法打破循环依赖(如果有多个属性,可依据实际情况对造成依赖关系的属性进行地址比较等方法)

番外

字符串常量池

在Java中,创建的字符串字面常量会被虚拟机优化存储到常量池中,与创建对象所在的堆区并不在一个地方,可能会影响==的结果。每次创建字面字符串常量时,JVM首先检查常量池中有无相同字符串常量,有的话直接用已经存在的,否则重新在常量池中创建对象,将引用传递回来。但是通过new创建的对象,则会强制在堆区创建新的变量

1
2
3
4
5
6
7
String str1 = "hello";       // 常量池中创建
String str2 = "hello"; // 复用常量池中国对象
String str3 = new String("hello"); // 强制在堆中新建对象

System.out.println(str1 == str2); // true
System.out.println(str1 == str3); // false
System.out.println(str1.equals(str3)); // true

此外,通过intern()方法可以将字符串手动加入常量池

HashMap方法探究

首先我们来看HashMap中的equals()方法,该方法继承自AbstractMap类,源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public boolean equals(Object o) {
if (o == this) // 1. 自反性检查:传入当前HashMap对象本身,返回true
return true;

if (!(o instanceof Map)) // 2. 类型检查
return false;
Map<?,?> m = (Map<?,?>) o;
if (m.size() != size()) // 3. 大小比较
return false;

try {
Iterator<Entry<K,V>> i = entrySet().iterator();
while (i.hasNext()) {
Entry<K,V> e = i.next();
K key = e.getKey();
V value = e.getValue();
if (value == null) {
if (!(m.get(key) == null && m.containsKey(key)))
return false;
} else {
if (!value.equals(m.get(key))) // 需要正确实现键和值的equals()方法
return false;
}
}
} catch (ClassCastException unused) {
return false;
} catch (NullPointerException unused) {
return false;
}

return true;
}

在比较时,一定要正确实现键和值的equals()hashCode方法一般同时重写

  • 键对象: 必须正确覆盖hashCode()equals()方法,确保相同的逻辑键能正确匹配。
  • 值对象:如果值是自定义对象,需正确实现equals(),否则默认比较引用地址。

此外,hashMap的键对象最好采用不可变字段,否则键对象如果发生改变,哈希值变化,但是HashMap不会重新分配桶位置,导致后续如get()操作通过hashCode的值寻找键的时候无法找到该键

HashMap数据结构原理:
数组(桶数组)
HashMap内部维护一个Node<K,V>[] table数组,每个数组元素称为一个桶(Bucket)

  • 桶的初始容量默认为16,可通过构造函数调整,但始终是2的幂次方(优化索引计算)。
    链表与红黑树
  • 当多个键映射到同一桶时,会以链表形式存储(拉链法)。
  • 当链表长度超过8且桶数组长度≥64时,链表转为红黑树(时间复杂度从O(n)降为O(log n))。
  • 当树节点数减少到6时,退化为链表。

HashMap官方文档:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/HashMap.html
本文参考deepseek