一、快速入门

示例:有一定基础的小伙伴们可以选择性的跳过该步骤

hashmap是java程序员使用频率最高的用于映射键值对(key和value)处理的数据类型。随着jdk版本的跟新,jdk1.8对hashmap底层的实现进行了优化,列入引入红黑树的数据结构和扩容的优化等。本文结合jdk1.7和jdk1.8的区别,深入探讨hashmap的数据结构实现和功能原理。
java为数据结构中的映射定义了一个接口java.uti.map,此接口主要有四个常用的实现类,分别是hashmap,linkedhashmap,hashtable,treemap,identityhashmap。本篇文章主要讲解hashmap以及底层实现原理。

1.hashmap的常用方法

//  hashmap存值:----------------------------------》 .put("key","value"); ----------》无返回值。
//
//  hashmap取值:----------------------------------》 .get("key");-------------------》 返回value的类型。
//
//  hashmap判断map是否为空:-----------------------》 .isempty(); -------------------》返回boolean类型。
//
//  hashmap判断map中是否存在这个key:--------------》.containskey("key");------------》返回boolean类型。
//
//  hashmap判断map中是否含有value:----------------》.containsvalue("value");-------》返回boolean类型。
//
//  hashmap删除这个key值下的value:----------------》.remove("key");-----------------》返回value的类型。
//
//  hashmap显示所有的value值:---------------------》.values(); --------------------》返回value的类型。
//
//  hashmap显示map里的值得数量:-------------------》.size(); ----------------------》返回int类型
//
//  hashmap显示当前已存的key:---------------------》 .keyset();-------------------》返回key的类型数组。
//
//  hashmap显示所有的key和value:-----------------》.entryset());------------------》返回key=value类型数组。
//
//  hashmap添加另一个同一类型的map:--------------》.putall(map); -----------------》(参数为另一个同一类型的map)无返回值。
//
//  hashmap删除这个key和value:------------------》.remove("key", "value");-------》(如果该key值下面对应的是该value值则删除)返回boolean类型。
//
//  hashmap替换这个key对应的value值(jdk8新增):---》.replace("key","value");-------》返回被替换掉的value值的类型。
//
//  克隆hashmap:-------------------------------》.clone(); ---------------------》返回object类型。
//
//  清空hashmap:-------------------------------》.clear(); ---------------------》无返回值。

2.hashmap的几个重要知识点

  • hashmap是无序且不安全的数据结构。
  • hashmap 是以key–value对的形式存储的,key值是唯一的(可以为null),一个key只能对应着一个value,但是value是可以重复的。
  • hashmap 如果再次添加相同的key值,它会覆盖key值所对应的内容,这也是与hashset不同的一点,set通过add添加相同的对象,不会再添加到set中去。
  • hashmap 提供了get方法,通过key值取对应的value值,但是hashset只能通过迭代器iterator来遍历数据,找对象。

二、jdk7与jdk8的hashmap区别

既然讲hashmap,那就不得不说一下jdk7与jdk8(及jdk8以后)的hashmap有什么区别:

  • jdk8中添加了红黑树,当链表长度大于等于8的时候链表会变成红黑树
  • 链表新节点插入链表的顺序不同(jdk7是插入头结点,jdk8因为要把链表变为红 黑树所以采用插入尾节点)
  • hash算法简化 ( jdk8 )
  • resize的逻辑修改(jdk7会出现死循环,jdk8不会)

三、hashmap的容量与扩容机制

1.hashmap的默认负载因子

/**
  * the load factor used when none specified in constructor.
  */
 static final float default_load_factor = 0.75f;
 /**
  *默认的负载因子是0.75f,也就是75% 负载因子的作用就是计算扩容阈值用,比如说使用
  *无参构造方法创建的hashmap 对象,他初始长度默认是16 阈值 = 当前长度 * 0.75 就
  *能算出阈值,当当前长度大于等于阈值的时候hashmap就会进行自动扩容
  */

面试的时候,面试官经常会问道一个问题:为什么hashmap的默认负载因子是0.75,而不是0.5或者是整数1呢?
答案有两种:

  • 阈值(threshold) = 负载因子(loadfactor) x 容量(capacity) 根据hashmap的扩容机制,他会保证容量(capacity)的值永远都是2的幂 为了保证负载因子x容量的结果是一个整数,这个值是0.75(4/3)比较合理,因为这个数和任何2的次幂乘积结果都是整数。
  • 理论上来讲,负载因子越大,导致哈希冲突的概率也就越大,负载因子越小,费的空间也就越大,这是一个无法避免的利弊关系,所以通过一个简单的数学推理,可以测算出这个数值在0.75左右是比较合理的

2.hashmap的扩容机制

写数据之后会可能触发扩容,hashmap结构内,我记得有一个记录当前数据量的字段,这个数据量字段到达扩容阈值的话,它就会触发扩容的操作

阈值(threshold) = 负载因子(loadfactor) x 容量(capacity)
当hashmap中table数组(也称为桶)长度 >= 阈值(threshold) 就会自动进行扩容。

扩容的规则是这样的,因为table数组长度必须是2的次方数,扩容其实每次都是按照上一次tablesize位运算得到的就是做一次左移1位运算,
假设当前tablesize是16的话 16转为二进制再向左移一位就得到了32 即 16 << 1 == 32 即扩容后的容量,也就是说扩容后的容量是当前
容量的两倍,但记住hashmap的扩容是采用当前容量向左位移一位(newtablesize = tablesize << 1),得到的扩容后容量,而不是当前容量x2

问题又来了,为什么计算扩容后容量要采用位移运算呢,怎么不直接乘以2呢?
这个问题就比较简单了,因为cpu毕竟它不支持乘法运算,所有的乘法运算它最终都是再指令层面转化为了加法实现的,这样效率很低,如果用位运算的话对cpu来说就非常的简洁高效。

3.hashmap中散列表数组初始长度

 /**
  * the default initial capacity - must be a power of two.
  */
 static final int default_initial_capacity = 1 << 4; // aka 16

 /**
  * hashmap中散列表数组初始长度为 16 (1 << 4)
  * 创建hashmap的时候可以设置初始化容量和设置负载因子,
  * 但hashmap会自动优化设置的初始化容量参数,确保初始化
  * 容量始终为2的幂
  */

老问题又来了,为啥hashmap中初始化大小为什么是16呢?

首先我们看hashmap的源码可知当新put一个数据时会进行计算位于table数组(也称为桶)中的下标:

int index =key.hashcode()&(length-1);

hahmap每次扩容都是以 2的整数次幂进行扩容

因为是将二进制进行按位于,(16-1) 是 1111,末位是1,这样也能保证计算后的index既可以是奇数也可以是偶数,并且只要传进来的key足够分散,均匀那么按位于的时候获得的index就会减少重复,这样也就减少了hash的碰撞以及hashmap的查询效率。

那么到了这里你也许会问? 那么就然16可以,是不是只要是2的整数次幂就可以呢?

答案是肯定的。那为什么不是8,4呢? 因为是8或者4的话很容易导致map扩容影响性能,如果分配的太大的话又会浪费资源,所以就使用16作为初始大小。

四、hashmap的结构

jdk7与jdk8及以后的hashmap结构与存储原理有所不同:
jdk1.7:数组 + 链表 ( 当数组下标相同,则会在该下标下使用链表)
jdk1.8:数组 + 链表 + 红黑树 (预值为8 如果链表长度 >=8则会把链表变成红黑树 )
jdk1.7中链表新元素添加到链表的头结点,先加到链表的头节点,再移到数组下标位置
jdk1.8中链表新元素添加到链表的尾结点
(数组通过下标索引查询,所以查询效率非常高,链表只能挨个遍历,效率非常低。jdk1.8及以
上版本引入了红黑树,当链表的长度大于或等于8的时候则会把链表变成红黑树,以提高查询效率)

五、hashmap存储原理与存储流程

1.hashmap存储原理

  • 获取到传过来的key,调用hash算法获取到hash值
  • 获取到hash值之后调用indexfor方法,通过获取到的hash值以及数组的长度算
  • 出数组的下标 (把哈希值和数组容量转换为二进,再在数组容量范围内与哈希值
  • 进行一次与运算,同为1则1,不然则为0,得出数组的下标值,这样可以保证计算出的数组下标不会大于当前数组容量)
  • 把传过来的key和value存到该数组下标当中。
  • 如该数组下标下以及有值了,则使用链表,jdk7是把新增元素添加到头部节点 jdk8则添加到尾部节点。

2.hashmap存储流程

前面寻址算法都是一样的,根据key的hashcode经过高低位异或之后的值,再按位与 &(table.lingth – 1),得到一个数组下标,然后根据这个数组下标内的状况,状况不同,然后情况也不同,大概分为了4种状态:

( 1.)第一种就是数组下标下内容为空:
这种情况没什么好说的,为空据直接占有这个slot槽位就好了,然后把当前.put方法传进来的key和value包装成一个node对象,放到这个slot中就好了。

( 2.)第二种情况就是数组下标下内容不为空,但它引用的node还没有链化:
这种情况下先要对比一下这个node对象的key与当前put对象的key是否完全.相等,如果完全相等的情况下,就行进行replace操作,把之前的槽位中node.下的value替换成新的value就可以了,否则的话这个put操作就是一个正儿.八经的hash冲突,这种情况在slot槽位后面追加一个node就可以了,用尾插法 ( 前面讲过,jdk7是把新增元素添加到头部节点,而jdk8则添加到尾部节点)。

( 3.)第三种就是该数组下标下内容已经被链化了:
这种情况和第二种情况处理很相似,首先也是迭代查找node,看看链表上中元素的key,与当前传过来的key是否完全一致,如果完全一致的话还是repleace操作,用put过来的新value替换掉之前node中的value,否则的话就是一致迭代到链表尾节点也没有匹配到完全一致的node,就和之前的一样,把put进来数据包装成node追加到链表的尾部,再检查一下当前链表的长度,有没有达到树化阈值,如果达到了阈值就调用一个树化方法,树化操作都是在这个方法里完成的。

( 4.)第四种情况就是冲突很严重的情况下,这个链表已经转化成红黑树了:
红黑树就比较复杂 要将清楚这个红黑树还得从treenode说起 treenode继承了node结构,在node基础上加了几个字段,分别是指向父节点parent字段,指向左子节点left字段,指向右子节点right字段,还有一个表示颜色的red字段,这就是treenode的基本结构,然后红黑树的插入操作,首先找到一个合适的插入点,就是找到插入节点的父节点,然后红黑树它又满足二叉树的所有特性,所以找这个父节点的操作和二叉树排序是完全一致的,然后说一下这个二叉树排序,其实就是二分查找算法映射出来的结构,就是一个倒立的二叉树,然后每个节点都可以有自己的子节点,本且左节点小于但前节点,右节点大于当前节点,然后每次向下查找一层就能那个排除掉一半的数据,查找效率非常的高效,当查找的过程中也是分情况的。

首先第一种情况就是一直向下探测,直到查询到左子树或者右子树位null,说明整个树中,并没有发现node链表中的key与当前put key一致的treenode,那此时探测节点就是插入父节点的所在了,然后就是判断插入节点的hash值和父节点的hash值大小决定插入到父节点的左子树还是右子树。当然插入会打破平衡,还需要一个红黑树的平衡算法保持平衡。

其次第二种情况就是根节点在向下探测过程中发现treenode中key与当前put的key完全一致,然后就也是一次repleace操作,替换value。

六、jdk8中hashmap为什么要引入红黑树?

其实主要就是为了解决jdk1.8以前hash冲突所导致的链化严重的问题,因为链表结构的查询效率是非常低的,他不像数组,能通过索引快速找到想要的值,链表只能挨个遍历,当hash冲突非常严重的时候,链表过长的情况下,就会严重影响查询性能,本身散列列表最理想的查询效率为o(1),当时链化后链化特别严重,他就会导致查询退化为o(n)为了解决这个问题所以jdk8中的hashmap添加了红黑树来解决这个问题,当链表长度>=8的时候链表就会变成红黑树,红黑树其实就是一颗特殊的二叉排序树嘛,这个时间复杂…反正就是要比列表强很多

七、扩容后的新table数组,那老数组中的这个数据怎么迁移呢

迁移其实就是挨个桶位推进迁移,就是一个桶位一个桶位的处理,主要还是看当前处理桶位的数据状态把,这里也是分了大概四种状态:
这四种的迁移规则都不太一样

(1.)第一种就是数组下标下内容为空:
这种情况下就没什么可说的,不用做什么处理。

( 2.)第二种情况就是数组下标下内容不为空,但它引用的node还没有链化:
当slot它不为空,但它引用的node还没有链化的时候,说明这个槽位它没有发生过hash冲突,直接迁移就好了,根据新表的tablesize计算出他在新表的位置,然后存放进去就好了。

( 3.)第三种就是slot内储存了一个链化的node:
当node中next字段它不为空,说明槽位发生过hash冲突,这个时候需要把当前槽位中保存的这个链表拆分成两个链表,分别是高位链和低位链

(4.)第四种就是该槽位储存了一个红黑树的根节点treenode对象:
这个就很复杂了,本文章暂时不做过多的介绍(博主还没整明白 =_=! )

到此这篇关于hashmap底层实现原理详解的文章就介绍到这了,更多相关hashmap底层实现原理内容请搜索www.887551.com以前的文章或继续浏览下面的相关文章希望大家以后多多支持www.887551.com!