从源码分析HashMap面试回答

1,142 阅读15分钟

前言

HashMap一直是面试官特别喜欢问的。本文笔者也将重学一下HashMap并且源码的角度去分析1.7版本HashMap和1.8版本的HashMap

准备工作

先选择1.7版本的java,然后new一个HashMap,这里我使用IDEA查看的1.7版本的HashMap,不知道为什么Android Studio选择1.7版本依然显示的是1.8版本的HashMap,没有下载1.7版本的,笔者这里给出百度网盘的下载地址

链接: pan.baidu.com/s/1mxhqVClc… 密码: dwpt

前期知识铺垫

这里主要是对一些不熟悉数据结构的小伙伴做一个简单的介绍,如果对数据结构熟悉的小伙伴,可以直接跳过这一小节。

  • 1.7版本的HashMap用到的数据结构是数组+链表
  • 1.8版本的HashMap用到的数据结构是数组+链表+红黑树

对数据结构一点不懂的小伙伴可以去看一下 (小甲鱼)数据结构与算法补一下基础知识。

1.7版本HashMap原理简介

在看源码之前,还是先简要介绍一下1.7版本HashMap原理,这样后续看源码不至于一脸懵。在前期知识铺垫中介绍了 「1.7版本的HashMap用到的数据结构是数组+链表

给定一个key,先经过Hash运算,判断其应该放在哪个数组。🌰 例如 map.put("name","江海洋"),经过Hash运算,得到name下标为0,那么将这个value:江海洋填入下边为0的数组

但是Hash运算出现相同的下标。🌰map.put("size","18cm"); 经过Hash运算发现size得到的下标也是0,那么就发生了所谓的Hash碰撞,这个时候。会将size,以链表的方式插入到name之前,形成一个链表。

但是put那么多key,下标总会有用完的时候。那么就需要扩容机制,主动增大数组的长度。

那么就有以下几个问题需要在源码中找到答案

  • 数组长度应该多长?
  • 什么时候扩容?
  • 如何做到扩容?
  • 如何通过key去查询到对应的值的?

带着这些问题。开始看源码吧

当我们new一个HashMap发生了什么

点击进入看一下其无参构造方法。可以看到无参的构造方法,调用了双参的构造方法。并且传入两个固定的值。这两个值很重要。

  • DEFAULT_INITIAL_CAPACITY 数组的长度,默认给到16
  • DEFAULT_LOAD_FACTOR 负载系数,当数组占有率达到 16*0.75即12 则自动扩容
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 值为16
static final float DEFAULT_LOAD_FACTOR = 0.75f;
public HashMap() {
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor) {
	//判断数组长度不能小于0
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
	//判断长度不能大于 1073741824 
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    //负载系数 不能小于0
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
	//重新将 数组的长度 和 负载系数 赋值给变量
    this.loadFactor = loadFactor;
    threshold = initialCapacity;
    //空方法。给子类(LinkedHashMap)使用
    init();
}

可以看到。new了一个HashMap并没有创建数组,只是指定了数组的长度,和负载系数。

put发生了什么

继续看源码。看一下put发生了什么

public V put(K key, V value) {
	//判断数组长度是否是一个空数组
    if (table == EMPTY_TABLE) {
    	//创建一个数组 threshold 在上面创建的时候知道是16
        //此方法详细信息查看 『如何创建数组』小节
        inflateTable(threshold);
    }
    // 判断key 是否是null
    if (key == null)
    	//详细可查看「如果key是null」小节
        return putForNullKey(value);
    //hash运算获取hash值    
    int hash = hash(key);
    //通过hash值获取数组的下标
    int i = indexFor(hash, table.length);
    //查找下标为i的数组。获取他的链表结构
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        //hash值相同 并且 key 相同 覆盖旧值,
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
	
    modCount++;
    //如果找不到 添加一个 详细信息查看 「addEntry」小节
    addEntry(hash, key, value, i);
    return null;
}

如何创建数组

上面看到inflateTable(threshold)是创建一个数组。接下来我们分析是如何创建的

private void inflateTable(int toSize) {
    // 将初始化得到的指定数组长度传入 即 16 返回一个capacity
    // 这个capacity 一定是 16 的倍数 即 16 32 等
    int capacity = roundUpToPowerOf2(toSize);
    // 获取到阈值的大小。即 数组长度*负载系数  16*0.75 = 12
    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    // 创建数组 即 长度为16的数组
    table = new Entry[capacity];
    initHashSeedAsNeeded(capacity);
}

可以看到,数组其实是在put元素的时候被创建出来的。

如果key是null

上诉发现如果key是null,会发生什么

private V putForNullKey(V value) {
	//寻找下标为0的链表。按照链表的结构一个一个查找
    for (Entry<K,V> e = table[0]; e != null; e = e.next) {
    	//如果找到了 key 是 null 
        if (e.key == null) {
        	//key == null  对应的value 赋值给局部变量oldValue
            V oldValue = e.value;
            // 将他的值重新赋值 设置成新设置进入的 value 覆盖原来的value
            e.value = value;
            //空方法 给子类(LinkedHashMap)使用
            e.recordAccess(this);
            //返回参数
            return oldValue;
        }
    }
    //修改的次数+1
    modCount++;
    //如果没找到就添加一个元素第一个数组
    //详细信息查看 「addEntry」小节
    addEntry(0, null, value, 0);
    return null;
}

addEntry

「如果key是null」「put发生了什么」这两个小节中,都看到这addEntry的身影。

void addEntry(int hash, K key, V value, int bucketIndex) {
    //数量超过了设置的阈值 即超过了 16*0.75  = 12
    //并且数组中的也有值。不为null
    // 达到了扩容的需求
    if ((size >= threshold) && (null != table[bucketIndex])) {
    	// 扩容  此方法详情可查看 「resize」小节
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        //通过hash 冲 0-15中选择一个数组下标
        bucketIndex = indexFor(hash, table.length);
    }
   //如果没有达到扩容的要求,则创建新的Entry 此方法详细信息查看「createEntry」小节
    createEntry(hash, key, value, bucketIndex);
}

resize

这个方法是实际扩容的代码。

void resize(int newCapacity) {
    Entry[] oldTable = table;
    //获取原来的数组长度 注意 此时第一次应该是16
    int oldCapacity = oldTable.length;
    //判断不能超过最大值
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }
    //创建一个新的数组 长度为32了
    Entry[] newTable = new Entry[newCapacity];
    //将老数组中的数据迁移到新的数组中 详细信息查看 『transfer』小节
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    //旧指针指向新数组
    table = newTable;
    //原来是12 现在 变成 32*0.75 = 24 新的阈值是24
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

transfer

resize小节中知道,这个方法是 将老数组中的数据迁移到新的数组中

void transfer(Entry[] newTable, boolean rehash) {
  int newCapacity = newTable.length;
  //遍历数组
  for (Entry<K,V> e : table) {
      //遍历链表
      while(null != e) {
          Entry<K,V> next = e.next;
          if (rehash) {
        	 //二次hash运算
              e.hash = null == e.key ? 0 : hash(e.key);
          }
          //通过hash 冲 0-31中选择一个数组下标
          int i = indexFor(e.hash, newCapacity);
          e.next = newTable[i];
          newTable[i] = e;
          e = next;
      }
  }
}

createEntry

void createEntry(int hash, K key, V value, int bucketIndex) {
    //找到原来的数据
    Entry<K,V> e = table[bucketIndex];
    //创建新的数据。
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    //size+1 这里的size就是在「addEntry」小节中判断 是否超过阈值的size
    size++;
}

size在createEntry的时候+1,在map.remove("key")的时候会-1 详细信息可以查看「removeEntryForKey」小节查看

🌰举例说明

put("name","江海洋")然后在put("age","22")。假设其hash相同。并且都在第0位的数组中。那么现在他的数据结构就如下图所示一样。注意,这里使用的是头插法next指向的是下一个元素的。使用头插法而不是用尾插发,原因是因为最新被插入的元素可能会先开发者去查找,使用头插法将最新的元素放在前面,那么如果找新的元素,查找就比较快,还记得在前期知识铺垫小节中说的,链表的查找时间复杂度是O(n).查找是比较慢的

removeEntryForKey

final Entry<K,V> removeEntryForKey(Object key) {
      if (size == 0) {
          return null;
      }
      //通过hash计算得到hash值
      int hash = (key == null) ? 0 : hash(key);
      //通过hash值得到数组下标
      int i = indexFor(hash, table.length);
      //找到对应数组拿到他链表
      Entry<K,V> prev = table[i];
      Entry<K,V> e = prev;
	  //遍历链表
      while (e != null) {
      	  //获取下一个数组的指针 
          Entry<K,V> next = e.next;
          Object k;
          //判断hash值 判断key 确保找到key
          if (e.hash == hash &&
              ((k = e.key) == key || (key != null && key.equals(k)))) {
              modCount++;
              //size - 1
              size--;
              //如果刚好链表中只有一个元素。next 就是null
              if (prev == e)
              	  //就是null
                  table[i] = next;
              else
              	  //将指针指向下一个元素
                  prev.next = next;
              //空方法
              e.recordRemoval(this);
              return e;
          }
          prev = e;
          e = next;
      }
      return e;
  }

🌰举例子

连续put name age cool,假设其hash运算得到结果相同。那么其链表如下所示。

map.put("name","江海洋");
map.put("age","22");
map.put("cool","很帅");

如果需要删除age,只需要将cool对应的next变成name即可,修改如下,就完成了remove的操作。

查询元素

public V get(Object key) {
    if (key == null)
        return getForNullKey();
    Entry<K,V> entry = getEntry(key);

    return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
    //没有添加任何元素就直接返回 size 在 「createEntry」会自动+1
    if (size == 0) {
        return null;
    }
    //hash运算得到hash值
    int hash = (key == null) ? 0 : hash(key);
    //通过hash值得到数组下标。在遍历对应下标所在的链表
    for (Entry<K,V> e = table[indexFor(hash, table.length)];
         e != null;
         e = e.next) {
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
            return e;
    }
    return null;
}

1.8版本HashMap

看一下1.8版本的HashMap

初始化

在1.7当中们分析发现其设置了两个很重要的参数

  • DEFAULT_INITIAL_CAPACITY 数组的长度,默认给到16
  • DEFAULT_LOAD_FACTOR 负载系数,当数组占有率达到 16*0.75即12 则自动扩容

而在1.8中只设置了一个负载系数 默认依然是0.75

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

put

1.8版的的put和1.7版本也不一样

public V put(K key, V value) {
    //详细可查看「putVal」小节
    return putVal(hash(key), key, value, false, true);
}

首先不同的是hash算法

putVal

1.8版本的put方法老长了。代码可读性真差!差评

第一次put元素的时候。默认的数组是null,阈值也是0.所以执行resize()方法,去创建一个长度为16的数组。并且将阈值等信息记录下来,得到数组以后,将key进行Hash运算得到hash值,在通过hash值得到处于数组哪一个下标中。即哪一个桶当中。确认了桶的位置。会创建一个新的Node对应的就是1.7代码中的Entry作为链表的第一个元素。

第二次put元素的时候,依然通过key得到桶的位置,然后对比桶对应的链表对一个元素的值,如果是相同的元素,就将值替换了,说明put的key是相同的,如果第一个元素不相同,则判断是否是树结构,如果不是树结构则遍历当前桶中的链表。匹配到了,说明put的key是相同的,将值替换成新值,如果都找不到,那么就说明是一个新的key,使用尾插法追加链表。

使用尾插法插入以后,判断链表的长度是否是超过7,如果大于7,则将链表转成树结构。当树的节点小于6,则树会退化成链表结构,当节点过多时,红黑树可以更高效的查找到节点。毕竟红黑树是一种二叉查找树

在put的最后,判断是否map数量是否超过阈值。超过则需要扩容

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    //声明变量。源码中写在一行。这里为了好看 笔者分成3行
    Node<K,V>[] tab; 
    Node<K,V> p; 
    int n, i;
    //【第一次】先将table赋值给局部变量 判断其是否是null
    //【第一次】再将tab 此时也就是table的长度复制给n 判断是否是0
    if ((tab = table) == null || (n = tab.length) == 0){
    	 //【第一次】执行resize() 将获得新的数组赋值给tab (resize 方法请查看 「resize」小节)
        //【第一次】在将新获取的数据长度复制给n 第一次结果n = 16
        n = (tab = resize()).length;
    }
    //【第一次】通过hash值获取到数组的下标。将下标复制给i
    //【第一次】再将数组下标为i的数据赋值给p 
    //【第一次】在判断p 是否为null 
    //【第一次】😤这代码可读性真差!!!!!
    if ((p = tab[i = (n - 1) & hash]) == null)
    	//【第一次】是null的话创建一个新的对象 
        tab[i] = newNode(hash, key, value, null);
    else {
        //【第二次进入】数组中的链表元素不是null 
        Node<K,V> e; 
        K k;
        //【第二次进入】判断hash值和key是否相同 和1.7一样
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k)))){
            //【第二次进入】复制给局部变量e上
            e = p;
        //是否满足树节点的属性。1.8数据结构中有红黑树
        }else if (p instanceof TreeNode){
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        }else {
        	//启动循环,不是之前的key,需要在链表中加入新的元素
            for (int binCount = 0; ; ++binCount) {
            	//当最后指向null 表示链表结束了 结束了还没找到就创建一个新的Node
                if ((e = p.next) == null) {
                	//创建一个新的Node 使用的是尾插发。插入到最后
                    p.next = newNode(hash, key, value, null);
                    //判断是达到了转换成树的阈值  (8 - 1 ) = 7
                    if (binCount >= TREEIFY_THRESHOLD - 1){ 
                    	//链表转成树
                        treeifyBin(tab, hash);
                    }
                    break;
                }
                //如果找到了就break 然后下面(e != null)的方法进行覆盖
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k)))){
                    break;
                }    
                p = e;
            }
        }
        //【第二次进入】如果刚好是在同一个数组中那么e不为null,如果不在同一个数组,则为null
        // 即表示 put了之前有的key,执行覆盖操作
        if (e != null) { 
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null){
            	//将新的value设置到value字段上
                e.value = value;
            }
            //空方法
            afterNodeAccess(e);
            //返回旧值
            return oldValue;
        }
    }
    ++modCount;
    //判断是否需要扩容代码 第一次 判断是否大于 12 (数组长度*负载系数)
    if (++size > threshold){
    	//满足扩容条件 扩容
        resize();
    }
    afterNodeInsertion(evict);
    return null;
}

resize

此方法主要是为了扩容。还是比较长的。阅读此代码,先看一下【第一次】进入.在看一下【满足扩容条件】,然后在看一下如何【迁移数据】

【第一次】的时候会先生成一个长度16的数组,之后如果【满足扩容条件】,即阈值大于 数组长度*负载系数则将旧数组中的数据迁移中新数组。这里相比1.7少了一个二次hash的过程。

final Node<K,V>[] resize() {
      //【第一次】oldTab 指向 table 然而table 是null 所以 oldTab也是null
      Node<K,V>[] oldTab = table;
      //【第一次】put的时候table是null;oldTab也是null 那么oldCap = 0
      int oldCap = (oldTab == null) ? 0 : oldTab.length;
      //【第一次】put 阈值也是0 
      int oldThr = threshold;
      int newCap, newThr = 0;
      //【满足扩容条件】当之后在进入 已经不是0了 值为16
      if (oldCap > 0) {
      	  //【满足扩容条件】限定最大值
          if (oldCap >= MAXIMUM_CAPACITY) {
              threshold = Integer.MAX_VALUE;
              return oldTab;
          //【满足扩容条件】新的数组长度 newCap = 老数组长度 oldCap * 2
          }else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                   oldCap >= DEFAULT_INITIAL_CAPACITY){
              //【满足扩容条件】新的阈值变成老阈值的2倍
              //【满足扩容条件】第一次的时候是16 第二次扩容则是32
              newThr = oldThr << 1; 
          }    
      }else if (oldThr > 0){
          newCap = oldThr;
      }else {               
          //【第一次】进入,在这里进行复制 数组长度16 阈值 16*0.75 = 12
          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赋值成第一次计算得到的 12
      threshold = newThr;
      //【第一次】创建一个长度为16的数组
      //【满足扩容条件】会创建一个老数组长度*2的新数组
      Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
      //【第一次】将数组指针给table
      table = newTab;
      //【第一次】上面知道第一次oldTab肯定是null 所以不会走到下面的代码
      if (oldTab != null) {
          //【迁移数据】遍历老的数组
          for (int j = 0; j < oldCap; ++j) {
              Node<K,V> e;
              //【迁移数据】获取第j个数组 并且将数组赋值给e,在判断e是否为null
              if ((e = oldTab[j]) != null) {
                  oldTab[j] = null;
                  //【迁移数据】如果next是null表示此数组只有一个元素
                  if (e.next == null){
                  	  //【迁移数据】创建一个新数组,将新数组直接指向e,表示直接把e的链表给到了新数组
                      newTab[e.hash & (newCap - 1)] = e;
                  } else if (e instanceof TreeNode){
                  	  //【迁移数据】如果是树结构。
                      ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                  } else { 
                  	   //【迁移数据】如果是链表,并且链表中又不止一个数据
                      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;
                      }
                  }
              }
          }
      }
      return newTab;
  }

快速失败(fail—fast)

HashMap遍历使用的是一种快速失败机制,它是Java非安全集合中的一种普遍机制,这种机制可以让集合在遍历时,如果有线程对集合进行了修改、删除、增加操作,会触发并发修改异常。

它的实现机制是在遍历前保存一份modCount,在每次获取下一个要遍历的元素时会对比当前的 modCount 和保存的 modCount是否相等。

快速失败也可以看作是一种安全机制,这样在多线程操作不安全的集合时,由于快速失败的机制,会抛出异常ConcurrentModificationException

为什么说1.8版本效率高于1.7版本

从两个地方分析

  • hash算法:在JDK1.8的实现中。优化了高位运算的算法
  • 引入红黑树:在桶中有大量数据的时候,红黑树属于二叉查找树,其效率优于链表

为什么说HashMap是线程不安全的

  • 在多线程中,有可能会形成环形链表导致Infinite Loop.具体可以参考Java 8系列之重新认识HashMap中的示例
  • 删除操作、修改操作,会有覆盖问题,导致数据不准确。

线程安全的HashMap

  • ConcurrentHashMap 使用了分段锁的技术(segment + Lock)比较优化的策略. 详细可参考Map 综述(三):彻头彻尾理解 ConcurrentHashMap

  • Hashtable 通过加锁实现线程安全。Java 1.1提供的旧有类,从性能上和使用上都不如其他的替代类,因此已经不推荐使用

  • SynchronizeMap

他是Collections.synchronizeMap()方法返回的对象,他实现线程安全的方法和Hashtable一致,直接加锁

public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
    return new SynchronizedMap<>(m);
}

什么是hash表

散列表也叫做hash表,根据键(Key)而直接访问在内存储存位置的数据结构,上面看到的HashMap通过key得到hash值,在通过hash值得到数组的下标,这个数组就是一个hash表,而得到hash值的方法,也被称为散列函数

为啥负载系数默认是0.75

如果是1,只有等到全部填充完了才能触发扩容,这就带来了很大的问题,因为Hash冲突时避免不了的。当负载因子是1.0的时候,意味着会出现大量的Hash的冲突,底层的红黑树变得异常复杂。对于查询效率极其不利。这种情况就是牺牲了时间来保证空间的利用率

负载系数是0.5的时候,这也就意味着,当数组中的元素达到了一半就开始扩容,既然填充的元素少了,Hash冲突也会减少,那么底层的链表长度或者是红黑树的高度就会降低。查询效率就会增加。但是空间利用率就会大大的降低,原本存储1M的数据,现在就意味着需要2M的空间。0.75 是时间和空间的权衡

为什么数组大小16的整数倍

先看一下我们如何获取通过key得到下标的,将key经过hash运算,然后hash值 & 数组长度-1

🌰 下面的hash值 & 15 看一下结果,其实看到最后4位即可,因为后面4位全是1,

0000 0000 1010 1001
0000 0000 0000 1111
>结果
0000 0000 0000 1001

其无论与何种hash值进行&计算,取值一定都是后面的4位,也就是 [0,15]区间

同理看一下32-1的2进制

0000 0000 0001 1111

在看一下 64-1的2进制

0000 0000 0011 1111

当这些值与数组长度-1进行&运算时候,都是在[0,数组的长度-1]这个区间,实现了均匀分布

为什么将1.7的头插法改成了1.8的尾插法

JDK1.7中扩容时,每个元素的rehash之后,都会插入到新数组对应索引的链表头,所以这就导致原链表顺序为A->B->C,扩容之后,rehash之后的链表可能为C->B->A,元素的顺序发生了变化。在并发场景下,扩容时可能会出现循环链表的情况。而JDK1.8从头插入改成尾插入元素的顺序不变,避免出现循环链表的情况。

参考