一、概述

在分析要理解HashMap源码前有必要对hashcode进行说明。
以下是关于HashCode的官方文档定义:

hashcode方法返回该对象的哈希码值。支持该方法是为哈希表提供一些优点,例如,java.util.Hashtable 提供的哈希表。 hashCode 的常规协定是: 在 Java 应用程序执行期间,在同一对象上多次调用 hashCode 方法时,必须一致地返回相同的整数,前提是对象上 equals 比较中所用的信息没有被修改。从某一应用程序的一次执行到同一应用程序的另一次执行,该整数无需保持一致。 如果根据 equals(Object) 方法,两个对象是相等的,那么在两个对象中的每个对象上调用 hashCode 方法都必须生成相同的整数结果。 以下情况不 是必需的:如果根据 equals(java.lang.Object) 方法,两个对象不相等,那么在两个对象中的任一对象上调用 hashCode 方法必定会生成不同的整数结果。但是,程序员应该知道,为不相等的对象生成不同整数结果可以提高哈希表的性能。 实际上,由 Object 类定义的 hashCode 方法确实会针对不同的对象返回不同的整数。(这一般是通过将该对象的内部地址转换成一个整数来实现的,但是 JavaTM 编程语言不需要这种实现技巧。) 当equals方法被重写时,通常有必要重写 hashCode 方法,以维护 hashCode 方法的常规协定,该协定声明相等对象必须具有相等的哈希码。

以上这段官方文档的定义,我们可以抽出成以下几个关键点:

  1. hashCode的存在主要是用于查找的快捷性,如Hashtable,HashMap等,hashCode是用来在散列存储结构中确定对象的存储地址的;
  2. 如果两个对象相同,就是适用于equals(java.lang.Object) 方法,那么这两个对象的hashCode一定要相同;
  3. 如果对象的equals方法被重写,那么对象的hashCode也尽量重写,并且产生hashCode使用的对象,一定要和equals方法中使用的一致,否则就会违反上面提到的第2点;
  4. 两个对象的hashCode相同,并不一定表示两个对象就相同,也就是不一定适用于equals(java.lang.Object) 方法,只能够说明这两个对象在散列存储结构中,如Hashtable,他们“存放在同一个篮子里”

再归纳一下就是hashCode是用于查找使用的,而equals是用于比较两个对象的是否相等的。以下这段话是从别人帖子回复拷贝过来的:

1.hashcode是用来查找的,如果你学过数据结构就应该知道,在查找和排序这一章有 例如内存中有这样的位置 0 1 2 3 4 5 6 7 而我有个类,这个类有个字段叫ID,我要把这个类存放在以上8个位置之一,如果不用hashcode而任意存放,那么当查找时就需要到这八个位置里挨个去找,或者用二分法一类的算法。 但如果用hashcode那就会使效率提高很多。 我们这个类中有个字段叫ID,那么我们就定义我们的hashcode为ID%8,然后把我们的类存放在取得得余数那个位置。比如我们的ID为9,9除8的余数为1,那么我们就把该类存在1这个位置,如果ID是13,求得的余数是5,那么我们就把该类放在5这个位置。这样,以后在查找该类时就可以通过ID除 8求余数直接找到存放的位置了。 2.但是如果两个类有相同的hashcode怎么办那(我们假设上面的类的ID不是唯一的),例如9除以8和17除以8的余数都是1,那么这是不是合法的,回答是:可以这样。那么如何判断呢?在这个时候就需要定义 equals了。 也就是说,我们先通过 hashcode来判断两个类是否存放某个桶里,但这个桶里可能有很多类,那么我们就需要再通过 equals 来在这个桶里找到我们要的类。 那么。重写了equals(),为什么还要重写hashCode()呢? 想想,你要在一个桶里找东西,你必须先要找到这个桶啊,你不通过重写hashcode()来找到桶,光重写equals()有什么用啊

二、底层数据结构

HashMap源码解析 - 图1HashMap源码解析 - 图2 上面这两张图分别画出了JDK 1.7、1.8底层数据结构,在JDK 1.7、1.8中都使用 了散列算法,但是在JDK 1.8中引入了红黑树,在链表的长度大于等于8并且hash桶的长度大于等于64的时候,会将链表进行树化。这里的树使用的数据结构是红黑树,红黑树是一个自平衡的二叉查找树,查找效率会从链表的o(n)降低为o(logn),效率是非常大的提高。
那为什么不将链表全部换成二叉树呢?这里主要有两个方面。

  • 第一个是链表的结构比红黑树简单,构造红黑树要比构造链表复杂,所以在链表的节点不多的情况下,从整体的性能看来, 数组+链表+红黑树的结构不一定比数组+链表的结构性能高。
  • 第二个是HashMap频繁的resize(扩容),扩容的时候需要重新计算节点的索引位置,也就是会将红黑树进行拆分和重组其实 这是很复杂的,这里涉及到红黑树的着色和旋转,有兴趣的可以看看红黑树的原理,这又是一个比链表结构耗时的操作,所以为链表树化设置一个阀值是非常有必要的。

    三、源码分析

    3.1 类结构

    HashMap源码解析 - 图3 上图是HashMap的类结构,大家看看有个概念

    3.2 类注释

    我建议大家在读源码时可以先看看类注释,往往类注释会给我们一些重要的信息,这里给大家总结一下。
    (1)允许NULL值,NULL键
    (2)不要轻易改变负载因子,负载因子过高会导致链表过长,查找键值对时间复杂度就会增高,负载因子过低会导致hash桶的 数量过多,空间复杂度会增高
    (3)Hash表每次会扩容长度为以前的2倍
    (4)HashMap是多线程不安全的,我在JDK1.7进行多线程put操作,之后遍历,直接死循环,CPU飙到100%,在JDK 1.8中进行多线程操作会出现节点和value值丢失,为什么JDK1.7与JDK1.8多线程操作会出现很大不同,是因为JDK 1.8的作者对resize方法进行了优化不会产生链表闭环。
    (5)尽量设置HashMap的初始容量,尤其在数据量大的时候,防止多次resize

    3.3 类常量

    1. //默认hash桶初始长度16
    2. static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
    3. //hash表最大容量2的30次幂
    4. static final int MAXIMUM_CAPACITY = 1 << 30;
    5. //默认负载因子 0.75
    6. static final float DEFAULT_LOAD_FACTOR = 0.75f;
    7. //链表的数量大于等于8个并且桶的数量大于等于64时链表树化
    8. static final int TREEIFY_THRESHOLD = 8;
    9. //hash表某个节点链表的数量小于等于6时树拆分
    10. static final int UNTREEIFY_THRESHOLD = 6;
    11. //树化时最小桶的数量
    12. static final int MIN_TREEIFY_CAPACITY = 64;

    3.4 实例变量

    1. //hash桶
    2. transient Node<K,V>[] table;
    3. //键值对的数量
    4. transient int size;
    5. //HashMap结构修改的次数
    6. transient int modCount;
    7. //扩容的阀值,当键值对的数量超过这个阀值会产生扩容
    8. int threshold;
    9. //负载因子
    10. final float loadFactor;

    3.5 构造函数

    1. public HashMap(int initialCapacity, float loadFactor) {
    2. if (initialCapacity < 0)
    3. throw new IllegalArgumentException("Illegal initial capacity: " +
    4. initialCapacity);
    5. if (initialCapacity > MAXIMUM_CAPACITY)
    6. initialCapacity = MAXIMUM_CAPACITY;
    7. if (loadFactor <= 0 || Float.isNaN(loadFactor))
    8. throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
    9. this.loadFactor = loadFactor;
    10. //下面介绍一下这行代码的作用
    11. this.threshold = tableSizeFor(initialCapacity);
    12. }
    13. public HashMap(int initialCapacity) {
    14. this(initialCapacity, DEFAULT_LOAD_FACTOR);
    15. }
    16. public HashMap() {
    17. this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    18. }
    19. public HashMap(Map<? extends K, ? extends V> m) {
    20. this.loadFactor = DEFAULT_LOAD_FACTOR;
    21. putMapEntries(m, false);
    22. }

    HashMap有4个构造函数。
    hash桶没有在构造函数中初始化,而是在第一次存储键值对的时候进行初始化。 这里重点看下 tableSizeFor(initialCapacity)方法,这个方法的作用是,将你传入的initialCapacity做计算,返回一个大于等于initialCapacity 最小的2的幂次方。
    所以这个操作保证无论你传入的初始化Hash桶长度参数是多少,最后hash表初始化的长度都是2的幂次方。比如你输入的是6,计算出来结果就是8。
    下面贴出源码。

    1. static final int tableSizeFor(int cap) {
    2. int n = cap - 1;
    3. n |= n >>> 1;
    4. n |= n >>> 2;
    5. n |= n >>> 4;
    6. n |= n >>> 8;
    7. n |= n >>> 16;
    8. return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    9. }

    上面的代码长的有点不太好看,反正我第一次看的时候不明白它想干啥。不过后来在纸上画画,知道了它的用途。总结起来就一句话:找到大于或等于 cap 的最小2的幂。至于为啥要这样,后面再解释。我们先来看看 tableSizeFor 方法的图解:
    HashMap源码解析 - 图4
    上面是 tableSizeFor 方法的计算过程图,这里cap = 536,870,913 = 2^29+ 1,多次计算后,算出n + 1 = 1,073,741,824 = 2^30。通过图解应该可以比较容易理解这个方法的用途,这里就不多说了。

    3.6 查找

    HashMap 的查找操作比较简单,查找步骤与原理篇介绍一致,即先定位键值对所在的桶的位置,然后再对链表或红黑树进行查找。通过这两步即可完成查找,该操作相关代码如下:

    1. public V get(Object key) {
    2. Node<K,V> e;
    3. return (e = getNode(hash(key), key)) == null ? null : e.value;
    4. }
    5. final Node<K,V> getNode(int hash, Object key) {
    6. Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    7. // 1. 定位键值对所在桶的位置
    8. if ((tab = table) != null && (n = tab.length) > 0 &&
    9. (first = tab[(n - 1) & hash]) != null) {
    10. if (first.hash == hash && // always check first node
    11. ((k = first.key) == key || (key != null && key.equals(k))))
    12. return first;
    13. if ((e = first.next) != null) {
    14. // 2. 如果 first 是 TreeNode 类型,则调用黑红树查找方法
    15. if (first instanceof TreeNode)
    16. return ((TreeNode<K,V>)first).getTreeNode(hash, key);
    17. // 2. 对链表进行查找
    18. do {
    19. if (e.hash == hash &&
    20. ((k = e.key) == key || (key != null && key.equals(k))))
    21. return e;
    22. } while ((e = e.next) != null);
    23. }
    24. }
    25. return null;
    26. }

    查找的核心逻辑是封装在 getNode 方法中的,getNode 方法源码我已经写了一些注释,应该不难看懂。我们先来看看查找过程的第一步 - 确定桶位置,其实现代码如下:

    1. // index = (n - 1) & hash
    2. first = tab[(n - 1) & hash]

    这里通过(n - 1)& hash即可算出桶的在桶数组中的位置,可能有的朋友不太明白这里为什么这么做,这里简单解释一下。HashMap 中桶数组的大小 length 总是2的幂,此时,(n - 1) & hash 等价于对 length 取余。但取余的计算效率没有位运算高,所以(n - 1) & hash也是一个小的优化。举个例子说明一下吧,假设 hash = 185,n = 16。计算过程示意图如下:
    HashMap源码解析 - 图5
    在上面源码中,除了查找相关逻辑,还有一个计算 hash 的方法。这个方法源码如下:

    1. /**
    2. * 计算键的 hash 值
    3. */
    4. static final int hash(Object key) {
    5. int h;
    6. return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    7. }

    看这个方法的逻辑好像是通过位运算重新计算 hash,那么这里为什么要这样做呢?为什么不直接用键的 hashCode 方法产生的 hash 呢?大家先可以思考一下,我把答案写在下面。
    这样做有两个好处,我来简单解释一下。我们再看一下上面求余的计算图,图中的 hash 是由键的 hashCode 产生。计算余数时,由于 n 比较小,hash 只有低4位参与了计算,高位的计算可以认为是无效的。这样导致了计算结果只与低位信息有关,高位数据没发挥作用。为了处理这个缺陷,我们可以上图中的 hash 高4位数据与低4位数据进行异或运算,即 hash ^ (hash >>> 4)。通过这种方式,让高位数据与低位数据进行异或,以此加大低位信息的随机性,变相的让高位数据参与到计算中。此时的计算过程如下:
    HashMap源码解析 - 图6
    在 Java 中,hashCode 方法产生的 hash 是 int 类型,32 位宽。前16位为高位,后16位为低位,所以要右移16位。
    上面所说的是重新计算 hash 的一个好处,除此之外,重新计算 hash 的另一个好处是可以增加 hash 的复杂度。当我们覆写 hashCode 方法时,可能会写出分布性不佳的 hashCode 方法,进而导致 hash 的冲突率比较高。通过移位和异或运算,可以让 hash 变得更复杂,进而影响 hash 的分布性。这也就是为什么 HashMap 不直接使用键对象原始 hash 的原因了。

    3.7 遍历

    和查找一样,遍历操作也是大家使用频率比较高的一个操作。对于 遍历 HashMap,我们一般都会用下面的方式:

    1. for(Object key : map.keySet()) {
    2. // do something
    3. }

    1. for(HashMap.Entry entry : map.entrySet()) {
    2. // do something
    3. }

    从上面代码片段中可以看出,大家一般都是对 HashMap 的 key 集合或 Entry 集合进行遍历。上面代码片段中用 foreach 遍历 keySet 方法产生的集合,在编译时会转换成用迭代器遍历,等价于:

    1. Set keys = map.keySet();
    2. Iterator ite = keys.iterator();
    3. while (ite.hasNext()) {
    4. Object key = ite.next();
    5. // do something
    6. }

    大家在遍历 HashMap 的过程中会发现,多次对 HashMap 进行遍历时,遍历结果顺序都是一致的。但这个顺序和插入的顺序一般都是不一致的。产生上述行为的原因是怎样的呢?大家想一下原因。我先把遍历相关的代码贴出来,如下:

    1. public Set<K> keySet() {
    2. Set<K> ks = keySet;
    3. if (ks == null) {
    4. ks = new KeySet();
    5. keySet = ks;
    6. }
    7. return ks;
    8. }
    9. /**
    10. * 键集合
    11. */
    12. final class KeySet extends AbstractSet<K> {
    13. public final int size() { return size; }
    14. public final void clear() { HashMap.this.clear(); }
    15. public final Iterator<K> iterator() { return new KeyIterator(); }
    16. public final boolean contains(Object o) { return containsKey(o); }
    17. public final boolean remove(Object key) {
    18. return removeNode(hash(key), key, null, false, true) != null;
    19. }
    20. // 省略部分代码
    21. }
    22. /**
    23. * 键迭代器
    24. */
    25. final class KeyIterator extends HashIterator
    26. implements Iterator<K> {
    27. public final K next() { return nextNode().key; }
    28. }
    29. abstract class HashIterator {
    30. Node<K,V> next; // next entry to return
    31. Node<K,V> current; // current entry
    32. int expectedModCount; // for fast-fail
    33. int index; // current slot
    34. HashIterator() {
    35. expectedModCount = modCount;
    36. Node<K,V>[] t = table;
    37. current = next = null;
    38. index = 0;
    39. if (t != null && size > 0) { // advance to first entry
    40. // 寻找第一个包含链表节点引用的桶
    41. do {} while (index < t.length && (next = t[index++]) == null);
    42. }
    43. }
    44. public final boolean hasNext() {
    45. return next != null;
    46. }
    47. final Node<K,V> nextNode() {
    48. Node<K,V>[] t;
    49. Node<K,V> e = next;
    50. if (modCount != expectedModCount)
    51. throw new ConcurrentModificationException();
    52. if (e == null)
    53. throw new NoSuchElementException();
    54. if ((next = (current = e).next) == null && (t = table) != null) {
    55. // 寻找下一个包含链表节点引用的桶
    56. do {} while (index < t.length && (next = t[index++]) == null);
    57. }
    58. return e;
    59. }
    60. //省略部分代码
    61. }

    如上面的源码,遍历所有的键时,首先要获取键集合KeySet对象,然后再通过 KeySet 的迭代器KeyIterator进行遍历。KeyIterator 类继承自HashIterator类,核心逻辑也封装在 HashIterator 类中。HashIterator 的逻辑并不复杂,在初始化时,HashIterator 先从桶数组中找到包含链表节点引用的桶。然后对这个桶指向的链表进行遍历。遍历完成后,再继续寻找下一个包含链表节点引用的桶,找到继续遍历。找不到,则结束遍历。举个例子,假设我们遍历下图的结构:
    HashMap源码解析 - 图7
    HashIterator 在初始化时,会先遍历桶数组,找到包含链表节点引用的桶,对应图中就是3号桶。随后由 nextNode 方法遍历该桶所指向的链表。遍历完3号桶后,nextNode 方法继续寻找下一个不为空的桶,对应图中的7号桶。之后流程和上面类似,直至遍历完最后一个桶。以上就是 HashIterator 的核心逻辑的流程,对应下图:
    HashMap源码解析 - 图8
    遍历上图的最终结果是 19 -> 3 -> 35 -> 7 -> 11 -> 43 -> 59,为了验证正确性,简单写点测试代码跑一下看看。测试代码如下:

    1. /**
    2. * 应在 JDK 1.8 下测试,其他环境下不保证结果和上面一致
    3. */
    4. public class HashMapTest {
    5. @Test
    6. public void testTraversal() {
    7. HashMap<Integer, String> map = new HashMap(16);
    8. map.put(7, "");
    9. map.put(11, "");
    10. map.put(43, "");
    11. map.put(59, "");
    12. map.put(19, "");
    13. map.put(3, "");
    14. map.put(35, "");
    15. System.out.println("遍历结果:");
    16. for (Integer key : map.keySet()) {
    17. System.out.print(key + " -> ");
    18. }
    19. }
    20. }

    遍历结果如下:
    HashMap源码解析 - 图9

    3.8 插入

    1. public V put(K key, V value) {
    2. return putVal(hash(key), key, value, false, true);
    3. }
    4. final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
    5. boolean evict) {
    6. Node<K,V>[] tab; Node<K,V> p; int n, i;
    7. //当table为空时,这里初始化table,不是通过构造函数初始化,而是在插入时通过扩容初始化,有效防止了初始化HashMap没有数据插入造成空间浪费可能造成内存泄露的情况
    8. if ((tab = table) == null || (n = tab.length) == 0)
    9. n = (tab = resize()).length;
    10. //存放新键值对
    11. if ((p = tab[i = (n - 1) & hash]) == null)
    12. tab[i] = newNode(hash, key, value, null);
    13. else {
    14. Node<K,V> e; K k;
    15. //旧键值对的覆盖
    16. if (p.hash == hash &&
    17. ((k = p.key) == key || (key != null && key.equals(k))))
    18. e = p;
    19. //在红黑树中查找旧键值对更新
    20. else if (p instanceof TreeNode)
    21. e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    22. else {
    23. //将新键值对放在链表的最后
    24. for (int binCount = 0; ; ++binCount) {
    25. if ((e = p.next) == null) {
    26. p.next = newNode(hash, key, value, null);
    27. //当链表的长度大于等于树化阀值,并且hash桶的长度大于等于MIN_TREEIFY_CAPACITY,链表转化为红黑树
    28. if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
    29. treeifyBin(tab, hash);
    30. break;
    31. }
    32. //链表中包含键值对
    33. if (e.hash == hash &&
    34. ((k = e.key) == key || (key != null && key.equals(k))))
    35. break;
    36. p = e;
    37. }
    38. }
    39. //map中含有旧key,返回旧值
    40. if (e != null) {
    41. V oldValue = e.value;
    42. if (!onlyIfAbsent || oldValue == null)
    43. e.value = value;
    44. afterNodeAccess(e);
    45. return oldValue;
    46. }
    47. }
    48. //map调整次数加1
    49. ++modCount;
    50. //键值对的数量达到阈值需要扩容
    51. if (++size > threshold)
    52. resize();
    53. afterNodeInsertion(evict);
    54. return null;
    55. }

    插入操作的入口方法是 put(K,V),但核心逻辑在V putVal(int, K, V, boolean, boolean) 方法中。putVal 方法主要做了这么几件事情:

  1. 当桶数组 table 为空时,通过扩容的方式初始化 table
  2. 查找要插入的键值对是否已经存在,存在的话根据条件判断是否用新值替换旧值
  3. 如果不存在,则将键值对链入链表中,并根据链表长度决定是否将链表转为红黑树
  4. 判断键值对数量是否大于阈值,大于的话则进行扩容操作

HashMap插入跟我们平时使用时的感觉差不多,下面总结一下。
(1)插入的键值对是新键值对,如果hash表没有初始化会进行初始化,否则将键值对插入链表尾部,可能需要链表树化和 扩容
(2)插入的键值对中的key已经存在,更新键值对在put的方法里我们注意看下hash(key)方法,这是计算键值对hash值的方法,下面给出源码

  1. static final int hash(Object key) {
  2. int h;
  3. return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  4. }

hashCode()是一个int类型的本地方法,也就将key的hashCode无符号右移16位然后与hashCode异或从而得到hash值在putVal方法中(n - 1)& hash计算得到桶的索引位置 ,那么现在有两个疑问,为什么要计算hash值?为什么不用 hash % n?

  • 为什么要计算hash值,而不用hashCode,用为通常n是很小的,而hashCode是32位,如果(n - 1)& hashCode那么当n大于2的16次方加1,也就是65537后(n - 1)的高位数据才能与hashCode的高位数据相与,当n很小是只能使用上hashCode低 16位的数据,这会产生一个问题,既键值对在hash桶中分布不均匀,导致链表过长,而把hashCode>>>16无符号右移16位让 高16位间接的与(n - 1)参加计算,从而让键值对分布均匀。降低hash碰撞。
  • 为什么使用(n - 1)& hash 而不使用hash% n呢?其实这两种结果是等价的,但是&的效率比%高,原因因为&运算是二进制直接运算,而计算机天生就认得二进制。下面画图说明一下

HashMap源码解析 - 图10 上图 hash&(n - 1)的结果是2,而其实hash%n 的结果也是2, hash&(n - 1)与hash%n的结果是等价的。

3.9 扩容

  1. final Node<K,V>[] resize() {
  2. Node<K,V>[] oldTab = table;
  3. int oldCap = (oldTab == null) ? 0 : oldTab.length;
  4. int oldThr = threshold;
  5. int newCap, newThr = 0;
  6. //如果旧hash桶不为空
  7. if (oldCap > 0) {
  8. //超过hash桶的最大长度,将阀值设为最大值
  9. if (oldCap >= MAXIMUM_CAPACITY) {
  10. threshold = Integer.MAX_VALUE;
  11. return oldTab;
  12. }
  13. //新的hash桶的长度2被扩容没有超过最大长度,将新容量阀值扩容为以前的2倍
  14. else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
  15. oldCap >= DEFAULT_INITIAL_CAPACITY)
  16. newThr = oldThr << 1; // double threshold
  17. }
  18. //如果hash表阈值已经初始化过
  19. else if (oldThr > 0) // initial capacity was placed in threshold
  20. newCap = oldThr;
  21. //如果旧hash桶,并且hash桶容量阈值没有初始化,那么需要初始化新的hash桶的容量和新容量阀值
  22. else {
  23. newCap = DEFAULT_INITIAL_CAPACITY;
  24. newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
  25. }
  26. //新的局部变量阀值赋值
  27. if (newThr == 0) {
  28. float ft = (float)newCap * loadFactor;
  29. newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
  30. (int)ft : Integer.MAX_VALUE);
  31. }
  32. //为当前容量阀值赋值
  33. threshold = newThr;
  34. @SuppressWarnings({"rawtypes","unchecked"})
  35. //初始化hash桶
  36. Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
  37. table = newTab;
  38. //如果旧的hash桶不为空,需要将旧的hash表里的键值对重新映射到新的hash桶中
  39. if (oldTab != null) {
  40. for (int j = 0; j < oldCap; ++j) {
  41. Node<K,V> e;
  42. if ((e = oldTab[j]) != null) {
  43. oldTab[j] = null;
  44. //只有一个节点,通过索引位置直接映射
  45. if (e.next == null)
  46. newTab[e.hash & (newCap - 1)] = e;
  47. //如果是红黑树,需要进行树拆分然后映射
  48. else if (e instanceof TreeNode)
  49. ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
  50. else {
  51. //如果是多个节点的链表,将原链表拆分为两个链表,两个链表的索引位置,一个为原索引,一个为原索引加上旧Hash桶长度的偏移量
  52. Node<K,V> loHead = null, loTail = null;
  53. Node<K,V> hiHead = null, hiTail = null;
  54. Node<K,V> next;
  55. do {
  56. next = e.next;
  57. //链表1
  58. if ((e.hash & oldCap) == 0) {
  59. if (loTail == null)
  60. loHead = e;
  61. else
  62. loTail.next = e;
  63. loTail = e;
  64. }
  65. //链表2
  66. else {
  67. if (hiTail == null)
  68. hiHead = e;
  69. else
  70. hiTail.next = e;
  71. hiTail = e;
  72. }
  73. } while ((e = next) != null);
  74. //链表1存于原索引
  75. if (loTail != null) {
  76. loTail.next = null;
  77. newTab[j] = loHead;
  78. }
  79. //链表2存于原索引加上原hash桶长度的偏移量
  80. if (hiTail != null) {
  81. hiTail.next = null;
  82. newTab[j + oldCap] = hiHead;
  83. }
  84. }
  85. }
  86. }
  87. }
  88. return newTab;
  89. }

那么什么时候回产生扩容呢?
(1)初始化HashMap时,第一次进行put操作
(2)当键值对的个数大于threshold阀值时产生扩容,threshold=size*loadFactor
上面就是HashMap扩容的源代码,我已经加上了注释,相信大家都能看懂了。总结一下,HaspMap扩容就是就是先计算 新的hash表容量和新的容量阀值,然后初始化一个新的hash表,将旧的键值对重新映射在新的hash表里。这里实现的细节当然 没有我说的那么简单,如果在旧的hash表里涉及到红黑树,那么在映射到新的hash表中还涉及到红黑树的拆分。
在扩容的源代码中作者有一个使用很巧妙的地方,是键值对分布更均匀,不知道读者是否有看出来。在遍历原hash桶时的 一个链表时,因为扩容后长度为原hash表的2倍,假设把扩容后的hash表分为两半,分为低位和高位,如果能把原链表的键值对, 一半放在低位,一半放在高位,这样的索引效率是最高的。那看看源码里是怎样写的。大师通过e.hash & oldCap == 0来判断, 这和e.hash & (oldCap - 1) 有什么区别呢。下面我通过画图来解释一下。
HashMap源码解析 - 图11 因为n是2的整次幂,二进制表示除了最高位为1外,其他低位全为0,那么e.hash & oldCap 是否等于0,取决于n对应最高位 相对于e.hash那一位是0还是1,比如说n = 16,二进制为10000,第5位为1,e.hash & oldCap 是否等于0就取决于e.hash第5 位是0还是1,这就相当于有50%的概率放在新hash表低位,50%的概率放在新hash表高位。大家应该明白了e.hash & oldCap == 0的好处与作用了吧。
上面的源码有点长,希望大家耐心看懂它的逻辑。上面的源码总共做了3件事,分别是:

  1. 计算新桶数组的容量 newCap 和新阈值 newThr
  2. 根据计算出的 newCap 创建新的桶数组,桶数组 table 也是在这里进行初始化的
  3. 将键值对节点重新映射到新的桶数组里。如果节点是 TreeNode 类型,则需要拆分红黑树。如果是普通节点,则节点按原顺序进行分组。

上面列的三点中,创建新的桶数组就一行代码,不用说了。接下来,来说说第一点和第三点,先说说 newCap 和 newThr 计算过程。该计算过程对应 resize 源码的第一和第二个条件分支,如下:

  1. // 第一个条件分支
  2. if ( oldCap > 0) {
  3. // 嵌套条件分支
  4. if (oldCap >= MAXIMUM_CAPACITY) {...}
  5. else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
  6. oldCap >= DEFAULT_INITIAL_CAPACITY) {...}
  7. }
  8. else if (oldThr > 0) {...}
  9. else {...}
  10. // 第二个条件分支
  11. if (newThr == 0) {...}

通过这两个条件分支对不同情况进行判断,进而算出不同的容量值和阈值。它们所覆盖的情况如下:
分支一:

条件 覆盖情况 备注
oldCap > 0 桶数组 table 已经被初始化
oldThr > 0 threshold > 0,且桶数组未被初始化 调用 HashMap(int) 和 HashMap(int, float) 构造方法时会产生这种情况,此种情况下 newCap = oldThr,newThr 在第二个条件分支中算出
oldCap == 0 && oldThr == 0 桶数组未被初始化,且 threshold 为 0 调用 HashMap() 构造方法会产生这种情况。

这里把oldThr > 0情况单独拿出来说一下。在这种情况下,会将 oldThr 赋值给 newCap,等价于newCap = threshold = tableSizeFor(initialCapacity)。我们在初始化时传入的 initialCapacity 参数经过 threshold 中转最终赋值给了 newCap。这也就解答了前面提的一个疑问:initialCapacity 参数没有被保存下来,那么它怎么参与桶数组的初始化过程的呢?
嵌套分支:

条件 覆盖情况 备注
oldCap >= 2 桶数组容量大于或等于最大桶容量 2 这种情况下不再扩容
newCap < 2 && oldCap > 16 新桶数组容量小于最大值,且旧桶数组容量大于 16 该种情况下新阈值 newThr = oldThr << 1,移位可能会导致溢出

这里简单说明一下移位导致的溢出情况,当 loadFactor小数位为 0,整数位可被2整除且大于等于8时,在某次计算中就可能会导致 newThr 溢出归零。见下图:
HashMap源码解析 - 图12
分支二:

条件 覆盖情况 备注
newThr == 0 第一个条件分支未计算 newThr 或嵌套分支在计算过程中导致 newThr 溢出归零

说完 newCap 和 newThr 的计算过程,接下来再来分析一下键值对节点重新映射的过程。
在 JDK 1.8 中,重新映射节点需要考虑节点类型。对于树形节点,需先拆分红黑树再映射。对于链表类型节点,则需先对链表进行分组,然后再映射。需要的注意的是,分组后,组内节点相对位置保持不变。关于红黑树拆分的逻辑将会放在下一小节说明,先来看看链表是怎样进行分组映射的。
我们都知道往底层数据结构中插入节点时,一般都是先通过模运算计算桶位置,接着把节点放入桶中即可。事实上,我们可以把重新映射看做插入操作。在 JDK 1.7 中,也确实是这样做的。但在 JDK 1.8 中,则对这个过程进行了一定的优化,逻辑上要稍微复杂一些。在详细分析前,我们先来回顾一下 hash 求余的过程:
HashMap源码解析 - 图13
上图中,桶数组大小 n = 16,hash1 与 hash2 不相等。但因为只有后4位参与求余,所以结果相等。当桶数组扩容后,n 由16变成了32,对上面的 hash 值重新进行映射:
HashMap源码解析 - 图14
扩容后,参与模运算的位数由4位变为了5位。由于两个 hash 第5位的值是不一样,所以两个 hash 算出的结果也不一样。上面的计算过程并不难理解,继续往下分析。
HashMap源码解析 - 图15
假设我们上图的桶数组进行扩容,扩容后容量 n = 16,重新映射过程如下:
依次遍历链表,并计算节点 hash & oldCap 的值。如下图所示
HashMap源码解析 - 图16
如果值为0,将 loHead 和 loTail 指向这个节点。如果后面还有节点 hash & oldCap 为0的话,则将节点链入 loHead 指向的链表中,并将 loTail 指向该节点。如果值为非0的话,则让 hiHead 和 hiTail 指向该节点。完成遍历后,可能会得到两条链表,此时就完成了链表分组:
HashMap源码解析 - 图17
最后再将这两条链接存放到相应的桶中,完成扩容。如下图:
HashMap源码解析 - 图18
从上图可以发现,重新映射后,两条链表中的节点顺序并未发生变化,还是保持了扩容前的顺序。

3.10 清除

HashMap的clear()方法,下面贴出源码。

  1. public void clear() {
  2. Node<K,V>[] tab;
  3. modCount++;
  4. if ((tab = table) != null && size > 0) {
  5. size = 0;
  6. for (int i = 0; i < tab.length; ++i)
  7. tab[i] = null;
  8. }
  9. }

HashMap其实这段代码特别简单,为什么贴出来呢,是因为我在看过别的博客里产生过疑问,到底是clear好还是新建一 个HashMap好。我认为clear()比新建一个HashMap好。下面从空间复杂度和时间复杂度来解释一下。
从时间角度来看,这个循环是非常简单无复杂逻辑,并不十分耗资源。而新建一个HashMap,首先他在在堆内存中年轻代中查看是否有足够空间能够存储,如果能够存储,那么创建顺利完成,但如果HashMap非常大,年轻代很难有足够的空间存储,如果老年代中有足够空间存储这个HashMap,那么jvm会将HashMap直接存储在老年代中,如果老年代中空间不够,这时候会触发一次minor gc,会产生小规模的gc停顿,如果发生minor gc之后仍不能存储HashMap,那么会发生整个堆的gc,也就是 full gc,这个gc停顿是很恐怖的。实际上的gc顺序就是这样的,并且可能发生多次minor gc和full gc,如果发现年轻代和老年代 均不能存储HashMap,那么就会触发OOM,而clear()是肯定不会触发OOM的,所以数据里特别大的情况下,千万不要创建一 个新的HashMap代替clear()方法。
从空间角度看,原HashMap虽然不用,如果数据未被清空,是不可能被jvm回收的,因为HashMap是强引用类型的,从而造成内存泄漏。所以综上所述我 是不建议新建一个HashMap代替clear()的,并且很多源码中clear()方法很常用,这就是最好的证明。

3.11 删除

HashMap 的删除操作并不复杂,仅需三个步骤即可完成。第一步是定位桶位置,第二步遍历链表并找到键值相等的节点,第三步删除节点。相关源码如下:

  1. public V remove(Object key) {
  2. Node<K,V> e;
  3. return (e = removeNode(hash(key), key, null, false, true)) == null ?
  4. null : e.value;
  5. }
  6. final Node<K,V> removeNode(int hash, Object key, Object value,
  7. boolean matchValue, boolean movable) {
  8. Node<K,V>[] tab; Node<K,V> p; int n, index;
  9. if ((tab = table) != null && (n = tab.length) > 0 &&
  10. // 1. 定位桶位置
  11. (p = tab[index = (n - 1) & hash]) != null) {
  12. Node<K,V> node = null, e; K k; V v;
  13. // 如果键的值与链表第一个节点相等,则将 node 指向该节点
  14. if (p.hash == hash &&
  15. ((k = p.key) == key || (key != null && key.equals(k))))
  16. node = p;
  17. else if ((e = p.next) != null) {
  18. // 如果是 TreeNode 类型,调用红黑树的查找逻辑定位待删除节点
  19. if (p instanceof TreeNode)
  20. node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
  21. else {
  22. // 2. 遍历链表,找到待删除节点
  23. do {
  24. if (e.hash == hash &&
  25. ((k = e.key) == key ||
  26. (key != null && key.equals(k)))) {
  27. node = e;
  28. break;
  29. }
  30. p = e;
  31. } while ((e = e.next) != null);
  32. }
  33. }
  34. // 3. 删除节点,并修复链表或红黑树
  35. if (node != null && (!matchValue || (v = node.value) == value ||
  36. (value != null && value.equals(v)))) {
  37. if (node instanceof TreeNode)
  38. ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
  39. else if (node == p)
  40. tab[index] = node.next;
  41. else
  42. p.next = node.next;
  43. ++modCount;
  44. --size;
  45. afterNodeRemoval(node);
  46. return node;
  47. }
  48. }
  49. return null;
  50. }

四、总结

(1)HashMap允许NULL值,NULL键
(2)不要轻易改变负载因子,负载因子过高会导致链表过长,查找键值对时间复杂度就会增高,负载因子过低会导致hash桶的数量过多,空间复杂度会增高
(3)Hash表每次会扩容长度为以前的2倍
(4)HashMap是多线程不安全的,我在JDK 1.7进行多线程put操作,之后遍历,直接死循环,CPU飙到100%,在JDK 1.8中 进行多线程操作会出现节点和value值丢失,为什么JDK1.7与JDK1.8多线程操作会出现很大不同,是因为JDK 1.8的作者对resize 方法进行了优化不会产生链表闭环。这也是本章的重点之一,具体的细节大家可以去查阅资料。这里我就不解释太多了
(5)尽量设置HashMap的初始容量,尤其在数据量大的时候,防止多次resize