Java 容器 - 一文详解HashMap

Map 类集合

Java Map类集合,与Collections类集合存在很大不一样。它是与Collection 类平级的一个接口。java

在集合框架中,经过部分视图方法这一根 微弱的线联系起来。node

(在以后的分享中,咱们会讨论到Collections 框架的内容)面试

Map类集合中的存储单位是K-V键值对,就是 使用必定的哈希算法造成一组比较均匀的哈希值做为Key,Value值挂在Key上。算法

Map类 的特色:bootstrap

  • 没有重复的Key,能够具备多个重复的Value
  • Value能够是List/Map/Set对象
  • KV是否容许为null,以实现类的约束为准
Map集合类 Key Value Super JDK 说明
Hashtable 不容许为 null 不容许为 null Dictionary 1.0 (过期)线程安全类
ConcurrentHashMap 不容许为 null 不容许为 null AbstractMap 1.5 锁分段技术或CAS(JDK8 及以上)
TreeMap 不容许为 null 容许为 null AbstractMap 1.2 线程不安全(有序)
HashMap 容许为 null 容许为 null AbstractMap 1.2 线程不安全(resize 死链问题)
从jdk1.0-1.5,这几个重点KV集合类,见证了Java语言成为工业级语言的成长历程。

知识点:数组

  1. Map 类 特有的三个方法是keySet()values()entrySet(),其中values()方法返回的视图的集合实现类是Values extends AbstractCollection<V>,没有实现add操做,实现了remove/clear等相关操做,调用add方法时会抛出异常。
  2. 在大多数状况下,直接使用ConcurrentHashMap替代HashMap没有任何问题,性能上面差异不大,且线程安全。
  3. 任何Map类集合中,都要尽可能避免KV设置为null值。
  4. Hashtable - HashMap - ConcurrentHashMap 之间的关系 大体至关于 Vector - ArrayList - CopyOnWriteArrayList 之间的关系,固然HashMap 和 ConcurrentHashMap之间性能差距更小。

1、hashCode()

哈希算法 哈希值

在Object 类中,hashCode()方法是一个被native修饰的类,JavaDoc中描述的是返回该对象的哈希值。安全

那么哈希值这个返回值是有什么做用呢?数据结构

主要是保证基于散列的集合,如HashSet、HashMap以及HashTable等,在插入元素时保证元素不可重复,同时为了提升元素的插入删除便利效率而设计;主要是为了查找的便捷性而存在。

拿Set进行举例,app

众所周知,Set集合是不能重复,若是每次添加数据都拿新元素去和集合内部元素进行逐一地equal()比较,那么插入十万条数据的效率能够说是很是低的。框架

因此在添加数据的时候就出现了哈希表的应用,哈希算法也称之为散列算法,当添加一个值的时候,先去计算出它的哈希值,根据算出的哈希值将数据插入指定位置。这样的话就避免了一直去使用equal()比较的效率问题。

具体表如今:

  • 若是指定位置为空,则直接添加
  • 若是指定位置不为空,调用equal() 判断两个元素是否相同,若是相同则不存储

上述第二种状况中,若是两个元素不相同,可是hashCode()相同,那就是发生了咱们所谓的哈希碰撞。

哈希碰撞的几率取决于hashCode()计算方式和空间容量的大小。

这种状况下,会在相同的位置,建立一个链表,把key值相同的元素存放到链表中。

在HashMap中就是使用拉链法来解决hashCode冲突。

总结

hashCode是一个对象的标识,Java中对象的hashCode是一个int类型值。经过hashCode来指定数组的索引能够快速定位到要找的对象在数组中的位置,以后再遍历链表找到对应值,理想状况下时间复杂度为O(1),而且不一样对象能够拥有相同的hashCode。

HashMap 底层实现

带着问题

  1. HashMap 的长度为何默认初始长度是16,而且每次resize()的时候,长度必须是2的幂次方?
  2. 你熟悉HashMap的扩容机制吗?
  3. 你熟悉HashMap的死链问题吗?
  4. Java 7 和 Java 8 HashMap有哪些差异?
  5. 为何Java 8以后,HashMap、ConcurrentHashMap要引入红黑树?

0. 简介

  1. HashMap 基于哈希表的Map接口实现的,是以Key-Value存储形式存在;
  2. 非线程安全;
  3. key value均可觉得null;
  4. HashMap中的映射不是有序的;
  5. 在 JDK1.8 中,HashMap 是由 数组+链表+红黑树构成,新增了红黑树做为底层数据结构;
  6. 当一个哈希桶存储的链表长度大于8 会将链表转换成红黑树,小于6时则从红黑树转换成链表;
  7. 1.8以前和1.8及之后的源码,差异较大

1. 存储结构

在 JDK1.8 中,HashMap 是由 数组+链表+红黑树构成,新增了红黑树做为底层数据结构。

经过哈希来确认到数组的位置,若是发生哈希碰撞就以链表的形式存储 ,可是这样若是链表过长来的话,HashMap会把这个链表转换成红黑树来存储,阈值为8。

下面是HashMap的结构图:

2. 重要属性

2.1 table

/**
     * The table, initialized on first use, and resized as
     * necessary. When allocated, length is always a power of two.
     * (We also tolerate length zero in some operations to allow
     * bootstrapping mechanics that are currently not needed.)
     */
    transient Node<K,V>[] table;

在JDK1.8中咱们了解到HashMap是由数组加链表加红黑树来组成的结构其中table就是HashMap中的数组。

2.2 size

/**
     * The number of key-value mappings contained in this map.
     */
    transient int size;

HashMap中 键值对存储数量。

2.3 loadFactor

/**
     * The load factor for the hash table.
     *
     * @serial
     */
    final float loadFactor;

负载因子。负载因子是权衡资源利用率与分配空间的系数。当元素总量 > 数组长度 * 负载因子 时会进行扩容操做。

2.4 threshold

/**
     * The next size value at which to resize (capacity * load factor).
     *
     * @serial
     */
    // (The javadoc description is true upon serialization.
    // Additionally, if the table array has not been allocated, this
    // field holds the initial array capacity, or zero signifying
    // DEFAULT_INITIAL_CAPACITY.)
    int threshold;

扩容阈值。threshold = 数组长度 * 负载因子。超事后执行扩容操做。

2.5 TREEIFY_THRESHOLD/UNTREEIFY_THRESHOLD

/**
     * The bin count threshold for using a tree rather than list for a
     * bin.  Bins are converted to trees when adding an element to a
     * bin with at least this many nodes. The value must be greater
     * than 2 and should be at least 8 to mesh with assumptions in
     * tree removal about conversion back to plain bins upon
     * shrinkage.
     */
    static final int TREEIFY_THRESHOLD = 8;

    /**
     * The bin count threshold for untreeifying a (split) bin during a
     * resize operation. Should be less than TREEIFY_THRESHOLD, and at
     * most 6 to mesh with shrinkage detection under removal.
     */
    static final int UNTREEIFY_THRESHOLD = 6;

树形化阈值。当一个哈希桶存储的链表长度大于8 会将链表转换成红黑树,小于6时则从红黑树转换成链表。

3. 增长元素

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

3.1 hash()

能够看到实际执行添加元素的是putVal()操做,在执行putVal()以前,先是对key执行了hash()方法,让咱们看下里面作了什么

static final int hash(Object key) {
        int h;
        // key.hashCode():返回散列值也就是hashcode
        // ^ :按位异或
        // >>>:无符号右移,忽略符号位,空位都以0补齐
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

key==null说明,HashMap中是支持key为null的状况的。

一样的方法在Hashstable中是直接用key来获取hashCode,没有key==null的判断,因此Hashstable是不支持key为null的。

再回来讲这个hash()方法。这个方法用专业术语来称呼就叫作扰动函数

使用hash()也就是扰动函数,是为了防止一些实现比较差的hashCode()方法。换句话来讲,就是为了减小哈希碰撞

JDK 1.8 的 hash方法 相比于 JDK 1.7 hash 方法更加简化,可是原理不变。咱们再看下JDK1.7中是怎么作的。

// code in JDK1.7
        static int hash(int h) {
            // This function ensures that hashCodes that differ only by
            // constant multiples at each bit position have a bounded
            // number of collisions (approximately 8 at default load factor).
            h ^= (h >>> 20) ^ (h >>> 12);
            return h ^ (h >>> 7) ^ (h >>> 4);
        }

相比于 JDK1.8 的 hash 方法 ,JDK 1.7 的 hash 方法的性能会稍差一点点,由于毕竟扰动了 4 次。

知识延伸外链: JDK 源码中 HashMap 的 hash 方法原理是什么? - 胖君的回答 - 知乎
https://www.zhihu.com/questio...

3.2 putVal()

再来看真正执行增长元素操做的putVal()方法,

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        // 当数组为空或长度为0,初始化数组容量(resize() 方法是初始化或者扩容用的)
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 计算数组下标 i = (n-1) & hash
        // 若是这个位置没有元素,则直接建立Node并存值
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            // 这个位置已有元素
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                // hash值、key值相等,用e变量获取到当前位置这个元素的引用,后面用于替换已有的值
                e = p;
            else if (p instanceof TreeNode)
                // 当前是以红黑树方式存储,执行其特有的putVal方法 -- putTreeVal
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                // 当前是以链表方式存储,开始遍历链表
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        // 这里是插入到链表尾部!
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            // 超过阈值,存储方式转化成红黑树
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    // onlyIfAbsent 若是为true - 不覆盖已存在的值
                    // 把新值赋值进去
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        // 记录修改次数
        ++modCount;
        // 判断元素数量是否超过阈值 超过则扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

3.3 HashMap 的长度为何默认初始长度是16,而且每次resize()的时候,长度必须是2的幂次方?

这是一个常见的面试题。这个问题描述的设计,实际上为了服务于从Key映射到数组下标index的Hash算法。

前面提到了,咱们为了让HashMap存储高效,应该尽可能减小哈希碰撞,也就是说,应该让元素分配得尽量均匀。

Hash 值的范围值-21474836482147483647,先后加起来大概40亿的映射空间,只要哈希函数映射得比较均匀松散,通常应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。因此这个散列值是不能直接拿来用的。

因此才须要一个映射的算法。这个计算方式就是3.2中有出现的(n - 1) & hash

咱们来进一步演示一下这个算法:

  1. 假设有一个key="book"
  2. 计算book的hashCode值,结果为十进制的3029737,二进制的101110001110101110 1001。
  3. 假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111。
  4. 把以上两个结果作与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,因此 index=9。

经过这种与运算的方式,可以和取模运算同样的效果hashCode % length,在上述例子中就是3029737 % 16=9

而且经过位运算的方式大大提升了性能。

可能到这里,你仍是不知道为何长度必须是2的幂次方,也是由于这种位运算的方法。

长度16或者其余2的幂,Length-1的值是全部二进制位全为1,这种状况下,index的结果等同于HashCode后几位的值。只要输入的HashCode自己分布均匀,Hash算法的结果就是均匀的。若是HashMap的长度不是2的幂次方,会出现某些index永远不会出现的状况,这个显然不符合均匀分布的原则和指望。因此在源码里面一直都在强调power-of-two expansionsize must be power of two

另外,HashMap 构造函数容许用户传入的容量不是 2 的 n 次方,由于它能够自动地将传入的容量转换为 2 的 n 次方。

/**
 * Returns a power of two size for the given target capacity.
 */
static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

4. HashMap 扩容

接下来咱们来说讲HashMap扩容相关的知识。

4.1 扩容

HashMap的初始长度是16,假设HashMap中的键值对一直在增长,可是table数组容量一直不变,那么就会发生哈希碰撞,查找的效率确定会愈来愈低。因此当键值对数量超过某个阈值的时候,HashMap就会执行扩容操做。

那么扩容的阈值是怎么计算的呢?

阈值 = 数组长度 * 负载因子

threshold = capacity * loadFactor

每次扩容后,threshold 加倍

上述计算就出如今resize()方法中。下面会详细解析这个方法。咱们先继续往下讲。

loadFactor这个参数,咱们以前提到过,负载因子是权衡资源利用率与分配空间的系数。至于为何是0.75呢?这个实际上就是一个做者认为比较好的权衡,固然你也能够经过构造方法手动设置负载因子 。public HashMap(int initialCapacity, float loadFactor) {...)

接下去再来到这里的主角resize()方法

final Node<K,V>[] resize() {
        // 旧数组引用
        Node<K,V>[] oldTab = table;
        // 旧数组长度
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        // 旧阈值
        int oldThr = threshold;
        // 新数组长度、新阈值
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                // 旧数组已经超过了数组的最大容量
                // 阈值改为最大,直接返回旧数组,不操做了
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // newCap 变成原来的 两倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                // 执行扩容操做,新阈值 = 旧阈值 * 2
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            // 初始阈值被手动设置过
            // 数组容量 = 初始阈值
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            // 初始化操做
            // 数组容量 = 默认初始容量
            newCap = DEFAULT_INITIAL_CAPACITY;
            // 初始阈值 = 容量 * 默认负载因子
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            // 若是在前面阈值都没有被设置过
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        // 更新阈值
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
            // 建立数组
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        // 更新table引用的数组
        table = newTab;
        if (oldTab != null) {
            // 扩容
            for (int j = 0; j < oldCap; ++j) {
                // 遍历旧数组
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    // 取出这个位置的头节点
                    // 把旧引用取消,方便垃圾回收
                    oldTab[j] = null;
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                        // 红黑树的处理
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        ....
                    }
                }
            }
        }
        return newTab;
    }
// 链表的处理  这个链表处理实际上很是的巧妙
                        // 定义了两条链
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }

上述代码红黑树和链表的处理不知道你们看懂了没有,我反正在第一次看的时候有点晕乎。可是理解了以后有感受很是的巧妙。

拿链表处理打比方,它干的就是把在遍历旧的table数组的时候,把该位置的链表分红high链表和low链表。具体是什么意思呢?看下下面的举例。

  1. 有一个size为16的HashMap。有A/B/C/D/E/F六个元素,其中A/B/C的Hash值为5,D/E/F的Hash值为21,咱们知道计算数组下标的方法是与运算(效果至关于取模运算),这样计算出来,A/B/C/D/E/F的index = 5,都会被存在index=5的位置上中。
  2. 假设它们是依次插入,那么在index为5的位置上,就会有A->B->C->D->E->F这样一个链表。
  3. 当这个HashMap要进行扩容的时候,此时咱们有旧数组oldTable[],容量为16,新数组newTable[],容量为32(扩容数组容量加倍)。
  4. 当遍历到旧数组index=5的位置的时候,进入到上面提到的链表处理的代码段中,对链表上的元素进行Hash & oldCapacity的操做,Hash值为5的A/B/C计算以后为0,被分到了low链表,Hash为21的D/E/F被分到了high链表。
  5. 而后把low链表放入新数组的index=5的位置,把high链表放入到新数组的index=5+16=21的位置。

红黑树相关的操做虽然代码不一样,可是实际上要干的事情是同样的。就是把相同位置的不一样Hash大小的链表元素在新table数组中进行分离。但愿讲到这里你能听懂。

4.2 HashMap 死链问题

Java7的HashMap会存在死循环的问题,主要缘由就在于,Java7中,HashMap扩容转移后,先后链表顺序倒置,在转移过程当中其余线程修改了原来链表中节点的引用关系,致使在某Hash桶位置造成了环形链表,此时get(key),若是key不存在于这个HashMap且key的Hash结果等于那个造成了循环链表的Hash位置,那么程序就会进入死循环

Java8在一样的前提下并不会引发死循环,缘由是Java8扩容转移后先后链表顺序不变,保持以前节点的引用关系

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }

    Entry[] newTable = new Entry[newCapacity];
    // JDK8 移出了hashSeed计算,由于计算时会调用Random.nextInt(),存在性能问题
    // 很重要的transfer()
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    // 在此步骤完成以前,旧表上依然能够进行元素的增长操做,这就是对象丢失缘由之一
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
// 寥寥几行,却极为重要
void transfer(Entry[] newTable, boolean rehash) {
    // newCapacity 是旧表的两倍,这个扩容大小
    int newCapacity = newTable.length;
    // 使用foreach 方式遍历整个数组下标
    for (Entry<K,V> e : table) {
        // 若是在这个slot上面存在元素,则开始遍历上面的链表,知道e==null,退出循环
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            // 当前元素老是直接放在数组下标的slot上,而不是放在链表的最后
            // 倒序插入新表
            // 这里是造成死链的关键步骤
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}
延伸阅读。

https://www.yuque.com/docs/sh...

5. Java 8 与 Java 7对比

  1. 发生hash冲突时,Java7会在链表头部插入,Java8会在链表尾部插入
  2. 扩容后转移数据,Java7转移先后链表顺序会倒置,Java8仍是保持原来的顺序
  3. 引入红黑树的Java8极大程度地优化了HashMap的性能‘
  4. put 操做达到阈值时,Java7中是先扩容再新增元素,Java8是先新增元素再扩容;
  5. Java 8 改进了 hash() 扰动函数,提升了性能

6. 为何要使用红黑树?

不少人可能都会答上一句,为了提升查找性能,但更确切地来讲的话,采用红黑树的方法是为了提升在极端哈希冲突的状况下提升HashMap的性能。

极端哈希冲突的状况下,去测量Java7和Java8版本的HashMap的查询性能差距。

Java 7的结果是能够预期的。 HashMap.get()的性能损耗与HashMap自己的大小成比例增加。 因为全部键值对都在一个巨大的链表中的同一个桶中,查找一个条目须要平均遍历一半这样的列表(大小为n)。 所以O(n)复杂性在图上可视化。

与此相对的是Java8,性能提升了不少,发生灾难性哈希冲突的状况下,在JDK 8上执行的相同基准测试会产生O(logn)最差状况下的性能。

关于此处的算法优化实际上在 JEP-180中有描述到,

另外若是Key对象若是不是Comparable的话,那么发生重大哈希冲突时,插入和删除元素的效率会变不好。(由于底层实现时红黑树,须要经过compare方法去肯定顺序)

当HashMap想要为一个键找到对应的位置时,它会首先检查新键和当前检索到的键之间是否能够比较(也就是实现了Comparable接口)。若是不能比较,它就会经过调用tieBreakOrder(Objecta,Object b) 方法来对它们进行比较。这个方法首先会比较两个键对象的类名,若是相等再调用System.identityHashCode 方法进行比较。这整个过程对于咱们要插入的500000个元素来讲是很耗时的。另外一种状况是,若是键对象是可比较的,整个流程就会简化不少。由于键对象自身定义了如何与其它键对象进行比较,就没有必要再调用其余的方法,因此整个插入或查找的过程就会快不少。值得一提的是,在两个可比的键相等时(compareTo 方法返回 0)的状况下,仍然会调用tieBreakOrder 方法。

又可能会有人说了,哪有这么极端的哈希冲突?

这个其实是一个安全性的考虑,虽然在正常状况下不多有可能发生不少冲突。可是想象一下,若是Key来自不受信任的来源(例如从客户端收到的HTTP头名称),那么就有可能收到伪造key值,而且这种作法不难,由于哈希算法是你们都知道的,假设有人有心去伪造相同的哈希值的key值,那么你的HashMap中就会出现上述这种极端哈希冲突的状况。 如今,若是你去对这个HashMap执行屡次的查询请求,就会发现程序执行查询的效率会变得很慢,cpu占用率很高,程序甚至会拒绝对外提供服务。

延伸外链: https://www.yuque.com/docs/sh...
相关文章
相关标签/搜索