HashMap底层数据结构剖析

187 阅读4分钟

hashmap 作为高频面试的问题,所涉及到的知识也是非常多,我们从以下几个方面进行入手

1.数据结构(1.7和1.8)

在1.7中hashmap的实现是基于 数组+链表的实现方式。在1.8中hashmap中的实现是基于数组 + 链表 + 红黑树(复杂版二叉查找树)。

从两个版本的数据结构不同进而,引发一下问题。

  1. 为什么在1.8中要引入红黑树这种数据结构。
  2. 为什么不一来就进行树化。
  3. 为什么链表长度阈值要设为8
  4. 什么时候要树化。
  5. 什么时候会退化为链表

为什么在1.8中要引入红黑树这种数据结构。

首先我们要了解hashmap是如何进行数据的存储的。我们知道hashmap中添加元素的方法是put方法。那么put一个元素的流程就很关键。

首先hashmap是懒加载,当用无参构造创建hashmap时,数组容量为0.只有当第一次put操作时,才初始化长度为16。hashmap的默认加载因子为0.75,当数据元素个数超过数组长度*加载因子,就会进行扩容。扩容是增加为当前的两倍。

当进行put操作时,会通过key的hashcode()方法拿到该key的hash码,再进行二次hash,再将二次hash后所得的值与数组长度进行取余运算,所得到的值,就是所在的槽位(下标)。如果该位置没有其他node,就直接将key和value封装到node中,并放入该位置。 如果该位置已经有元素。那么用equals进行判断key是否相等,如果相等就进行覆盖。如果不相等,就判断该槽位是否已经树化(该槽位第一个节点是否为treeNode(红黑树节点)),如果是,用红黑树的方法进行插入。如果不是,就遍历该链表,进行尾插法,此时当链表长度>=8时,会进入树化方法(treeifyBin),但不会立即树化,这个方法中还会判断数组长度是否是>=64的。只有当这两个条件同时满足才会进行树化。

以上就是简单put流程。

再来说说hashmap具有高效的查询功能,比如一组数据,放在一个数组中,我们想要找到某一个数据,我们需要遍历整个数组进行查找,时间复杂度是O(n)。如果我们把这组数据放入hashmap中。当我们进行查找是,hashmap会根据key的二次hash很快的找到在数组中所对应的位置。非常高效。

虽说hashmap具有高效的查询功能。但也只是找到所在的槽位,但如果该槽位所形成的链表非常长,那么也需要进行对列表的遍历,也会降低效率。所以 在链表超过一定的长度时会进行树化,二叉查找树。这个数的特点就是,根节点的左子节点比他小,右子节点比他大。这样更能提高效率。这也是为什么会引入红黑树这种结构了。

为什么不一来就进行树化。

1.当链表长度较短时,查询性能和红黑树相差不大。2.并且红黑树维护的数据要比链表维护的数据更加多,所占内存也较大。3.维护红黑树的平衡也比较消耗性能,所以并没有必要依赖就进行树化。

为什么链表长度阈值要设为8

为什么阈值是8,这个数是经过数学论证了的。在使用 HashMap 的默认参数情况下,长度为8的链表出现概率大约是 0.00000006。在千万数据中,长度超过8的数据小于1。由此看出 树化的情况是一种不正常的状况。 在一般的正常业务中,很难产生树化。出现树化情况,很有可能会被攻击,攻击者用 DoS 攻击 ,制造大量相同的hash值数据,导致系统性能急剧下降。红黑树就是为了防止dos攻击。

什么时候要树化。

树化必须要满足两个条件

1.数组长度大于等于64

2.链表长度大于等于8

什么时候会退化为链表

两种操作会导致树退化为链表

第一种:当数组扩容时,此时key的槽位将会重新计算。当该槽位的树发生拆分时,且当前树的个数<=6时,会退化成链表

第二种:当删除树的节点元素时。在移除树的某个节点前,会进行一次判断,如果root节点,root.left,root.right,root.left.left中有一个为null,就会退化成链表。

可能会有疑问。为什么扩容时,当前树的个数<=6才进行退化,而不是小于8。有7这个缓冲值,防止频繁的树化和退化。

2.索引如何计算

首先通过key的hashCode方法得到第一次hash。再通过hashmap的hash()得到第二次hash值。 再通过把第二次的hash与数组长度取余得到对应的数组下标。但看源码发现,int index = hash & (capacity -1) 这种方式进行下标的计算。capacity一般称为桶的数量,也就是数组长度。我们要让元素随机分布到每个桶中,最好的方式就是对桶数量进行取余运算,余数就是下标。但这里好像又不是取余运算,这是为什么呢。这就涉及到数学中的一个规律。对于一个2的n次方的整数,取余运算和按位与运算结果相同。那这里为什么原则按位与的运算呢。肯定是因为按位与运算要比取余运算效率要快很多

3.数组容量为什么是 2 的 n 次幂?

现在来解释这个问题就比较容易了。在计算索引时可以利用数学规律,对数组容量进行按位与运算,这样能大大提高运算效率。在进行扩容时,也可以用按位与运算进行很快的计算出哪些元素不动,哪些元素需要移动到哪儿。 当然不仅这些,选择2的n次方,可以使的很多计算更快速。

当然2的n次方不是固定的容量规则。与hashmap相似的hashtable的扩容规则就是,它初始扩容后为 11 ,之后为 23 ,其规律为上次容量翻倍 + 1。

4.为什么要二次hash

二次哈希让原始哈希值的高 16 位与低 16 位进行融合,增加哈希码的随机性。而更加随机的哈希值可以使得桶中元素相对平均,防止某些桶中出现超长链表甚至树化,而其他桶都是空的

5.负载因子为什么选择0.75

在空间和时间中取得一个良好的平衡。

如果负载因子较大,扩容不那么频繁,空间节省了。但hash冲突的可能也会提高,进而会引起链表过长,导致查询效率降低。

如果负载因子较小,会频繁的扩容,会造成空间浪费,但hash冲突会降低,链表的长度并不会过长,时间上也大大缩短。

6.多线程下操作hashmap会有什么问题

主要两个方面。

第一:可能会造成数据的丢失。由于hashmap是线程不安全的,当多个线程同时进行put操作时,可能会造成数据覆盖问题。

假如两个线程将相同的hash的key进行put操作,当两个同时进入if()判断,就会造成后者赋值的会覆盖前者赋值的,所以会造成数据覆盖,也就是数据丢失问题。

第二:并发扩容死链问题,这种问题只会出现在1.7中,1.8中不会出现。有兴趣可以自己了解。

7.hashmap中对于key的要求

1.key能否为null

hashmap中的key可以有一个为null。但hashtable和concurrenthashmap中的key不可以为null,不然会报空指针异常。

2.作为key的对象有什么要求

作为key的对象,必须要实现hashCode()和equals,并且key的内容不能修改(不可变)

为什么要重写hashCode()和equals方法,目的是为了确保hashCode相等的时候equals的值也是true。

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 3 天,点击查看活动详情