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

来源:互联网 发布:女生提气质知乎 编辑:程序博客网 时间:2024/05/16 04:32

首先说一条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两个方法,其对于一个健壮的代码是不可或缺的。相信通过源码层面的解析我们能够对这两个方法有更深入的了解,关于这两个方法覆盖的规范,也就是如何正确的覆盖,达到设计的功能,我之后也会做一个总结。

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

1 0