浅析Java HashMap

前言

javaAPI中的hashMap是我工作中用的特别多的一种数据结构,底层原理也都知道,平时虽然也就put、get,但是深入思考分析才发现原来我忽略掉了最重要的”hashMap设计思考的过程“。
散列表是一种由数组演变过来的数据结构,查找的时间复杂度从O(n)到了O(1),但是我们都知道这种高效的数据结构必然有弊端,它相较于数组多了较为复杂的hash函数跟hash冲突问题,一旦处理不好,跟数组没什么差别,而且每次插入查找都要进行hash计算,对于我们来说数据量小的时候可以使用数组进行存储,但是数据量较大的时候散列表就是一个很好的选择。

hashMap之hash函数设计

int hash(Object key) {
    int h = key.hashCode()return (h ^ (h >>> 16)) & (capicity -1); //capicity表示散列表的大小
}

散列函数的设计并不复杂,追求的是简单高效、分布均匀
其实这里的散列函数的设计我们还可以贴合业务进行实现,比如现在有这样一个需求,要求缓存数据并实现根据班级Id返回学生列表,其实这个时候散列函数可以直接返回班级Id,当然如果你不是特别追求极致性能的话,那么上面的hash函数其实就很ok了,一句话就是定制的效率肯定要比通用的好。

hash函数讲解

首先hashcode本身是个32位整型值,在系统中,这个值对于不同的对象必须保证唯一(JAVA规范),这也是大家常说的,重写equals必须重写hashcode的重要原因。

获取对象的hashcode以后,先进行移位运算,然后再和自己做异或运算,即:hashcode ^ (hashcode >>> 16),这一步甚是巧妙,是将高16位移到低16位,这样计算出来的整型值将“具有”高位和低位的性质

最后,用hash表当前的容量减去一,再和刚刚计算出来的整型值做位与运算。进行位与运算,很好理解,是为了计算出数组中的位置。但这里有个问题:
为什么要用容量减去一?
因为 A % B = A & (B - 1),所以,(h ^ (h >>> 16)) & (capitity -1) = (h ^ (h >>> 16)) % capitity,可以看出这里本质上是使用了「除留余数法」

综上,可以看出,hashcode的随机性,加上移位异或算法,得到一个非常随机的hash值,再通过「除留余数法」,得到index

hashMap之hash冲突问题

HashMap 底层采用链表法来解决冲突。即使负载因子和散列函数设计得再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响 HashMap 的性能。于是,在 JDK1.8 版本中,为了对 HashMap 做进一步优化,我们引入了红黑树。而当链表长度太长(默认超过 8)时,链表就转换为红黑树。我们可以利用红黑树快速增删改查的特点,提高 HashMap 的性能。当红黑树结点个数少于 8 个的时候,又会将红黑树转化为链表。因为在数据量较小的情况下,红黑树要维护平衡,比起链表来,性能上的优势并不明显

解决冲突还有一种方式叫开放寻址法,这里我就不讲解了,直接说一下对于这二种方式的理解

  • 开放寻址法数据都存储在数组中,可以有效地利用 CPU 缓存加快查询速度。
  • 比起链表法来说,冲突的代价更高。所以,使用开放寻址法解决冲突的散列表,装载因子的上限不能太大。这也导致这种方法比链表法更浪费内存空间。

当数据量比较小、装载因子小的时候,适合采用开放寻址法。比如 Java 中的ThreadLocalMap

  • 链表法虽然不浪费存储空间但是会存储链表对前置节点这些格外的数据,所以对于空间比较苛刻的业务来说就不适合
  • 链表法对数据冲突容忍度更高,即使冲突很多,大部分情况也要比开发寻址法快,而且链表法可以改造为其他高效的动态数据结构,比如跳表、红黑树。

链表法适用于大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表

hashMap中如何解决低效扩容?

散列表装载因子过高,启动扩容,我们需要重新申请内存空间,重新计算哈希位置,并且搬移数据,时间复杂度是 O(n)

举一个例子,如果散列表当前大小为 2GB,要想扩容为原来的两倍大小,那就需要对 2GB 的数据重新计算哈希值,并且从原来的散列表搬移到新的散列表,是不是就很耗时,尽管大部分情况下,插入一个数据的操作都很快,但是,极个别非常慢的插入操作,就会很慢。这个时候,“一次性”扩容的机制就不合适了。

为了解决一次性扩容耗时过多的情况,我们可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,我们只申请新空间,但并不将老的数据搬移到新散列表中。当有新数据要插入时,我们将新数据插入新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表,我们都重复上面的过程。经过多次插入操作之后,老的散列表中的数据就一点一点全部搬移到新散列表中了。这样没有了集中的一次性数据搬移,插入操作就都变得很快了,也就是均摊了,插入操作从O(1)变为了O(2)。

对于查询操作,为了兼容了新、老散列表中的数据,我们先从新散列表中查找,如果没有找到,再去老的散列表中查找。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值