HashMap面试,看这一篇就够了(上)

603 阅读7分钟

以下HashMap源码的解析都是基于java8来讲解的。

HashMap的结构是数组加链表的形式(jdk7中也是),在java8中引入了红黑树,由于红黑树的时间复杂度是O(log n),引入红黑树是为了解决在哈希冲突很严重的时候导致链表太长,从而引起的查找效率太低的问题。

常量

// HashMap的默认初始容量为16,且容量必须为2的幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 

// HashMap的最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;

// HashMap默认的负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 树化的阈值,当链表长度超过该值时,链表转化为红黑树
static final int TREEIFY_THRESHOLD = 8;

// 当链表的长度小于6时,红黑树转为链表
static final int UNTREEIFY_THRESHOLD = 6;

// 树化的前提是capacity大于MIN_TREEIFY_CAPACITY值
static final int MIN_TREEIFY_CAPACITY = 64;

※ 为什么链表长度大于8才进行树化

源码中有下面一段解释

* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins.  In
* usages with well-distributed user hashCodes, tree bins are
* rarely used.  Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0:    0.60653066
* 1:    0.30326533
* 2:    0.07581633
* 3:    0.01263606
* 4:    0.00157952
* 5:    0.00015795
* 6:    0.00001316
* 7:    0.00000094
* 8:    0.00000006
* more: less than 1 in ten million

大体意思就是:由于TreeNode所占空间是普通Node的两倍,所以只有在bin包含足够多的node的时候才会转化为Tree(有TREEIFY_THRESHOLD决定)。当bin变的很小的时候,又会转为链表。当hashCode分布良好的时候,几乎用不到tree,hash冲突很小。但是在随机hashCode时,离散性差,会导致hash冲突严重,所以导致链表很长,这时候就要转化为红黑树来提高查询效率。按上面java官方给出的概率,链表长度达到8的概率是0.00000006,是很低很低的概率了,所以java也是通过大概率统计得出大于8的时候才转化为红黑树。

内部类 Node

从java8开始,HashMap的节点改为了使用Node(java7使用Entry),其实都差不多,内部结构都类似。

  • hash hash值
  • key key值
  • value value值
  • next 下一个节点

方法

  1. put方法

    put方法调用了下面的putVal方法,这里和java7版本的不同有两处:

    • 当链表长度大于8的时候,会转化为红黑树;而且如果Node是TreeNode类型,则按照红黑树的方式进行put
    • put采用的是尾插法,这样在扩容的时候避免了多线程情况下出现死循环(java7采用头插法,会存在扩容时出现死循环)
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        // 如果table为空,则先初始化这个table
        if ((tab = table) == null || (n = tab.length) == 0)
          n = (tab = resize()).length;
        // 判断key在table[]中的位置是否有值,如果没值,直接构造Node放在这个位置
        if ((p = tab[i = (n - 1) & hash]) == null)
          tab[i] = newNode(hash, key, value, null);
        else {
          //有值的情况
          //此时p为Node的头
          Node<K,V> e; K k;
          //这里先判断了一下p是不是和要插入的元素hash相同、key相同
          if (p.hash == hash &&
              ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
          else if (p instanceof TreeNode)
            //树化
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
          else {
            //循环Node的next,判断hash和equal是否相同
            for (int binCount = 0; ; ++binCount) {
              //p.next==null说明到链表结尾了,此时构造新Node值放到了p.next
              //可以看出这里是尾插法(java7是头插法)
              if ((e = p.next) == null) {
                p.next = newNode(hash, key, value, null);
                //链表长度超过8的时候转化为红黑树
                if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                  treeifyBin(tab, hash);
                break;
              }
              //hash相同且equal时跳出循环
              if (e.hash == hash &&
                  ((k = e.key) == key || (key != null && key.equals(k))))
                break;
              p = e;
            }
          }
          //存在hash相同且key值equal的值时,覆盖value值
          if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
              e.value = value;
            afterNodeAccess(e);
            return oldValue;
          }
        }
        ++modCount;
        //超过阈值时,扩容
        if (++size > threshold)
          resize();
        //留给子类实现
        afterNodeInsertion(evict);
        return null;
      }
    
  2. get方法

    get方法调用了getNode方法

    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
          //检查node的头是否是要查找的元素
          if (first.hash == hash && // always check first node
              ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
          if ((e = first.next) != null) {
            //如果是TreeNode,则使用红黑树的方式查找
            if (first instanceof TreeNode)
              return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            //循环去判断node的next是否是要找的元素
            do {
              if (e.hash == hash &&
                  ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
            } while ((e = e.next) != null);
          }
        }
        return null;
    }
    

HashMap扩容

HashMap的扩容存在的问题也是面试中经常问到的,首先来看下扩容的源码

final Node<K,V>[] resize() {
    //...省略数组长度的计算
    threshold = newThr;
    // 构造出新的Node数组
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    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)
                    // #1
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        //e.hash & oldCap是java8优化的地方,在下面会详细介绍
                        //构造新的Node链
                        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;
                    }
                }
            }
        }
    }
    return newTab;
}

在这段代码中,java8进行了优化的,一部分是元素位置不变的,一部分是元素位置+old capacity。它是这样实现的:e.hash & oldCap 这段代码不是为了判断元素所在的位置,而是判断hash值在old capacity的那一位是不是0,举个例子:hashCode是20,old capacity是16,new capacity是32,计算元素位置的方式:(n - 1) & hash

扩容前:
hashCode:00010100
n-1:     00001111
元素位置: 00000100

扩容后:
hashCode:00010100
n-1:     00011111
元素位置: 00010100

扩容前后对比发现,差距就在old capacity二进制1的位置那,所以e.hash & oldCap可以判断出那个位置是否为1。如果为1,那么元素的新位置就是old capacity+扩容前的元素位置 即为扩容后的位置;如果为0,则扩容后的位置与扩容前相同。java8之所以这样优化,首先是因为HashMap的capacity始终是2的幂次方,这样就保证了e.hash & oldCap的正确性;其次这样优化去掉了扩容时的重新hash运算,提高了效率。

扩容存在的问题

  1. 数据覆盖

    看resize源码可以发现,当两个线程同时走到#1的位置时,如果线程1和线程2的key的hash值相同,那线程1赋值后让出cpu,此时线程2获得时间片,同样也进行了赋值操作,那么线程1赋的值就被线程2给覆盖掉了。

  2. 死循环

    在java8中已经不存在死循环了。但是在java7中是存在的。有与java7是头插法,所以在resize的时候,链表的顺序会反转,此时两个线程同时进行resize,就有可能形成链式的圆圈,造成死循环。

HashMap线程不安全,那该用什么

HashMap的线程版本有HashTable和Collections.synchronizedMap(map),但是这两个都是直接加synchronized实现的,效率很低。而HashMap的线程安全版本常用的是ConcurrentHashMap,这个也是面试中经常问到的。明天会更新ConcurrentHashMap的相关面试点,欢迎大家继续关注。

更多精彩内容,欢迎关注我的公众号:猿圈话源 也可以关注我的百家号:猿圈话源