概览

image.png

  • HashTable 是早期 Java 类库提供的一个哈希表实现,本身是同步的,不支持 null 键和值,由于同步导致的性能开销,所以已经很少被推荐使用。
  • HashMap 是应用更加广泛的哈希表实现,行为上大致上与 HashTable 一致,主要区别在于 HashMap 不是同步的,支持 null 键和null值等。通常情况下,HashMap 进行 put 或者 get 操作,可以达到常数时间的性能,所以它是绝大部分利用键值对存取场景的首选,比如,实现一个用户 ID 和用户信息对应的运行时存储结构。
  • TreeMap 则是基于红黑树的一种提供顺序访问的 Map,和 HashMap 不同,它的 get、put、remove 之类操作都是 O(log(n))的时间复杂度,具体顺序可以由指定的 Comparator 来决定,或者根据键的自然顺序来判断。
  • LinkedHashMap 通常提供的是遍历顺序符合插入顺序,它的实现是通过为条目(键值对)维护一个双向链表。

hashCode 和 equals 的一些基本约定,比如:

  • equals 相等,hashCode 一定要相等。
  • 重写了 hashCode 也要重写 equals。
  • hashCode 需要保持一致性,状态改变返回的哈希值仍然要一致。
  • equals 的对称、反射、传递等特性。

HashMap的实现

hash方法

  1. //为什么使用高16位和低16位进行异或操作来计算hash呢?
  2. //这是因为有些数据计算出的哈希值差异主要在高位,而 HashMap 里的哈希寻址是忽略容量以上的高位的,那么这种处理就可以有效避免类似情况下的哈希碰撞
  3. static final int hash(Object key) {
  4. int h;
  5. return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  6. }

resize方法

  1. /**
  2. * Initializes or doubles table size. If null, allocates in
  3. * accord with initial capacity target held in field threshold.
  4. * Otherwise, because we are using power-of-two expansion, the
  5. * elements from each bin must either stay at same index, or move
  6. * with a power of two offset in the new table.
  7. * 扩容后,老元素 要么留在原地i,要么移动到 i + N(N是扩容前的table size)
  8. * @return the table
  9. */
  10. final Node<K,V>[] resize() {
  11. Node<K,V>[] oldTab = table;
  12. int oldCap = (oldTab == null) ? 0 : oldTab.length;
  13. int oldThr = threshold;
  14. int newCap, newThr = 0;
  15. //这次是扩容操作,不是初始化
  16. if (oldCap > 0) {
  17. // 超过最大值就不再扩充了,就只好随你碰撞去吧
  18. if (oldCap >= MAXIMUM_CAPACITY) {
  19. threshold = Integer.MAX_VALUE;
  20. return oldTab;
  21. }
  22. //newCap和 newThr都乘以2
  23. else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
  24. oldCap >= DEFAULT_INITIAL_CAPACITY)
  25. newThr = oldThr << 1; // double threshold
  26. //初始化,且oldThr > 0,那么说明用户初始化时用了HashMap(int initialCapacity, float loadFactor) 这个构造方法,因此未被初始化,且oldThr不为0
  27. {
  28. else if (oldThr > 0) // initial capacity was placed in threshold
  29. newCap = oldThr;
  30. else { // zero initial threshold signifies using defaults
  31. //用户没有传initialCapacity进来,因此按默认值来
  32. newCap = DEFAULT_INITIAL_CAPACITY;
  33. newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
  34. }
  35. if (newThr == 0) {
  36. float ft = (float)newCap * loadFactor;
  37. newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
  38. (int)ft : Integer.MAX_VALUE);
  39. }
  40. threshold = newThr;
  41. @SuppressWarnings({"rawtypes","unchecked"})
  42. Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
  43. table = newTab;
  44. if (oldTab != null) {
  45. for (int j = 0; j < oldCap; ++j) {
  46. Node<K,V> e;
  47. if ((e = oldTab[j]) != null) {
  48. oldTab[j] = null;
  49. if (e.next == null)
  50. //链表上只有一个点,直接放到指定位置即可
  51. newTab[e.hash & (newCap - 1)] = e;
  52. else if (e instanceof TreeNode)
  53. //红黑树,额外处理
  54. ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
  55. else { // preserve order
  56. //链表,额外处理
  57. //loHead,原地不变的所有节点,都会挂在loHead后面
  58. Node<K,V> loHead = null, loTail = null;
  59. //hiHead,位置要移动的所有节点,都会挂在hiHead后面
  60. Node<K,V> hiHead = null, hiTail = null;
  61. Node<K,V> next;
  62. do {
  63. next = e.next;
  64. // 不移动的节点,处理逻辑
  65. if ((e.hash & oldCap) == 0) {
  66. if (loTail == null)
  67. loHead = e;
  68. else
  69. loTail.next = e;
  70. loTail = e;
  71. }
  72. // 需要移动的节点,处理逻辑
  73. else {
  74. if (hiTail == null)
  75. hiHead = e;
  76. else
  77. hiTail.next = e;
  78. hiTail = e;
  79. }
  80. } while ((e = next) != null);
  81. if (loTail != null) {
  82. loTail.next = null;
  83. newTab[j] = loHead;
  84. }
  85. if (hiTail != null) {
  86. hiTail.next = null;
  87. newTab[j + oldCap] = hiHead;
  88. }
  89. }
  90. }
  91. }
  92. }
  93. return newTab;
  94. }

为什么数组的size一定是2的幂呢?

  • 获取桶位置快
    • 如果length为2的次幂 则length-1 转化为二进制必定是11111……的形式,在与h的二进制操作效率会非常的快。
  • 空间不浪费
    • 如果length不是2的次幂,比如length为15,则length-1为14,对应的二进制为1110,在于h与操作,最后一位都为0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,
    • 空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!这样就会造成空间的浪费

      JDK 7 中HashMap的实现

      1. void resize(int newCapacity)
      2. {
      3. Entry[] oldTable = table;
      4. int oldCapacity = oldTable.length;
      5. ......
      6. //创建一个新的Hash Table
      7. Entry[] newTable = new Entry[newCapacity];
      8. //将Old Hash Table上的数据迁移到New Hash Table上
      9. transfer(newTable);
      10. table = newTable;
      11. threshold = (int)(newCapacity * loadFactor);
      12. }
  1. //迁移的核心代码,也是1.7HashMap并发下出现环形链表的核心代码
  2. void transfer(Entry[] newTable)
  3. {
  4. Entry[] src = table;
  5. int newCapacity = newTable.length;
  6. // 从OldTable里摘一个元素出来,然后放到NewTable中
  7. for (int j = 0; j < src.length; j++) {
  8. Entry<K,V> e = src[j];
  9. if (e != null) {
  10. src[j] = null;
  11. do {
  12. Entry<K,V> next = e.next;
  13. int i = indexFor(e.hash, newCapacity);
  14. e.next = newTable[i];
  15. newTable[i] = e;
  16. e = next;
  17. } while (e != null);
  18. }
  19. }
  20. }

并发下HashMap的问题

JDK7版本

可能会出现环形链表
resize采用了头插法

参考 https://coolshell.cn/articles/9606.html

JDK8 版本

可能会出现数据覆盖
参考 https://www.codenong.com/cs105238992/

从 JDK7 与 JDK8 对比详细分析 HashMap 的原理与优化

参数分析

两个重要参数:容量和负载因子
这是因为容量和负载系数决定了可用的桶的数量,空桶太多会浪费空间,如果使用的太满则会严重影响操作的性能。极端情况下,假设只有一个桶,那么它就退化成了链表,完全不能提供所谓常数时间存的性能。

既然容量和负载因子这么重要,我们在实践中应该如何选择呢?

  • 如果能够知道 HashMap 要存取的键值对数量,可以考虑预先设置合适的容量大小。具体数值我们可以根据扩容发生的条件来做简单预估,根据前面的代码分析,我们知道它需要符合计算条件:
    • 负载因子 * 容量 > 元素数量
  • 负载因子不建议修改


  • 如果链表大小超过阈值(TREEIFY_THRESHOLD, 8),链表就会被改造为树形结构。
  • 上述操作有个前提,要求table size >= (MIN_TREEIFY_CAPACITY,64)。table太小,优先resize, 而不是树化
  • resize时,重新找到元素的放置 位置。如果发现红黑树中的元素个数、<=(UNTREEIFY_THRESHOLD, 6), 红黑树就会回退到链表结构。