JAVA基础之HashMap源码(JDK 1.8)

这段时间一直想研究一下hashmap的源码,由于实习生的笔试、面试这些事,感觉把自己所有的复习计划都打乱了,最后的结果也很惨,所以决定好好地静下来认真的把基础巩固好。
我电脑里装的是JDK1.8,JDK1.8相比于JDK1.6的版本在hashmap的实现上面做了一些比较大的改动。

  1. JDK1.6实现hashmap的方式是采用位桶+链表的方式,即散列链表方式
  2. JDK1.8则是采用位桶+链表/红黑叔的方式,即当某个位桶的链表长度达到某个阈值的时候,这个链表就转化成红黑树。

    1. 当位桶采用散列链表存储时:
      JAVA基础之HashMap源码(JDK 1.8)_第1张图片

JAVA代码:

    //位桶的声明
    transient Node[] table;

    /**
     * Holds cached entrySet(). Note that AbstractMap fields are used
     * for keySet() and values().
     */
    transient Set> entrySet;
    //链表以及数组里存放的基本的数据结构
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node next;
        ...
    }

hashMap的get(Object key)方法

    public V get(Object key) {
        Node e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

    static final int hash(Object key) {
        int h;
        //key.hashCode()如果没有重写则是获得key的内存地址
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

    final Node getNode(int hash, Object key) {
        Node[] tab; Node first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {//通过(n-1)&hash定位原因下面说明
            if (first.hash == hash && // 总是先检查第一个元素
            //当hash值一样之后,通过key.equals(k)来对链表进行搜索,若没有重写k类的equals方法,则直接默认调用Object的方法,直接判断两个key的内存地址是否相等
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)//判断这个位桶的链表已经转化成红黑树,稍后再细讲
                    return ((TreeNode)first).getTreeNode(hash, key);
                do {//如果没有,则遍历链表寻找key值相同的节点
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }
  1. hash算法
    我们可以看到在hashmap中要找到某个元素,需要根据key的hash值来求得在数组中的位置,如何计算这个位置就是hash算法。前面说过hashmap的数据结构是数组和链表的结合,所以我们希望这个hashmap里面的元素位置尽量分布的均匀些尽量使得每个位置上的元素只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置上的元素就是我们要的,而不用再去遍历链表。

    所以我们首先想到的是把hashcode对数组长度进行取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,取模运算的消耗还是比较大的,能不能找一种更快速,消耗更小的方式呢?JAVA中是这样做的: (n - 1) & hash
    首先计算得key的hashcode值,然后和数组的长度-1做一次“与”运算(&)。看上去很简单,其实比较有玄机,数组的长度一般为2的幂次方,然而,为什么hashmap的容量为2的次方大小时,hashmap的效率最高,下面以2的4次方举例,来解释一下为什么数组大小为2的幂次时性能最高。

    看下图,左边两组是数组长度为16(2的4次方),右边两组是数组长度为15。两组的hashcode均为8和9,但是很明显,当它们和1110“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!
    JAVA基础之HashMap源码(JDK 1.8)_第2张图片

    所以说,当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。
    说到这里,我们再回头看一下hashmap中默认的数组大小是多少,查看源代码可以得知是16,为什么是16,而不是15,也不是20呢,看到上面annegu的解释之后我们就清楚了吧,显然是因为16是2的整数次幂的原因,在小数据量的情况下16比15和20更能减少key之间的碰撞,而加快查询的效率。
    所以,在存储大容量数据的时候,最好预先指定hashmap的size为2的整数次幂次方。就算不指定的话,也会以大于且最接近指定值大小的2次幂来初始化的

    1. hashmap的resize方法
      当hashmap中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对hashmap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,所以这是一个通用的操作,很多人对它的性能表示过怀疑,不过想想我们的“均摊”原理,就释然了,而在hashmap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。

      那么hashmap什么时候进行扩容呢?当hashmap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。
      比如说,我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,不过上面已经说过,即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.75*1000 < 1000, 也就是说为了让0.75 * size > 1000, 我们必须这样new HashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题。

    2. key的hashcode与equals方法改写
      在第一部分hashmap的数据结构中,annegu就写了get方法的过程:首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。所以,hashcode与equals方法对于找到对应元素是两个关键方法。
      Hashmap的key可以是任何类型的对象,例如User这种对象,为了保证两个具有相同属性的user的hashcode相同,我们就需要改写hashcode方法,比方把hashcode值的计算与User对象的id关联起来,那么只要user对象拥有相同id,那么他们的hashcode也能保持一致了,这样就可以找到在hashmap数组中的位置了。如果这个位置上有多个元素,还需要用key的equals方法在对应位置的链表中找到需要的元素,所以只改写了hashcode方法是不够的,equals方法也是需要改写滴~当然啦,按正常思维逻辑,equals方法一般都会根据实际的业务内容来定义,例如根据user对象的id来判断两个user是否相等。
      在改写equals方法的时候,需要满足以下三点:
      (1) 自反性:就是说a.equals(a)必须为true。
      (2) 对称性:就是说a.equals(b)=true的话,b.equals(a)也必须为true。
      (3) 传递性:就是说a.equals(b)=true,并且b.equals(c)=true的话,a.equals(c)也必须为true。
      通过改写key对象的equals和hashcode方法,我们可以将任意的业务对象作为map的key(前提是你确实有这样的需要)。

    本文主要描述了HashMap的结构,和hashmap中hash函数的实现,以及该实现的特性,同时描述了hashmap中resize带来性能消耗的根本原因,以及将普通的域模型对象作为key的基本要求。尤其是hash函数的实现,可以说是整个HashMap的精髓所在,只有真正理解了这个hash函数,才可以说对HashMap有了一定的理解。

你可能感兴趣的:(Java基础)