之前看过一次HashMap源码,忒难看。直接怼源码容易迷失在黑暗的森林中,开debug跟流程又不能一一睹其完整的芳容。于是尝试着先按自己的思路手撕一版简单的HashMap,再拿来和jdk的HashMap做比较,以此来拨开HashMap面纱下的真容。

按照预想,我的HashMap也采用了数组+链表的形式,它包含了如下的成员变量:

private Node<K,V>[] tab; //用来存放Key-Value对的数组private int capacity; //HashMap的容量,也是tab数组的大小private float loadFactor;  //负载因子private int threshold;  //扩容阈值。等于capacity × loadFactor,HashMap中的元素个数超过此值时,触发自动扩容private int size;  //HashMap中当前的元素个数

其中静态内部类Node定义如下:

//一个元素是一个Node
static class Node<K,V>{private K key;private V value;private int hash;private Node<K,V> next;private Node<K,V> prev;public Node(int hash,K key,V value){this.hash = hash;this.key = key;this.value = value;}}

包含了如下的静态全局变量:

private static int DEFAULT_CAPACITY = 1 << 4; private static float DEFAULT_LOAD_FACTOR = 0.75f;private static int MAX_CAPACITY = 1 << 30;

包含如下成员函数:

  • 构造函数

    //还有2个重载的构造函数,方法签名如下
    //public HasMap()
    //public HashMap(int capacity)
    //他们都调用了这个函数,只是对应属性传了DEFAULT的静态变量值
    public HashMap(int capacity,float loadFactor){//获得不小于capacity的最近的2的幂capacity = tableSize(capacity);this.capacity = capacity;this.loadFactor = loadFactor;threshold = (int)(capacity * loadFactor);}
    
  • get函数

    //大概思路是根据key的hash值,取出tab数组中对应位置的元素
    //然后判断该元素的key是否和要取的key一致,若一致则取出,不一致则尝试遍历链表,循环判断
    public V get(K key){Node<K, V> node = getNode(key);if (node == null){return null;}else if (node.hash == hash(key) && Objects.equals(key,node.key)){return node.value;}else {node = node.next;while (node != null){if (node.hash == hash(key) && Objects.equals(key,node.key)){return node.value;}node = node.next;}return null;}}private Node<K,V> getNode(K key){int hash = hash(key);Node<K,V>[] table;Node<K,V> node;int i;if ((table = tab) == null || (node = table[i = (hash & (capacity - 1))]) == null){return null;}else {return table[i];}}
    
  • put函数

    public V put(K key,V value){return putVal(hash(key),key,value);
    }private V putVal(int hash,K key,V value){Node<K,V>[] table;Node<K,V> node;V oldValue = null;int i;if ((table = tab) == null || table.length == 0){resize();}else {node = getNode(key);if (node == null){/**  不存在 **/ensureInternalCapacity(size + 1);table[hash & capacity - 1] = new Node<>(hash,key,value);size++;}else {if (node.hash == hash && Objects.equals(node.key,key)) {oldValue = node.value;node.value = value;}else {Node prev = node;node = node.next;while (node != null){if (node.hash == hash && Objects.equals(node.key,key)){oldValue = node.value;node.value = value;break;}prev = node;node = node.next;}if (node == null){node = new Node<>(hash,key,value);prev.next = node;node.prev = prev;size++;}}}}return oldValue;}
    
  • remove函数

    //大概思路是先获取key的hash值找到tab对应位置的Node元素,再比较该元素的hash是否等于key的hash,该元素的key是否等于key,若是,若该元素后还有冲突元素,则调整链表头为下一个元素,否则把tab[i]直接置空。若该元素不是要找的元素,则遍历链表,尝试移除要找的元素
    public V remove(K key){Node<K, V> node = getNode(key);if (node == null)return null;V oldValue = null;Node<K,V> prev = null;if (node.hash == hash(key) && Objects.equals(node.key,key)){Node<K,V> next = node.next;tab[hash(key) & (capacity - 1)] = next;oldValue = node.value;node = null;}else {prev = node;node = node.next;while (node != null){if (node.hash == hash(key) &&Objects.equals(node.key,key)){prev.next = node.next;oldValue = node.value;node.next = null;node = null;break;}}}if (oldValue != null)size--;return oldValue;}
    
  • resize函数

    //大概思路是
    //扩容为原来的2倍,然后遍历旧的tab,将位置为i的元素的链表,根据每个链表节点的hash值大小,拆分为2个链表,小于原capacity的为一个链表,大于原capacity的为一个链表。小于原capacity的链表保持原先的位置i不变,大于原capacity的链表,放到i + oldCap 的位置
    private void resize(){Node<K,V>[] oldTab = tab;int oldCap = capacity;int oldThr = threshold;int newCap = 0;int newThr = 0;if (tab == null || tab.length == 0){newCap = oldCap;newThr = threshold;}else {newCap = (oldCap << 1) > MAX_CAPACITY ? MAX_CAPACITY : (oldCap << 1);newThr = (int)(newCap * loadFactor);}tab = new Node[newCap];for (int i = 0; i < oldCap; i++){Node<K,V> node = oldTab[i];if (node != null){Node<K,V> lowHead = null,lowTail = null;Node<K,V> highHead = null,highTail = null;do{if ((node.hash & oldCap) == 0){if (lowHead == null){lowHead = node;}else {lowTail.next = node;}lowTail = node;}else {if (highHead == null){highHead = node;}else {highTail.next = node;}highTail = node;}}while ((node = node.next) != null);tab[i] = lowHead;tab[i + oldCap] = highHead;}}}
    
  • 其他的功能性私有函数

       /** return pow of 2 **/private int tableSize(int size){int n = size - 1;n |= n >> 1;n |= n >> 2;n |= n >> 4;n |= n >> 8;n |= n >> 16;return n <= 0 ? 1 : (n + 1) >= MAX_CAPACITY ? MAX_CAPACITY : (n + 1);}/** 获得Key的hash值 **/private int hash(K k){int h;return k == null ? 0 : (h = k.hashCode()) ^ (h >>> 16);}//put插入新元素时,先调用该函数private void ensureInternalCapacity(int newSize){if (newSize > threshold){resize();}}

再来看JDK的HashMap,先只关心基本的get/put/remove函数,不关心树化等操作

对比了一下,发现JDK的HashMap,和我自己手撕的HashMap,主要有以下几点不同:

  1. JDK的HashMap,其成员变量只有如下的几个,并不包含capacity。它使用threshold作为此处是容量的placeholder,其实capacity这个属性,在之后貌似也没有什么地方需要用到了,需要获得容量时,直接取table.length就可以了

  2. 其构造函数,只设置了loadFactor和threshold(无参构造函数的threshold默认为初始值0)



    可以看到若指定了初始容量,会调用tableSizeFor函数,来调整容量为2的幂

  3. get函数

    get函数调用了一个getNode函数,入参是key的hash值,和key

    可以看到getNode函数会先取出tab对应位置上的Node,然后判断是否是要找的Node,若不是,则循环遍历这个Node对应的链表。这样找到的Node就是最终要找的元素。我自己的getNode实现是直接取出tab对应位置的Node,而把判断Node是否是要找的元素这一步,放到了get里面去做

  4. put函数


    当第一次调用put函数时,由于table为null,会进入到resize方法进行扩容。然后根据要插入的元素的key,定位到table中对应的位置,若该位置的元素为null,说明没有发生冲突,则直接插入。

    否则,说明待插入的元素已经存在,或者存在Hash冲突。它首先判断了是否有相同元素,若有,则e不为空,若没有相同元素,则直接以尾插法插入到链表尾部。若e不为空,则说明是存在相同元素,则进行value的覆盖,并直接return。若e不为空,才会继续往下走,对modCount和size进行+1,然后若size超过threshold,在进行一次扩容。

    和我自己实现的put方法不一样的地方在于:我是在插入新元素之前,调用ensureCapacity根据需要进行扩容,而jdk的HashMap,是在每次插入完后,根据需要进行扩容。并且他是通过一个Node变量e,来表示是否存在重复元素,统一再最后来对重复元素做判断,以及对size进行+1。而我则是用比较笨的分支控制,没有他写的优雅

  5. remove函数

    看上去没什么不一样,也是先定位到table中的某个位置,然后判断hash值和key是否一样,若要移除的是链表头元素,则需要将table对应的位置设置为要移除的Node的next,否则,就是从一个普通链表中移除一个中间元素

  6. resize函数

    这个函数太长了,分两次截图


    这写法也是没shei了,我愣生生看了好几遍才琢磨清楚。

    resize函数分为2部分来说,第一部分是new出一个新的table,第二部分是把oldTable里的元素重新放到newTable中。

    先说说调用resize的2种场景:

    1. 第一次调用put函数
    2. 元素个数超过阈值进行扩容

    对于场景1,第一次调用put函数。则resize函数只是做了个初始化,故可以只看resize函数中new出一个table的逻辑。这又得看是调用的什么构造函数

    1. 调用无参构造函数

      对于无参构造函数,我们知道,仅仅是设置了loadFactor为0.75。这时oldCap=0,oldThr=0。则会进入到如下的代码片段

      可以看到将newCap设为了默认容量16,newThr则是16 * 0.75=12

    2. 调用含参构造函数

      若调用了含参的构造函数,则threshold为大于指定的容量的最小的2的幂。那么此时oldCap=0,oldThr不为0,进入到如下代码片段

      将newCap设为了oldThr,这个oldThr暂存了初始容量(这也就体现了threshold作为初始容量的placeholder),而后更新了newThr为 newCap * loadFactor

    设置完newCap,newThr后,就是对table进行初始化了

    场景1,第一次调用put函数,说完了。现在来说场景2,元素个数达到阈值后的自动扩容

    此时很明显,oldCap和oldThr都不为0。则new一个新的table时,走如下的代码片段

    新的table创建完毕后,就要将旧的table里的值,塞到新的table里去了。

    开始遍历oldTable,获取当前的元素Node,若当前Node不为空,且当前Node没有next节点,则直接根据hash值塞到新的Table中

    否则,说明当前Node存在冲突,是一个链表,则要对该链表进行rehash

    大概原理是,遍历该链表,对于每个节点,看该节点的hash值,是否小于原来的capacity,这是通过计算 hash & oldCap是否等于0来判断的,因为我们的capacity是2的幂,表示为二进制则是1后面很多个0,如10000000,那只要hash值小于这个oldCap,与操作得到的结果一定是0,若hash值大于oldCap,与操作得到的结果不为0。于是把hash值小于oldCap的节点,放在了链表loHead上,把hash值大于oldCap的节点,放在了链表hiHead上。划分完成后,再把loHead节点,放在新的table里,原先的位置j,把hiHead节点,放在位置 j + oldCap 上。因为原先放在同一个位置j上的节点,其hash值一定是 n*oldCap + j,而现在扩容为原先的2倍,所以放在j + oldCap位置上的节点,其hash值本身是满足 hash & (newCap - 1) = j + oldCap的。

    HashMap的基本操作就分析到这里,另外说说HashMap里暗藏的一些小玄机。

    首先是各个静态变量都使用了位操作来提高效率:

    还有HashMap自带的hash函数,用来计算key的hash值

    返回的hash值是个int变量,由于int是4个字节,32位,算hash值的时候,将key本来的hashCode的高16位和低16位做了异或,将高16位的特征也apply在了低16位上了。这样能够很好的避免某些key的hashCode值只在高位不同,而低位相同的问题。由于在日常使用中,HashMap的大小通常不会超过216,而一个元素存放的位置下标,是由取模运算决定的,而HashMap的容量固定是2的幂,取模运算简化为了与运算,则可认为,一个元素存放的位置下标,就是其hash值的低x位的值(HashMap的容量为2x)。如果不将高位的特征apply到低位,则会导致多个key,由于低位特征相似, 而被放在HashMap的相同位置,产生频繁的碰撞。

    还有这个计算不小于数n的最小的2的整数次幂,比如1,算出来就是1,3,算出来是4;9,算出来是16…

    先看看为什么能达到这样的效果,一个正数cap的二进制表示,从高位往低位看,最高位一定在第一次出现1的位置。

    比如 000010101111011,则最高位是最左侧的1。

    把cap右移1位,再和cap做或运算,则能保证最高两位都是1,再将这个结果继续右移2位,做或运算,则能保证最高的前4位都是1,再继续右移4位,做或,保证了最高的前8位都是1…依次类推…由于int是32位,所以最后右移16位就可以保证,最高位之后的所有位,都是1,再将这个数加1,就得到一个2的幂,并且这个2的幂,是刚刚不小于原来的数cap的最小的2的幂。那么这个函数一开始为什么要对cap进行减1呢?想想如果传入的cap恰好是2的幂,比如4,如果不减掉1再做运算,则算出来的结果是8,而我们想要取不小于一个数的最小的2次幂,当这个数本来就是2的幂的时候,直接返回该数就可以了。

    另外,关于树化阈值8和非树化阈值6。

    当table的一个位置,链表长度达到树化阈值,并且HashMap的容量超过了最小树化容量(64)的时候,会触发树化,将该位置的链表,转换为红黑树。从而提高查询效率。

    为什么树化阈值定为了8?根据源码的注释,是与一个统计学概念有关。当选择了一个散列性较好的hash函数时,table某个位置上的元素个数(链表的长度)的概率分布,服从泊松分布(泊松分布是一种离散概率分布,它描述的大概是在一段时间内,n次独立事件发生的次数的概率分布,比如我丢10次硬币,出现5次正面的概率,当然这个比喻不够严谨,详细的可以参考文末的链接)。当loadFactor为0.75时,泊松分布的均值为0.5,从而计算出出现相应元素个数时对应的概率值

    出现元素个数为8的概率非常的小。所以其实在普通使用的场景下,是见不到树化操作的。个人认为,树化是处于安全性的考虑。如果用户有意或无意地使用了散列性能十分糟糕的hash函数,为了避免链表过长而导致的查询性能下降,才采取了树化的策略,牺牲一定的空间(树化后的节点比普通节点所占的空间要多一倍),来换取时间。另外,如果有黑客发起哈希碰撞的攻击,树化的策略也能为查询性能提供一个兜底的保障,再不济也有O(logn)的性能,而不至于下降到O(n)。

    非树化阈值为6,和树化阈值8之间保留了7的位置,可以一定程度避免反复插入,删除会产生碰撞的元素,导致频繁地在树化,非树化之间进行转换,要知道树化和非树化的操作是比较耗时的。

    还有一个最小树化容量64

    这个参数,是考虑到这样一种场景,若table的某个位置上链表的长度达到了树化阈值8,可能是由于HashMap本身的容量太小,比如HashMap容量为1或2。此时应该采取的措施是扩容而不是树化。

    (完)

泊松分布参考链接:
http://www.ruanyifeng.com/blog/2015/06/poisson-distribution.html
https://blog.csdn.net/ccnt_2012/article/details/81114920
https://baijiahao.baidu.com/s?id=1640575113204709427&wfr=spider&for=pc

拨开HashMap的隐秘森林相关推荐

  1. leetcode 781. 森林中的兔子(hashmap)

    森林中,每个兔子都有颜色.其中一些兔子(可能是全部)告诉你还有多少其他的兔子和自己有相同的颜色.我们将这些回答放在 answers 数组里. 返回森林中兔子的最少数量. 示例: 输入: answers ...

  2. 小森林顺序_【青春】冬日里的隐秘心事——评《小森林 冬春篇》

    <小森林>系列根据漫画家五十岚大介的同名人气漫画改编,由桥本爱饰演从都市回到乡村的主人公市子,描述了这位在严酷大自然和乡下独居生活中自给自足的年轻女主角的沉吟心事.母亲不辞而别后,市子曾经 ...

  3. HashMap 和 Hashtable 的 6 个区别,最后一个没几个人知道!

    HashMap 和 Hashtable 是 Java 开发程序员必须要掌握的,也是在各种 Java 面试场合中必须会问到的. 但你对这两者的区别了解有多少呢? 现在,栈长我给大家总结一下,或许有你不明 ...

  4. 估值再翻番的元气森林,该如何“长红”?

    文|螳螂观察(TanglangFin) 作者|图霖 新消费领域的"降温潮"比想象中来得更猛烈. 一方面,资本集体转向点心茶饮等线下餐饮,线上新消费品牌投资热度刹车.以去年大热的美妆 ...

  5. spark mllib源码分析之随机森林(Random Forest)

    Spark在mllib中实现了tree相关的算法,决策树DT(DecisionTree),随机森林RF(RandomForest),GBDT(Gradient Boosting Decision Tr ...

  6. Leetcode--781.森林中的兔子

    森林中,每个兔子都有颜色.其中一些兔子(可能是全部)告诉你还有多少其他的兔子和自己有相同的颜色.我们将这些回答放在 answers 数组里. 返回森林中兔子的最少数量. 示例: 输入: answers ...

  7. 【Java】HashMap 和 Hashtable 的 6 个区别

    转载:https://mp.weixin.qq.com/s?__biz=MzI3ODcxMzQzMw==&mid=2247487842&idx=1&sn=9974be8f5d9 ...

  8. 基于java的随机森林算法_基于Spark实现随机森林代码

    本文实例为大家分享了基于Spark实现随机森林的具体代码,供大家参考,具体内容如下 public class RandomForestClassficationTest extends TestCas ...

  9. 《看不见的森林:林中自然笔记》书摘一

    笔记摘自 看不见的森林:林中自然笔记 [美]戴维·乔治·哈斯凯尔 2017年10月17日 序 ,喇嘛与学生从事的是同样的工作:凝视一座坛城,提升自己的心灵.这种相似性并不止于语言与象征意义上的重合,而 ...

最新文章

  1. 2021 年不可错过的 40 篇 AI 论文,你都读过吗?
  2. 快速排序到底有多快?
  3. 机器学习算法在自动驾驶领域的应用大盘点!
  4. 机器学习 感知机算法_0(Matlab实现)
  5. 5-8 离散点检测(改进版无error)
  6. Mysql数据库的简单备份与还原_史上最简单的MySQL数据备份与还原教程
  7. MySQL数据库的用户授权_查看权限
  8. sqlmap的简单用法
  9. 在Python程序中设置函数最大递归深度
  10. Silverlight在IE中无法显示但在Firefox中正常的原因和解决办法
  11. 个人作业六:单元测试
  12. 三相桥式全控整流电路simulink仿真_维修电工高级仿真-教学软件
  13. Mac OS 连内外网
  14. OSTU 最佳全局阈值处理-最大类间方差法
  15. 安装虚拟机VMware 出现failed to install the hcmon driver 问题 避坑
  16. 2w 字长文带你搞懂 Linux 命令行
  17. cpu之RegDst_Ins
  18. 3步上架iOS APP【2022最新教程】
  19. survival | 生存分析(3):生存曲线(下)
  20. PayPal 如何付款

热门文章

  1. 多媒体编程——摄像头录像预览
  2. 【java】蓝桥杯 甲乙回合战斗
  3. 智慧景区总体技术架构
  4. RT-Thread 邮箱(学习笔记)
  5. 计算机毕业设计之java+ssm公交站牌广告灯箱管理系统
  6. layui树形菜单右键_layui树形菜单写的树形列表(treetable)
  7. Rime输入法引擎配置( 小狼毫输入法常用设置)
  8. 折腾报表那些事儿(1)RDLC
  9. 开发实现物理加速度移动_《无限法则》开发经验分享:射击游戏的物理引擎应用和移动模拟...
  10. python变量pi和pi被看作相同的变量_python分享pi的方法 两种用python分享p