【源码笔记】痛苦来源于比较——什么是相等,hashCode() 和 equals(Object)

痛苦来源于比较——什么是相等,hashCode() 和 equals(Object)

1. 需求

最近在搬砖的时候,接到了这样的一个需求:在一个统计调用次数的柱状图里,显示调用次数为0的项目。而现有柱状图的数据是直接从调用日志表里统计得出的,显然调用次数为0的项目不会在日志表中有记录,所以需要先把调用日志表的数据统计好,再和全部的项目合并,然后去重,就能得到想要的结果了。

说到去重,我啪的一下,很快就想到了上学时学到的使用Set的特性去重的方法

public static void main(String[] args) {
    List<Integer> list1 = Arrays.asList(1, 2, 3, 4);
    List<Integer> list2 = Arrays.asList(2, 3, 4, 5);

    Set<Integer> set = new HashSet<>();
    set.addAll(list1);
    set.addAll(list2);

    System.out.println(new ArrayList<>(set));
}
//输出 [1, 2, 3, 4, 5]

而我需要返回的结果结构是这样的

@Data
@ToString
public class Bar {
    private String id;
    private String name;
    private String number;
}

于是稍加改造,我写出了这样的代码

public static void main(String[] args) {
    
    	//模拟从日志表中统计查询调用记录
        List<Bar> logList = new ArrayList<>();
        logList.add(new Bar("1", "规则长方体空间移动项目", 996));
        logList.add(new Bar("2", "纸质实体化信息传播项目", 120));

    	//模拟从项目表中查询全部项目
        List<Bar> projectList = new ArrayList<>();
        projectList.add(new Bar("1", "规则长方体空间移动项目", 0));
        projectList.add(new Bar("2", "纸质实体化信息传播项目", 0));
        projectList.add(new Bar("3", "软件工程项目", 0));

        Set<Bar> set = new LinkedHashSet<>(); //为了保证顺序,使用LinkedHashSet
        set.addAll(logList);
        set.addAll(projectList);
    
        System.out.println(new ArrayList<>(set));
    }

//输出 [Bar(id='1', name='规则长方体空间移动项目', number=996), Bar(id='2', name='纸质实体化信息传播项目', number=120), Bar(id='1', name='规则长方体空间移动项目', number=0), Bar(id='2', name='纸质实体化信息传播项目', number=0), Bar(id='3', name='软件工程项目', number=0)]

从结果来看,显然并没能达到去重的目的。对比两块代码,问题显然是出现在了Bar这个类上,在Set对两个数组进行合并的时候,我们并没有定义如何判断两个Set的实例是否相等。

说到相等,我啪的一下就想到了啊,那不就是equals(Object)方法么,于是我把Bar改成了这样:

@Data
@ToString
public class Bar {
    private String id;
    private String name;
    private String number;
    
    //重写equals方法,id相同的即为相等
    @Override
    public boolean equals(Object o) {
        if (o instanceof Bar) {
            return ((Bar) o).getId().equals(id);
        }
        return false;
    }
}

再执行一遍,得到的结果却完全没有变化…

这是为什么?

所谓遇事不决看源码,于是我打开了addAll方法的定义

package java.util;

public abstract class AbstractCollection<E> implements Collection<E> {
    // ...
    
    public boolean addAll(Collection<? extends E> c) {
        boolean modified = false;
        for (E e : c)
            if (add(e))
                modified = true;
        return modified;
    }
    
    // ...
}

看起来addAll是循环调用了add方法,注意,这里addAll方法的定义是在了AbstractCollection这个抽象类里,而在这个类里面add方法的定义是这样的

    public boolean add(E e) {
        throw new UnsupportedOperationException();
    }

那就是说,这个抽象类并没有实现add方法,而是需要子类去做实现。看到这里聪明的你肯定想到了,这是什么,这不就是传说中的模板方法模式么!看一下继承关系,AbstractCollection这个抽象类还有三个抽象子类AbstractList AbstractSet AbstractQueue,而我们要找的add方法的实现,在AbstractSet的子类HashSet里,是这么写的

package java.util;

public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable {
	private transient HashMap<E,Object> map;
    // Dummy value to associate with an Object in the backing Map
    private static final Object PRESENT = new Object();
	// ...
    public boolean add(E e) {
        return map.put(e, PRESENT)==null;
    }
    // ...
}

我把变量map和常量PRESENT的定义一起贴在了这里,可以看的出来,原来HashSet的本质就是一个value全都相同(也就是这里的PRESENT)的HashMap,而add方法实际上就是HashMapput方法,那么HashSet实现元素不重复的原理也就一目了然了:因为HashMap中的key是不重复的。

HashMapput方法是如何定义的呢

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
    // ...
	public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
    
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
    
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    
    // ...
}

核心的逻辑都在putVal方法里,这时我们再回头来看HashSetadd方法,返回的是map.put(e, PRESENT)==null,也就是说,当put方法,也就是putVal方法返回null的时候,HashSet认为这个元素增加到了集合里,反之就是没有添加成功,也就是判定元素重复了,那么我们只需要看一下putVal什么时候不返回null就可以了。

方法里只有两个返回语句,返回oldValue或者null,而满足返回只有当(p = tab[i = (n - 1) & hash]) == null不成立而e != null成立时,会返回oldValue,其它情况都会返回null。看到这里,我们最初遇到的问题的答案已经能猜个八九不离十了,由于HashSet是通过对象的hashCode方法来计算hash值的,而我们重写了equals方法,自然无法让集合判断两个对象是相等的。

我们先来看(p = tab[i = (n - 1) & hash]) == null这个判断,其中nHashMapNode数组的长度,而hash是通过hash方法计算出来的哈希值,对这两个值进行&计算,得出元素所在Node数组的下标,如果这个节点为空,就将这个元素放在这个节点,而我们想要的就是节点不为空的情况。其实到这里已经验证了我们之前的想法,因为下标是根据哈希值计算出来的,那么我们只要确保我们认为的两个相同的对象的hashCode方法返回值相同,就可以达到去重的目的了。

于是把Bar类的代码改成这样

@Data
@ToString
public class Bar {
    private String id;
    private String name;
    private String number;
    
    //重写equals方法,id相同的即为相等
    @Override
    public boolean equals(Object o) {
        if (o instanceof Bar) {
            return ((Bar) o).getId().equals(id);
        }
        return false;
    }
    
    //重写hashCode方法,根据id计算hash值
    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}

就能得到我们想要的结果了

[Bar(id='1', name='规则长方体空间移动项目', number=996), Bar(id='2', name='纸质实体化信息传播项目', number=120), Bar(id='3', name='软件工程项目', number=0)]

但其实这里还存在一个问题,现在看起来equals方法没有什么用了,那么我们如果把它注释掉会怎样呢?尝试之后发现,去重又一次失效了,那么这又是为什么呢?

又仔细看了几遍源码,发现问题出在

if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))

这个判断,这里调用了keyequals方法,而如果equals返回false,在这个分支里是把e赋值为了null。也就是说,当两个对象的哈希值相同,但equalsfalse时,HashMap也认为这两个对象不相等。所以equals方法也是必须要重写的。

到这里,这个简单的需求就算是完成了,学到了HashSet如何判定元素相等和如何实现元素不重复的同时,我也产生了更多的疑问。hashCodeequals方法到底是如何要求的,我们在重写时需要注意什么呢?

2. Object

众所周知,hashCodeequals方法都是定义在Object类中的,而Object的源码中是这样描述自己的。

Object类是所有类层次结构的根。

每个类的父类都是Object

包括数组在内的所有对象,都实现了这个类的方法。

听起来就很厉害是不是,毕竟是所有类的爸爸,就是厉害。

3. hashCode()

public native int hashCode();

Object类中对于hashCode()方法的描述是这样的:

返回对象的一个hash code值。支持这个方法的好处是为了提供像是java.util.HashMap提供的那样的hash表。

hashCode的一般约定包括以下几点:

  • 在一个Java应用的执行过程中,对同一个对象调用多次此方法,在equals方法用到的信息没有被修改的情况下,hashCode()方法必须始终如一的返回一个相同的整型值。而对于同一个Java应用的两次不同的执行,这个整型值并不需要保持一致。
  • 如果equals(Object)方法判定两个对象相等,那么它们各自调用hashCode()方法必须返回相同的整型值。
  • 如过equals(Object)方法判定两个对象不相等,那么它们各自调用hashCode()方法并不必须相同的整型值。然而,开发者应该清楚,对两个不相等的对象返回不通的hash code值可以提升hash表的性能。

事实上,Object类中定义的hashCode()方法对于不同的对象确实返回不同的整型值。(这通常是通过将对象的内部地址转换为整数来实现的,但是这种实现技术并不是Java™所必须的。(所以hashCode()是native方法 ))

看到这里,我一下子就意识到了我遇到的问题,原来在源码的注释里早就已经做了要求,equals方法判定相等的对象,hashCode()返回的值也必须相同。如果早看过源码的话,就不会犯我一开始的错误。不过通过看HashMap的源码,让我更加理解了为什么hashCode()方法要做这样的要求。

4. equals(Object)

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

Object类中对于equals(Object)方法的描述是这样的:

指明这个对象和一个其它的对象是否**“相等”**

equals方法实现对两个非空对象的引用的等价关系的判断,具备议下几个性质:

  • 自反性:对于任意非空对象的引用xx.equals(x)应该返回true
  • 对称性:对于任意非空对象的引用xy,当且仅当x.equals(y)返回true时,y.equals(x)返回true
  • 传递性:对于任意非空对象的引用xyz,如果x.equals(y)返回truey.equals(z)返回true,那么x.equals(z)返回true
  • 一致性:对于任意非空对象的引用xy,在equals方法用到的信息没有被修改的情况下,多次调用x.equals(y)要一直返回true或一直返回false
  • 对于任意非空对象的引用xx.equals(null)应该返回false

Object类中的equals方法实现了最具辨别能力的对象等价关系的判断,即,对于任意非空对象的引用xy,当且仅当xy是同一个对象的引用(x == y)时,这个方法才返回true

请记住,当你重写了这个方法时,一般都有必要重写hashCode方法,以保持对hashCode方法的一般约定中的相等的对象必须有相等的hash code值。

这里再次强调了,重写equals时要重写hashCode方法,看来这一点确实非常的重要。

5. 挖坑

这次虽然对HashMap如何判断两个元素是否相同有了深刻的理解,但是对HashMap的源码中的其它部分,比如它是如何计算下标,如何解决hash冲突的,诸如此类的问题,先在这挖个坑,有时间再仔细研究。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值