HashMap的数据结构和源码分析

      如果想透彻理解什么是HashMap,首先需要知道HashMap的数据结构是什么;其次需要厘清它能做什么,即它的功能;最后,还需要知道HashMap怎么实现这些功能的。下面我们针对这三个方面展开剖析。

什么是HashMap

      在疯狂飙车之前,先聊聊 HashMap 是什么吧!HashMap是基于哈希表设计的、Map接口的非同步(synchronized)实现。此实现提供所有可选的映射操作,并允许使用null值和null键。它存储的是键值对,速度很快。它不保证映射的顺序,特别是不保证该顺序恒久不变。

      HashMap实际上是一个“链表散列”的数据结构,关于其底层实现,在Java7中依靠数组+单链表实现,自Java8开始依靠数组+单链表+红黑树实现。它平衡了多种数据结构的优缺点,实现了寻址容易,插入删除也容易。

      HashMap有哪些构造函数?它共包括4个构造函数:

  • public HashMap()// 默认构造函数,常用
  • public HashMap(int initialCapacity, float loadFactor) / /指定容量大小和加载因子
  • public HashMap(int initialCapacity) // 指定容量大小,常用
  • public HashMap(Map<? extends K, ? extends V> m) // 包含子Map,将m中的全部元素逐个添加到HashMap中

 

Java 7中HashMap的数据结构

      在Java 7中,它的数据结构示意图如下:

 

        数组中的每个元素都是Entry<K,V>类型的一个对象。当出现哈希冲突的时候,把发生冲突的元素放入相同散列地址中,构造成一个单链表。Entry<K,V>定义如下所示:

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key; 
    V value; 
    Entry<K,V> next; //
    int hash; //
    /** * Creates new entry. */ 
   Entry(int h, K k, V v, Entry<K,V> n) {
      value = v; 
      next = n; 
      key = k; 
      hash = h; 
}

      在代码①,next存储指向下一个Entry的引用。代码②是对key的hashCode值进行哈希运算后得到的数组索引,存储在Entry中以避免重复计算。

      简单来说,HashMap由数组+单链表组成的,左侧的数组是HashMap的主体,也称为哈希数组,数组的每个元素都是一个单链表的头节点;右侧的单链表则是主要为了解决哈希冲突而存在的,如果根据哈希函数定位到的哈希地址不含链表(当前entry的next指向null),那么对于查找和添加等操作速度很快,仅仅需要一次寻址即可。如果定位到的数组包含链表,则对于添加操作,其时间复杂度为O(n),需要首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对判断是否相等。所以,考虑到时间复杂度,在HashMap中链表出现的越少,性能就会越好。

 

Java 8中HashMap的数据结构

      在Java 8中,HashMap的数据结构是数组+单链表+红黑树。之所以引入红黑树,是因为遍历单链表的时间复杂度是O(n),而红黑树的是O(logn)。它的数据结构示意图如下:

       从JDK8开始,HashMap将插入的键值对封装在Node对象中,每个Node对象包含四个属性——hash值,键对象key,值对象value和指向下一个元素的next。Node是HashMap的一个内部类,实现了Map.Entry接口,本质上就是一个映射(键值对)。Node对象定义如下:

static class Node<K,V> implements Map.Entry<K,V> { 
    final int hash;
    final K key; 
    V value;
    Node<K,V> next; 
}

      红黑树中,节点类型TreeNode定义如下:

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> { 
      TreeNode<K,V> parent; // red-black tree links 
      TreeNode<K,V> left; 
      TreeNode<K,V> right; 
      TreeNode<K,V> prev; // needed to unlink next upon deletion 
      boolean red; 
}

      红黑树比链表多了四个变量,parent父节点、left左节点、right右节点、prev上一个同级节点,红黑树内容较多,不在赘述。当遇到哈希碰撞时,新增的Node会被next变量指向,组成单链表。当该单链表的长度超过8时,将单链表转换为红黑树。而内部类TreeNode是Node的子类,关系图如下:

       下图同样很形象的展示了HashMap的数据结构(数组+链表+红黑树),桶(bucket)中的结构可能是链表,也可能是红黑树,红黑树的引入是为了提高效率。

        有了数组+单链表+红黑树3个数据结构,我们可以大致联想到HashMap的实现了。首先有一个每个元素都是链表(可能表述不准确)的数组,当添加一个元素(key-value)时,就首先计算元素key的hash值,以此确定插入数组中的位置,但是可能存在同一hash值的元素已经被放在数组同一位置了,这时就添加到同一hash值的元素的后面,他们在数组的同一位置,但是形成了链表,所以说数组存放的是链表。而当链表长度太长时,链表就转换为红黑树,这样大大提高了查找的效率。 

扩容时如何确定数组大小

      在介绍如何确定数组索引之前,先介绍三个本文即将用到的位运算符:

      1. >>> : 无符号右移,忽略符号位,空位都以0补齐;

      2. ^ : 按位异或运算,第一个操作数的第n位于第二个操作数的第n位相反,那么结果的第n为也为1,否则为0;

      3. & : 按位与运算,针对二进制,只要有一个为0,就为0。

       下面分析求table大小的方法:

    /**
     *计算大于给定容量的最小的2的幂次,扩容后的容量必须是2的幂次
     */
    /**
     * Returns a power of two size for the given target capacity.
     */
    static final int tableSizeFor(int cap) {
        //从二进制cap的最左边的1开始,全部设置为 1,得到 n,这样 n + 1就是要求的值
        int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1); // cap - 1 再计算避免cap假设刚好是8,但 n=16 这是不对的
        // cap 是 0 或 1 的时候 n 是 -1,此时返回 1
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

      这就保证了HashMap扩容后,容量总是2的幂次。

确定元素数组索引位置

      不管增加、删除或者查找键值对,定位元素存储的位置都是很关键的第一步。前面说过HashMap的数据结构是数组和链表的结合,所以我们当然希望这个HashMap里面的元素位置尽量分布均匀,尽量使得每个位置上的元素数量只有一个,那么当我们用哈希函数求得这个位置的时候,马上就可以知道数组索引所对应的元素就是我们想要的,不用遍历链表,大大优化了查询的效率。先看看源码的实现:

    /**
     * Computes key.hashCode() and spreads (XORs) higher bits of hash
     * to lower.  Because the table uses power-of-two masking, sets of
     * hashes that vary only in bits above the current mask will
     * always collide. (Among known examples are sets of Float keys
     * holding consecutive whole numbers in small tables.)  So we
     * apply a transform that spreads the impact of higher bits
     * downward. There is a tradeoff between speed, utility, and
     * quality of bit-spreading. Because many common sets of hashes
     * are already reasonably distributed (so don't benefit from
     * spreading), and because we use trees to handle large sets of
     * collisions in bins, we just XOR some shifted bits in the
     * cheapest possible way to reduce systematic lossage, as well as
     * to incorporate impact of the highest bits that would otherwise
     * never be used in index calculations because of table bounds.
     */
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//
}
//Java7的源码,Java8没有这个方法,但是实现原理一样
static int indexFor(int h, int length) {
return h & (length-1); //取模运算
}

      函数hash(Object key)中,代码①的实现可以拆分为如下两步:

      第一步计算hashCode:h = key.hashCode();

      第二步应用位运算: h ^ (h >>> 16)。

      从Java 8开始,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么设计可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与散列地址的计算中,同时不会有太大的开销。

      函数hash(Object key)在Java 7和Java 8之后的版本中都有。至于indexFor函数,则是计算数组索引的最后一步取模运算:h & (length-1)。

Java 7中封装了indexFor函数,但在Java 8中不再单独抽象为一个方法,但是采用了同样的计算原理。所以最终存储位置的确定流程是这样的:

       上述两个函数共同构成了HashMap中计算元素散列地址的哈希函数。这个函数被定义的非常巧妙,它通过h & (table.length -1)来得到给定元素的哈希地址,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。 

Reference

https://www.jianshu.com/p/aa715ff9a572

http://www.importnew.com/20386.html

https://www.cnblogs.com/xiaoxi/p/7233201.html

 

posted @ 2020-04-28 21:41  楼兰胡杨  阅读(229)  评论(0编辑  收藏  举报