理解Java的hashCode和equals方法

最近参加工作室面试,一直被问到一些关于Object类的,赶紧恶补一下,本篇是关于从源码角度理解 Java 的 hashCode 和 equals 方法的。

测试用例

    这里新建一个很普通的测试类和一个测试方法。

    我们在 HashTest 里面重写了 toString 方法,在输出对象的时候更容易区分。

class HashTest {
    Integer i;
    String data;

    public HashTest(Integer i, String data) {
        this.i = i;
        this.data = data;
    }

    @Override
    public String toString() {
        return "HashTest{i=" + i + ", data='" + data + "\'";
    }
}

...

public void test () {
    HashTest hashTest1 = new HashTest(1, "");
    HashTest hashTest2 = new HashTest(2, "");
    HashTest hashTest3 = new HashTest(1, "");

    HashSet<HashTest> hashSet = new HashSet<>();
    hashSet.add(hashTest1);
    hashSet.add(hashTest2);
    hashSet.add(hashTest3);

    hashSet.stream().forEach((e)->System.out.println(e));
}

/**
 * HashTest{i=2, data=''}
 * HashTest{i=1, data=''}
 * HashTest{i=1, data=''}
 */

    这里就出现了一个很神奇的现象,在本来不允许存在多个相同对象的 HashSet 中居然存在两个相同的对象 ( HashTest{i=1, data=’’} )

寻找问题

    既然要寻找哪里出了问题,我们就要以程序的角度去看待问题。

    HashSet 不允许有多个相同对象,那 HashSet 是怎么确认相不相同的呢?

    Java 有一个口号 “ Everything is an object. " 从一个层面上说明了所有对象都基于 Object 类,都具有共同的 Object 的属性,在翻找 Object 类的方法后,不难发现一个 equals 方法。

    下面是对前一个示例的拓展。

System.out.println("hashTest1.equals(hashTest2) = " + hashTest1.equals(hashTest2));
System.out.println("hashTest1.equals(hashTest3) = " + hashTest1.equals(hashTest3));
/**
 * hashTest1.equals(hashTest2) = false
 * hashTest1.equals(hashTest3) = false
 */

    可以看到通过 equals 函数比较出来的 hashTest1 和其他对象(包括内容一样的 hashTest3)都不一样,这就不难明白 HashSet 把这三个对象当做均不一样的对象来保存。

探索 equals 方法

    为了了解 Object 类默认是怎么实现,我截取了一段关于 equals 方法的源码

/**
 * Indicates whether some other object is "equal to" this one.
 * ···
 */
public boolean equals(Object obj) {
    return (this == obj);
}

    从源码中不难看出,Java 默认的比较只是对引用地址进行了比较,并没有对具体内容进行比较,所以造成了以上的 HashSet 存在多个相同对象的现象。

    既然默认提供的 equals 方法不行,那我们就自己重写来实现。

class HashTest {

    ...

    @Override
    public boolean equals(Object obj) {
        if(obj instanceof HashTest){
            if(((HashTest) obj).data.equals(this.data) && ((HashTest) obj).i.equals(this.i)){
                return true;
            }
        }
        return false;
    }
}

/**
 * hashTest1.equals(hashTest2) = false
 * hashTest1.equals(hashTest3) = true
 */

    重新运行 equals 方法,发现回到了我们预期的情况。

重写 equals 方法时的规定

    从 Java 官方的注解中可以发现:

    The equals method implements an equivalence relation on non-null object references:

  • It is reflexive: for any non-null reference value x, x.equals(x) should return true.
  • It is symmetric: for any non-null reference values x and y, x.equals(y) should return true if and only if y.equals(x) returns true.
  • It is transitive: for any non-null reference values x, y, and z, if x.equals(y) returns true and y.equals(z) returns true, then x.equals(z) should return true.
  • It is consistent: for any non-null reference values x and y, multiple invocations of x.equals(y) consistently return true or consistently return false, provided no information used in equals comparisons on the objects is modified.
  • For any non-null reference value x, x.equals(null) should return false.

    翻译成中文就是,你重写了这个方法后要满足自反性、对称性、传递性、一致性。

  • 自反性:自己比较自己永远是真的
  • 对称性:既然A等于B,那么B等于A
  • 传递性:A等于B、B等于C,所以A等于C
  • 一致性:多次比较结果不变

    下面对我们刚才自己重写的 equals 方法进行检测。

// 自反性
System.out.println("自反性 hashTest1.equals(hashTest1) = " + hashTest1.equals(hashTest1));

// 对称性
System.out.println("对称性 hashTest1.equals(hashTest2) = " + hashTest1.equals(hashTest2));
System.out.println("对称性 hashTest2.equals(hashTest1) = " + hashTest2.equals(hashTest1));

// 传递性
System.out.println("传递性 hashTest1.equals(hashTest2) = " + hashTest1.equals(hashTest2));
System.out.println("传递性 hashTest2.equals(hashTest3) = " + hashTest2.equals(hashTest3));
System.out.println("传递性 hashTest1.equals(hashTest3) = " + hashTest1.equals(hashTest3));

/**
 * 自反性 hashTest1.equals(hashTest1) = true
 * 对称性 hashTest1.equals(hashTest2) = true
 * 对称性 hashTest2.equals(hashTest1) = true
 * 传递性 hashTest1.equals(hashTest2) = true
 * 传递性 hashTest2.equals(hashTest3) = true
 * 传递性 hashTest1.equals(hashTest3) = true
 */

    可以看出我们写的 equals 方法满足自反性、对称性、传递性。

探索 HashSet 添加的实现

    在前文我们找到了 Java 底层去实现比较两个对象的方法,但回到 HashSet 上来又是怎么实现的呢。

    同样的,我们在源码中找到 HashSet 的 add 方法。

public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable
{
    private transient HashMap<E,Object> map;
    // Dummy value to associate with an Object in the backing Map
    private static final Object PRESENT = new Object();
    ···
    public boolean add(E e) {
        return map.put(e, PRESENT)==null;
    }
    ···
}

    可以看到 add 方法实际上是调用了 HashSet 所封装的 HashMap 的 put 方法,那么我们进到 put 方法看看。

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
    ···
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
    ···
}

    可以看到 put 方法又调用了另外一个 putVal 方法,并多了一些参数,因此我们又找到 putVal 方法。

/**
 * Implements Map.put and related methods.
 *
 * @param hash hash for key
 * @param key the key
 * @param value the value to put
 * @param onlyIfAbsent if true, don't change existing value
 * @param evict if false, the table is in creation mode.
 * @return previous value, or null if none
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict)

    从注释中可以看出 putVal 需要 hash、key、value 等参数,剩下的参数都可以从注释中清楚地了解到,但这里为什么多了一个 hash 参数,它又和 HashMap( HashSet )的 ’Hash’ 有什么关系?

    在一番 Google 之后了解到 hash 在 HashMap( HashSet )中扮演了一个索引的角色。在 HashMap 执行添加操作时会先检测是否存在传入的哈希值,如果存在则比较 equals 方法;如果不存在则直接插入。

    ( 具体的putVal 方法实现分析和 HashMap 类的分析以后会补上 )

    因此我们回到上一层,可以发现这个哈希是通过一个 hash 的方法计算的。

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

    不难看出,如果传入的对象非空,就通过调用其自身的 hashCode 方法再加以计算返回哈希值。

    为了了解 hashCode 方法,我找到了默认实现这个方法的源码。

/**
 * Returns a hash code value for the object. This method is
 * supported for the benefit of hash tables such as those provided by
 * java.util.HashMap.
 */
@HotSpotIntrinsicCandidate
public native int hashCode();

    从源码中可以看到,hashCode 是一个 native (由 JNI 实现的)方法。返回一个对象的哈希值,用于与哈希表有关的类。


    那么我们回到最初最初的例子,执行 hashCode 方法,看看结果是什么。

public void test () {
    HashTest hashTest1 = new HashTest(1,"");
    HashTest hashTest2 = new HashTest(2,"");
    HashTest hashTest3 = new HashTest(1,"");

    System.out.println("hashTest1.hashCode = " + hashTest1.hashCode() + " " + hashTest1);
    System.out.println("hashTest2.hashCode = " + hashTest2.hashCode() + " "  + hashTest2);
    System.out.println("hashTest3.hashCode = " + hashTest3.hashCode() + " "  + hashTest3);
}

/**
 * hashTest1.hashCode = 1802598046 HashTest{i=1, data=''}
 * hashTest2.hashCode = 788117692 HashTest{i=2, data=''}
 * hashTest3.hashCode = 1566723494 HashTest{i=1, data=''}
 */

    程序输出的 hashCode 来看,三个对象都不一样。

    但是对象相同时哈希值必须相同,对象不同时哈希值有可能相同(取决于哈希值计算算法)。

    在这里程序调用的是 Object 类默认的 hashCode 方法,对于 Object 来说这三个对象肯定是不一样的,但是这并不是我们想要的结果,所以我们需要自己来重写默认的 hashCode 方法。

class HashTest {

    ...

    @Override
    public int hashCode() {
        return data.hashCode() ^ i.hashCode();
    }
}
/**
 * hashTest1.hashCode = 1 HashTest{i=1, data=''}
 * hashTest2.hashCode = 2 HashTest{i=2, data=''}
 * hashTest3.hashCode = 1 HashTest{i=1, data=''}
 */

    在这里我选择的计算方式是 data.hashCode() ^ i.hashCode()。

    这样看来,就满足了对象相同时哈希值必须相同的要求。

 public void test () {
    HashTest hashTest1 = new HashTest(1, "");
    HashTest hashTest2 = new HashTest(2, "");
    HashTest hashTest3 = new HashTest(1, "");

    HashSet<HashTest> hashSet = new HashSet<>();
    hashSet.add(hashTest1);
    hashSet.add(hashTest2);
    hashSet.add(hashTest3);

    hashSet.stream().forEach((e)->System.out.println(e));

 }
/**
 * HashTest{i=1, data=''}
 * HashTest{i=2, data=''}
 */

    这个时候我们再重新执行 add 方法就满足我们预期的期望了。

关于 hashCode 的小实验

    既然 hashCode 同意对象不同时哈希值有可能相同,那如果我让所有的 hashCode 都为 1 会出现什么结果。

class HashTest {

    ...

    @Override
    public int hashCode() {
        return 1;
    }
}

public void test () {
    HashTest hashTest1 = new HashTest(1, "");
    HashTest hashTest2 = new HashTest(2, "");
    HashTest hashTest3 = new HashTest(1, "");

    HashSet<HashTest> hashSet = new HashSet<>();
    hashSet.add(hashTest1);
    hashSet.add(hashTest2);
    hashSet.add(hashTest3);

    hashSet.stream().forEach((e)->System.out.println(e));
}

/**
 * HashTest{i=1, data=''}
 * HashTest{i=2, data=''}
 */

    重复前面的测试,结果依然满足预期。

    但如果添加的量大了,结果就不是那么好看了。

    下面是一段 hashCode 永远相同与否插入效率比较的程序。

class HashTest {

    ...

    @Override
    public int hashCode() {
        return data.hashCode() ^ i.hashCode();
    }
}

public void test () {
    int testSize = 10000;

    HashSet<HashTest> hashSet = new HashSet<>(testSize*2);

    long start = System.currentTimeMillis();
    for(int i = 0;i<testSize;i++) {
        hashSet.add(new HashTest(i, UUID.randomUUID().toString()));
    
    System.out.println("Using time: " + (System.currentTimeMillis() - start) + " ms");
    }
}

/**
 * Test size: 1000 Using time: 23 ms
 * Test size: 10000 Using time: 52 ms
 * Test size: 100000 Using time: 277 ms
 * Test size: 1000000 Using time: 1719 ms
 * Test size: 10000000 Using time: 15617 ms
 */

    从结果可以看出,当数据规模达到千万级别是,HashSet还是可以在 15s 左右完成所有任务。

class HashTest {

    ...

    @Override
    public int hashCode() {
        return 1;
    }
}

···

/**
 * Test size: 1000 Using time: 40 ms
 * Test size: 10000 Using time: 1570 ms
 * Test size: 20000 Using time: 5930 ms
 * Test size: 30000 Using time: 15548 ms
 */

    可以看出 hash 唯一时,当数据规模达到三万时就已经要 15s 了。

    下图是 hashCode 永远相同与否插入时间的拟合图。

hashCode

    由图可知,在数据量为几百时,两者表现都差不多,但是一旦超过 1000 ,相同 hashCode 的曲线迅速飙高。

    因为 hashCode 作为一个的索引,可以快速判重,当 hashCode 失效时只能一个个去执行 equals 方法,大大降低了效率。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值