写点什么

Java- 从 -Map- 到 -HashMap- 的一步步实现,看这篇足矣了!

  • 2021 年 11 月 11 日
  • 本文字数:7793 字

    阅读完需:约 26 分钟

二、HashMap

/*


  • @author Doug Lea

  • @author Josh Bloch

  • @author Arthur van Hoff

  • @author Neal Gafter

  • @see Object#hashCode()

  • @see Collection

  • @see Map

  • @see TreeMap

  • @see Hashtable

  • @since 1.2*/


首先 HashMap 由 Doug Lea 和 Josh Bloch 两位大师的参与。同时 Java 的 Collections 集合体系,并发框架 Doug Lea 也做出了不少贡献。

2.1 基本原理

对于一个插入操作,首先将键通过 Hash 函数转化为数组的下标。 若该数组为空,直接创建节点放入数组中。若该数组下标存在节点,即 Hash 冲突,使用拉链法,生成一个链表插入。



如果存在 Hash 冲突,使用拉链法插入,我们可以在这个链表的头部插入,也可以在链表的尾部插入,所以在 JDK 1.7 中使用了头部插入的方法,JDK 1.8 后续的版本中使用尾插法。JDK 1.7 使用头部插入的可能依据是最近插入的数据是最常用的,但是头插法带来的问题之一,在多线程会链表的复制会出现死循环。所以 JDK 1.8 之后采用的尾部插入的方法。 在 HashMap 中,前面说到的 数组+链表 的数组的定义


transient Node<K,V>[] table;


链表的定义:


static class Node<K,V> implements Map.Entry<K,V>

2.1.2 提供的构造函数

public HashMap() { // 空参 this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted}public HashMap(int initialCapacity) { //带有初始大小的,一般情况下,我们需要规划好 HashMap 使用的大小,因为对于一次扩容操作,代价是非常的大的 this(initialCapacity, DEFAULT_LOAD_FACTOR);}public HashMap(int initialCapacity, float loadFactor); // 可以自定义负载因子


三个构造函数,都没有完全的初始化 HashMap,当我们第一次插入数据时,才进行堆内存的分配,这样提高了代码的响应速度。

2.2 HashMap 中的 Hash 函数定义

static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); // 将 h 高 16 位和低 16 位 进行异或操作。}// 采用 异或的原因:两个进行位运算,在与或异或中只有异或到的 0 和 1 的概率是相同的,而 &和|都会使得结果偏向 0 或者 1。


这里可以看到,Map 的键可以为 null,且 hash 是一个特定的值 0。 Hash 的目的是获取数组 table 的下标。Hash 函数的目标就是将数据均匀的分布在 table 中。 让我们先看看如何通过 hash 值得到对应的数组下标。第一种方法:hash%table.length()。但是除法操作在 CPU 中执行比加法、减法、乘法慢的多,效率低下。第二种方法 table[(table.length - 1) & hash] 一个与操作一个减法,仍然比除法快。 这里的约束条件为 table.length = 2N。


table.length =16table.length -1 = 15 1111 1111 任何一个数与之与操作,获取到这个数的低 8 位,其他位为 0


上面的例子可以让我们获取到对应的下标,而 (h = key.hashCode()) ^ (h >>> 16) 让高 16 也参与运算,让数据充分利用,一般情况下 table 的索引不会超过 216,所以高位的信息我们就直接抛弃了,^ (h >>> 16) 让我们在数据量较少的情况下,也可以使用高位的信息。如果 table 的索引超过 216, hashCode() 的高 16 为 和 16 个 0 做异或得到的 Hash 也是公平的。

2.3 HashMap 的插入操作

上面我们已经知道如果通过 Hash 获取到 对应的 table 下标,因此我们将对应的节点加入到链表就完成了一个 Map 的映射,的确 JDK1.7 中的 HashMap 实现就是这样。让我们看一看 JDK 为实现现实的 put 操作。 定位到 put() 操作。


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


可以看到 put 操作交给了 putVal 来进行通用的实现。


final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict);//onlyIfAbsent 如果当前位置已存在一个值,是否替换,false 是替换,true 是不替换 evict // 钩子函数的参数,LinkedHashMap 中使用到,HashMap 中无意义。

2.3.1 putVal 的流程分析

其实 putVal() 流程的函数非常的明了。这里挑了几个关键步骤来引导。


是否第一次插入,true 调用 resizer() 进行调整,其实此时 resizer() 是进行完整的初始化,之后直接赋值给对应索引的位置。


if ((tab = table) == null || (n = tab.length) == 0) // 第一次 put 操作, tab 没有分配内存,通过 redize() 方法分配内存,开始工作。n = (tab = resize()).length;if ((p = tab[i = (n - 1) & hash]) == null)tab[i] = newNode(hash, key, value, null);


如果链表已经转化为树,则使用树的插入。


else if (p instanceof TreeNode)e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);


用遍历的方式遍历每个 Node,如果遇到键相同,或者到达尾节点的 next 指针将数据插入,记录节点位置退出循环。若插入后链表长度为 8 则调用 treeifyBin() 是否进行树的转化 。


for (int binCount = 0; ; ++binCount) {if ((e = p.next) == null) {p.next = newNode(hash, key, value, null);if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1sttreeifyBin(tab, hash);break;}if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))break;p = e;}


对键重复的操作:更新后返回旧值,同时还取决于 onlyIfAbsent,普通操作中一般为 true,可以忽略。


if (e != null) { // existing mapping for keyV oldValue = e.value;if (!onlyIfAbsent || oldValue == null)e.value = value;afterNodeAccess(e); //钩子函数,进行后续其他操作,HashMap 中为空,无任何操作。return oldValue;}++modCount;if (++size > threshold)resize();afterNodeInsertion(evict);return null;


后续的数据维护。

2.3.2 modCount 的含义

fail-fast 机制是 java 集合(Collection)中的一种错误机制。当多个线程对同一个集合的内容进行操作时,就可能会产生 fail-fast 事件。例如:当某一个线程 A 通过 iterator 去遍历某集合的过程中,若该集合的内容被其他线程所改变了;那么线程 A 访问集合时,就会抛出 ConcurrentModificationException 异常,产生 fail-fast 事件。一种多线程错误检查的方式,减少异常的发生。 一般情况下,多线程环境 我们使用 ConcurrentHashMap 来代替 HashMap。

2.4 resize() 函数

HashMap 扩容的特点:默认的 table 表的大小事 16,threshold 为 12。负载因子 loadFactor .75,这些都是可以构造是更改。以后扩容都是 2 倍的方式增加。 至于为何是 0.75 代码的注释中也写了原因,对 Hash 函数构建了泊松分布模型,进行了分析。

2.4.1 HashMap 预定义的一些参数

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 HashMap 的默认大小。 为什么使用 1 <<4static final int MAXIMUM_CAPACITY = 1 << 30; // 最大容量 static final float DEFAULT_LOAD_FACTOR = 0.75f; // 加载因子,扩容使用


static final int UNTREEIFY_THRESHOLD = 6;// 树结构转化为链表的阈值 static final int TREEIFY_THRESHOLD = 8; // 链表转化为树结构的阈值 static final int MIN_TREEIFY_CAPACITY = 64; // 链表转变成树之前,还会有一次判断,只有数组长度大于 64 才会发生转换。这是为了避免在哈希表建立初期,多个键值对恰好被放入了同一个链表中而导致不必要的转化。


// 定义的有关变量 int threshold; // threshold 表示当 HashMap 的 size 大于 threshold 时会执行 resize 操作


这些变量都是和 HashMap 的扩容机制有关,将会在下文中用到。

2.4.2 resize() 方法解析

Node<K,V>[] oldTab = table;int oldCap = (oldTab == null) ? 0 : oldTab.length;int oldThr = threshold;int newCap, newThr = 0; // 定义了 旧表长度、旧表阈值、新表长度、新表阈值 if (oldCap > 0) { // 插入过数据,参数不是初始化的 if (oldCap >= MAXIMUM_CAPACITY) { // 如果旧的表长度大于 1 << 30;threshold = Integer.MAX_VALUE; // threshold 设置 Integer 的最大值。也就是说我们可以插入 Integer.MAX_VALUE 个数据 return oldTab; // 直接返回旧表的长度,因为表的下标索引无法扩大了。}else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && //oldCap >= DEFAULT_INITIAL_CAPACITY) //新表的长度为旧表的长度的 2 倍。newThr = oldThr << 1; // double threshold 新表的阈值为同时为旧表的两倍}else if (oldThr > 0) // public HashMap(int initialCapacity, float loadFactor) 中的 this.threshold = tableSizeFor(initialCapacity); 给正确的位置


newCap = oldThr;else { // zero initial threshold signifies using defaults ,如果调用了其他两个构造函数,则下面代码初始化。因为他们都没有对其 threshold 设置,默认为 0,newCap = DEFAULT_INITIAL_CAPACITY;newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);}if (newThr == 0) { // 修正 threshold,例如上面的 else if (oldThr > 0) 部分就没有设置。float ft = (float)newCap * loadFactor;newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?(int)ft : Integer.MAX_VALUE);}threshold = newThr;@SuppressWarnings({"rawtypes","unchecked"})


当一些参数设置正确后便开始扩容。


Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];


当扩容完毕之后,自然就是将原表中的数据搬到新的表中。下面代码完成了该任务。


if (oldTab != null) { //for (int j = 0; j < oldCap; ++j) {....}}


如何正确的,快速的扩容调整每个键值节点对应的下标?第一种方法:遍历节点再使用 put() 加入一遍,这种方法实现,但是效率低下。第二种,我们手动组装好链表,加入到相应的位置。显然第二种比第一种高效,因为第一种 put() 还存在其他不属于这种情况的判断,例如重复键的判断等。所以 JDK 1.8 也使用了第二种方法。我们可以继续使用 e.hash & (newCap - 1)找到对应的下标位置,对于旧的链表,执行 e.hash & (newCap - 1) 操作,只能产生两个不同的索引。一个保持原来的索引不变,另一个变为 原来索引 + oldCap(因为 newCap 的加入产生导致索引的位数多了 1 位,即就是最左边的一个,且该位此时结果为 1,所以相当于 原来索引 + oldCap)。所以可以使用 if ((e.hash & oldCap) == 0) 来确定出索引是否来变化。因此这样我们就可以将原来的链表拆分为两个新的链表,然后加入到对应的位置。为了高效,我们手动的组装好链表再存储到相应的下标位置上。

oldCap = 16newCap = 32hash : 0001 1011oldCap-1 : 0000 1111 结果为 : 0000 1011 对应的索引的 11

e.hash & oldCap 则定于 1,则需要进行调整索引 oldCap = 16hash : 0001 1011newCap-1 : 0001 1111 结果为 : 0001 1011 相当于 1011 + 1 0000 原来索引 + newCapfor (int j = 0; j < oldCap; ++j) // 处理每个链表


特殊条件处理


Node<K,V> e;if ((e = oldTab[j]) != null) {oldTab[j] = null;if (e.next == null) // 该 链表只有一个节点,那么直接复制到对应的位置,下标由 e.hash & (newCap - 1) 确定 newTab[e.hash & (newCap - 1)] = e;else if (e instanceof TreeNode) // 若是 树,该给树的处理程序((TreeNode<K,V>)e).split(this, newTab, j, oldCap);


普通情况处理:


else { // preserve orderNode<K,V> loHead = null, loTail = null; // 构建原来索引位置 的链表,需要的指针 Node<K,V> hiHead = null, hiTail = null; // 构建 原来索引 + oldCap 位置 的链表需要的指针 Node<K,V> next;do {next = e.next;if ((e.hash & oldCap) == 0) {if (loTail == null)loHead = e;elseloTail.next = e;loTail = e;}else {if (hiTail == null)hiHead = e;elsehiTail.next = e;hiTail = e;}} while ((e = next) != null); // 将原来的链表划分两个链表 if (loTail != null) { // 将链表写入到相应的位置 loTail.next = null;newTab[j] = loHead;}if (hiTail != null) {hiTail.next = null;newTab[j + oldCap] = hiHead;}}


到此 resize() 方法的逻辑完成了。总的来说 resizer() 完成了 HashMap 完整的初始化,分配内存和后续的扩容维护工作。

2.5 remove 解析

public V remove(Object key) {Node<K,V> e;return (e = removeNode(hash(key), key, null, false, true)) == null ?null : e.value;}


将 remove 删除工作交给内部函数 removeNode() 来实现。


final Node<K,V> removeNode(int hash, Object key, Object value,boolean matchValue, boolean movable) {Node<K,V>[] tab; Node<K,V> p; int n, index;if ((tab = table) != null && (n = tab.length) > 0 &&(p = tab[index = (n - 1) & hash]) != null) { // 获取索引,Node<K,V> node = null, e; K k; V v;if (p.hash == hash &&


((k = p.key) == key || (key != null && key.equals(k)))) // 判断索引处的值是不是想要的结果 node = p;


else if ((e = p.next) != null) { // 交给树的查找算法 if (p instanceof TreeNode)node = ((TreeNode<K,V>)p).getTreeNode(hash, key);else {do { // 遍历查找 if (e.hash == hash &&((k = e.key) == key ||(key != null && key.equals(k)))) {node = e;break;}p = e;} while ((e = e.next) != null);}}if (node != null && (!matchValue || (v = node.value) == value ||(value != null && value.equals(v)))) {if (node instanceof TreeNode) //树的删除((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);else if (node == p) // 修复链表,链表的删除操作 tab[index] = node.next;elsep.next = node.next;


++modCount;--size;afterNodeRemoval(node);return node;}}return null;}

三、 HashMap 从链表到红黑树的转变

如果链表的长度(冲突的节点数)已经达到 8 个,此时会调用 treeifyBin() ,treeifyBin() 首先判断当前 hashMap 的 table 的长度,如果不足 64,只进行 resize,扩容 table,如果达到 64,那么将冲突的存储结构为红黑树。 在源码还有这样的一个字段。


static final int UNTREEIFY_THRESHOLD = 6; // 这样表明了从红黑树转化为链表的阈值为 6,为何同样不是 8 那? 如果插入和删除都在 8 附近,将多二者相互转化将浪费大量的时间,对其性能影响。 如果是的二者转化的操作不平衡,偏向一方,则可以避免此类影响。

3.1 红黑树的数据结构

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {TreeNode<K,V> parent; // red-black tree linksTreeNode<K,V> left;TreeNode<K,V> right;TreeNode<K,V> prev; // 删除后需要取消链接,指向前一个节点(原链表中的前一个节点)boolean red;}


因为 继承了 LinkedHashMap.Entry<K,V> ,所以存储的数据还是在 Entry 中:


static class Node<K,V> implements Map.Entry<K,V> {final int hash;final K key;V value;Node<K,V> next;


Node(int hash, K key, V value, Node<K,V> next) {this.hash = hash;this.key = key;this.value = value;this.next = next;}}

3.2 承上启下的 treeifyBin()

treeifyBin() 决定了一个链表何时转化为一个红黑树。 treeifyBin() 有两种格式:


final void treeifyBin(Node<K,V>[] tab, int hash);final void treeify(Node<K,V>[] tab);final void treeifyBin(Node<K,V>[] tab


【一线大厂Java面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义】
浏览器打开:qq.cn.hn/FTf 免费领取
复制代码


, int hash) { // 简单的 Node 修改为 TreeNode,同时维护了 prev 属性。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) {TreeNode<K,V> hd = null, tl = null;do {TreeNode<K,V> p = replacementTreeNode(e, null);if (tl == null)hd = p;else {p.prev = tl;tl.next = p;}tl = p;} while ((e = e.next) != null);if ((tab[index] = hd) != null)hd.treeify(tab); // 真正生成红黑树的}}TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {return new TreeNode<>(p.hash, p.key, p.value, next);} // 实现 Node 链表节点到 TreeNode 节点的转化。


下面函数真正实现了链表的红黑树的转变。首先构建一个标准查询二叉树,然后在标准查询二叉树然后调整为一个红黑树。而 balanceInsertion() 实现了调整。


/**


  • Forms tree of the nodes linked from this node.*/final void treeify(Node<K,V>[] tab) {TreeNode<K,V> root = null;for (TreeNode<K,V> x = this, next; x != null; x = next) {next = (TreeNode<K,V>)x.next;x.left = x.right = null;if (root == null) { // 第一次转化过程,将链表的头节点作为根节点。x.parent = null;x.red = false; // 红黑树的定义 根节点必须为黑色 root = x;}else {K k = x.key;int h = x.hash;Class<?> kc = null;for (TreeNode<K,V> p = root;;) {int dir, ph;K pk = p.key;if ((ph = p.hash) > h) //// 通过 Hash 的大小来确定插入顺序 dir = -1; // dir 大小顺序的标识 else if (ph < h)dir = 1;else if ((kc == null && //当 两个 Hash 的值相同,进行特殊的方法,确定大小。(kc = comparableClassFor(k)) == null) || // Returns x's Class if it is of the form "class C implements Comparable ", else null. 如果 key 类的 源码书写格式为 C implement Comparable<C> 那么返回该类类型 C, 如果间接实现也不行。 如果是 String 类型,直接返回 String.class(dir = compareComparables(kc, k, pk)) == 0) // ((Comparable)k).compareTo(pk)); 强制转换后进行对比,若 dir == 0,则 tieBreakOrder(),继续仲裁 dir = tieBreakOrder(k, pk); // 首先通过二者的类类型进行比较,如果相等的话,使用 (System.identityHashCode(a) <= System.identityHashCode(b) 使用原始的 hashcode,不是重写的在对比。


TreeNode<K,V> xp = p; // 遍历的,上一个节点 if ((p = (dir <= 0) ? p.left : p.right) == null) { //通过 dir,将 p 向下查找,直到 p 为 null,找到一个插入时机 x.parent = xp;if (dir <= 0)xp.left = x;elsexp.right = x;root = balanceInsertion(root, x); //进行二叉树的调整 break;}}}}moveRootToFront(tab, root);}

3.3 将一个二叉树转化为红黑树的操作-balanceInsertion()

当红黑树中新增节点的时候需要调用 balanceInsertion 方法来保证红黑树的特性。 如果想要了解红黑树的插入过程那么必须对红黑树的性质有一个较为清晰的了解。


红黑树的性质:


  1. 每个结点或是红色的,或是黑色的

  2. 根节点是黑色的

  3. 每个叶结点(NIL)是黑色的

  4. 如果一个节点是红色的,则它的两个儿子都是黑色的。

  5. 对于每个结点,从该结点到其叶子结点构成的所有路径上的黑结点个数相同。


static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,TreeNode<K,V> x) {x.red = true; // 插入的子节点必须为 redfor (TreeNode<K,V> xp, xpp, xppl, xppr;;) { //// x 当前处理节点 xp 父节点 xpp 祖父节点 xppl 祖父左节点 xppr 祖父右节点 if ((xp = x.parent) == null) { // 如果 当前处理节点为根节点,满足红黑树的性质,结束循环 x.red = false;return x;}else if (!xp.red || (xpp = xp.parent) == null)return root;if (xp == (xppl = xpp.left)) {if ((xppr = xpp.right) != null && xppr.red) {xppr.red = false;xp.red = false;

评论

发布
暂无评论
Java-从-Map-到-HashMap-的一步步实现,看这篇足矣了!