Java学习之HashMap源码剖析 - 图文分析(附面试中常见问题)

       HashMap原理其本质就是那个我们习以为常的hash算法。

Hash算法

       自己先来设计一个普通的hash算法:

       1. 设计数组的长度(length):8。通常情况下是设计成素数,因为理论上证明取素数发生冲突的概率要小于合数。但是HashMap中数组长度设计为零16,2^ 4,是一个合数,主要是为了优化后续的计算过程;而HashTable初始化长度为11,为素数。

       2. 设计一个hash算法:hash =    key % length 。这真的是一个再简单不过的hash过程了! HashMap中的hash函数原理与此差距不大,就是在取模之前,将key值投到了一个盲盒中,扰动了一番再抛出来做映射,扰动的目的就是为了增加hash函数的复杂度,在映射的时候分布更加均匀一些,减少发生冲突的概率。如果是我来设计盲盒,我会这样:key = key ^ 2,再这样:key = key / 5,最后再这样: key = key & 7 。这波骚操作一看我就不是出色的设计师。

       3. 选取发生冲突的解决算法:拉链法。解决冲突无非就是 开放地址法 和 拉链法。HashMap中就是使用 拉链法 解决的冲突。冲突是否可以避免呢?不能!除非你能保证你的每一个hash值算出来都是不一样的,并且你又足够大的空间能够存储每一个数据。那为什么不用 开放地址法 呢?根据其的原理,开放地址法必须确保有连续的足够的存储空间,时间可能会大量的消耗在扩容上。由此,可以知道HashMap的底层实现就是 数组+链表 (JDK1.8之前),数组+链表+红黑树(JDK1.8)。

       4. 设计每一个结点的数据结构 Node:就是每一个位置里面,我想存储些什么数据。最简单的就是直接存储一个int型的数据。这里设计复杂一点:我想存一个key值,用于上面的hash计算,再存一个value值,就是我真实要存储的数据,还要存一个hash值,就是上面计算出来的hash索引位置,最后还需要拉链法解决冲突时的next指针。如出一辙,HashMap里面就是这么设计的:

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

设计好的hash算法可视化长这样:

Java学习之HashMap源码剖析 - 图文分析(附面试中常见问题)_第1张图片

       先来插入几个数据,key = 6、7、8、3、12、34、54、32、64、19、27、35;

                                        value= A、B、E、R、T、 U、   W   、R 、P、  Q、 C、  Z 。

插入之后长这样:

Java学习之HashMap源码剖析 - 图文分析(附面试中常见问题)_第2张图片

       由上图可以看出,这里采用的是尾插法,这正是HashMap的put函数中的插入原理:

//节选putVal函数
//先循环遍历找到链表的最后一个结点
//再在最后一个结点后插入新结点
 for (int binCount = 0; ; ++binCount) {
     if ((e = p.next) == null) {
           p.next = newNode(hash, key, value, null);
     

再插入几个数据:

  • key = 1,value = J.  该位置是空的,直接插入

Java学习之HashMap源码剖析 - 图文分析(附面试中常见问题)_第3张图片

  • key = 19,value = N. 此时key=19已经存在,即只需修改原结点的value值。HashMap源码如下:
//节选putVal函数
//判断哈希值及key值相等
if (p.hash == hash &&
    ((k = p.key) == key || (key != null && key.equals(k))))
    e = p;

//key已存在,只需修改value值
 if (e != null) { // existing mapping for key
      V oldValue = e.value;
      if (!onlyIfAbsent || oldValue == null)
          e.value = value;
      afterNodeAccess(e);
      return oldValue;
}

优化hash函数

    上面设计的hash算法可能存在几个问题,下面针对存在的问题进行优化:

  • 肉眼可见,上图中整个数组和结点看起来已经很拥挤了,再插入数据发生冲突的概率很高,那么需要对数组进行扩容。现在的数组长度是8,将其扩大一倍,变为16。扩容原理就是:重新开辟一块大小为16的数组,对每个结点重新计算其hash值,再按头插法重新插入到新的数组中。这就是JDK1.7的扩容方法。这种方法存在一些弊端:1)数据量很大,重新计算hash值的时间开销很大;2)采用头插法,原存储顺序会被逆置。 那为什么不采用尾插法呢?每次遍历查找尾结点的时间开销也很大。JDK1.8就利用了二进制的优势,进行了优化。(见后文)
  • 这里说的是肉眼可见,那用什么硬性的标准来定义数组拥挤了呢?例如,可以设计成当前结点总数大于数组容量的3/4就扩容。JDK1.8源码:
//节选自putVal函数
//现在的结点数>阈值 threshold 就扩容
//threshold = length * Loadfactor
//Loadfactor默认为0.75,length默认为16 
if (++size > threshold)
    resize();
  • 上面计算hash值时是用 % 操作,由于取的数组大小length = 8=2^ 3,所以可以采用速度更快的 & (与)操作。

       例: 6 用二进制表示为 00000110

               6 % 8 = 6 ,即6 % (2^3), 二进制下来看,就是取 00000110 末三位的结果,即 110 = 6 ;

               34%8 = 2, 34 = 000100010,末三位为 010 ,即2 

         故%操作可以优化为&操作:

                000100010        34

         &    000000111           7 (8-1)


               000000010          2

    JDK1.8的源码:

// 节选自putVal函数
// (n - 1) & hash 即表示取hash值的末4位,即取得映射索引位置
// n = length ,length = 16 = 2^4

if ((tab = table) == null || (n = tab.length) == 0)
       n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
       tab[i] = newNode(hash, key, value, null);
  • 再插入几个数后长成这样:

    Java学习之HashMap源码剖析 - 图文分析(附面试中常见问题)_第4张图片

           明显发现index = 3的链过长,那在查找 key = 51时,需要遍历完整条链,时间复杂度为O(n)。首先可以想到优化为二分查找法,时间复杂度降到O(lg n),由于此处采用链表存储,故可将链表转化为二叉查找树。如下图:

    Java学习之HashMap源码剖析 - 图文分析(附面试中常见问题)_第5张图片

           查找key = 51时,只需要比较3次。时间复杂度为O(lg n),与树的高度有关。而JDK1.8种采用的是 红黑树 ,这是因为二叉查找树 在某些特殊情况下会退化为线性查找,例如一次插入 3、4、5、6、7、8、9、10时,是一颗只有右孩子的二叉查找树,时间复杂度最坏情况O(n)。故而 红黑树 在二叉查找树的基础上做了一些限制,尽量保持树的相对平衡。那又为什么没有采用平衡二叉树呢?平衡二叉树的限制条件过于苛刻,插入数据时动不动就调整树形,大量的时间花费在旋转二叉树上。相对平衡二叉树,红黑树 的条件稍微放宽一点。JDK1.8中规定,链长达到8并且结点总数达到64便转化为红黑树。

HashMap源码(JDK 1.8)

//HashMap继承自AbstractMap类
public class HashMap extends AbstractMap
    implements Map, Cloneable, Serializable {

一些重要常量:

//默认数组长度,必须为2的幂次方,此处为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 

//所能容纳的结点最大叔,2^30,超过这个数便不再扩容
static final int MAXIMUM_CAPACITY = 1 << 30;

//默认负载因子,计算threshold = length * Loadfactor 时用
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//链表长度达到8便转化为红黑树
static final int TREEIFY_THRESHOLD = 8;

//结点总数达到64才转化为红黑树,否则即便链表长度达到8也不转化为红黑树,仅仅扩容
static final int MIN_TREEIFY_CAPACITY = 64;

一些重要变量:

//table即为hash数组
transient Node[] table;

//size存储当前结点数
transient int size;

//threshold = length * loadFactor 
int threshold;

//负载因子:默认为0.75,可以认为是哈希桶的满载程度 
final float loadFactor;

Node结点数据结构:

    static class Node implements Map.Entry {
        final int hash;     //存储hash值,便于扩容时优化
        final K key;     
        V value;
        Node next;     //指向下一个结点指针

        Node(int hash, K key, V value, Node next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

hash函数:

//计算hash值
//哈希函数(h = key.hashCode()) ^ (h >>> 16) 
//每一个不同的key对应的hashCode均不同
// ^ (h >>> 16) 进行扰动

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
  • 为什么 h >>> 16 右移16位 ?

        查看hashCode函数:

public native int hashCode();

        可以看到hashCode返回的是int型数据,为32位二进制位,将hashCode右移16位,前16位补0,得到后16位,h ^ (h >>> 16) 即将前16位与后16位进行异或,使整个32位均参与计算,使得到的结果分布更加的均匀。

  • 为什么采用 ^ (异或) 操作 ?

       异或操作时,只要有一个位置上的数位发生变化,得到的结果就会不同。也是为了能够使hash更加的分布均匀,减少冲突概率。

get函数:

     本质就是常规的数组+链表+树的访问。

Java学习之HashMap源码剖析 - 图文分析(附面试中常见问题)_第6张图片

       如上图

  • 访问key = 1:本质就是访问数组。先计算出key = 1的hash索引位置,即1%8=1,取first = table[1%8],发现first.key == key,first.hash==hash,即first为所求,返回first。
  • 访问key = 64:本质就是访问链表。同上,取到first = table[0],e = first.next,再取e = e.next,即取到key = 64的结点,返回e。
  • 访问key = 51:本质就是访问二叉查找树。同上,取到first = table[3],发现first结点位置是一棵树,便进行树的遍历。取 p = first.right ,再取p = p.right,即取到 key = 51 得结点,返回p。
  • JDK1.8源码如下:
    final Node getNode(int hash, Object key) {
        Node[] tab; Node first, e; int n; K k;

        //计算hash值,取到first结点
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {

           // 判断first结点是否是所求结点
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;

            //取first的next结点
            if ((e = first.next) != null) {

                //判断first所在结点是否是树结点,是则进行树的遍历
                if (first instanceof TreeNode)
                    return ((TreeNode)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;
    }

      put函数:

         插入操作在上文中已演示过,便不再赘述。

         插入流程:计算新key的hash值-->判断哈希数组是否已存在-->判断新结点的key值是否已存在-->判断是否插入树中-->判断是否需要树化

         JDK1.8源码如下:


    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node[] tab; Node p; int n, I;
        
        //首先判断该数组是否已存在,不存在则创建
        //哈希数组在插入第一个元素时进行初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;

        //获得新插入结点的索引位置 p 
        //若新结点位置为空,则直接插入
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node e; K k;

            //p的key值正好是所求结点,则令e = p,跳出else,
            //直接执行下文的e.value = value; 直接修改value值,
            //可见put函数可用于结点值的修改
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;

            //插入的是一棵树结点
            else if (p instanceof TreeNode)
                e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);

            else {
                //插入后链表长度达到8,则将链表转化为一棵树
                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;
                    }
                    
                    //在遍历过程中发现key值已存在,直接break
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }

            //结点已存在,直接修改value值
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }

        //判断是否满足扩容条件
        //threshold = length * loadFactor
        //size为哈希桶的总结点数
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
  • 为什么数组的初始化阶段要放在put中?而不放在构造函数中?

        看一眼hashMap的构造函数,有4个!!!如若写在构造器中要写多少遍啊!!既然哈希对象构造出来就是要用的,肯定要调用put函数进行插入操作,那何不直接写一遍在put函数中。

扩容机制

       上文讲述了JDK1.7 扩容时的一些弊端,而JDK1.8 针对这些问题做了优化。

Java学习之HashMap源码剖析 - 图文分析(附面试中常见问题)_第7张图片

       在设计数据结构时,每一个node结点都保存了其hash值,我们在映射时只是取了其hash值的后3位,而在扩容后,将其数组长度*2,变为16,那么在映射时只需取其hash值的后4位,这避免了JDK1.7 重新计算hash值时带来的时间开销。而重新映射之后的位置取决于新增加的倒数第4位的值,若是0,则原索引位置不变;若为1,其实就在原索引位置+ 1000(二进制,十进制为8,正好是原来的数组长度值,于是新的索引位置 = 原来的索引位置 + 未扩容前的数组长度)。

      以index=3的链为例:

      扩容之前取hash值是 hash & 00000111

      扩容之后取hash值是 hash & 00001111    

      据上述原理,只需要看倒数第4位是0还是1 

      即只需计算 hash & 00001000 (hash & 8 )

Java学习之HashMap源码剖析 - 图文分析(附面试中常见问题)_第8张图片

      将table[3] 指向 第一条链,table[3+8]指向第二条链,即完成了链3的扩容:

Java学习之HashMap源码剖析 - 图文分析(附面试中常见问题)_第9张图片

   这种方式避免了JDK1.7 扩容后元素逆置的问题。

    JDK1.8扩容源码:

 final Node[] resize() {
        Node[] oldTab = table;

        //保存扩容前的数据:length 和 threshold
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;

        //原数组超出了最大容量2^30,便不再扩容
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }

            //正常扩容为原来的2倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }

        //修改新的length 和 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[] newTab = (Node[])new Node[newCap];
        table = newTab;

        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node 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)e).split(this, newTab, j, oldCap);

                    //正常链操作
                    else { // preserve order
                        Node loHead = null, loTail = null;
                        Node hiHead = null, hiTail = null;
                        Node next;
                        do {
                            next = e.next;

                            //倒数第x的为0的结点串成一条链
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }

                            //倒数第x的为1的结点串成另一条链
                            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;
                        }
                    }
                }
            }
        }
        return newTab;
    }

一些其他函数:

//计算threshold值时调用
//用于保证数值为2的幂次方

    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;
    }

一些常见问题:

HashMap 和 HashTable 的区别:

  • HashMap不保证线程安全,HashTable是线程安全的。但是要保证线程安全的话,一般用ConcurrentHashMap。
  • HashMap比HashTable高效,因为HashTable里面大量的使用的synchronized字段,加锁解锁也要耗费不少时间。所以HashTable现在基本不用。
  • HashMap中key 和value值可以为null,但只有一个key可以为null;而HashTable不允许为null。
  • HashMap默认length = 16,必须为2的幂次方,为合数,每次扩充为原来的2倍;HashTable初始化大小为11,为素数,每次扩充为 2n+1 。
  • JDK1.8中HashMap引入了红黑树来提高访问效率,而HashTable没有。

HashSet 和 HashMap 的区别:

  • HashSet的底层是基于HashMap实现的。
  • HashSet存储对象,HashMap存储键值对。
  • HashSet调用add()方法添加元素,HashMap调用put()方法添加元素。
  • HashSet使用对象计算hashCode,两个不同的对象其值可能相等;HashMap使用key值计算hashCode
  • HashMap比HashSet访问快。

HashMap导致多线程死锁的问题:

       HashMap是线程不安全的,在不停的put元素时,可能导致扩容的发生,而多线程下,对原哈希桶和新哈希桶的同时操作可能导致结点之间形成死循环,从而导致线程的死锁。要保证线程安全可以使用ConcurrentHashMap。

你可能感兴趣的:(Java,java,hashmap)