HashMap实现原理-常见问题

129 阅读5分钟

HashMap实现原理--数据结构

哈希表结构(链表散列:数组+链表)实现,结合数组和链表的优点。当链表长度超过 8 时,链表转换为红黑树。

// HashMap中的数组结构
transient Node<K,V>[] table;
// hash表容量
transient int size;
// 扩容的临界值
transient treshold;
// hash表的加载因子
transient loadFactor;

  • 链表结构如下:

image.png

  • 红黑树结构如下: image.png

HashMap实现原理--为啥不选择二叉树或者平衡二叉树

  • 二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。
  • 平衡二叉树追求的是一种完全平衡的状态,它的定义是任何结点的左右子树的高度差不会超过 1,这样的优势是树的结点是很平均分配的;
  • 红黑树不追求这种完全平衡,而是追求一种弱平衡的状态,就是让整个树最长路径不会超过最短路径的 2 倍。这样的话,红黑树虽然牺牲了一部分查找的性能效率,但是能够换取一部分维持树平衡状态的成本。

HashMap实现原理--工作流程

HashMap 底层是 hash 数组和单向链表实现,数组中的每个元素都是链表,由 Node 内部类(实现 Map.Entry接口)实现,HashMap 通过 put & get 方法存储和获取。

存储对象时,将 K/V 键值传给 put() 方法:

  1. 调用 hash(K) 方法计算 K 的 hash 值,然后结合数组长度,计算得数组下标;index = hash & (lengh - 1)

  2. 调整数组大小(当容器中的元素个数大于 capacity * loadfactor 时,容器会进行扩容resize 为 2n);

  3. ①如果 K 的 hash 值在 HashMap 中不存在,则执行插入,若存在,则发生碰撞;

    ②如果 K 的 hash 值在 HashMap 中存在,且它们两者 equals 返回 true,则更新键值对;

    ③如果 K 的 hash 值在 HashMap 中存在,且它们两者 equals 返回 false,则插入链表的尾部(尾插法)或者红黑树中(树的添加方式)。(JDK 1.7 之前使用头插法、JDK 1.8 使用尾插法)(注意:当碰撞导致链表大于 TREEIFY_THRESHOLD = 8 时,就把链表转换成红黑树)

获取对象时,将 K 传给 get() 方法:

  1. 调用 hash(K) 方法(计算 K 的 hash 值)从而获取该键值所在链表的数组下标;
  2. 顺序遍历链表,equals()方法查找相同 Node 链表中 K 值对应的 V 值。

hashCode 是定位的,存储位置;equals是定性的,比较两者是否相等。

HashMap实现原理--hash方法实现

JDK 1.8 中,是通过 hashCode() 的高 16 位异或低 16 位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度,功效和质量来考虑的,减少系统的开销,也不会造成因为高位没有参与下标的计算,从而引起的碰撞。

image.png

高16位和低16位使用异或运算,保证了对象的 hashCode 的 32 位值只要有一位发生改变,整个 hash() 返回值就会改变。尽可能的减少碰撞。

HashMap实现原理--解决hash冲突方法

  • 我认为 Java 给予 HashMap 的定位是一个相对通用的散列表容器,它应该在面对各种输入的时候都表现稳定。而开发地址法相对来说容易出现数据堆积,在数据量较大时可能出现连续冲突的情况,性能不够稳定。

  • 我们可以举个反例,在 Java 原生的数据结构中,也存在使用开放地址法的散列表 —— 就是 ThreadlLocal。因为项目中不会大量使用 ThreadLocal 线程局部存储,所以它是一个小规模数据场景,这里使用开发地址法是没问题的。

HashMap实现原理--容量为何设计为2的幂次

  • 我们知道 HashMap 在确定元素对应的数组下标时,是采用了 hashCode 对数组长度取余的运算,它其实等价于 hashCode 对数组长度 - 1 的与运算(h % length 等价于 h & (lenght -1),与运算效率更高,偶数才成立);
  • 而 2^n 次幂对应的 length - 1 恰好全是 1(1000-1 = 111),这样就把影响下标的因素归结于 hashCode 本身,因而能够实现尽可能均摊。
  • 在扩容过程中也可以保证尽量少的数据移动

HashMap实现原理--扩容

  1. table 数组大小是由 capacity 这个参数确定的,默认是16,也可以构造时传入,最大限制是1<<30;
  2. loadFactor 是装载因子,主要目的是用来确认table 数组是否需要动态扩展,默认值是0.75,比如table 数组大小为 16,装载因子为 0.75 时,threshold 就是12,当 table 的实际大小超过 12 时,table就需要动态扩容;
  3. 扩容时,调用 resize() 方法,将 table 长度变为原来的两倍(注意是 table 长度,而不是 threshold)
  4. 如果数据很大的情况下,扩展时将会带来性能的损失,在性能要求很高的地方,这种损失很可能很致命。

HashMap线程不安全问题

  • 数据覆盖问题: 如果两个线程并发执行 put 操作,并且两个数据的 hash 值冲突,就可能出现数据覆盖(线程 A 判断 hash 值位置为 null,还未写入数据时挂起,此时线程 B 正常插入数据。接着线程 A 获得时间片,由于线程 A 不会重新判断该位置是否为空,就会把刚才线程 B 写入的数据覆盖掉);
  • 环形链表问题:  在 HashMap 触发扩容时,并且正好两个线程同时在操作同一个链表时,就可能引起指针混乱,形成环型链条(因为 JDK 1.7 版本采用头插法,在扩容时会翻转链表的顺序,而 JDK 1.8 采用尾插法,再扩容时会保持链表原本的顺序)。
  • 类型转换问题: 在JDK1.8中,可能存在类型转换异常,Node和TreeNode两种类型。