从什么是althashing开始解读java的HashMap

从什么是althashing开始解读java的HashMap

本文不包含一行代码,你可以读完,回头再去看代码,或者你一边看着源码,一边品味也可以。建议看完再重看一边源代码。

写在前面

  • 本文的目的是搞懂Map的设计思想,HashMap的实现原理。
    capacity, thresholder, loadFactor究竟是什么奇淫巧计。

  • 所谓的rehash,是业界在扯淡还是真有其事?

  • 被众人称道的HashMap求余算法,究竟是怎么来的?

  • 搜索了整个谷歌百度,也找到答案的【jdk.map.althashing.threshold系统参数】是什么奇门遁甲?怎么用?有什么特异功能?

为了节省你的时间,先给答案。
  • Map设计思想就是不计空间代价的设计一种读取快,插入也快,删除也快的算法,快到接近O(1)。这种神一样存在的神话,完全超越了数组和链表,或者说同时具备了数组和链表的优点,是无敌的存在。唯一的缺点就是不计空间代价。

HashMap的实现原理,重点在于3个变量:

  • thresholder初始化当一次主角,之后跑龙套的最佳配角。
  • capacity 全程C位出道,负责求余,负责扩容,负责算阈值,从白天忙到黑夜,而且拥有触发很多程序员一辈子都可能无法预见的rehash算法的特异功能。
  • 如果说capacity是thresholder的父亲,那么loadfactory就是thresholder的母亲。
  • 很多人口中的rehash就是在扯淡,从key变成index需要经历两个过程,第一个过程是hash().第二个过程是indexFor求余。后面几乎不会再调用rehash算法。因为每次扩容都只需要重新求余即可。不需要再进行rehash,因为rehash是一个麻烦的过程,复杂度是O(n)。所以扩展只会触发重新求余,不会触发rehash。
  • 除非设置系统参数【jdk.map.althashing.threshold系统参数】才会重新rehash的这道极光。
  • 被众人称道的HashMap求余算法其实就是用【&运算】来代替低性能的求余运算【%】,而巧妙的固定了capacity位2的指数倍数,以及*2的扩容方式,既遵守了空间换时间的精髓思想,也和【&运算】左右逢春巧妙结合,完美替代了低效的【%运算】。

哈希表的两个重要初始化参数:

  • initialCapacity,初始化容量

  • loadFactor,负载因子

了解初始化过程

  • 获取capacity,用他来初始化阈值thresholder,从某种意义上来说,和thresholder异曲同工,都是通过扩展降低碰撞率。硬要说的话,capacity鸡肋多了。(capacity后面只能用来做resize()的扩容起步值,或者叫初始值。以及用来触发一个可能永远都不可能发生的算法,那就是rehash算法。)

  • 使用capacity初始化thresholder之后,就是预设负载因子了,负载因子是一个用来调整thresholder增长的因子。

  • 截止此时已经完成了初始化工作。

  • 那么什么时候创建表呢?当put元素值得时候,通过碰撞算法inflateTable()来实现的。

  • inflateTable会以thresholder的两倍速度来碰撞。然后再利用负载因子重新算阈值thresholder。

  • 到这里已经完成了初始化工作了。inflateTable算法之后不再使用了,只有初始化table的时候才会用到一次。

了解put过程

  • 这是一个新增的过程,对于哈希表,新增数据的过程其实是一个update指定数组元素值得过程所以,理想时间复杂是O(1)。为什么呢?因为哈希表是无序的离散存储,所有位置都是预先申请好内存空间。

  • 所以不存在顺序插入这种O(n)时间复杂度的操作。

  • 但是,当元素哈希码碰撞的情况下,复杂度会下降为O(lgn),如果是1.7旧版本的hashmap会降低为O(n).

  • 避免这个事情发生的办法就是申请大空间,减少碰撞概率(修改哈希码算法也是可以做到,但是很少人会这么干,除非自己设计一个map类,或者重写hashmap)

  • 有时候会触发扩容,扩容会重新计算所有key的index值,即重新求模(而不是rehash),还需要进行元素复制,等于要遍历一遍数组了。那么需要时间复杂度是O(n)。(虽然扩容会创建一个大一倍的数组空间,但是空间复杂度依然是O(n))

  • 所以Put操作,最好的时间复杂度是O(1),最差的时间复杂度是O(n)。哈希码碰撞并不是HashMap的错,是HashMap使用者的错,因为会导致O(n)复杂度频繁出现的罪魁祸首是整天不给HashMap预设足够空间的人。

  • 但是HashMap的扩容机制,使得越到后面对这种拙劣操作的忍耐性提高很多。因为hashmap的扩展是成倍提高的。

  • 触发扩展的事件有两个: 红黑树太高,总容量达到了阈值threshold.

  • 如果key一样,会对值进行替换,如果哈希码一样就会进行碰撞。HashMap为了区分你是替换还是碰撞,就会把put进来的对象和其他同哈希码的Entry进行逐个比较,如果key一样就是替换操作,不一样就会进行追加。(所以Key才是主键)

  • 然后通过addEntry()进行适当的tables大小调整(文档就是这么说的)。具体扩展方案就是当存储的Entry个数(只要key不为null都算,包括value为Null的key不为null也算)大于 threshold 时就会把容量乘以2。(threshold类似告警水位)。如果容量等于最大Integer值MAX_VALUE的一半时就不再扩容了。扩容后的大小为原来capacity的2倍,而且所有原tables的值会被重新求模计算Index.(rehash?有些人这辈子都不会用到这个算法)

  • 缕一缕,重新哈希,扩容,表容量,阈值的因果关系:

    • 达到阈值,触发扩容。
    • 达到表容量,触发重新哈希。
      ( 需要通过设置系统属性jdk.map.althashing.threshold触发)
    • 阈值一般小于容量。(除非阈值废弃了,才比容量大)
  • 新的告警阈值等于新容量 * 负载因子(newCapacity * loadFactor)。

  • 负载因子可以理解为认为调控阈值的比例值。

  • 总结:初始化碰撞算法inflateTable按照【thresholder向上调整为2的N次方】后再乘以2来膨胀。扩容resize()按照capacity的两倍.

  • 初始化和赋值复制用膨胀算法inflateTable(),而put得扩容永resize().

  • 膨胀和扩容得区别:膨胀按照预警值,扩容按照capacity容量值。(因为扩容是急所,而复制时松所,扩容是一件很着急的事情。其实主要还是为了方便求模。因为总不能老是动不动就[向上取整],很耗性能的。)

  • 所以resize()是一个高频操作,而inflateTables初始化和复制拷贝的时候会用到。

  • 采用头插法。代码在createEntry()中。

  • 哈希表真正的主键是用户定义的key值

了解get过程

  • get操作是从哈希表读取值得过程,复杂度大概率是O(1),可能偶尔会有一两次操作会变成O(lgn).(新版本的碰撞值用红黑树存储,复杂度是O(logN)).

了解remove过程

  • 在头部删除(因为在头部插入)
  • 删除的本质操作就是将元素从桶里溢出,如此依赖Entry会因为没有引用而被VM自动GC。

了解values()过程

其实就是一个迭代器生成算法。
通过遍历表生成迭代器。

了解size()过程

其实就是每次加一个元素就+1,删除就-1。

modCount参数的功能

  • 这个值会记录hash的修改记录,在生成迭代器的时候会备份这个值,如果在迭代过程中,这个值被并发修改了。那么迭代器就中断遍历,并抛出并发需改异常,这个叫快速失败。
  • 所以这个值是为迭代器快速失败用的。

rehash的时机

  • 在resize()后会根据 hashSeed 和jdk.map.althashing.threshold参数判断是否进行重新哈希rehash。重新哈希一般发生在容量很大的时候。

  • 默认是用永远都不会重新计算key的哈希值的,因为hash很浪费事件。

  • 然是会重新做模运算,为哈希桶重新分配下标,但是由于没有重新计算哈希值,所以,原来在一个桶的值都在一个桶。

  • 所以你得明白,一个key被put进来会做两件事情,第一件事情是hash()耗时的hash计算操作,将hashcode与hashseed做异或和移位运算,只要hashseed不变,最终的哈希值不变。第二件事情就是indexFor()将从第一步获得hashcode与capacity做与运算,等于是在做求余了,这是个高效率的过程。

  • rehash会重构桶,indexFor()求余会改变桶的下标位置,但不会改变桶结构。

  • rehash可以降低过去碰撞率,因为会对过去所有元素重新算哈希值。

  • indexFor不会调整过去的碰撞率,但是会使新进来的,。元素碰撞率降低,本质上通过提高容量的手段。

  • 默认情况下,只要不人工修改jdk.map.althashing.threshold参数这个系统参数。所以,不可能会帮你重新计算hash的了。

  • 所以,正常情况下,减少碰撞率仅能通过扩容。而扩容是一件讲究技巧的事情。如果你打从一开始就把capacity调的很大,那么先不说是否浪费空间,要命的是,触发阈值告警的概率就会很低了,因此,触发resize()的次数少了,那么重新算模的次数也低了,key就会很容易扎堆了。

  • 容量太小,虽然算模的次数提高了,但是扩容频繁性能会有一定的降低(每次扩容会翻倍,所以也不会太低),而且早期的碰撞因容量小引起的,模运算结果相似而引发的碰撞也不可小看。

  • 最佳实践就是告警欲望第一点,提高算模的频率。起步容量稍微大一点,减少早期碰撞的概率。这个方案唯一的确定就是浪费空间,但是碰撞率很低,性能棒棒的。而且到了后期,阈值也会随着扩容翻倍。最后出来的哈希表可是很完美的。

总结:

目标:降低碰撞,后期性能稳定高速。

做法:调低阈值,增加算模次数。提高容量,降低早期碰撞概率。必要时使用jdk.map.althashing.threshold触发rehash().

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值