HashMap浅析

序言

最近看了一些关于HashMap的教学视频,写了蛮多的笔记,借此来整理一下,

否则过于凌乱不好回顾,且通过整理对HashMap知识的印象也必会有更系统的认识吧

注:本篇围绕JDK1.8的HashMap进行讲述,相关1.7的也会补充一点


HashMap的定义

HashMap就是一个(JDK1.8)Node<K,V>数组((JDK1.7)Entry<K,V>数组)

其中Node<K,V>和Entry<K,V>都实现了Map中的内部接口Entry(它们都是HashMap的静态内部类)

其实Node<K,V>和Entry<K,V>是一个东西,只不过是名称改变了而已。

与HashMap相类似的数据结构

1、HashTable

1)、大部分方法都添加了synchronized关键字,所以HashTable是同步的(线程安全,效率低)

2)、储存的数据类型和HashMap可以说是一样的,但HashTable关键字不能有null,HashMap可以有

2、ConcurrentHashMap

1)、采用分段锁实现HashMap的线程安全(效率高)

2)、ConcurrentHashMapJDK1.8和1.8之前的实现方法不相同了

JDK1.8:

放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保证并发安全进行实现

JDK1.8之前:

采用Segment + HashEntry的方式进行实现

关于JDK1.7和JDK1.8的HashMap并发下分别出现的问题

关于HashMap和HashTable的区别

ConcurrentHashMap基础

ConcurrentHashMap1.8实现方法前后区别

注:可使用如下代码对HashMap保证线程安全(实质是对每个方法加上synchronized关键字)

Map map = Collections.synchronizeMap(hashMap);

进入正题

一、HashMap组成

1、数组(元素为Node<K,V>,和1.7前Entry<K,V>一个东西)

2、链表(解决哈希冲突,常用)

3、红黑树(解决链表过长影响查找效率的问题,但很少机会用到(JDK1.7没有红黑树))


二、HashMap的重要的成员属性:

1、默认初始化容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16


2、最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;


3、负载因子,默认0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;


4、链表长度阈值
(长度超过8但数组长度小于64,扩容;长度超过8且数组长度大于64
链表转换成红黑树)
static final int TREEIFY_THRESHOLD = 8;


5、红黑树节点数小于6时转换会链表
static final int UNTREEIFY_THRESHOLD = 6;


6、链表转红黑树数组的最小长度为64
static final int MIN_TREEIFY_CAPACITY = 64;


7、储存元素的数组(必须是2的n次幂)
transient Node<K,V>[] table;


8、存放缓存
transient Set<Map.Entry<K,V>> entrySet;


9、存放元素的个数,不是table的长度
transient int size;


10、记录HashMap的修改次数
由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap的结构发生变化了
(比如put,remove等操作),在一个迭代器初始的时候会赋予它调用这个迭代器的对象的mCount,如何在迭代器遍历的过程中,一旦发现这个对象的mcount和迭代器中存储的mcount不一样那就抛异常
transient int modCount;


11、容量阈值 (容量×负载因子, 16*0.75=12int threshold;


12、加载因子默认值就是0.75
final float loadFactor;

特别注意点:

1、关于链表转换成红黑树的条件(反之,红黑树转换成链表的条件)

1)、链表长度超过8但数组长度小于64,进行扩容

2)、长度超过8且数组长度大于64,链表转换成红黑树

3)、红黑树节点节点数小于6时,转换成链表

问题一:为什么将链表转换成红黑树?

答:

因为权衡了链表与红黑树的时间和空间的成本

1)、链表查询复杂度O(n),红黑树查询复杂度O(log n)

     明显红黑树查询速度比链表快

2)、红黑树节点的存储空间大小是链表普通节点的两倍

     明显红黑树节点的空间占存比链表多

综上两个条件

1、节点数量少的时候,链表和红黑树的查询效率相差不大,所以采用链表(牺牲时间换取空间)

2、节点数量多的时候,链表和红黑树的查询效率相差可不是一个级别了,所以采用红黑树(牺牲空间换取时间)

问题二:为什么当链表长度超过8才转换成红黑树?(数组长度默认超过64)

答:
在这里插入图片描述
从图中我们得知了链表的各8个节点被使用到的概率,

很明显第八个节点的概率几乎为0了,

既然HashMap链表的第八个节点概率这么低,几乎为不可能事件,

那么是否说明了HashMap设计者是否不想让链表转红黑树的情况发生呢?

那么 红黑树对于HashMap来说是否是一个很鸡肋的存在?(难道大数据的情况??)


三、HashMap的两个重要概念

1、加载因子 ---------------- 默认0.75(官方验证过的最好数值)

含义:指数组的充满程度到达百分之75%(0.75)就要进行扩容为原来2倍

2、阈值 --------------------默认12(16 × 0.75) 等于加载因子 × 数组长度

含义:指数组大于等于阈值就要进行扩容为原来2倍

共同作用:尽量避免频繁的哈希冲突从而造成链表过长导致查询效率低的问题


四、HashMap的数组的初始化(注意点)

1、构建HashMap对象不初始化数组大小,默认数组大小为16

2、传入初始化数组的值,HashMap内部数组初始化大小是2的n次幂且刚好大于等于传入的值
(如:传入9,数组初始化大小就是16;传入8,数组初始化大小就是8)

3、在常规构造器中,没有为数组table分配内存空间(有一个入参为指定Map的构造器例外),而是在执put操作的时候才真正构建table数组(JDK1.7和1.8都是这个机制)

问题一:HashMap数组长度为什么一定是2的n次幂?

答:

1)、使用的哈希函数是按位与运算 hash&(leng-1)

2的n次幂有利于该算法得出不同的索引值,使数据均匀分布

减少hash碰撞

2)、如果数组长度不是2的n次幂,计算出的索引值很容易相等,

导致频繁的哈希碰撞,导致数组其余空间很大程度上浪费,

最终链表或红黑树过长,导致查询效率降低

举个例子明白了:
在这里插入图片描述
1)、当数组长度为17时(不符合2的n次幂)

17减一后变成16,16的二进制只有最高位有1而其他位则全为0,这样的数值与哈希值进行运算很

容易计算出相同的索引下标,导致频繁的哈希冲突。

2)、当数组长度为16时(符合2的n次幂)

16减一后变成15, 15的二进制对应的位数都为1,这样对哈希值的辨识度很高,很大程度上避免了

频繁的哈希冲突


五、HashMap的下标形成过程

put添加数据是如何定位到HashMap中的数组下标位置的呢?

没错,就是哈希值,但哈希值并不是数组的索引哦!!!!看图(改图截自其它博主,侵删)
在这里插入图片描述
1、key就是添加键值对的键嘛,这个很简单

2、hashCode()方法根据key的值获取了一个很大的hashcode(该值不超过2的32次幂),
(hashCode()是调用String类中的重写方法,hashCode()是属于Object类的)

3、hashcode再通过hash方法得到了h(最终哈希值)

4、h通过h &(length - 1)与运算计算出索引值(结果与h%length相同)

问题一:为什么hashcode还要经过hash方法的处理?

答:因为根据哈希值计算数组索引时是通过位运算来运算的。我们得知这个hashcode是一个非常大的

数值,那么这个数值可能在高位(前16位)有辨识度,但在低位(后16位)是没有辨识度的,我们

必须要对hashcode进行异或运算,h = hashcode^hashcode>>>16,目的就是将hashcode高位带到低位

中去,最终得到h(最终哈希值)。

假设我们不对 hashcode进行处理,则可能低位一样的情况,用hashcode计算数组下标时,

hashcode &(length - 1),则会非常容易计算出一样的下标值,出现频繁的哈希冲突,导致链表

长度过长(以下省略N字)

举个简单的例子你就会懂了
在这里插入图片描述
结果很明显,两个a 、b的索引值其实高位(前16位)根本不一样,但由于低位(后16位)一样

导致了计算出的索引值也相同了,所以我们是不是要将hashcode的高位和低位进行一波异或运算

提高索引值不同的概率呢。

问题二:既然h &(length - 1)和 h % length的结果相同为什么采用前者而不是后者?

答:很简单,计算机相较于普通的乘除运算,对于位运算更加熟悉(0,1),所以

位运算的性能效率高。

从效率上看,使用移位指令有更高的效率,因为移位指令占2个机器周期,而乘除法指令占4个机器周期。从硬件上看,移位对硬件更容易实现,所以会用移位,移一位就乘2,这种乘法当然考虑移位了。

扩展:

除了使用h &(length - 1)这种方式计算出索引,还有哪种哈希函数呢?

除了使用链表,红黑树解决哈希冲突,还有哪些方法呢?

大家有兴趣可以参考这篇博客——https://blog.csdn.net/qq_32635069/article/details/79798741


六、HashMap扩容机制

1、触发条件

jdk1.7:

1)、put时size大于阈值且put的数组位置不为空(table[index]!=null)

jdk1.8:

1)、put时size大于阈值

2)、put时链表长度大于等于8且数组长度小于64

2、过程:

1、准备新数组,新的数组是原来数组长度的2倍

2、关于JDK版本:扩容时哈希值的重新计算问题:

1)、jdk1.8之后扩容hash值不会重新计算,而是重新计算索引下标

2)、jdk1.7时hash值可以重新计算,但要符合一定条件,但一般不会生效。

所以1.7扩容一般hash值也不会重新计算,而是重新计算索引下标

3、结果:

1、HashMap新的数组是原来数组长度的2倍

问题一:扩容时jdk1.8如何计算索引下标(jdk1.7使用仍是使用 hash&(length-1))

答:

我们知道计算索引下标的公式为 hash&(length-1)

但这里使用了一种更为巧妙,效率更高的方法——比较,判断得到新数组的索引

PS:其实这种巧妙的方法是利用了 hash&(length-1) 公式的计算特点(继续往下看就知道了)

1)、h & length == 0,则该节点在新数组中仍在旧索引值处

2)、h & length != 0,则该节点在新数组中的旧索引 + 旧容量的位置

为什么这样判断就可以定位原节点在新数组中的索引下标呢?

举个例子就很容易懂了(使用 hash&(length-1) 来寻找规律(滑稽))
在这里插入图片描述

可以观察到,新索引25确实是旧索引9 + 旧容量 16,但这取决于旧容量×2 - 1后进一位的位置且索引相同

的位置是否有1,如果没有,很可惜,即使是扩容了仍是被分配到相同的位置,如果有1,

则被分配到旧索引+旧容量的索引位置。

那么 h & length == 0h & length != 0 这两个判断语句的意图是否就很明显了呢?

它们的目的就是利用 length 相对于 length -1 二进制数高一位的特点来判断哈希值的对应的位

是否为1呀。

七、HashMap添加数据put机制

jdk1.7:

选用头插法原因:

1、数据插入在头部,速度快(尾部需要遍历)

2、可以最快访问新插入的热点数据(但扩容后会打乱)

3、多线程扩容时会出现死链(无线循环)

jdk1.8:

选用尾插法原因:

1、不存在(解决)多线程扩容使用头插法可能会出现死链


要了解死链(死循环),我们必须先了解什么是头插法:

1)、将要移动的节点的next指向数组桶处

2)、再将节点覆盖数组桶处(向下移动)

3)、以此类推。
在这里插入图片描述

死链图示

1、线程一与线程二同时进行put操作,e1、e2,next1、next2引用分别指向节点1与节点2

2、线程一开始运行,线程二此时被挂起

3、线程一运行完毕,线程二此时开始运行。此时e1仍指向节点1,next2仍指向节点2

4、扩容时,节点1、节点二继续头插法,出现==死链现象==
在这里插入图片描述

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值