Java JDK1.8(四) - HashMap查删源码解析与常见问题(三)

红黑树演示网站:https://www.cs.usfca.edu/~galles/visualization/RedBlack.html

查找

/**

 * 根据key获取value

 * @param key

 * @return V

 */

public V get(Object key) {

    //1、查找出来的结果

    Node<K,V> e;

   

    //2、调用getNode()方法,如果查询到了node,就返回nodevalue,如果没有查询到,就返回null

    return (e = getNode(hash(key), key)) == null

         ?

         null : e.value;

}

/**

 * 获取Node

 * @param hash keyhash值,即存储在数组的位置

 * @param key key

 * @return Node<K,V>

 */

final Node<K,V> getNode(int hash, Object key) {

    //1、存放数组table(临时)

    Node<K,V>[] tab;

   

    Node<K,V> first, //链表的第一个节点

           e//存放first的下一个节点

    int n//存放数组的长度

    K k; //存放的key(临时)

   

   

    //2、判断存放元素数组不为空,并且长度大于0,而且数组中首个元素不为空

    if ((tab = table) != null //存放数组的table赋给临时的tab

         && (n = tab.length) > 0 //table的长度赋给临时变量n

         && (first = tab[(n - 1) & hash]) != null) { //获取数组里存放元素位置的的第一个链表元素

   

    //2.1、判断存放在数组的第一个元素的hash值,是否和要查找的valuekeyhash值相等,并且key也相等

    //keyhashCode相等,equals也相等

        if (first.hash == hash &&

            ((k = first.key) == key || (key != null && key.equals(k))))

            //2.1.1、直接返回

        return first;

       

        //2.2、如果key和第一个元素不相等,那么则和第一个元素的下一个元素,即第二个元素比较,并将这个元素赋给变量e,进而判断e是否为空

        if ((e = first.next) != null) {

        //2.2.1、第一个元素是否是树结构类型

            if (first instanceof TreeNode)

              //2.2.1.1、按照树结构的查找方法进行查找,keyfirst为根节点

                return ((TreeNode<K,V>)first).getTreeNode(hash, key);

           

            //2.2.2、不是红黑树,那么就是一个普通的建表

            do {

              //2.2.2.1、判断keyhashCodeequals是否同时相等

                if (e.hash == hash &&

                    ((k = e.key) == key || (key != null && key.equals(k))))

                   //2.2.2.2.1.1、相等直接返回

                   return e;

           

            //2.2.3、一直循环下一个节点,直到节点为null,结束循环

            } while ((e = e.next) != null);

        }

    }

    //3、查找不到,返回null

    return null;

}

 

/**

 * 树结构,根据key查找value

 * @author suzhiwei

 * @param h

 * @param k

 * @return TreeNode<K,V>

 */

final TreeNode<K,V> getTreeNode(int h, Object k) {

    //如果当前节点的父节点不为空,调用root,去查找根节点

    //如果当前节点的父节点为空,代表当前节点为根节点,则用当前节点去调用find

    return ((parent != null) ? root() : this)

         .find(h, k, null);

}

/**

 * 查找父节点

 * @return TreeNode<K,V>

 */

final TreeNode<K,V> root() {

    //1、该循环没有退出条件,从内部退出,将当前节点赋给变量r,并定义变量p

    for (TreeNode<K,V> r = this, p;;) {

    //1.1、获取变量r(当前节点)的父节点,是否为空,如果为空,则证明这个节点就是根节点返回回去

        if ((p = r.parent) == null)

            //1.1.1、返回根节点

        return r;

       

        //1.2、如果这个节点还有父节点,那么则将这个节点赋给变量r,继续下一轮的父节点判断,直到某个节点的父节点为空

        r = p;

    }

}

/**

 * 通过节点,查找key

 * @param h keyhash

 * @param k key

 * @param kc key的类型

 * @return TreeNode<K,V>

 */

final TreeNode<K,V> find(int h, Object k, Class<?> kc) {

    //获取到主节点,后续会循环变成当前节点,先理解为root节点或者是父节点

    //(入参是父节点,针对get方法的first.getTreeNode.find,所以也可以理解为根节点或父节点)

    TreeNode<K,V> p = this;

   

    //进入循环

    do {

   

        int ph, //父节点的hash

        dir//位置

        K pk; //父节点的key

       

        TreeNode<K,V> pl = p.left, //父节点的左节点

                    pr = p.right, //父节点的右节点

                    q; //

       

        //注:左节点(60) < 父节点(110 < 右节点(120

       

        //如果父节点的hash值大于查找的目标keyhash值,说明目标在父节点的左边,进入左节点循环

        if ((ph = p.hash) > h)

        //父节点 = 左节点

            p = pl;

       

        //如果父节点的hash值小于查找的目标keyhash值,说明目标在父节点的右边,进入右节点循环

        else if (ph < h)

        //父节点 = 右节点

            p = pr;

       

        //如果父节点不大于目标节点,也不小于目标节点,那么则比较是否相同,hashCode && eqauls相等

        else if ((pk = p.key) == k || (k != null && k.equals(pk)))

            //相等直接返回即可

         return p;

       

        //如果左节点为空,进入右节点循环

        else if (pl == null)

            //右节点赋给父节点

        p = pr;

       

        //如果右节点为空,进入左节点循环

        else if (pr == null)

        //左节点赋给父节点

            p = pl;

        //如果key的类型不为nullkeyComparable的子类则执行相应的compareTo接口判断左右节点,即验证类型是否一致

        //注:已经说明了,key不相等,并且左右节点都不为空,并且hash值相等

        else if ((kc != null

              || (kc = comparableClassFor(k)) != null) //代表实现了Comparable接口

              && (dir = compareComparables(kc, k, pk)) != 0) //k < pk = dir(-1)k > pk = dir(1)

        //比较的结果中,如果dir<0,那么就是左节点,如果dir>0则是右节点

            p = (dir < 0) ? pl : pr;

       

        //如果上面所有条件都不满足,即叶不满足Comparable的子类,那么指定向右遍历

        else if ((q = pr.find(h, k, kc)) != null)

        //返回遍历之后的结果

            return q;

       

        //在向右遍历还是找不到的情况下,向左遍历

        else

            p = pl;

       

    //直到p为空,即一直找不到

    } while (p != null);

   

    //返回null

    return null;

}

 

删除

/**

 * HashMap中删除指定的key对应的Entry,并

 * @param key 要删除的节点的key

 * @return V 1、返回被删除的Value2、返回null,说明key可能不存在(可用containsKey方法确认),也有可能key对应的值就是null

 */

public V remove(Object key) {

    //1、临时变量,存储要删除的节点

    Node<K,V> e;

   

    //2、计算这个keyhash值,调用removeNode方法进行删除,并将要删除的节点赋给变量e

    //如果返回值是null,就返回null,否则返回被删除的节点的value

    return (e = removeNode(hash(key), key, null, false, true)) == null

         ?

         null : e.value;

}

 

/**

 * 注:这个方法是final修饰,子类可以通过实现afterNodeRemoval方法来增强处理逻辑

 * @param hash keyhash

 * @param key 要删除的Nodekey

 * @param value 删除的Nodevalue,这个值作为是否删除的条件取决于matchValue是否为true

 * @param matchValue 如果为true,则key对应的Nodevalueequals(vaue)true时才删除,否则不关心value的值,即如果为true,说明key相同,value相同才删除

 * @param movable 删除后是否移动节点,如果为false就不移动

 * @return Node<K,V>

 */

final Node<K,V> removeNode(int hash, Object key, Object value,

        boolean matchValue, boolean movable) {

   

    //1、临时变量

    Node<K,V>[] tab; //存储节点的数组

    Node<K,V> p//当前节点

    int n//数组长度

         index; //当前节点的索引值

   

    //2、一系列的判断,避免为空,浪费查找资源

    if ((tab = table) != null  //先将节点数组变量赋给临时变量tab,判断这个数组不为空

             && (n = tab.length) > 0 //将数组长度赋给临时变量n,判断这个长度>0

             &&(p = tab[index = (n - 1) & hash]) != null) { //根据hash定位到节点对象,并赋给临时变量p(该节点为树的根节点或链表首节点) ,判断不为空

        

         Node<K,V> node = null, //要返回的节点对象

                    e; //临时节点

         K k; //

         V v; //

        

         //2.1、如果当前节点的key和传进来的key相等(hashCode && eqauls相等),那么当前节点就是要删除的节点

         //当前节点的key赋值给临时变量k

         if (p.hash == hash

                  && (

                          (k = p.key) == key

                          || (key != null && key.equals(k))

                      )

             )

             //2.1.1、赋值给要返回的临时变量

             node = p;

        

         //2.2、到这说明首节点没有匹配上,那么将当前节点的下一个节点赋给变量e

         //如果没有next节点,就说明该节点所在的位置上没有发生hash碰撞,就一个节点并且还没有匹配上,也就没有这个节点,即没得删,直接返回null

         //如果存在next节点,说明这个数组位置上发生了hash碰撞,此时可能存在一个链表,也可能是一颗树

         else if ((e = p.next) != null) {

             //2.2.1、判断是否是一个树

             if (p instanceof TreeNode)

                  //2.2.1.1、调用树结构查找节点的方法,获得要删除的节点

                  node = ((TreeNode<K,V>)p).getTreeNode(hash, key);

            

             //2.2.2、如果不是一个树类型的,那么说明是一个链表结构

             else {

                  //进入循环这个链表

                  do {

                   //2.2.2.1、判断这个链表内的元素和当前元素是否keyhashCode && equals相等

                   if (e.hash == hash &&

                       ((k = e.key) == key ||

                        (key != null && key.equals(k)))) {

                       //2.2.2.1.1、相等,直接将这个要删除的对象赋给临时变量node

                       node = e;

                       //2.2.2.1.2、终止循环,跳出

                       break;

                   }

                   //2.2.2.2、如果在2.2.2.1的判断条件中不相等,那么则将e赋给p,说明p永远指向就是下一次循环的e的父节点了,如果下一次匹配上了,p就是node的下一个节点。

                   p = e;

                   //2.2.2.3、如果e存在下一个节点就继续匹配,直到某个节点跳出,或者遍历完链表所有节点

                  } while ((e = e.next) != null);

             }

         }

        

         //2.3、如果node不为空,说明根据key匹配到了要删除的节点

         //如果不需要对比value值,或需要对比value值,但是value值也相等

         //那么就可以删除node节点了

         if (node != null

                  && (

                          !matchValue

                          || (v = node.value) == value

                          || (value != null && value.equals(v))

                      )

             ) {

             //2.3.1、如果这个节点是树结构的节点,那么调用树的删除方法进行删除removeTreeNode

             if (node instanceof TreeNode)

                  ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);

            

             //2.3.2、如果该节点不是树结构的节点,判断该节点是否是首节点

             else if (node == p)

                  //首节点删除的话,直接将该节点数组对应位置指向第二个节点即可

                  tab[index] = node.next;

            

             //2.3.3、如果node节点不是首节点

             else

                  //2.3.3.1、此时pnode的父节点,由于要删除node,所有只需要把p的下一个节点指向node的下一个节点即可把node从链表删除

                  p.next = node.next;

            

             //2.3.4、修改次数+1

             ++modCount;

             //2.3.5HashMap的容量 -1

             --size;

             //2.3.6、这个方法没有任何实现逻辑,目的是让子类根据需要重写,并且是应用在LinkedHashMap

             afterNodeRemoval(node);

             return node;

         }

    }

    //3、没有找到返回null

    return null;

}

/**

 * 删除树结构节点

 * @param map hashMap对象

 * @param tab 存储数组的table

 * @param movable 删除后是否移动节点,如果为false就不移动

 */

final void removeTreeNode(HashMap<K,V> map, Node<K,V>[] tab,

        boolean movable) {

    //1、数组table的长度

    int n;

   

    //2table为空,或者length0直接返回

    if (tab == null || (n = tab.length) == 0)

         return;

   

    //3、根据节点的hash计算出索引的位置,即当前树节点所在的桶位置

    int index = (n - 1) & hash;

   

    //4、将索引位置的头节点赋给firstroot

    TreeNode<K,V> first = (TreeNode<K,V>)tab[index],

                    root = first,

                            rl;

   

    //5succ指向当前删除节点的后一个节点,前一个节点pred指向当前删除节点的后一个节点prev

    TreeNode<K,V> succ = (TreeNode<K,V>)next,

                    pred = prev;

   

    //6、如果前一个节点pred为空,说明删除的节点为根节点

    if (pred == null)

         //6.1firsttab[index]需指向删除节点的后一个节点

         tab[index] = first = succ;

   

    //7、如果前一个节点pred不为空,说明删除的节点非根节点

    else

         //7.1、前一个节点pred的下一个节点,为当前删除节点的后一个节点(succ

         pred.next = succ;

   

    //8succ(当前删除节点的后一个节点)不为空

    if (succ != null)

         //8.1、后一个节点不为空,则后一个节点的前一个节点pred构建关联

         succ.prev = pred;

   

    //9、如果first为空,说明当前桶内无节点,直接返回

    if (first == null)

         return;

   

    //10、如果root节点有父类则需要进行调整

    if (root.parent != null)

         //10.1、调整根节点

         root = root.root();

   

    //11、根自身或者左右儿子其中一个为空,或左子树为空,需要转换为链表结构

    //红黑树的特性下,这几种情况下,如果真的发生了,说明节点较少,进行树向链表转换

    if (root == null

             || (movable

                      && (root.right == null

                          || (rl = root.left) == null

                          || rl.left == null)

                  )

         ) {

         //11.1、树转为链表

         tab[index] = first.untreeify(map);  // too small

         return;

    }

   

    //--- 以上为链表的修正,下面是红黑树的修正

   

    TreeNode<K,V> p = this, //要删除的node节点

                    pl = left//p的左节点

                    pr = right//p的右节点

                    replacement; //

   

    //12、如果p的左右节点都不为空

    if (pl != null && pr != null) {

         //12.1s节点赋值为p的右节点

         TreeNode<K,V> s = pr,

                            sl;//s的左节点,即p的右节点的左节点

        

         //12.2、向左查找,跳出循环时,s为没有左节点的节点

         while ((sl = s.left) != null) // find successor

             s = sl;

        

         //12.3、交换p节点和s节点的颜色,c代表的是s节点的颜色

         boolean c = s.red;

                  s.red = p.red;

                  p.red = c; // swap colors

        

         //s的右节点

         TreeNode<K,V> sr = s.right;

         //s的左节点

         TreeNode<K,V> pp = p.parent;

        

         //--- 至今是p节点和s节点进行了位置交换,即p节点和p节点的右节点交换了

        

         //--- 开始第一次调整

        

         //12.4、如果p节点的右节点为s节点

         if (s == pr) {

             //12.4.1p的父节点赋值为s

             p.parent = s;

             //12.4.2s的右节点赋值为p

             s.right = p;

         }

         //12.5、如果p节点的右节点不是s节点

         else {

             //12.5.1、将sp赋值为s的父节点

             TreeNode<K,V> sp = s.parent;

            

             //12.5.2、将p的父节点赋值为sp,即s的父节点

             if ((p.parent = sp) != null) {

                  //12.5.2.1、如果s节点为sp的左节点

                  if (s == sp.left)

                      //12.5.2.1.1、将sp的左节点赋值为p节点

                      sp.left = p;

                 

                  //12.5.2.2、如果s节点不是sp的左节点,即右节点

                  else

                      //12.5.2.2.1、将sp的右节点赋值为p节点

                      sp.right = p;

             }

        

             //12.5.3s的右节点赋值为p节点的右节点

             if ((s.right = pr) != null)

                  //12.5.3.1、如果pr不为空,则将pr的父节点赋值为s

                  pr.parent = s;

         }

        

         //--- 开始第二次调整

        

         //12.6、将p节点的左节点赋值为空,临时变量pl已经保存了该节点

         p.left = null;

        

         //12.7、将p节点的右节点赋值为sr,如果sr不为空

         if ((p.right = sr) != null)

             //12.7.1、将sr的父节点赋值为p节点

             sr.parent = p;

        

         //12.8、将s节点左节点赋值为pl,如果pl不为空

         if ((s.left = pl) != null)

             //12.8.1pl的父节点为s

             pl.parent = s;

        

         //12.9、将s的父节点赋值为p的父节点pp,如果s的父节点为空

         if ((s.parent = pp) == null)

             //12.9.1pp为空,交换后,s成为新的root节点

             root = s;

        

         //12.10、如果p节点不为root节点,并且ppp的左节点

         else if (p == pp.left)

             //12.10.1、将pp左节点赋值为s节点

             pp.left = s;

        

         //12.11、如果p节点不为root节点,并且ppp的右节点

         else

             //12.11.1、将pp的右节点赋值为s节点

             pp.right = s;

        

         //12.12、如果sr不为空

         //注:开始寻找replacement节点,用来替换掉p节点

         if (sr != null)

             //12.12.1replacement节点为sr,因为s没有左节点,所以使用s的右节点来替换p的位置

             replacement = sr;

         //12.13.1、如果sr为空,则s为叶子节点

         else

             //12.13.2replacementp本身,只需要将p节点直接去除即可

             replacement = p;

    }

    //13、继续12p的左右节点都不为空)的判断,如果p的左节点不为空,右节点为空

    else if (pl != null)

         //13.1replacementp的左节点

         replacement = pl;

    //14、如果p的右节点不为空,左节点为空

    else if (pr != null)

         //14.1replacementp的右节点

         replacement = pr;

    //15、如果p的左右节点都为空,即p为叶子节点

    else

         //15.1replacement节点为p节点

         replacement = p;

   

    //--- 第三次调整:使用replacement节点替换掉p节点,将p节点移除

   

    //16、如果p节点不是叶子节点

    if (replacement != p) {

         //16.1、将p节点的父节点复制给replacement节点的父节点,同时赋给pp节点

         TreeNode<K,V> pp = replacement.parent = p.parent;

        

         //16.2、如果p没有父节点,即proot节点

         if (pp == null)

             //16.2.1、将root节点赋值为replacement节点

             root = replacement;

        

         //16.3、如果p不是root节点,并且ppp的左节点

         else if (p == pp.left)

             //16.3.1、将pp的左节点赋值为替换节点replacement

             pp.left = replacement;

         //16.4、如果p节点不是root节点,并且ppp的右节点

         else

             //16.4.1、将pp的右节点赋值为替换节点replacement

             pp.right = replacement;

        

         //16.5p节点的位置已经被完整的替换为replacement,将p节点清空,也让垃圾回收器方便回收

         p.left = p.right = p.parent = null;

    }

   

    //17、如果p节点不为红色,则进行红黑树删除平衡调整(与balanceInsertion平衡插入差不多)

    TreeNode<K,V> r = p.red

                          ?

                          root : balanceDeletion(root, replacement);

   

    //18、如果p节点为叶子节点,则简单的将p节点取出即可

    if (replacement == p) {  // detach

         //18.1、将p父节点赋给临时变量pp

         TreeNode<K,V> pp = p.parent;

        

         //18.2、将p节点的父节点设置为空

         p.parent = null;

        

         //18.3、如果p的父节点不为空

         if (pp != null) {

             //18.3.1、如果p节点为父节点的左节点

             if (p == pp.left)

                  //18.3.1.1、将父节点的左节点置空

                  pp.left = null;

            

             //18.3.2、如果p节点为父节点的右节点

             else if (p == pp.right)

                  //18.3.2.1、将父节点的右节点置空

                  pp.right = null;

         }

    }

    //19、删除后是否要移动节点

    if (movable)

         //19.1、将root节点移到索引位置的头节点

         moveRootToFront(tab, r);

}

 

 

总结

1、 HashMap的底层是个Node数组(Node<K,V>[] table),在数组的具体索引位置,如果存在多个节点,则可能是以链表或红黑树的形式存在。

HashMap虽然是用Node/TreeNode对象,但在底层是将K-V作为一个Entry对象来处理的。

 

2、增加、删除、查找键值对时,定位到hash桶数组的位置,是最关键的一步,HashMap的数据结构就是数组+链表/红黑树的结合,所以我们当然希望这个HashMap里面的元素位置尽量分布均匀些,尽量是的每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是目标元素,不用遍历链表/红黑树,大大优化了查询的效率,HashMap定位数组元素索引位置,直接决定了hash方法的离散性能,在源码中是通过三个操作来完成的。

一是拿到key的hashCode值;

二是将hashCode的高位参与运算,重新计算hash值;

三是将计算出来的hash值与table.length – 1进行 & 运算;

 

3、HashMap的默认初始化容量(capacity)是16,capacity必须是2的幂次方;默认负载因子(load factor)是0.75;实际能够存放的节点个数(threshold,即扩容阀值) = capactiry * load factor。

负载因子为0.75是在时间和空间上的折衷,但增大负载因子可以减少Hash表所占用的内存空,但会增加查询时间的时间开销,而查询时最频繁的操作(put()和put()都会用到查询)。如果减小负载因子会提高数据的查询性能,但是会增加Hash表所占用的内存空间。

在创建HashMap时,根据实际需要适当的调整load Factor的值。一是如应用比较关心空间开销、内存紧张,可以适当的增加负载因子。二是如应用比较关心时间开销,内存也比较大,可以适当的减少负载因子。

一般情况小并不需要修改。

对于Capacity * load Factor的问题上,在一开始创建HashMap时,也可以适当的根据实际情况调整,尽量减少扩容的次数,毕竟扩容是一个很浪费时间的操作。

 

4、HashMap在触发扩容后,阈值会变为原来的2倍,并且会对所有节点进行重hash分布,重hash分布后节点的新分布位置只有可能有两个,原索引位置或原索引位置+oldCap位置。

如capacity为16,索引位置5的节点扩容后,只可能分布在新表索引位置5和索引位置21(5+16)。

 

5、导致HashMap扩容后,同一个索引位置的节点重hash最多分布在两个位置的根本原因有。

一是table的长度适中为2的n次方。

二是索引位置的计算方法为(table.length - 1)& hash。

注:HashMap扩容时一个比较耗时的操作,定义HashMap时尽量给个接近的初始容量值。

 

6、HashMap有threshold属性和loadFactor属性,但是没有capacity属性,初始化时,如果传了初始化容量值,该值存在threshold变量,并且Node数组是在第一次put时,才回进行初始化。初始化时,会将此时的threshold值作为新表的capacity值,然后用capacity和loadFactor计算新表的真正threshold值。

 

7、当同一个索引位置的节点在增加后,达到9个时,并且此时数组的长度大于等于64,则会触发链表节点(Node)转红黑树节点(TreeNode),转换成红黑树节点后,其实链表的结构还存在,通过next属性维持。

链表节点转红黑树节点具体方法在源码的treeifyBin()方法中,而如果数组长度小于64,则不会触发链表转红黑树,而是会进行扩容。

 

8、当同一个索引位置的节点在移除后,数组元素达到6个时,并且该索引位置的节点为红黑树节点,会触发红黑树节点转链表节点,红黑树节点转链表节点的具体方法在源码中的untreeify()方法中。

 

9、HashMap是非线程安全的,在并发场景下可使用ConcurrentHashMap来替代。

 

10、红黑树的根节点不一定是索引位置的头节点(也就链表的头节点),HashMap通过moveRootToFront方法来维持红黑树的根节点就是索引位置的投节点,但是在removeTreeNode方法中,当movable为false时,就不会调用moveRootToFront方法,此时红黑树的根节点不一定是索引位置的头节点,这个场景发生在HashIterator的remove方法中。

 

11、HashMap在JDK1.8之后不再有死循环的问题,JDK1.8之前存在死循环的根本原因在于扩容后同一索引位置的节点顺序会反掉。

 

12、在JDK1.6、JDK1.7中,HashMap采用位桶+链表的实现,即使用链表处理冲突,同一hash值的键值对会被放在同一个位桶中,但桶中元素较多时,通过key值查找的效率较低。

 

13、在通过迭代器遍历HashMap的Node过程中,如果进行了结构性的更改,会触发Fail-Fast机制,这个是和ArrayList是一样的,会抛出ConcurrentModificationException。

 

14、构建Hash表时,如果不指明初始化大小,默认大小为16(即Node数组的大小16),如果Node[]数组中的元素达到(填充比*Node.length)重新调整HashMap大小,变为原来2倍大小,扩容是很耗时的操作。

 

15、在JDK1.7中,HashMap处理碰撞的时候,都是采用链表来存储,但碰撞的节点很多是,查询时间是O(n)。

在JDK1.8中,HashMap处理碰撞增加了红黑树的这种数据结构,但碰撞节点较少时,采用链表存储,但较大时(>8个),采用红黑树(特点是查询时间是O(logn))存储(有一个阈值控制,大于阈值8个将链表转换为红黑树存储)。

如果多个hashCode的值都落在一个桶内的时候,这些值是存储到一个链表中的,最坏的情况下,所有的key都映射到同一个桶中,这样HashMap就退化成了一个链表,查找时间从O(1)到O(n)。

随着HashMap的大小的增长,get()方法的开销也越来越大,由于所有记录都在同一个桶里的超长链表内,平均查询一条记录就需要遍历一半的链表

JDK1.8中HashMap的红黑树是对,如果某个桶中的记录过大的话(当前的TREEIFY_THRESHOLD=8),HashMap会动态的使用一个TreeMap实现来替换掉,这样做的结果是从O(n)变成O(logn)。

前面产生冲突的的哪些Key对应的记录只是简单的追加到一个链表后面,这些记录只能通过遍历来查找,但是超过这个阈值后HashMap开始将列表升级成一个二叉树,使用hash值来作为树的分支遍历,如果两个hash值不相等,但指向同一个桶的haul,较大的那个会插入到右子树,如果哈希值相等,HashMap希望key值,最好是实现了Comparable接口,这样就有按照顺序插入。这对HashMap的key来说并不是必须的,不过如果实现了当然是最好的,如果没有实现这个接口,在出现严重的hash碰撞的时候,就不用指望性能提升了

 

常见面试题

1、HashMap的添加过程是怎么样的?

      校验table是否为空或length等于0,如果是则调用resize()方法进行初始化。

      计算key的hash值,看位于哪个桶里,在看这个桶内是否有节点了(p)。

      如果p节点不是目标节点,则判断p节点是否为TreeNode,如果是则调用红黑树的putTreeVal方法查找目标节点。

      校验节点数是否超过8个,如果超过了,则调用treeifyBin方法将链表节点转换为红黑树节点。

      如果没有超过8个,则按照链表的形式,添加到这个桶内。

      注:红黑树是比较两个节点的key大小,左 < 根 < 右,即比较大小便可决定存放的方向。

 

2、HashMap的扩容过程?

      老表的容量为0,并且老表的阈值大于0,这种情况是新建HashMap时,传了初始化容量,此时由于HashMap没有Capacity属性,此时的Capacity会被暂存在threshold属性。

      因此threshold的值就是此时要新创建的HashMap的Capacity,所以将新表的容量设置为threshold。

      如果新表的阈值为空,则通过新容量 * 负载因子获得阈值(这种情况是初始化的时候传了初始容量,或者初始化容量设置的太小导致老表的容量没有超过16)。

      如果是红黑树节点的扩容。

      计算每个节点位于扩容后的新表的位置是否与老表相同,相同的维持原位,不同的话,索引位置则是hash + oldCap。

 

2、HashMap扩容机制?

      每次扩容,都是翻倍扩容,容量会是之前的两倍,默认容量是16,也可以指定初始化容量,初始化的时候,阈值threshold为capacity,也就是说第一次threshold并不是等于capacity*loadFactor,但元素个数达到threshold时,会扩容,即resize。

 

3、红黑树的特点和优缺点?

特点:

      根节点一定是黑色。

      每个节点不是黑红色就是红色。

      每个子节点是黑色,即叶子节点,是指为NIL或NULL的叶子节点。

      如果有一个节点是红色,则它的子节点一定是黑色。

      从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点(到叶子节点的路径)。

      左节点(60) < 根节点(100) < 右节点(110)

优点:

      查找、插入、删除都快,树总是平衡的。

缺点:算法复杂

 

4、在get方法中的getNode方法,为什么TreeNode可以强转为Node?

HashMap.Node 是 LinkedHashMap.Entry 爹

LinkedHashMap.Entry 是 TreeNode 爹

形成爷孙关系。

static class LinkedHashMap.Entry<K,V> extends HashMap.Node<K,V>

TreeNode<K,V> extends LinkedHashMap.Entry<K,V>

 

5、JDK1.8为什么要把HashMap的链表转换为红黑树?

      链表的时间复杂度是O(n),红黑树的时间复杂度是O(logn),很显然,红黑树的复杂度是优于链表的。

      树节点所占空间是普通节点的两倍,所以只有当节点足够多的时候,才回使用树节点,即节点少的时候,尽管时间复杂度上,红黑树比链表好一点,但是占用空间比较多,综合考虑之下,认为只能在节点太多的时候,红黑树占空间大这一劣势不太明显的情况下,才会舍弃链表,使用红黑树。

      为了配合使用分布良好的hashCode,树节点很少使用,并且在理想状态下,受随机分布的hashCode影响,链表中的节点遵循泊松分布,而且根据统计,链表中节点数是8的概率已经接近千分之一,而且此时链表的性能已经很差了。

      所以在这种罕见和极端的情况下,才会把链表转换为红黑树,因为链表转换为红黑树也是需要消耗性能的。

      特殊情况特殊处理,为了挽回性能,权衡之下,才回使用红黑树,提升性能,也就是大部分情况下,HashMap还是使用的链表,如果是理想的均匀分布,节点数不到8,HashMap就自动扩容了。

      通常情况下链表长度很难达到8,但是特殊情况下链表长度为8,哈希表的容量又很大,造成链表的性能很差的时候,只能采用红黑树提高性能的这种应对策略。

      链表长度为8,平均查询长度为8/2=4,如果是小于等于6,6/2=3。

      在6和8之间原因是,中间还有个差值7可以防止链表和树之间频繁的转换,假设设计成链表个数超过8,则链表转换为树结构,联保小于8就转换为链表,在一个HashMap不停插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

 

6、单链表和双链表的区别?HashMap是单链表还是双链表?

      单链表是只有一个指针,只会指向下一个节点。

      双链表是有两个指针,分别指向上一个节点和下一个节点。

      HashMap是单链表,LinkedHashMap是双向链表。

 

7、JDK1.7中的HashMap有什么缺陷?JDK1.8中,HashMap又有什么提升?

      JDK1.7中,是基于一个数组多个链表实现的,在hash值冲突的时候,就将对应节点以链表的形式存储。

      这样的话,HashMap在性能上就有一定的疑问了,如果成千上百个节点在hash时,发生碰撞,存储在一个链表中,那么如果要查找其中一个节点,就不可避免的花费O(n)的查找时间,这是极大的性能损失。

      在JDK1.8中,对以上的这个问题已经解决了,在最坏的情况下,链表的查询时间复杂度是O(n),而红黑树一直是O(logn),这样会提高HashMap的效率。

      JDK1.7中,HashMap采用的是位桶+链表的方式,即散列链表的方式,而JDK1.8,是使用位桶+链表/红黑树的方式,也是非线程安全的,在但某个桶的链表长度达到某个阀值时,这个链表就会转换为红黑树。

      即JDK1.8中,但一个hash值的节点不小于8时,将不在以单链表的形式存储了,会调整为红黑树,这也是JDK1.7和JDK1.8中HashMap实现的最大区别。

      另外JDK1.7插入节点使用头插入法(会产生环形链表死循环的问题)和JDK1.8使用尾插入法,hash定址计算方式也不一样,最重点的就是刚才说的JDK1.8引入红黑树,JDK1.7是没有的。

 

8、负载因子为什么默认取0.75?

      负载因子高,空间利用率高,查询效率低,容易出现哈希碰撞。

      负载因子低,空间利用率低,查询效率高,因此折中。

      只有哈希碰撞严重时,才会出现红黑树结构,红黑树节点占用空间大约是普通节点的2倍,实际上很少出现。

      理想情况的随机hash下,节点桶上出现node个数和概率满足泊松分布,但负载因子是0.75时,泊松分布的lambda值是0.5,每个桶链表节点node出现的个数和概率满足一个公式。

      但出现8-9个node时,概率算法理想上,只有千万分1。

 

9、hash函数是怎么设计的,hash是怎么定址的?

      使用key.hashCode的高16位保持不变,低16位为高16位和低16位的异或,即(h = key.hashCode()) ^ (h >>> 16)

      通常的hashCode函数已经足够合理分布了,一般情况下没有必要打乱节奏,考虑到位预算的便捷和快速,减少系统损耗,容量又是2的幂,hash值至少比特位不一样,因此用高位和低位异或,加入对高位的影响。

      而hash碰撞用红黑树处理,hash定址采用hash值对容量取模,源码中是通过与capacity-1进行位运算,并且位运算也快。

 

10、如果hash碰撞严重,可能是有哪些原因?

      可能是重写key的hash函数设计的不合理,尽量用系统自带的Objects.hash即可,在有可能是负载因子设置的过高。

 

11、单链表和红黑树的结构对比?

      红黑树需要左旋、右旋,而单链表不用。

      单链表:如果元素小于8,查询效率高,新增成本低。

      红黑树:如果元素大于8,查询成本低,新增效率高。

 

12、JDK1.8的HashMap为什么也不是线程安全的?

      Resize函数就是不安全的,还没复制完,另一个线程访问,此时table部分bin为null,源码中的处理是先开辟新数组,再复制元素。

      Put的时候也会出现问题,bin有值,然而读不到,本该形成链表

 

13、HashMap里Node的结构是什么样的?

      Map.Entry是Map接口的public内部子接口。

      HashMapNode是普通链表节点内部类,实现了Map.Entry。

      HashMap.Treeode是红黑树节点,是final内部类,继承了LinkedHashMap.Entry。

      LinkedHashMap.Entry是继承了HashMap.Node。

     

      Node类的内部结构有位于桶的hash值,即key的hashCode值;K-V对;指向下一个数据的指针。

 

14、Hashtable和HashMap的区别?

      Hashtable继承Dictionary类,而HashMap继承自AbstractMap类,但二者都实现了Map接口。

      Hashtable是线程安全的,每个方法都加入了synchronized,对整个table加锁,而HashMap是非线程安全的。

      HashMap允许K-V为null,Hashtable不允许K-V为null。

      HashMap默认初始化容量为16,Hashtable为11。

      HashMap的扩容为原来的2倍,Hashtable是为原来的2倍+1。

      HashMap的hash值重新计算过,Hashtable直接使用hashCode。

      HashMap去掉了Hashtable中的contains方法。

 

15、HashMap.Node的hash属性和key属性为什么时final的?

      hash值计算过一次就不用重复计算了,节约计算代价。

 

16、红黑树的排序规则是怎么样的?

      红黑树的排序过程,一是使用hash值比较;二是key的compareTo方法(判断实现Comparable接口);三是类名字符串和identityHashCode值排序,构建红黑树。

 

17、说了那么多,现在来介绍下HashMap?

      HashMap存储的是键值对,非线程安全,存储比较快,K-V可以为null。

      按照工作原来来看,Map的put(K,V)来存储元素,通过get(k)来得到value值,通过hash算法来计算hashCode值,用hashCode标识Entry在桶中存储的位置,存储结构就是hash表。

 

18、HashMap工作原理是怎么样的?HashMap的get()方法的工作原理是怎么样?

      HashMap是基于hashing的原理,使用put(k,v)存储对象到HashMap中,使用get(k),从HashMap中获取对象。

      当给put()方法传递键值对时,先对键调用hashCode()方法,返回的hashCode用于找到桶的位置来存储对象。

      get的原理就是,每次获取都计算这个key的桶位置,在看这个桶内是否只有一个元素,如果有多个元素,则看是是否是树结构,如果是就按照树结构查找,不是就按照链表查找。

 

19、HashMap中两个key的hashCode相同,说明了什么?

      hashCode相同,桶的位置就会相同,也就是发生了hash碰撞了,HashMap是采用链地址法来解决的。

 

20、HashMap的rehashing过程?

      如果HashMap的大小超过了负载因子(loadFactor)的默认大小0.75,也就是一个Map填满了75%的桶时,和其他集合类一样,将会创建原来HashMap大小2倍的桶数组,来重新调整Map的大小,将原来的对象放入新的桶数组中,这个过程就是rehashing,因为调用了hash方法找到新的桶的位置。

 

21、JDK1.8前,为什么HashMap并发执行put会引起死循环?

      JDK1.7中,因为多线程会导致HashMap的Node链表形成环形链表,一旦形成环形链表,Node的next节点永远不会为空,就会产生死循环获取Node,从而导致CPU利用率接近100%。

 

22、为什么String、Integer这样的包装类,适合作为键?

      包装类一般是不可变的,即final,而且还重写了equals和hashCode方法,避免了K-V改写,提高了HashMap性能。

 

23、可以使用ConcurrentHashMap代替Hashtable?

      可以,但是Hashtable提供的线程更加安全,都是synchronized的,但是COncurrentHashMap同步性能更好,因为它仅仅根据同步级别对Map的一部分进行上锁。

 

24、说明是hashing?

      散列法或hash法,是将字符串经过hash函数进行计算后,转换为固定的长度,通过更短的hash值比用原始值进行数据库搜索更快,一般是用于数据库中建立索引并进行搜索的,还常用于各种解密算法中。

 

25、为什么要重写equals()方法?

      Object中的equals方法只能判断两个引用变量是否是同一个对象,而要判断两个对象在逻辑上是否相等,如根据类的成员变量来判断两个类的实例是否相等就需要重写eqauls()方法了。

 

26、重写equals方法的注意点有哪些?

      自反性:对于任何非空应用x,x.equals(x)应该返回true。

      对称性:对于任何引用x和y,如果x.equals(y)返回true,那么y.equals(x)也应该返回true。

      传递性:对于任何引用x、y、z,如果x.equals(y)返回true,y.equals(z)返回true,那么x.equals(z)也应该返回true。

      一致性:如果x、y引用对象没有发生变化,那么反复调用x.equals(y)应该返回同样的结果。

      非空性:对于任意非空引用x,x.equals(null)应该返回false。

 

发布了103 篇原创文章 · 获赞 34 · 访问量 7万+

猜你喜欢

转载自blog.csdn.net/Su_Levi_Wei/article/details/105142010