关于Java编程,你知道吗?(11)HashMap深度解析

Posted 海鼎Fun

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了关于Java编程,你知道吗?(11)HashMap深度解析相关的知识,希望对你有一定的参考价值。

HashMap是最被广泛使用的Map接口的实现,了解它的内部实现机制将有利于我们更好的使用这个强有力的工具。


首先,我们来一起看看HashMap内部的结构。它可以看作是数组(Node[] table)和链表结合组成的复合结构。数组被分为一个个桶(Bin),通过哈希值决定了键值对在这个数组的寻址。落在同一个桶内的键值对,则以链表形式存储,参考下图。需要注意的是,如果链表大小超过阈值(TREEIFY_THRESHOLD,8),图中的链表就会被改造为树形结构。



1、解析put()方法


除非使用拷贝功能的构造函数,HashMap是按照lazy-load原则设计的。也就是说HashMap在对象构造阶段并不会分配空间。既然如此,我们去看看put方法实现,似乎只有一个putVal的调用:


public V put(K key, V value) {
  return putVal(hash(key), key, value, false, true);
}


看来主要的秘密似乎藏在putVal里面,到底有什么秘密呢?为了节省空间,我这里只截取了putVal比较关键的几部分:


final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
  Node<K,V>[] tab; Node<K,V> p; int n, i;
  if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;
  if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
  else {
    // ...
    if (binCount >= TREEIFY_THREDHOLD - 1) // -1 for first
      treeifyBin(tab, hash);
    // ...
  }
}


从putVal方法最初的几行,我们就可以发现几个有意思的地方:


01

如果表格是null,resize方法会负责初始化它,这从tab = resize()可以看出。

02

resize方法兼顾两个职责,创建初始存储表格,或者在容量不满足需求的时候,进行扩容。

03

在放置新的键值对的过程中,如果发生下面条件,就会发生扩容。

  if (++size > threshold)
    resize();

04

具体键值对在哈希表中的位置(数组索引)取决于下面的位运算:

 i = (n - 1) & hash


仔细观察哈希值的源头,我们会发现,它并不是key本身的hashCode,而是来自于HashMap内部的另外一个hash方法:


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


注意,为什么这里需要将高位数据移位到低位进行异或运算呢?这是因为有些数据计算出的哈希值差异主要在高位,而HashMap里的哈希寻址是忽略容量以上的高位的,那么这种处理就可以有效避免类似情况下的哈希碰撞。


2、解析resize()方法


我们进一步分析一下身兼多职的resize方法。


final Node<K,V>[] resize() {
  // ...
  else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
            oldCap >= DEFAULT_INITIAL_CAPACITY)
    newThr = oldThr << 1; // double threshold
  // ...
  else if (oldThr > 0) // initial capacity was placed in threshold
    newCap = oldThr;
  else { // zero initial threshold signifies using defaults
    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 = newThr;
  Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
  table = n;
  // 移动到新的数组结构
}


依据resize源码,不考虑极端情况,我们可以归纳为:

01

门限值等于负载因子×容量,如果构建HashMap的时候没有指定它们,那么就是依据相应的默认常量值。

02

门限通常是以倍数进行调整(newThr = oldThr << 1)。前面提到,根据putVal中的逻辑,当元素个数超过门限大小时,则调整Map大小。

03

扩容后,需要将老的数组中的元素重新放置到新的数组,这是扩容的一个主要开销来源。


3、容量和负载因子


前面我们快速梳理了HashMap从创建到放入键值对的相关逻辑,现在思考一下,为什么我们需要在乎容量和负载因子呢?


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


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


根据前面的代码分析,我们知道它需要符合计算条件:


负载因子 * 容量 > 元素数量


所以,预先设置的容量需要满足,大于“预估元素数量 / 负载因子”,同时它是2的幂数,结论已经非常清晰了。对于负载因子的建议:


01

如果没有特别需求,不要轻易进行更改,因为JDK自身的默认负载因子是非常符合通用场景的需求的。

02

如果确实需要调整,建议不要设置超过0.75的数值,因为会显著增加冲突,降低HashMap的性能。

03

如果使用太小的负载因子,按照上面的公式,预设容量值也进行调整,否则可能会导致更加频繁的扩容,增加无谓的开销。


4、树化


前面提到了树化改造,对应逻辑主要在putVal和treeifyBin中。


final void treeifyBin(Node<K,V>[] tab, int hash) {
  int n, index; Node<K,V> e;
  if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
    resize();
  else if ((e = tab[index = (n - 1) & hash]) != null) {
    // 树化改造逻辑
  }
}


那么,为什么HashMap要树化呢?


本质上这是个安全问题。因为在元素放置过程中,如果一个对象哈希冲突,都被放置到同一个桶里,则会形成一个链表。我们知道链表查询是线性的,会严重影响存取的性能。


而在现实世界,构造哈希冲突的数据并不是非常复杂的事情。恶意代码就可以利用这些数据大量与服务器端交互,导致服务器端CPU大量占用。这就构成了哈希碰撞拒绝服务攻击,国内一线互联网公司就发生过类似攻击事件。



*本文内容整理自Oracle首席工程师杨晓峰《Java核心技术》。


系列前文请戳↓












海鼎Fun

有料,有爱,有梦想

空·


↓敬请查看wiki原文

以上是关于关于Java编程,你知道吗?(11)HashMap深度解析的主要内容,如果未能解决你的问题,请参考以下文章

Java HashMap工作原理及实现

Java HashMap工作原理及实现

Java HashMap工作原理及实现

为啥面试要问hashmap 的原理

头条面试之----HashMap原理

小编带你HashMap的工作原理