从JDK源码分析Java中的equals与hashCode

首先说一条Java编程规范,就是覆盖Object的equals方法时总要覆盖hashCode,并且如果两个对象的equals方法比较结果是相等的,那么他们的hashCode方法就应该返回相同的整数结果;而如果equals比较结果不同,那么他们的hashCode方法最好返回截然不同的结果,以提高散列表的性能(Object规范)。
以上内容在《Effective Java》中也提到了,可是这个规范的来源是什么呢?究竟返回相同和不同的hashCode结果有什么区别呢?这就涉及到了HashSet等一系列运用散列技术的数据结构的实现,为了弄明白这个问题,我们从它们的源码中来进行分析。
注:本篇使用的JDK源码版本为jdk1.8.0_65,不同版本的实现可能略有不同

1、简单的例子

我们都知道Map的作用就是存储“键值对”映射,在我的开源项目MyEventBus中,主要核心就是利用了一个Map来存储事件和函数实体的映射关系:

/**
* 核心Map,存储事件和对应调用实体的Map
*/
private final Map<EventType, CopyOnWriteArrayList<RegisterEntity>> mainMap = new
            ConcurrentHashMap<EventType, CopyOnWriteArrayList<RegisterEntity>>();

当找到注册函数时,将它放入此Map中:

mainMap.put(eventType, registerEntityList);

之后当某个事件产生时,利用此Map来寻找到需要执行的函数实体:

EventType eventType = new EventType(event.getClass());
List<RegisterEntity> entityList = mainMap.get(eventType);

看似简单的例子,让我们看一下Map中作为键的类EventType:

public class EventType {
    /**
     * 参数类型,用于识别
     */
    private Class<?> paramType;

    public EventType(Class<?> paramType){
        this.paramType = paramType;
    }

    @Override
    public int hashCode() {
        final int prime = 30;
        int result = 1;
        result = prime * result + ((paramType == null) ? 0 : paramType.hashCode());
        return result;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if(getClass() != obj.getClass())
            return false;
        EventType other = (EventType) obj;
        if (paramType == null) {
            if (other.paramType != null)
                return false;
        } else if (!paramType.equals(other.paramType))
            return false;
        return true;
    }
}

你会看到我覆盖了equals与hashCode两个方法,如果你删除了hashCode,那么当你使用此Map时就会发现,尽管你已经正确放入了键值对,再用键来进行获取的时候就得不到你之前存入的正确对象。

2、源码分析

这里主要的关键就是HashMap为什么在删除了hashCode后就不能正常使用了,而使用基本上就来自于两个常见方法put和get,那就让我们直接深入源码看看到底这两个方法和equals与hashCode有什么关系。

(1)put方法
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

我们可以看到put方法调用了putVal方法,参数第一个就调用了一个hash函数:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

这其实就是为了你传入的key进行数学计算得出Hash码的数学方法,我们可以看出,如果key的hashCode返回的结果一样,那么计算出来的Hash码就是相同的。
我们接着看putVal方法:

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

我们一步一步分析putVal方法:

如果没有初始化:初始化
if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;

首先Node就表示一个Map中存储的键值对,我们也知道,Map存储键值对的时候和Value是没有关系的,最关键的就是Key的值,而这里用到的table就是索引的存储数组,如果一开始table是空的,就调用resize来进行初始化操作;

不冲突:直接插入
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);

这里的i存储的就是应该保存键值对的位置,我们可以看出它用了(n - 1) & hash这个运算来计算应该存储的槽的位置,这里n就是存储数组table的长度,hash就是我们计算出来的Hash值,其实这个计算在n为偶数的情况下,计算出来的就是hash%(n-1),让计算出来的存储位置一直在数组的长度内以避免越界(这也是散列的基本)。
这里可以看出如果应该存储的位置没有存储对象,就直接通过newNode创建一个新的键值对存储进去:

Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
    return new Node<>(hash, key, value, next);
}
冲突情况:分情况处理

当发生冲突的情况,这里就涉及到了一个JDK1.8之后的改变,它不光有原始的数组结合链表的实现方法,当一个槽里存储的链表长度超过8之后,就使用红黑树来存储以增加效率,让我们来看一看它的实现:

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

如果插入的key与槽的链表头结点的key相同,那么就用之前创建的e把它存储下来,这里使用了equals来判断,说明如果你重写了equals,那么在HashMap中使用equals比对相同,那么就认为插入的key是同一个;第二种情况是如果p是一个树节点,那么就是使用树来处理冲突。

if ((e = p.next) == null) {
    p.next = newNode(hash, key, value, null);
    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
        treeifyBin(tab, hash);
    break;
}

这是相对于上面两种情况的,当你的key不相同并且还是链表结构的时候,那么就需要在链表中进行处理:如果直到链表尾都没有相同的key值,那么就创建新的键值对并插入在最后,并且做了这个判断:

if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
    treeifyBin(tab, hash);

如果在中间某处找到了相同key值的,就会中断并跳出循环。

if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))
    break;

如果找到了相同key的值的节点就自然地跳出循环。

if (e != null) { // existing mapping for key
    V oldValue = e.value;
    if (!onlyIfAbsent || oldValue == null)
        e.value = value;
    afterNodeAccess(e);
    return oldValue;
}

最后做判断:如果e不为空,代表什么呢?e就是我们一旦发现已经存在key相同的节点就使用e将它保存下来,e不为空就表示存在相同key的节点。那么就把已经存在的节点的value更新为新的值并且返回。

最后:收尾处理
++modCount;
if (++size > threshold)
    resize();
afterNodeInsertion(evict);
return null;

在冲突中我们知道如果存在一个相同的key值的节点就更新并返回,如果不存在就插入,那么插入过后就要做一些收尾,并且如果接近了阈值,那么就要利用resize来扩展数组大小。

(2)get方法
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

get同样还是调用了hash计算了Hash值并且调用了getNode方法来获取,我们已经知道hash就是利用hashCode来计算存入key的hash码的方法,接着看getNode:

final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

还是来一步一步分析:

获取保存槽索引
first = tab[(n - 1) & hash]

还是用过(n - 1) & hash运算,利用计算的Hash码获取槽的位置

比较第一个索引
if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
    return first;

如果第一个索引的key相同或者利用equals比较相同,那么就返回得到的节点;如果不相同就继续比较:

如果有下一个节点,则分情况处理:红黑树或者链表

首先做判断:if ((e = first.next) != null) 即判断第一个索引之后是否有,如果有就分情况处理:

如果是红黑树结构
if (first instanceof TreeNode)
    return ((TreeNode<k,v>)first).getTreeNode(hash, key);

先判断:if (first instanceof TreeNode) 如果为真就表示采用的是红黑树结构来存储一个槽内的所有节点(之前说过超过8就会用红黑树结构来存储),那么就利用红黑树来处理;

链表结构的处理
do {
    if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))
        return e;
} while ((e = e.next) != null);

链表处理起来就很简单,一直往下遍历,如果找到就返回,直到链表尾

最后:没有找到,返回null

最后没有找到,就return null;

(3)回到例子

解析了上面两个方法的源代码,我们回到我们之前举的例子,就知道为什么如果要正确使用一个Map,equals与hashCode两个方法正确很重要:

存入

存入过程就需要hashCode的返回值来决定Hash码,从而决定你的键值对存入哪一个槽中;而equals决定了是否替换,如果存在与你存入的key相同(equals返回真)的对象,就会替换其所对应的value。

取出

取出过程同样需要hashCode的返回值来决定Hash码,从而决定从哪一个槽中来取,这是你的hashCode发生至关重要作用的地方:如果你不重写hashCode,其返回一个随机值,那么即使你的equals结果是相同的也不会取到正确的结果,因为你的槽就不是同一个!这也算回答了例子里的问题。找到槽之后,就从槽中寻找与你的key相同或者equals比较返回真的对象并返回,这时你的equals也起了作用,只有hashCode相同还不行,equals也必须写正确。

3、总结

通过上面的源码解析,我们已经可以了解到,对于一个想要正确使用利用Hash技术的数据结构的对象,就必须正确覆盖equals与hashCode两个方法,其对于一个健壮的代码是不可或缺的。相信通过源码层面的解析我们能够对这两个方法有更深入的了解,关于这两个方法覆盖的规范,也就是如何正确的覆盖,达到设计的功能,我之后也会做一个总结。

如果觉得我的文章里有任何错误,欢迎评论指正!如果觉得写得好也欢迎大家留言或者点赞,一起进步、一起学习!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值