HashCode作用以及使用

前言

博主github

博主个人博客http://blog.healerjean.com

感谢大神HashCode

感谢大神HashMap

1、一些常见的HashCode

1.1、Integer


    @Test
    public void Integer_HashCode(){

        Integer one  = new Integer(20);
        System.out.println(one.hashCode()); //20

    }

    /**
     * Integer 的 hashCode 就是它的value
     *
     *     public int hashCode() {
     *         return Integer.hashCode(value);
     *     }
     */

1.2、String


@Test
public void String_HashCode(){
    String str1  ="123";
    System.out.println(str1.hashCode()); // 48690

}

/**
 * ASCII http://tool.oschina.net/commons?type=4

* String 类的散列值就是依次遍历其每个字符成员,
* 递归的将当前得到的hashCode乘以31然后加上下一个字符成员的ASCII值 (h = 31 * h + val[i];)
*
*   h 初始为 0
*  '1'  49   h = 31 * 0  + 49 = 49
*  '2'  50   h = 31 * 49 + 50 = 1569
*  '3'  51   h = 31 * 1569 + 51 = 48690   
*
*     public int hashCode() {
*         int h = hash;
*         if (h == 0 && value.length > 0) {
*             char val[] = value;
*
*             for (int i = 0; i < value.length; i++) {
*                 h = 31 * h + val[i];
*             }
*             hash = h;
*         }
*         return h;
*     }
*/

2、为什么HashCode 会使用31

关于网上的一些解释

Why does Java’s hashCode() in String use 31 as a multiplier?

排名第一的答案

The value 31 was chosen because it is an odd prime. If it were even and the multiplication overflowed, information would be lost, as multiplication by 2 is equivalent to shifting. The advantage of using a prime is less clear, but it is traditional. A nice property of 31 is that the multiplication can be replaced by a shift and a subtraction for better performance: 31 * i == (i << 5) - i`. Modern VMs do this sort of optimization automatically.


选择数字31是因为它是一个奇质数,,相对来说,如果选择一个偶数会在乘法运算中产生溢出,导致数值信息丢失,因为乘二相当于移位运算。

选择质数的优势并不是特别的明显,但这是一个传统。同时,数字31有一个很好的特性,即乘法运算可以被移位和减法运算取代,来获取更好的性能:31 * i == (i << 5) - i,现代的 Java 虚拟机可以自动的完成这个优化。

(h = 31 * h + val[i];)

排名第二的答案 ,后面有可视化验证

As Goodrich and Tamassia point out, If you take over 50,000 English words (formed as the union of the word lists provided in two variants of Unix), using the constants 31, 33, 37, 39, and 41 will produce less than 7 collisions in each case. Knowing this, it should come as no surprise that many Java implementations choose one of these constants.

正如 Goodrich 和 Tamassia 指出的那样,如果你对超过 50,000 个英文单词(由两个不同版本的 Unix 字典合并而成)进行 hash code 运算,并使用常数 31, 33, 37, 39 和 41 作为乘子,每个常数算出的哈希值冲突数都小于7个,所以在上面几个常数中,常数 31 被 Java 实现所选用也就不足为奇了。

2.1、原因,后面可视化也有更详细的解释

2.1、31是一个不大不小的质数(素数)
2.2、31可以被 JVM 优化,31 * i = (i << 5) - i。现代的 Java 虚拟机可以自动的完成这个优化

假设 n=3
i=0 -> h = 31 * 0 + val[0]
i=1 -> h = 31 * (31 * 0 + val[0]) + val[1]
i=2 -> h = 31 * (31 * (31 * 0 + val[0]) + val[1]) + val[2]
       h = 31*31*31*0 + 31*31*val[0] + 31*val[1] + val[2]
       h = 31^(n-1)*val[0] + 31^(n-2)*val[1] + val[2]


这里先分析质数2。-------仅计算公式中次数最高的那一项-----------
首先,假设 n = 6,然后把质数2和 n 带入上面的计算公式。结果是2^5 = 32,是不是很小。所以这里可以断定,当字符串长度不是很长时,用质数2做为乘子算出的哈希值,数值不会很大。也就是说,哈希值会分布在一个较小的数值区间内,分布性不佳,最终可能会导致冲突率上升,质数2做为乘子会导致哈希值分布在一个较小区间内

那么如果用一个较大的大质数101会产生什么样的结果呢?根据上面的分析,我想大家应该可以猜出结果了。就是不用再担心哈希值会分布在一个小的区间内了,因为101^5 = 10,510,100,501。但是要注意的是,这个计算结果太大了。如果用 int 类型表示哈希值,结果会溢出,最终导致数值信息丢失。如果不在意质数101容易导致数据信息丢失问题,或许其是一个更好的选择。

尽管数值信息丢失并不一定会导致冲突率上升,但是我们暂且先认为质数101(或者更大的质数)也不是很好的选择。最后,我们再来看看质数31的计算结果: 31^5 = 28629151,结果值相对于32和10,510,100,501来说。是不是很nice,不大不小。


2.2、可视化得出结论

计算哈希算法冲突率并不难,比如可以一次性将所有单词的 hash code 算出,并放入 Set 中去除重复值。之后拿单词数减去 set.size() 即可得出冲突数,有了冲突数,冲突率就可以算出来了。当然,如果使用 JDK8 提供的流式计算 API,则可更方便算出,代码片段如下:

public static Integer hashCode(String str, Integer multiplier) {
    int hash = 0;
    for (int i = 0; i < str.length(); i++) {
        hash = multiplier * hash + str.charAt(i);
    }

    return hash;
}
    
/**
 * 计算 hash code 冲突率,顺便分析一下 hash code 最大值和最小值,并输出
 * @param multiplier
 * @param hashs
 */
public static void calculateConflictRate(Integer multiplier, List<Integer> hashs) {
    Comparator<Integer> cp = (x, y) -> x > y ? 1 : (x < y ? -1 : 0);
    int maxHash = hashs.stream().max(cp).get();
    int minHash = hashs.stream().min(cp).get();

    // 计算冲突数及冲突率
    int uniqueHashNum = (int) hashs.stream().distinct().count();
    int conflictNum = hashs.size() - uniqueHashNum;
    double conflictRate = (conflictNum * 1.0) / hashs.size();

    System.out.println(String.format("multiplier=%4d, minHash=%11d, maxHash=%10d, conflictNum=%6d, conflictRate=%.4f%%",
                multiplier, minHash, maxHash, conflictNum, conflictRate * 100));
}

1557195124268

从上图可以看出,

简单总结

1、 质数2、冲突率达到了 55%以上,而且hash值分布不是很广泛,,仅仅分布在了整个哈希空间的正半轴部分,即 0 ~ 2^31-1。而负半轴 -2^31 ~ -1,则无分布。这也证明了我们上面断言,即质数2作为乘子时,对于短字符串,生成的哈希值分布性不佳。

2、奇质数,101、109 表现也不错,冲突率很低,说明了哈希值溢出不一定导致冲突率比较高,但是溢出的话,我们不认为是我们的优选乘子 ,如果不在意质数101容易导致数据信息丢失问题,或许其是一个更好的选择。

3、偶数 32、36 这两个并不好,32的冲突率已经超过了50%,尽管36表现好一些,但是和31,37相比,冲突率还是比较高的,但是**偶数也不一定作为乘子冲突率就比较高 **

4、奇质数 31、37 、41 表现都不出,冲突数都小于7个,使用较小的质数做为乘子时,冲突率会很高。 选择比较大的质数作为乘子时,冲突率会降低,但是可能会再次哈希值溢出

2.3、哈希值分布可视化

上面的2.2介绍了不同数字作为乘子的冲突率情况,下面分析一下不同数字作为乘子时,hash值得分布情况

https://segmentfault.com/a/1190000010799123

3、HashCode使用

3.1、HashCode定义(确定位置,但不能确定地址)

从Object角度看,JVM每new一个Object,它都会将这个Object丢到一个Hash表中去,这样的话,下次做Object的比较或者取这个对象的时候(读取过程),它会根据对象的HashCode再从Hash表中取这个对象。这样做的目的是提高取对象的效率。若HashCode相同再去调用equal。

(1)HashCode的存在主要是用于查找的快捷性,如Hashtable,HashMap等,HashCode是用来在散列存储结构中确定对象的存储地址的;

(2)如果两个对象相同, equals方法一定返回true,并且这两个对象的HashCode一定相同;除非重写了方法

(3)如果对象的equals方法被重写,那么对象的HashCode也尽量重写,并且产生HashCode使用的对象,一定要和equals方法中使用的一致,否则就会违反上面提到的第2点;

(4)两个对象的HashCode相同,并不一定表示两个对象就相同,也就是equals方法不一定返回true,只能够说明这两个对象在散列存储结构中,如Hashtable,他们存放在同一个篮子里。

3.2、HashCode作用

Java中的集合(Collection)有两类,一类是List,再有一类是Set。前者集合内的元素是有序的,元素可以重复;后者元素无序,但元素不可重复。 equals方法可用于保证元素不重复,但是,如果每增加一个元素就检查一次,如果集合中现在已经有1000个元素,那么第1001个元素加入集合时,就要调用1000次equals方法。这显然会大大降低效率。

于是,Java采用了哈希表的原理。

哈希算法也称为散列算法,是将数据依特定算法直接指定到一个地址上。这样一来,当集合要添加新的元素时,先调用这个元素的HashCode方法,就一下子能定位到它应该放置的物理位置上。

(1)如果这个位置上没有元素,它就可以直接存储在这个位置上,不用再进行任何比较了;

(2)如果这个位置上已经有元素了,就调用它的equals方法与新元素进行比较,相同的话就不存了;

(3)不相同的话,也就是发生了Hash key相同导致冲突的情况,那么就在这个Hash key的地方产生一个链表,将所有产生相同HashCode的对象放到这个单链表上去,串在一起(很少出现)。这样一来实际调用equals方法的次数就大大降低了,几乎只需要一两次。 (下面1、的实例就为这里的测试实例)

3.3、HashCode是用于查找使用的,而equals是用于比较两个对象的是否相等的。

(1)例如内存中有这样的位置 :0 1 2 3 4 5 6 7

而我有个类,这个类有个字段叫ID,我要把这个类存放在以上8个位置之一,如果不用HashCode而任意存放,那么当查找时就需要到这八个位置里挨个去找,或者用二分法一类的算法。

但如果用HashCode那就会使效率提高很多。 定义我们的HashCode为ID%8,比如我们的ID为9,9除8的余数为1,那么我们就把该类存在1这个位置,如果ID是13,求得的余数是5,那么我们就把该类放在5这个位置。依此类推。

(2)但是如果两个类有相同的HashCode,例如9除以8和17除以8的余数都是1,也就是说,我们先通过 HashCode来判断两个类是否存放某个桶里,但这个桶里可能有很多类,比如hashtable,那么我们就需要再通过 equals 在这个桶里找到我们要的类。

3.4、Set测试

3.4.1、实例重写HashCode方法

public class HashTest {    
    private int i;    
    
    public int getI() {    
        return i;    
    }    
    
    public void setI(int i) {    
        this.i = i;    
}   
 
    @Override
    public int hashCode() {    
        return i % 10;    
}
    
/**
*  对象的内存地址与hashcode有关系,但并不是hashcode值相等,就是代表内存地址相同,这种想法是幼稚的
*  比如hashtable中hashcode值相等,
*  	但是存了很多的对象,这表明对象的== 肯定不相等,Ojbect逆向推理,equals不相等,==肯定不相等 
*  
*/

    public final static void main(String[] args) {    
        HashTest a = new HashTest();    
        HashTest b = new HashTest();  

        System.out.println(a.hashCode() == b.hashCode());  //true 人为制造hashcode值相同  
        System.out.println(a==b);    //false //== 比较对象的相等比较对象引用地址是否相等。还要要比较对象内容是否相等
        System.out.println(a.equals(b));    //false 不同的对象 object中 == 和euqals是一样的

        a.setI(1);    
        b.setI(1);    
        Set<HashTest> set = new HashSet<HashTest>();    
        set.add(a);    
        set.add(b);    
        //没有 equels 重写的情况
        System.out.println(a.hashCode() == b.hashCode());  //true hashcode相同   

        System.out.println(a.equals(b));    //false 不同的对象 ,创建出来的是地址就不同了

        //2 这个时候会发想存入了两个值  set中存放是根据hashcode值存放,如果hashcode值相同,
        //再比较equals值,如果equals值也相同,则产生一个单链表放进去
        System.out.println(set.size());    

    }    

1557200814335

3.4.2、重写equels方法

public class HashTest {    
    private int i;    
    
    public int getI() {    
        return i;    
    }    
    
    public void setI(int i) {    
        this.i = i;    
    }    
    
    @Override
    public boolean equals(Object object) {    
        if (object == null) {    
            return false;    
        }    
        if (object == this) {    
            return true;    
        }    
        if (!(object instanceof HashTest)) {    
            return false;    
        }    
        HashTest other = (HashTest) object;    
        if (other.getI() == this.getI()) {    
            return true;    
        }    
        return false;    
    }  
    
    @Override
    public int hashCode() {    
        return i % 10;    
    }    
     public final static void main(String[] args) {    
        HashTest a = new HashTest();    
        HashTest b = new HashTest();  
        
        System.out.println(a.hashCode() == b.hashCode());         
        System.out.println(a==b);           
        System.out.println(a.equals(b));   

        a.setI(1);    
        b.setI(1);    
        Set<HashTest> set = new HashSet<HashTest>();    
        set.add(a);    
        set.add(b);    
       
        System.out.println(a.hashCode() == b.hashCode());          
        System.out.println(a.equals(b));   
        
        System.out.println(set.size());    
        
    }    
}    
}

1557200890482

3.5、map测试
package com.hlj.util.Z009HashCode;

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

/**
 * @Description
 * @Author HealerJean
 * @Date 2018/4/12
 */
public class D02_Object {

    public static void main(String[] args) {
        Map <Object,Object> map = new HashMap<>();

        Object o = new Object();
        map.put(o,"Stromg");


        Object o2 = new Object();
        map.put(o2,3);


        System.out.println(map.get(o)); //Stromg
        System.out.println(map.get(o2)); //3

        o = 456;
        System.out.println(map.get(o)); //null  因为提供的key的hashcode的地址不是我们存储的地址,所以不能找到,但是之前存储的还在


        Person person = new Person();
        person.setAge(1);
        map.put(person,3);
        System.out.println(map.get(person)); //3

        person.setName("HealerJean");
        System.out.println(map.get(person)); //3
        System.out.println("map的大小"+map.size()); //map的大小3

        person = null ; // person设置为null, map中还是具有该person对象
        System.out.println(map.get(person));  //null
        System.out.println("map的大小"+map.size()); //map的大小3

        System.out.println("打印map的结果集");
        Object[] list = map.keySet().toArray();
        for (Object object :list){
            System.out.println("key="+object.toString()+"||||| value="+map.get(object).toString());
        }


    }


}

class Person {
    int age;
    String name;

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @Override
    public String toString() {
        return "Person{" +
                "age=" + age +
                ", name='" + name + '\'' +
                '}';
    }
}


打印结果

/**
* Stromg
* 3
* null
* 3
* 3
* map的大小3
* null
* map的大小3
* 打印map的结果集
* key=java.lang.Object@9807454||||| value=3
* key=Person{age=1, name='HealerJean'}||||| value=3
* key=java.lang.Object@4fca772d||||| value=Stromg
*/


4、HashMap

上面明白了hashcode的生成原理了,现在我们来看看 hash算法

4.1、 HashMap 的 hash 算法的实现原理(为什么右移 16 位,为什么要使用 ^ 位异或)

hash值的作用,知道hash是为了获取数组下标的,很明显就知道该hash值是为了均匀散列表的下标,仔细看看,就知道下面使用了 hashcode 右移16位再和自己异或得到的hash值

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

观察下HashMap查找到数组的

// 重点关注
first = tab[(n - 1) & hash]) != null
           

final Node<K,V> getNode(int hash, Object key) { // 该处的hash实际上是调用的上面的hash方法
       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;
   }

举例说明

对象 A 的 hashCode 为 0100 0010 0011 1000 1000 0011 1100 0000

对象 B 的 hashCode 为 0011 1011 1001 1100 0101 0000 1010 0000

如果不经过hash运算,如果数组长度是16(默认就是16),也就是 15 与运算这两个数,

15 的二进制数 0000 0000 0000 0000 0000 0000 0000 1111 ,发现结果都是0。这样的话数组小标就都是0了,这样的结果应该不是我们想看到的,因为这种情况其实出现的次数挺多的。

解决

如果我们将 hashCode 值右移 16 位,也就是取 int 类型的一半,刚好将该二进制数对半切开。并且使用位异或运算(如果两个数对应的位置相反,则结果为1,反之为0),这样的话,就能避免我们上面的情况的发生,即(h = key.hashCode()) ^ (h >>> 16)。 相当于让自己的前半段16位和后半段16位做一个异或的运算

总的来说,使用位移 16 位和 异或 就是防止这种极端情况。但是,该方法在一些极端情况下还是有问题,比如:10000000000000000000000000 和 1000000000100000000000000 这两个数,如果数组长度是16,那么即使右移16位,在异或,hash 值还是会重复。但是为了性能,对这种极端情况,JDK 的作者选择了性能。毕竟这是少数情况,为了这种情况去增加 hash 时间,性价比不高。

当数组吃长度n为 16 的时候数组下标: 1111 & 101010100101001001000(随便写的) 1000 = 8

4.2、HashMap 为什么使用 & 与运算代替模运算

其中 n 是数组的长度。其实该算法的结果和模运算的结果是相同的。但是,对于现代的处理器来说,**除法和求余数(模运算)**是最慢的动作,

通过上面的4.1末尾,可以看到,可以看到,当 n 为 2 的幂次方的时候,减一之后就会得到 一堆1111…… 的数字,这个数字正好可以掩码 (都是一堆 1111……),并且得到的结果取决于 hash 值。因为 hash 位值是1,那么最终的结果也是1 ,hash位 值是0,最终的结果也是0。

tab[   (n - 1) & hash   ];

4.3、HashMap 的容量为什么建议是 2的幂次方?

接4.2、hash 算法的目的是为了让hash值均匀的分布在桶中(数组),那么,如何做到呢?试想一下,如果不使用 2 的幂次方作为数组的长度会怎么样?

假设我们的数组长度是10,还是上面的公式:

1001 & 101010100101001001000 结果:1000 = 8

1001 & 101000101101001001001 结果:1001 = 9

1001 & 101010101101101001010 结果: 1000 = 8

1001 & 101100100111001101100 结果: 1000 = 8

结论

所以说,我们一定要保证 & 中的二进制位全为 1,才能最大限度的利用 hash 值,并更好的散列,只有全是1 ,才能有更多的散列结果。如果是 1001,有的散列结果是永远都不会出现的,比如 1111,1010,1011,…,只要 & 之前的数有 0, 对应的 1 肯定就不会出现(因为只有都是1才会为1)。大大限制了散列的范围。

4.4、我们自定义 HashMap 容量最好是多少

那我们如何自定义呢?自从有了阿里的规约插件,每次楼主都要初始化容量,如果我们预计我们的散列表中有2个数据,那么我就初始化容量为2嘛

绝对不行,如果大家看过源码就会发现,如果Map中已有数据的容量达到了初始容量的 75%,那么散列表就会扩容,而扩容将会重新将所有的数据重新散列,性能损失严重,所以,我们可以必须要大于我们预计数据量的 1.34 倍,如果是2个数据的话,就需要初始化 2.68 个容量。当然这是开玩笑的,2.68 不可以,3 可不可以呢?肯定也是不可以的,我前面说了,如果不是2的幂次方,散列结果将会大大下降。导致出现大量链表。那么我可以将初始化容量设置为4。 当然了,如果你预计大概会插入 12 条数据的话,那么初始容量为16简直是完美,一点不浪费,而且也不会扩容。

项目经验总计:

1、比如链表数很多,肯定是数组初始化长度不对,这样的话会造成拥挤

2、如果某个map很大,注意,肯定是事先没有定义好初始化长度

假设,某个Map存储了10000个数据,那么他会扩容到 20000,实际上,根本不用 20000,只需要 10000* 1.34= 13400 个,然后向上找到一个2 的幂次方,也就是 16384 初始容量足够。



感兴趣的,欢迎添加博主微信

哈,博主很乐意和各路好友交流,如果满意,请打赏博主任意金额,感兴趣的在微信转账的时候,备注您的微信或者其他联系方式。添加博主微信哦。

请下方留言吧。可与博主自由讨论哦

微信 微信公众号 支付宝
微信 微信公众号 支付宝
posted @ 2019-05-07 11:50  HealerJean  阅读(216)  评论(0编辑  收藏  举报