Java--深入解读equals与hashCode

注:博客内容主要摘抄自:重写equals时为什么也得重写hashCode之深度解读equals方法与hashCode方法渊源

引言

同样,在翻阅《阿里巴巴Java开发手册》时,碰到一条【强制】性规则:

1) 只要重写 equals,就必须重写 hashCode。
2) 因为 Set 存储的是不重复的对象,依据 hashCode 和 equals 进行判断,所以 Set 存储的对象必须重写这两个方法。
3) 如果自定义对象作为 Map 的键,那么必须重写 hashCode 和 equals。

这个问题博主已经碰到过多次,很经典,遂决定将其分析过程记录下来,以供以后参阅。


equals所属及内部原理

说起 equals 方法,我们都知道是超类 Object 中的一个基本方法,用于检测一个对象是否与另外一个对象相等。而在 Object 类中这个方法实际上是判断两个对象是否具有相同的引用,如果有,它们就一定相等。其源码如下:

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

实际上我们知道所有的对象都拥有标识(内存地址)和状态(数据),同时“==”比较两个对象的的内存地址,所以说 Object 的 equals() 方法是比较两个对象的内存地址是否相等,即若object1.equals(object2)为 true,则表示 equals1 和 equals2 实际上是引用同一个对象。


equals与“==”的区别

或许这是我们面试时更容易碰到的问题“equals 方法与“”运算符有什么区别?”,并且常常我们都会胸有成竹地回答:“equals 比较的是对象的内容,而“”比较的是对象的地址”。但是从前面我们可以知道 equals 方法在 Object 中的实现也是间接使用了“==”运算符进行比较的,所以从严格意义上来说,我们前面的回答并不完全正确。

因此前面的面试题我们应该这样回答更佳:默认情况下也就是从超类 Object 继承而来的 equals 方法与“==”是完全等价的,比较的都是对象的内存地址,但我们可以重写 equals 方法,使其按照我们的需求进行比较,如 String 类重写了 equals 方法,使其比较的是字符的序列,而不再是内存地址。


equals的重写规则

前面我们已经知道如何去重写 equals 方法来实现自己的需求了,但是我们在重写 equals 方法时,还要注意如下几点规则:

  • 自反性:对于任何非 null 的引用值 x,x.equals(x)应返回true;
  • 对称性:对于任何非 null 的引用值 x 与 y,当且仅当:y.equals(x)返回 true 时,x.equals(y)才返回 true;
  • 传递性:对于任何非 null 的引用值 x、y 与 z,如果y.equals(x)返回 true,y.equals(z)返回 true,那么x.equals(z)也应返回 true;
  • 一致性:对于任何非 null 的引用值 x 与 y,假设对象上 equals 比较中的信息没有被修改,则多次调用x.equals(y)始终返回 true 或者始终返回 false。

在通常情况下,如果只是进行同一个类两个对象的相等比较,一般都可以满足以上 5 点要求,但如果是子类与父类混合比较,那么我们就需要特别注意对称性与传递性。

接下来我们分析一个反面例子,看看不遵守这些规则到底会造成什么样的后果。

public class Car {
	private int batch;
	
	public Car(int batch) {
		this.batch = batch;
	}
	
	@Override
	public boolean equals(Object obj) {
		if (obj instanceof Car) {
			Car c = (Car) obj;
			return batch == c.batch;
		}
		return false;
	}
}
public class BigCar extends Car {
	private int count;
	
	public BigCar(int batch, int count) {
		super(batch);
		this.count = count;
	}
	
	@Override
	public boolean equals(Object obj) {
		if (obj instanceof BigCar) {
			BigCar bc = (BigCar) obj;
			
			return super.equals(bc) && count == bc.count;
		}
		
		return false;
	}
}
public class Main {
    public static void main(String[] args) {
        Car c = new Car(1);
        BigCar bc = new BigCar(1, 20);
        
        System.out.println(c.equals(bc));
        System.out.println(bc.equals(c));
    }
}

如果我们的需求是只要 BigCar 和 Car 的生产批次一样,我们就认为它们两个是相当的,我们来看一下输出结果:

true
false

可见并不符合对称性原则,在这样的情况下我们需要对 BigCar 的 equals 方法应该做如下修改:

@Override
public boolean equals(Object obj) {
	if (obj instanceof BigCar) {
		BigCar bc = (BigCar) obj;
		
		return super.equals(bc) && count == bc.count;
	}
	
	return super.equals(obj);
}

这样运行的结果就都为true了。但是到这里问题并没有结束,虽然符合了对称性,却还没符合传递性,如下:

public class Main {
    public static void main(String[] args) {
        Car c = new Car(1);
		BigCar bc = new BigCar(1, 20);
		BigCar bc2 = new BigCar(1, 22);
		
		System.out.println(bc.equals(c));
		System.out.println(c.equals(bc2));
		System.out.println(bc.equals(bc2));
    }
}

运行结果:

true
true
false

bc,bc2,c的批次都是相同的,按我们之前的需求应该是相等,而且也应该符合 equals 的传递性才对,但事实上运行结果却不是这样,违背了传递性。出现这种情况根本原因在于:

  1. 父类与子类进行混合比较;
  2. 子类中声明了新变量(count),并且在子类 equals 方法使用了新增的成员变量作为判断对象是否相等的条件。

只要满足上面两个条件,equals 方法的传递性便失效了。我们可以通过在子类中仍然只比较 batch 的值来解决问题,也可以通过组合的方式去解决这种由于使用继承而破坏 equals 重写规则的问题。具体的实现代码我不再贴出,大家以后在重写 equals 方法时只要注意这个问题就行了。


为什么重写equals的同时还得重写hashCode

这个问题主要是针对映射相关的操作(Map接口)。学过数据结构的同学都知道 Map 接口的类会使用到键对象的哈希码,当我们调用 put 方法或者 get 方法对 Map 容器进行操作时,都是根据键对象的哈希码来计算存储位置的,因此如果我们对哈希码的获取没有相关保证,就可能会得不到预期的结果。在 Java 中,我们可以使用 hashCode() 来获取对象的哈希码,这个方法在 Object 类中声明,因此所有的子类都含有该方法,其值就是对象的存储地址

在 Java API 文档中关于 hashCode 方法(这里指的是重写后的 hashCode 方法)有以下几点规定:

  1. 在 Java 应用程序执行期间,如果在 equals 方法比较中所用的信息没有被修改,那么在同一个对象上多次调用 hashCode 方法时必须一致地返回相同的整数。如果多次执行同一个应用时,不要求该整数必须相同;
  2. 如果两个对象通过调用 equals 方法是相等的,那么这两个对象调用 hashCode 方法必须返回相同的整数
  3. 如果两个对象通过调用 equals 方法是不相等的,不要求这两个对象调用 hashCode 方法必须返回不同的整数。但是程序员应该意识到对不同的对象产生不同的 hash 值可以提高哈希表的性能。

如果只重写equals方法会产生什么后果

由于 hashCode 默认返回的是对象的存储地址,因此当我们重写 equals 方法后,判断两个对象的内容相等时,而不重写 hashCode 方法就会造成由 hashCode 方法得到的结果还是两个对象不等,这就违反了第二条规定。此时如果我们通过 Map 接口操作相关对象时,就无法达到我们预期想要的效果。具体例子如下:

import java.util.HashMap;
import java.util.Map;

public class MapTest {
	public static void main(String[] args) {
		Map<String,Value> map1 = new HashMap<String,Value>();
		
		String s1 = new String("key");
		String s2 = new String("key");	
		Value value = new Value(2);
		
		map1.put(s1, value);
		
		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,Value> map2 = new HashMap<Key,Value>();
		
		Key k1 = new Key("A");
		Key k2 = new Key("A");
		
		map2.put(k1, value);
		
		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));
	}
	
	static 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;
		}
	}
	
	static class Value {
		private int v;
		
		public Value(int v) {
			this.v = v;
		}
		
		@Override
		public String toString() {
			return "类Value的值-->" + v;
		}
	}
}

运行结果:

s1.equals(s2):true
map1.get(s1):类Value的值-->2
map1.get(s2):类Value的值-->2
k1.equals(k2):true
map2.get(k1):类Value的值-->2
map2.get(k2):null

由于 String 类重写了 equals 方法和 hashCode 方法,使其比较的是内容和获取的是内容的哈希码。因此操作 map1 所产生的结果在我们的预料之中。而由于我们在 Key 中只重写 equals 方法没有重写 hashCode 方法,从逻辑上进行判断,k1,k2 两个对象是相同的,但由于没有重写 hashCode 方法,从对象的存储地址上来看,两个对象又不等,因此造成通过 k2 所获取的值为 null。

如何重写hashCode方法

《Effective Java》中给出了一个能最大程度上避免哈希冲突的写法,但我个人认为对于一般的应用来说没有必要搞的这么麻烦。如果你的应用中 HashSet 中需要存放上万上百万个对象时,那你应该严格遵循书中给定的方法。一般来说我们不用那么麻烦,只要通过合理的利用各个属性对象的散列码进行组合,最终便能产生一个相对比较好的或者说更加均匀的散列码:

public class Model {
	private String name;
	private double salary;
	private int sex;
	
	@Override
	public int hashCode() {
		return name.hashCode() + new Double(salary).hashCode() 
		    + new Integer(sex).hashCode();
	}
}

我们将基本类型装箱为包装类型,通过包装类型得到的 hashCode 是 JDK 给我们已经重写过的,可以直接使用。当然上面仅仅是个参考例子而已,我们也可以通过其他方式去实现,只要能使散列码更加均匀。不过这里有点要注意的就是 Java 7 中对 hashCode 方法做了两个改进,首先 Java 发布者希望我们使用更加安全的调用方式来返回散列码,也就是使用 null 安全的方法Objects.hashCode(),这个方法的优点是如果参数为 null,就只返回 0,否则返回对象参数调用的 hashCode 的结果。Objects.hashCode()源码如下:

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

因此我们修改后的代码如下:

import java.util.Objects;

public  class Model {
	private String name;
	private double salary;
	private int sex;
	
	@Override
	public int hashCode() {
		return Objects.hashCode(name) + new Double(salary).hashCode() + new Integer(sex).hashCode();
	}
}

java 7 还提供了另外一个方法java.util.Objects.hash(Object... objects),当我们需要组合多个散列值时可以调用该方法。进一步简化上述的代码:

import java.util.Objects;

public  class Model {
	private String name;
	private double salary;
	private int sex;
	
	@Override
	public int hashCode() {
		return Objects.hash(name, salary, sex);
	}
}

好了,到此hashCode()该介绍的我们都说了,还有一点要说的如果我们提供的是一个数组类型的变量的话,那么我们可以调用Arrays.hashCode()来计算它的散列码,这个散列码是由数组元素的散列码组成的。


重写equals中getClass与instanceof的区别

虽然前面我们都在使用 instanceof,但是在重写 equals 方法时,一般都是推荐使用 getClass 来进行类型判断(除非所有的子类有统一的语义才使用 instanceof)。我们都知道 instanceof 的作用是判断其左边对象是否为其右边类的实例,返回 boolean 类型的数据。可以用来判断继承中的子类的实例是否为父类的实现。下面我们来看一个例子:

public class Person {
    protected String name;
    
    public Person(String name) {
        this.name = name;
    }
    
    public String getName() {
        return name;
    }
    
    public void setName(String name) {
        this.name = name;
    }
    
    public boolean equals(Object object) {
        if(object instanceof Person) {
            Person p = (Person) object;
            
            return name.equals(p.getName());
        }
        
        return false;
    }
}
public class Employee extends Person {
    private int id;
    
    public Employee(String name,int id) {
        super(name);
        this.id = id;
    }
    
    public int getId() {
        return id;
    }
    
    public void setId(int id) {
        this.id = id;
    }

    @Override
    public boolean equals(Object object) {
        if(object instanceof Employee) {
            Employee e = (Employee) object;
            
            return super.equals(object) && e.getId() == id;
        }
        
        return false;
    }
}
public class Test {
    public static void main(String[] args) {
        Employee e1 = new Employee("chenssy", 23);
        Employee e2 = new Employee("chenssy", 24);
        Person p1 = new Person("chenssy");
        
        System.out.println(p1.equals(e1));
        System.out.println(p1.equals(e2));
        System.out.println(e1.equals(e2));
    }
}

运行结果如下:

true
true
false

出现上面的情况就是使用了关键字 instanceof,故在覆写 equals 时推荐使用 getClass 进行类型判断。而不是使用 instanceof(除非子类拥有统一的语义)。


编写一个完美equals的几点建议

下面给出编写一个完美的 equals 方法的建议(出自 Java 核心技术 第一卷:基础知识):

  1. 显式参数命名为 otherObject,稍后需要将它转换成另一个叫做 other 的变量(参数名命名,强制转换请参考建议5);
  2. 检测 this 与 otherObject 是否引用同一个对象:if(this == otherObject) return true
  3. 检测 otherObject 是否为 null,如果为 null,返回 false;
  4. 比较 this 与 otherObject 是否属于同一个类(视需求而选择),如果 equals 的语义在每个子类中有所改变,就使用 getClass 检测:if(getClass() != otherObject.getClass()) return false。如果所有的子类都拥有统一的语义,就使用 instanceof 检测:if(!(otherObject instanceof ClassName)) return false
  5. 将 otherObject 转换为相应的类类型变量:ClassName other = (ClassName) otherObject
  6. 现在开始对所有需要比较的域进行比较。使用 == 比较基本类型域,使用 equals 比较对象域。如果所有的域都匹配,就返回 true,否则就返回 flase。如果在子类中重新定义 equals,就要在其中包含调用super.equals(other)

总结

  1. 熟悉 equals 方法与“==”运算符的区别;
  2. 熟悉 equals 方法的重写规则;
  3. 熟悉重写 equals 方法为何还要重写 hashCode 方法的原因;
  4. 熟悉重写 hashCode 方法的规则;
  5. 熟悉 equals 中 getClass 与 instanceof 的区别;
  6. 了解如何去编写一个完美的 equals 方法。

参考阅读

重写equals时为什么也得重写hashCode之深度解读equals方法与hashCode方法渊源

如何重写hashCode()和equals()

面试官爱问的equals与hashCode

展开阅读全文

没有更多推荐了,返回首页