“==”、equals() 和 hashCode()

概述

本篇博客简单介绍一下 equals() 方法和 “==” 的区别,顺带解释一下为什么在重写 equals() 方法后还需要重写 hashCode() 方法。


equals() 和 ==

在 java 代码中,一般通过 == 判断两个对象的地址是否相等。如果比较的是常量数据,则比较他们的值是否相等。

equals() 是 Object 类方法,它的源码实际上也是直接调用的 == :

public boolean equals(Object obj) {
    return (this == obj);
}

也就是说,当我们调用 equals() 比较两个对象是否相等时,实际上作用和 == 是相似的。

在实际的业务场景中,经常需要比较的不是对象地址,而是业务属性。因此一般重写 java 类的 equals() 方法来实现上述业务需求。下面我们看一个具体代码:

class Node {
    private int num;

    public Node(int num) {
        this.num = num;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj instanceof Node) {
            return ((Node) obj).num == num;
        }
        return false;
    }
}

@Test
public void test() {
    Node node1 = new Node(1);
    Node node2 = new Node(1);
    System.out.println("使用 '==' 比较:" + (node1 == node2));
    System.out.println("使用 equals() 比较:" + node1.equals(node2));
}

执行结果

使用 '==' 比较:false
使用 equals() 比较:true

从执行结果可以看出:"==" 因为比较不同对象的地址返回 false,而 equals() 方法比较对象 num 属性值返回 true。

像上述这种重写 equals() 方法来比较对象属性的方式已经很常见了,我们经常使用的 String 类就通过重写 equals() 方法来比较对象具体的字符串值。


equals() 方法重写规则

在重写 equals() 方法时,一般我们需要遵守以下规则,否则将会带来意想不到的异常:

  • 自反性:equals() 方法参数为对象本身时必须返回 true。

  • 对称性:a.equals(b) 返回 true 时,b.equals(a) 也必须返回 true,相反也是。

  • 传递性:a.equals(b) 返回 true,b.equals© 返回为 true时,a.equals© 也必须返回 true

  • 连续性:在对象和方法参数没有做任何改变时,equals() 方法返回结果必须一致

上述四条准则有一个共同的前提:参数对象非 NULL,如果参数为 NULL,则必须返回false。值得一提的是,不存在 NULL 和 NULL 进行 equals() 方法判断是否相等的情况。

一般情况下,重写 equals() 方法为根据属性值判断后,上述四条准则都不会出问题。但如果对象本身和参数对象存在父子级关系时,如果没有做特殊处理,就很容易出现问题:

自反性一般不会出现问题,因为对象和自己本身肯定不存在父子级关系。下面我们先看一个关于对称性的反例:

class NodeSon extends Node {
    private int record;
    public NodeSon(int num, int record) {
        super(num);
        this.record = record;
    }
    @Override
    public boolean equals(Object obj) {
        if (obj instanceof NodeSon) {
            return super.equals(obj) && ((NodeSon) obj).record == record;
        }
        return false;
    }
}

@Test
public void test2() {
    Node node1 = new Node(1);
    NodeSon node2 = new NodeSon(1, 1);
    System.out.println(node1.equals(node2));
    System.out.println(node2.equals(node1));
}

执行结果

true
false

从输出结果我们可以看出,node1 对象调用 equals() 方法时返回为 ture,当我们反过来调用时,返回 false。

输出这样的结果其实也无可厚非,因为 NodeSon 类作为 Node 类的子类,它的对象可以向上转型,而 Node 类对象就不能向下转型为 NodeSon 类对象,因为它可能含有其他很多种子类。因此在通过关键字 instanceof 判断对象类型是否相等时就已经返回 false,实际还没有开始比较属性。

假如现在的业务要求对象 equals() 方法不再受父子类型转型的限制,它认为这两个类属于同根,只要求比较属性,也就是说要 equals() 方法在父子类间满足对称性。要解决该问题也比较简单,只需要将子类的 equals() 方法改为如下即可:

@Override
public boolean equals(Object obj) {
    if (obj instanceof NodeSon) {
        return super.equals(obj) && ((NodeSon) obj).record == record;
    }
    return super.equals(obj);
}

运行上述代码后,两者都返回 true。其实原理也很容易理解:在类型转换失败后,直接通过父类的方法进行判断,这样就不会因为对象类型转换出现对称性问题。

虽然上述处理方式可以满足对称性,但不能满足连续性:

@Test
public void test3() {
    Node node1 = new Node(1);
    NodeSon node2 = new NodeSon(1, 1);
    NodeSon node3 = new NodeSon(1, 2);
    System.out.println(node1.equals(node2));
    System.out.println(node1.equals(node3));
    System.out.println(node2.equals(node3));
}

执行结果

true
true
false

上述代码中,通过 equals() 方法比较,node1 对象等于 node2 和 node3 对象。但 node2 和 node3 对象本身不相等,也就违背了 equals() 方法的连续性。

出现这种结果其实也无可厚非,因为 NodeSon 类中引入新的属性,而 node2 对象和 node3 对象的新属性值也不相同,因此返回 false 也属实正常。

总结一下,当出现以下两种场景时容易不满足 equal() 特性:

  • 父类子类混合比较

  • 子类引入新属性时,根据父类做桥接但新熟悉不相等时

对于上述连续性问题,我认为没有解决方案。当然你可以通过以下特殊处理让输出结果都为 false。这样从输出结果来看是满足连续性问题了, 但没有任何实际价值。

    class NodeSonNoError {
        Node node;
        int count;

        public NodeSonNoError(int num, int count) {
            this.node = new Node(num);
            this.count = count;
        }

        public boolean equals(Object obj) {
            if (obj instanceof NodeSonNoError) {
                NodeSonNoError temp = (NodeSonNoError) obj;
                return node.equals(temp.node) && count == temp.count;
            }
            return false;
        }
    }

通过上述类运行会发现结果都是 false,也就实现了连续性和自反性。不过我觉得大可不必,这种类在实际业务场景中起不到任何作用。


深入理解 equals() 重写规则

有了上面的基础,我们再来看看如果 equals() 方法不遵守规则时,会带来哪些异常:

class Node {
    @Override
    public boolean equals(Object obj) {
        return obj instanceof Node;
    }
}
class NodeSon extends Node {
    @Override
    public boolean equals(Object obj) {
        return obj instanceof NodeSon;
    }
}
@Test
public void test() {
    List<Node> list = new ArrayList<>();
    Node node1 = new Node();
    Node node2 = new NodeSon();
    System.out.println("只添加对象 node2 时:");
    list.add(node2);
    System.out.println("集合是否包含node1:" + list.contains(node1));
    System.out.println("集合是否包含node2:" + list.contains(node2));
    list.clear();
    System.out.println("只添加对象 node1 时:");
    list.add(node1);
    System.out.println("集合是否包含node1:" + list.contains(node1));
    System.out.println("集合是否包含node2:" + list.contains(node2));
}

执行结果

只添加对象 node2 时:
集合是否包含node1:true
集合是否包含node2:true
只添加对象 node1 时:
集合是否包含node1:true
集合是否包含node2:false

从输出结果可以看出,当只添加对象 node1 时,输出结果正常。如果只添加 node2 对象,那么集合会认为两个对象都被添加。这显然是有问题的,下面我们来看一下 contains() 方法的源码。

public boolean contains(Object o) {
    return indexOf(o) >= 0;
}

public int indexOf(Object o) {
    if (o == null) {
        for (int i = 0; i < size; i++)
            if (elementData[i]==null)
                return i;
    } else {
        for (int i = 0; i < size; i++)
            if (o.equals(elementData[i]))
                return i;
    }
    return -1;
}

从源码可以看出,最终在集合中遍历通过 equals() 方法判断,如果 equals() 方法返回 true,就说明集合中存在当前元素。而根据前面的介绍,node2 对象是根据 Node 类子类创建的,而且我们重写之后的 equals() 方法直接根据对象类型判断,因此当集合添加 node2 对象时,会认为 node1 对象也被添加。而 node1 对象作为父类,不能向下转型,因此集合不会因为 node1 对象判断 node2 对象也在集合中。

造成上述结果的根本原因是由于我们在重写 equals() 方法时,没有保证对称性。实际上除了 List 之外,其他很多集合也会在 equals() 重写不满足上述特性出出现问题。


equals() 和 hashCode()

hashCode() 也是一个 Object 类方法,根据它获取对象的 hash 值。该方法经常被用在 Map 集合中,通过它快速定位对象处于哪个散列,方便后面操作。可以抽象的把 hashCode() 方法的作用理解为目录,通过目录就可以大概知道想要查询的内容属于哪一页,根据页数快速定位。

在正式介绍 equals() 和 hashCode() 方法前,我们先通过一个简答的案例来了解 hashCode() 方法:

@Test
public void test() {
    String s1 = "test";
    String s2 = "test";
    StringBuffer stringBuffer1 = new StringBuffer(s1);
    StringBuffer stringBuffer2 = new StringBuffer(s2);
    System.out.println("s1 对象的 hash 值:" + s1.hashCode());
    System.out.println("s2 对象的 hash 值:" + s2.hashCode());
    System.out.println("stringBuffer1 对象的 hash 值:" + stringBuffer1.hashCode());
    System.out.println("stringBuffer2 对象的 hash 值:" + stringBuffer2.hashCode());
}

执行结果

s1 对象的 hash 值:3556498
s2 对象的 hash 值:3556498
stringBuffer1 对象的 hash 值:1688376486
stringBuffer2 对象的 hash 值:2114664380

从输出结果我们会发现 String 对象值相同时,它们的 hash 值也是相同的。而 StringBuffer 对象的 hash 值就相对比较无序。

导致这种结果的原因其实也比较简单,因为 String 对象重写了 hashCode() 方法。下面我们看一下重写之后的 String 类的 hashCode() 方法源码:

public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;

        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

上述代码中,hash 是 String 对象的 hash 值,value 是保存字符串的字符数组。从代码可以很清晰的看出,String 对象的 hash 值是根据字符串算出来的,因此当字符值相同时,计算出来的 hash 值也相同。

Object 类的 hashCode() 方法是 native 类型的,它的值根据对象的地址计算,也就是说如果这个 Object 对象所在内存地址没有发生变化,它的 hash 值也不会发生变化。

关于 hashCode() 方法的作用显而易见:尽可能让对象散开一点提高效率

怎么理解这句话呢:假设现在存在 hashMap 集合,长度为10,现在我们要用它添加100个元素。如果我们的 hashCode() 方法计算出的结果尽可能均匀,这100个对象将会被平均分配到这10个槽点,后面无论是查询、修改、删除操作都只需要遍历十个左右的对象即可。如果 hashCode() 方法不均匀,所有的对象都被分配到第一个槽点,那么无论我们做任何操作,都需要遍历所有的对象。关于 hashMap 集合的原理,后面我们出博客专门介绍。

从上面的解释可以看出,hashCode() 方法的主要作用是将对象元素分类,方便查询等操作。既然涉及到查询,一定存在判断是否相等操作,而判断是否相等的方法上面我们介绍了很多,即 equals()。下面我们看一下 Set 集合中是如何判断对象是否存在:

public boolean contains(Object o) {
    return map.containsKey(o);
}

public boolean containsKey(Object key) {
    return getNode(hash(key), key) != null;
}

从源码可以清晰的看出,在 Set 集合中,要判断一个对象是否存在,首先根据 hash 值定位到槽点,然后在槽点集合中根据 equals() 方法判断是否存在。

也就是是说,要想在 Set() 集合中判断对象是否存在,还需要首先拥有相同的 hash 值。这也是为什么一般重写 equals() 方法后,还需要重写 hashCode() 方法的主要原因。和equals() 方法相同,下面我们总结下 hashCode() 方法需要遵守的约定:

  • equals() 返回 true 的两个对象,他们的 hashCode() 结果必须相同

  • equals() 返回 false 的两个对象,他们的 hashCode() 结果可以不相等

和 equals() 方法类似,如果一个类重写完 hashCode() 方法后没有满足上述条例,那么就会在 Map 集合中出现奇奇怪怪的异常信息。这里我使用Set方便验证,看过源码的同学一定知道,Set底层用的就是Map,只不过没有存 value 值。

class Node {
    int num;
    public Node(int num) {
        this.num = num;
    }
    @Override
    public boolean equals(Object obj) {
        if (obj instanceof Node) {
            return ((Node) obj).num == num;
        }
        return false;
    }
}

@Test
public void test2() {
    HashSet<Node> set = new HashSet<>();
    Node node1 = new Node(1);
    Node node2 = new Node(1);
    set.add(node1);
    System.out.println("node1.equals(node2) ->" + node1.equals(node2));
    System.out.println("set 集合是否包含node1:" + set.contains(node1));
    System.out.println("set 集合是否包含node2:" + set.contains(node2));
}

执行结果

node1.equals(node2) ->true
set 集合是否包含node1:true
set 集合是否包含node2:false

从输出结果可以看出,虽然 node1 对象和 node2 对象是相等的(根据equals()方法判断),但是 set 集合只认为它包含有 node1 对象,而不包含有 node2 对象。

造成这种现象的原因很简单,因为 node1 对象和 node2 对象的 hashCode() 值是不相等的。因此首先会被定位到不同的槽位,如果槽位都不相同,还没有到 equals() 那一步已经返回 false。

要解决上述问题很简单,只需要重写该类的 hashCode() 方法即可。重写的方法有很多种,这里我列出最简单也相对比较均衡并且和对象属性有关的一种:

@Override
public int hashCode() {
    return new Integer(num).hashCode();
}

将 Node 类的 hashCode() 方法重写为如上后,再次运行代码:

执行结果

node1.equals(node2) ->true
set 集合是否包含node1:true
set 集合是否包含node2:true

从输出结果可以看出,这次没有再出现上面的问题。


重写 equals() 方法的建议

  1. 判断前在方法中将参数对象强转为相应类型的对象
  2. 判断是否同意对象时用“==”,直接比较地址
  3. 判断参数对象是否为NULL,如果是直接返回 false
  4. 存在父子类关系时,如果业务上这两个类是相同的,用 instanceof 方法判断,否则通过 getClass() 判断是否同一类型
  5. 根据属性比较时,基础类型比较用 “==”,对象类型用 equals() ,两这都返回 true 时才返回 true。
  6. 子类重写 equals() 方法时,根据业务场景选择是否在类型不匹配时调用 super.equals(xxx),返回 true时,必须先调用 super.equals(xxx)。
  7. 重写 equals() 方法后,一定不能忘记重写 hashCode(),否则在 Map 集合中容易出错。

参考:
https://blog.csdn.net/javazejian/article/details/51348320
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章