面试题系列第4篇:重写了equals方法,为什么还要重写hashCode方法?

简介: 面试题系列第4篇:重写了equals方法,为什么还要重写hashCode方法?

《Java面试题系列》:一个长知识又很有意思的专栏。深入挖掘、分析源码、汇总原理、图文结合,打造公众号系列文章,面试与否均可提升Level。欢迎持续关注【程序新视界】。本篇为第4篇。

核心问题:重写了equals方法,为什么还要重写hashCode方法?

这不仅仅是一道面试题,而且是关系到我们的代码是否健壮和正确的问题。在前面两篇文章涉及到了equals方法的底层讲解:《说说==和equals的区别?你的回答可能是错误的》和《Integer等号判断的内幕,你可能不知道?》。

本篇文章,带大家从底层来分析一下hashcode方法重写的意义以及如何实现。

回顾equals方法

我们先回顾一下Object的equals方法实现,并简单汇总一下使用equals方法的规律。




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

通过上面Object的源代码,可以得出一个结论:如果一个类未重写equals方法,那么本质上通过“==”和equals方法比较的效果是一样的,都是比较两个对象的的内存地址。

前面两篇文章讲到String和Integer在比较时的区别,关键点也是它们对equals方法的实现。

面试时总结一下就是:默认情况下,从Object类继承的equals方法与“==”完全等价,比较的都是对象的内存地址。但我们可以重写equals方法,使其按照需要进行比较,如String类重写了equals方法,比较的是字符的序列,而不再是内存地址。

与hashCode方法的关系

那么equals方法与hashCode方法又有什么关系呢?我们来看Object上equals方法的一段注释。

Note that it is generally necessary to override the hashCode method whenever this method is overridden, so as to maintain the general contract for the hashCode method, which states that equal objects must have equal hash codes.

大致意思是:当重写equals方法后有必要将hashCode方法也重写,这样做才能保证不违背hashCode方法中“相同对象必须有相同哈希值”的约定。

此处只是提醒了我们重写hashCode方法的必要性,那其中提到的hashCode方法设计约定又是什么呢?相关的内容定义在hashCode方法的注解部分。

hashCode方法约定

关于hashCode方法的约定原文比较多,大家直接看源码即可看到,这里汇总一下,共三条:

(1)如果对象在使用equals方法中进行比较的参数没有修改,那么多次调用一个对象的hashCode()方法返回的哈希值应该是相同的。

(2)如果两个对象通过equals方法比较是相等的,那么要求这两个对象的hashCode方法返回的值也应该是相等的。

(3)如果两个对象通过equals方法比较是不同的,那么也不要求这两个对象的hashCode方法返回的值是不相同的。但是我们应该知道对于不同对象产生不同的哈希值对于哈希表(HashMap等)能够提高性能。

其实,看到这里我们了解了hashCode的实现规约,但还是不清楚为什么实现equals方法需要重写hashCode方法。但我们可以得出一条规律:hashCode方法实际上必须要完成的一件事情就是,为equals方法认定为相同的对象返回相同的哈希值。

其实在上面规约中提到了哈希表,这也正是hashCode方法运用的场景之一,也是我们为什么要重写的核心。

hashCode应用场景

如果了解HashMap的数据结构,就会知道它用到“键对象”的哈希码,当我们调用put方法或者get方法对Map容器进行操作时,都是根据键对象的哈希码来计算存储位置的。如果我们对哈希码的获取没有相关保证,就可能会得不到预期的结果。image.png而对象的哈希码的获取正是通过hashCode方法获取的。如果自定义的类中没有实现该方法,则会采用Object中的hashCode()方法。

在Object中该方法是一个本地方法,会返回一个int类型的哈希值。可以通过将对象的内部地址转换为整数来实现的,但是Java中没有强制要求通过该方式实现。

具体实现网络上有不同的说法,有说通过内置地址转换得来,也有说“OpenJDK8默认hashCode的计算方法是通过和当前线程有关的一个随机数+三个确定值,运用Marsaglia's xorshift scheme随机数算法得到的一个随机数”获得。

无论默认实现是怎样的,大多数情况下都无法满足equals方法相同,同时hashCode结果也相同的条件。比如下面的示例重写与否差距很大。










public void test1() {  String s = "ok";  StringBuilder sb = new StringBuilder(s);  System.out.println(s.hashCode() + "  " + sb.hashCode());
  String t = new String("ok");  StringBuilder tb = new StringBuilder(s);  System.out.println(t.hashCode() + "  " + tb.hashCode());}

上面这段代码打印的结果为:

3548  1833638914
3548  1620303253

String实现了hashCode方法,而StringBuilder并没有实现,这就导致即使值是一样的,hashCode也不同。

上个示例中问题还不太明显,下面我们以HashMap为例,看看如果没有实现hashCode方法会导致什么严重的后果。








































@Testpublic void test2() {  String hello = "hello";
  Map<String, String> map1 = new HashMap<>();  String s1 = new String("key");  String s2 = new String("key");  map1.put(s1, hello);  System.out.println("s1.equals(s2):" + s1.equals(s2));  System.out.println("map1.get(s1):" + map1.get(s1));  System.out.println("map1.get(s2):" + map1.get(s2));
  Map<Key, String> map2 = new HashMap<>();  Key k1 = new Key("A");  Key k2 = new Key("A");  map2.put(k1, hello);  System.out.println("k1.equals(k2):" + s1.equals(s2));  System.out.println("map2.get(k1):" + map2.get(k1));  System.out.println("map2.get(k2):" + map2.get(k2));}
class Key {
  private String k;
  public Key(String key) {    this.k = key;  }
  @Override  public boolean equals(Object obj) {    if (obj instanceof Key) {      Key key = (Key) obj;      return k.equals(key.k);    }    return false;  }}

实例中定义了内部类Key,其中实现了equals方法,但未实现hashCode方法。存放于Map中的value值都是字符串“hello”。

代码分两段,第一段演示当Map的key通过实现了hashCode的String时是什么效果;第二段演示了当Map的key通过未实现hashCode方法的Key对象时是什么效果。

执行上述代码,打印结果如下:

s1.equals(s2):true
map1.get(s1):hello
map1.get(s2):hello
k1.equals(k2):true
map2.get(k1):hello
map2.get(k2):null

分析结果可以看出,对于String作为key的s1和s2来说,通过equals比较相等是自然的,获得的值也是相同的。但k1和k2通过equals比较是相等,但为什么在Map中获得的结果却不一样?本质上就是因为没有重写hashCode方法导致Map在存储和获取过程中调用hashCode方法获得的值不一致。

此时在Key类中添加hashCode方法:

@Override
public int hashCode(){
  return k.hashCode();
}

再次执行,便可正常获得对应的值。

s1.equals(s2):true
map1.get(s1):hello
map1.get(s2):hello
k1.equals(k2):true
map2.get(k1):hello
map2.get(k2):hello

通过上面的典型实例演示了不重写hashCode方法的潜在后果。简单看一下HashMap中的put方法。























































public V put(K key, V value) {    return putVal(hash(key), key, value, false, true);}
static final int hash(Object key) {    int h;    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);}
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;    // 通过哈希值来查找底层数组位于该位置的元素p,如果p不为null,则使用新的键值对来覆盖旧的键值对    if ((p = tab[i = (n - 1) & hash]) == null)        tab[i] = newNode(hash, key, value, null);    else {        Node<K,V> e; K k;        // (二者哈希值相等)且(二者地址值相等或调用equals认定相等)。        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;            }        }        // 如果底层数组中存在传入的Key,那么使用新传入的覆盖掉查到的        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;}

在上述方法中,put方法在拿到key的第一步就对key对象调用了hashCode方法。暂且不看后面的代码,如果没有重写hashCode方法,就无法确保key的hash值一致,后续操作就是两个key的操作了。

重写hashCode方法

了解了重写hashCode方法的重要性,也了解了对应的规约,那么下面我们就聊聊如何优雅的重写hashCode方法。

首先,如果使用IDEA的话,那么直接使用快捷键即可。image.png生成的效果如下:

















@Overridepublic boolean equals(Object o) {  if (this == o) {    return true;  }  if (o == null || getClass() != o.getClass()) {    return false;  }  Key key = (Key) o;  return Objects.equals(k, key.k);}
@Overridepublic int hashCode() {  return Objects.hash(k);}

根据需要可对生成的方法内部实现进行修改。在上面的实例中用到了java.util.Objects类,它的hash方法的优点是如果参数为null,就只返回0,否则返回对象参数调用的hashCode的结果。Objects.hash方法源码如下:




public static int hash(Object... values) {    return Arrays.hashCode(values);}

其中Arrays.hashCode方法源码如下:












public static int hashCode(Object a[]) {    if (a == null)        return 0;
    int result = 1;
    for (Object element : a)        result = 31 * result + (element == null ? 0 : element.hashCode());
    return result;}

当然此处只有一个参数,也可以直接使用Objects类hashCode方法:




public static int hashCode(Object o) {    return o != null ? o.hashCode() : 0;}

如果是多个属性都参与hash值的情况建议可使用第一个方法。只不过需要注意,在类结构(成员变量)变动时,同步增减方法里面的参数值。

小结

当我们准备面试时,一直在背诵“实现equals方法的同时也要实现hashCode方法”,牢记这些结论并没有错。但我们也不能因为匆忙准备面试题,而忘记了这些面试题之所以频繁出现的原因是什么。当深入探索之后,会发现在那些枯燥的结论背后还有这么多不容忽视的知识点,还有这么多有意思的设计与陷阱。

我是觉得越研究越有意思,越研究越发现自己曾经的无知。你呢?关注一下,期待下一篇文章吧。

目录
相关文章
|
11月前
|
人工智能 前端开发 Java
Java 面试资料中相关代码使用方法与组件封装方法解析
这是一份详尽的Java面试资料代码指南,涵盖使用方法与组件封装技巧。内容包括环境准备(JDK 8+、Maven/Gradle)、核心类示例(问题管理、学习进度跟踪)、Web应用部署(Spring Boot、前端框架)、单元测试及API封装。通过问题库管理、数据访问组件、学习进度服务和REST接口等模块化设计,帮助开发者高效组织与复用功能,同时支持扩展如用户认证、AI推荐等功能。适用于Java核心技术学习与面试备考,提升编程与设计能力。资源链接:[点此下载](https://pan.quark.cn/s/14fcf913bae6)。
218 6
Java 面试资料中相关代码使用方法与组件封装方法解析
|
人工智能 算法 数据库
美团面试:LLM大模型存在哪些问题?RAG 优化有哪些方法?_
美团面试:LLM大模型存在哪些问题?RAG 优化有哪些方法?_
|
Java 程序员 调度
Java 高级面试技巧:yield() 与 sleep() 方法的使用场景和区别
本文详细解析了 Java 中 `Thread` 类的 `yield()` 和 `sleep()` 方法,解释了它们的作用、区别及为什么是静态方法。`yield()` 让当前线程释放 CPU 时间片,给其他同等优先级线程运行机会,但不保证暂停;`sleep()` 则让线程进入休眠状态,指定时间后继续执行。两者都是静态方法,因为它们影响线程调度机制而非单一线程行为。这些知识点在面试中常被提及,掌握它们有助于更好地应对多线程编程问题。
625 9
|
安全 Java 程序员
Java面试必问!run() 和 start() 方法到底有啥区别?
在多线程编程中,run和 start方法常常让开发者感到困惑。为什么调用 start 才能启动线程,而直接调用 run只是普通方法调用?这篇文章将通过一个简单的例子,详细解析这两者的区别,帮助你在面试中脱颖而出,理解多线程背后的机制和原理。
955 12
|
存储 Java 程序员
Java基础的灵魂——Object类方法详解(社招面试不踩坑)
本文介绍了Java中`Object`类的几个重要方法,包括`toString`、`equals`、`hashCode`、`finalize`、`clone`、`getClass`、`notify`和`wait`。这些方法是面试中的常考点,掌握它们有助于理解Java对象的行为和实现多线程编程。作者通过具体示例和应用场景,详细解析了每个方法的作用和重写技巧,帮助读者更好地应对面试和技术开发。
627 4
|
缓存 安全 Java
【JavaEE】——单例模式引起的多线程安全问题:“饿汉/懒汉”模式,及解决思路和方法(面试高频)
单例模式下,“饿汉模式”,“懒汉模式”,单例模式下引起的线程安全问题,解锁思路和解决方法
|
ARouter 测试技术 API
Android经典面试题之组件化原理、优缺点、实现方法?
本文介绍了组件化在Android开发中的应用,详细阐述了其原理、优缺点及实现方式,包括模块化、接口编程、依赖注入、路由机制等内容,并提供了具体代码示例。
469 3
|
存储 Java
【IO面试题 四】、介绍一下Java的序列化与反序列化
Java的序列化与反序列化允许对象通过实现Serializable接口转换成字节序列并存储或传输,之后可以通过ObjectInputStream和ObjectOutputStream的方法将这些字节序列恢复成对象。
|
存储 算法 Java
大厂面试高频:什么是自旋锁?Java 实现自旋锁的原理?
本文详解自旋锁的概念、优缺点、使用场景及Java实现。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:什么是自旋锁?Java 实现自旋锁的原理?
|
存储 缓存 算法
面试官:单核 CPU 支持 Java 多线程吗?为什么?被问懵了!
本文介绍了多线程环境下的几个关键概念,包括时间片、超线程、上下文切换及其影响因素,以及线程调度的两种方式——抢占式调度和协同式调度。文章还讨论了减少上下文切换次数以提高多线程程序效率的方法,如无锁并发编程、使用CAS算法等,并提出了合理的线程数量配置策略,以平衡CPU利用率和线程切换开销。
面试官:单核 CPU 支持 Java 多线程吗?为什么?被问懵了!