一、概述

equalshashCode作为Java基础经常在面试中提到,比如下面几个问题:

  1. equals==有什么区别?
  2. equalshashCode有什么关系?
  3. equalshashCode如何编写?

对于第一个问题不少人只停留在字符串equals比较的是内容,==比较的是内存地址,而对equals的本质极少过问。第二个问题,大多数都知道答案,也有不少记反了,但是更进一步为什么是那样的关系,就不知道了。对于第三个问题,大部分人一上手就把方法签名写错了,就别谈正确的写出实现了。带着这些问题,接下来谈谈自己的一点理解。

二、equals方法

先来看见equals方法的签名,

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

可以看到入参是Object,很多人没有注意到这一点,上来就写错了。equals方法顾名思义就判断对象的相等性,默认实现就是==,那么说到二者的区别,个人理解,equals方法是一种用户定义的“逻辑等”,而==是一种“物理等”,用俗语解释就是,equals判断是否相同,==判断是否一样。

equals方法在编写的时候需要遵循以下原则:

  • 自反性
  • 对称性
  • 传递性
  • 一致性

下面展开说一下,

  1. 自反性的意思是,对于一个非null的对象x,x.equals(x)一定为true,这是显而易见的,无须赘述。

  2. 对称性,对于非null对象x、y,x.equals(y) == true,当且仅当y.equals(x) == true。来看一个来自《Effective Java》的例子,

    // Broken - violates symmetry!
    public final class CaseInsensitiveString {
      private final String s;
    
      public CaseInsensitiveString(String s) {
        this.s = Objects.requireNonNull(s);
      }
    
      // Broken - violates symmetry!
      @Override public boolean equals(Object o) {
        if (o instanceof CaseInsensitiveString)
          return s.equalsIgnoreCase(
              ((CaseInsensitiveString) o).s);
        if (o instanceof String)  // One-way interoperability!
          return s.equalsIgnoreCase((String) o);
        return false;
      }
      ...  // Remainder omitted
    }
    CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
    String s = "polish";
    List<CaseInsensitiveString> list = new ArrayList<>();
    list.add(cis);
    // true or false
    list.contains(s);
    

    在JDK8运行list.contains(s)返回false,但是有的JDK可能会返回true,甚至直接崩溃,所以如果违反了对称性,程序的行为是不可预测的。

  3. 传递性,对于非null对象x、y、z,如果x.equals(y) == truey.equals(z) == true,那么x.equals(z) == true。同样是来自《Effective Java》的一个例子,

    public class Point {
      private final int x;
      private final int y;
    
      public Point(int x, int y) {
        this.x = x;
        this.y = y;
      }
    
      @Override public boolean equals(Object o) {
        if (!(o instanceof Point))
          return false;
        Point p = (Point)o;
        return p.x == x && p.y == y;
      }
    
      ...  // Remainder omitted
    }
    
    public class ColorPoint extends Point {
      private final Color color;
    
      public ColorPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
      }
    
      // Broken - violates transitivity!
      @Override public boolean equals(Object o) {
        if (!(o instanceof Point))
          return false;
    
        // If o is a normal Point, do a color-blind comparison
        if (!(o instanceof ColorPoint))
          return o.equals(this);
    
        // o is a ColorPoint; do a full comparison
        return super.equals(o) && ((ColorPoint) o).color == color;
      }
    
      ...  // Remainder omitted
    }
    
    ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
    Point p2 = new Point(1, 2);
    ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
    

    显然ColorPointequals实现违反了传递性,p1.equals(p2) == p2.equals(p3) != p1.equals(p3)。假如Point有两个子类ColorPointSmellPointcolorPoint.equals(smellPoint)将会导致无限递归,最终导致内存耗尽。引用《Effective Java》的说法,

    There is no way to extend an instantiable class and add a value component while preserving the equals contract, unless you’re willing to forgo the benefits of object-oriented abstraction.

    这句话的大意是如果你继承扩展一个类,就没法再保持equals的原则了,除非放弃使用继承。放弃继承?这不是让我们因噎废食嘛,咦,别说,还真能放弃继承,那就是组合,因为本文的重点是equalshashCode就不展开了。

  4. 一致性,对于非null对象x、y,多次调用x.equals(y)返回一致。一致性意味着equals方法不要依赖不可靠的变量,这里“可靠”的意思不光意味着“不该变时不变”,还意味着“想获取时能获取到”,比如java.net.URLequals实现依赖了ip地址,而网络故障时无法获取ip,这是一个不好的实现。

说了那么多,有人可能会说,哎呀这么多原则顾头不顾尾,都要满足,太难了吧,下面列出实现equals的一些tips,照着做实现起来就易如反掌,

  1. 使用==判断是否为nullthis,如果是前者返回false,后者就返回true
  2. 使用instanceof检测是否是正确的类型,如果不是直接返回false,如果是,强制转换为正确的类型,然后比较与“逻辑等”相关的变量。

三、hashCode方法

hashCode主要用来在Java中哈希数据结构HashMapHashSet生成哈希值,hashCode的方法签名,

public native int hashCode();

默认实现会将对象的内存地址转化为一个整数,因此只有同一个对象hashCode才一样,即使两个equals返回true的对象hashCode也不一样,如果不进行重写。和equals一样,hashCode也需要满足一些原则:

  1. 一致性,和equals相关的变量没有变化,hashCode返回值也不能变化。

  2. 两个对象equals返回truehashCode返回值应该相等。由上面得知,hashCode默认实现不满足这一条件,因此任何类如果实现了equals就必须实现hashCode,确保二者的步调一致,下面来看一个反例,

    public class Person {
      private int age;
      private String name;
    
      public Person(int age, String name) {
        this.age = age;
        this.name = name;
      }
    
      @Override
        public boolean equals(Object obj) {
          if (obj == null) {
            return false;
          }
          if (obj == this) {
            return true;
          }
    
          if (obj instanceof Person) {
            Person that = (Person) obj;
            return age == that.age && Objects.equals(name, that.name);
          }
    
          return false;
        }
    }
    
    Map<Person, Integer> map = new HashMap<>();
    map.put(new Person(10, "小明"), 1);
    map.get(new Person(10, "小明"));
    

    初学者可能觉得最后一条语句会返回1,事实上返回的是null,为什么会这样呢?明明将数据放进去了,而数据却像被黑洞吞噬一样,要解释得从HashMap的数据结构说起,HashMap是由数组和链表组成的一种组合结构,如下图,往里存放时,hashCode决定数组的下标,而equals用于查找值是否已存在,存在的话替换,否则插入;往外取时,先用hashCode找到对应数组下标,然后用equals挨个比较直到链表的尾部,找到返回相应值,找不到返回null。再回过头看刚才的问题,先放进去一个new Person(10, "小明"),然后取的时候又新建了一个new Person(10, "小明"),由于没有重写hashCode,这两个对象的hashCode是不一样的,存和取的数组下标也就不一样,自然取不出来了。

    HashMap数据结构

  3. 两个对象equals返回falsehashCode返回值可以相等,但是如果不等的话,可以改进哈希数据结构的性能。这条原则也可以用HashMap的数据结构解释,举一个极端的例子,假如Person所有对象的hashCode都一样,那么HashMap内部数组的下标都一样,数据就会进到同一张链表里,这张链表比正常情况下要长的多,而遍历链表是一项耗时的工作,性能也就下来了。

那么如何写一个好的hashCode呢?

  1. 声明一个变量int的变量result,将第一个和equals相关的实例变量的hashCode赋值给它。
  2. 然后按照下列规则依次计算剩下的实例变量的hashCodec
    1. 如果是null,设置一个常数,通常为0
    2. 如果是原始类型,使用Type.hashCode(f)Type为它们的装箱类型
    3. 如果是数组,如果每一个元素都是相关的,可以使用Arrays.hashCode;否则将相关元素看作独立的变量计算
  3. 使用result = 31 * result + c的形式将每个变量的哈希值组合起来,最后返回result

参考资料:《Effective Java》