HashMap底层原理

HashMap底层原理

# << : 左移运算符,num << 1,相当于num乘以2 低位补0 
举例:3 << 2 
将数字3左移2位,将3转换为二进制数字0000 0000 0000 0000 0000 0000 0000 0011,然后把该数字高位(左侧)的两个零移出,其他的数字都朝左平移2位,最后在低位(右侧)的两个空位补零。则得到的最终结果是0000 0000 0000 0000 0000 0000 0000 1100,则转换为十进制是12。 
数学意义: 
# 在数字没有溢出的前提下,对于正数和负数,左移一位都相当于乘以2的1次方,左移n位就相当于乘以2的n次方。
------------------------------------------------------------------------------------------------------
# >>: 右移运算符 
举例:11 >> 2 
则是将数字11右移2位,11 的二进制形式为:0000 0000 0000 0000 0000 0000 0000 1011,然后把低位的最后两个数字移出,因为该数字是正数,所以在高位补零。则得到的最终结果是0000 0000 0000 0000 0000 0000 0000 0010。转换为十进制是3。 
数学意义: 
# 右移一位相当于除2,右移n位相当于除以2的n次方。这里是取商,余数就不要了。
举例   
	-100带符号右移4位。
                   -100原码:   10000000    00000000    00000000   01100100

                   -100补码:    保证符号位不变,其余位置取反加1

                                11111111    11111111    11111111   10011100

                   右移4位   :   在高位补1

                                11111111    11111111    11111111   
                                11111001

                补码形式的移位完成后,结果不是移位后的结果,要根据补码写出原码才是我们所求的结果。其方法如下:

                    保留符号位,然后按位取反

                                10000000    00000000    00000000    00000110

                    然后加1,即为所求数的原码:

                                10000000    00000000    00000000    00000111

                    所有结果为:-7                        
---------------------------------------------------------------------------------------------------
# >>> : 无符号右移,忽略符号位,空位都以0补齐 
按二进制形式把所有的数字向右移动对应位数,低位移出(舍弃),高位的空位补零。对于正数来说和带符号右移相同,对于负数来说不同。 其他结构和>>相似。
举例
     -100无符号右移4位。

                   -100原码:   10000000    00000000    00000000   01100100

                   -100补码:    保证符号位不变,其余位置取反加1

                                11111111    11111111    11111111   10011100

                   无符号右移4位   :   在高位补0

                                00001111    11111111    11111111    11111001

                   即为所求:268435449
---------------------------------------------------------------------------------------------------
# % : 模运算 取余 
简单的求余运算
---------------------------------------------------------------------------------------------------
# 按位或运算符(|)or
参加运算的两个数,按二进制位进行“或”运算。
运算规则:参加运算的两个数只要两个数中的一个为1,结果就为1。
即  0 | 0= 0 ,  1 | 0= 1  , 0 | 1= 1  ,  1 | 1= 1 。
# 例:2 | 4 即 00000010 | 00000100 = 00000110,所以2 | 4的值为 6 
------------------------------------------------------------------------------------------------------
# 异或运算符(^)xor
参加运算的两个数,按二进制位进行“异或”运算。
运算规则:参加运算的两个数,如果两个相应位为“异”(值不同),则该位结果为1,否则为0。
即 0 ^ 0 = 0  , 0 ^ 1 = 1  , 1 ^ 0 = 1  , 1 ^ 1 = 0 。
# 例: 2 ^ 4 即 00000010 ^ 00000100 = 00000110 ,所以 2 ^ 4 的值为6 。
------------------------------------------------------------------------------------------------------
# 按位与(&) and
计算方法:
参加运算的两个数,换算为二进制(0、1)后,进行与运算。只有当相应位上的数都是1时,该位才取1,否则为0
---------------------------------------------------------------------------------------------------
# 按位取反(~) not
计算方法:
参加运算的两个数,换算为二进制(0、1)后,0变1,1变0。
---------------------------------------------------------------------------------------------------
# 优先级
(~按位取反)not > (&按位与)and > (^异或)xor > (|按位或)or
### HashMap中的hash算法的实现原理
# 疑惑一:如何将元素的位置建立一种一一对应的关系?
	借鉴数组小标访问思路,查找某个元素,不需要比较,直接找到这个元素,时间复杂度直接为O(1),和集合中的元素个数就没有关系了,那么如何知道这个元素的存储位置呢???
# Hash函数的出现
	现实生活中要存储的元素(key)的取值范围一般很大,不可能为其分配无穷大的空间,不太现实,那么怎么实现存储呢?

在这里插入图片描述

	函数映射:叫Hash函数,这个Hash函数代表着一类函数,即:把任意范围的元素可以通过映射关系压缩成固定的元素
# Hash函数的选择
	我们必须使经过Hash函数后关键字的分布均匀,尽量减少冲突
	元素 % m(选取的一个素数) ==	Hash值
# Hash函数的冲突解决
	1.链地址法
	有多个元素被Hash到同一个位置,而这个位置只能存储一个元素,那么就涉及到链表,来一个元素加一个,让这个位置存储一个指针,指向下一个链表,让所有相同位置的元素都放在链表中,如果该位置没有元素,则为null(本身为空).
-- 多个元素插入,怎么分先后顺序?
	java8之前利用头插法:就是新来的值会取代原有的值,原有的值就顺推到链表中去。
--为什么进行进行插入呢?
	因为设计者想着新加入的元素很可能会被再次访问到,所以放到头的话,如果查找就不用再遍历链表了。
# Rehash
	这样解决冲突固然好,但也有瓶颈,设计的Hash函数能够将元素均匀Hash(散列)开来,但是实际存储的值越来越多,这个链表也会随之越来越长,记性查找时,会遍历链表,效率会很慢。如果链表长度远大于数组长度,则就相当于用链表存储数据!
-- 解决方案:
	数组扩容约为原来的两倍,然后选取一个相关的新Hash函数(比如改变素数的值),将旧的Hash表中所有的元素通过新的Hash函数计算出新的Hash值,并将其插入到新表中(仍然使用链表),这种方法称为rehash.
	数组的扩容,由于大小要选取素数,那么就选取约为原数组素数二倍的一个素数(如:原数组素数为3 扩容后的素数为7),旧Hash表与新Hash表采用不同的Hash函数,但相关,只是m的取值变了.
# 装载因子α
	定义一个变量,α = 所有元素的个数/数组的大小 叫做:装载因子,它代表着我们的Hash表(也就是数组)的装满成都,在这里也代表链表的平均长度.
-- 比如: 一个数组长度为5,里面存有3个元素,那么α = 3/5 = 0.6,这个Hash表装满程度为60%,平均每条链表有0.6个元素.
* 	这个装载因子代表了Hash表的装满程度,这里也可以代表链表的平均长度,也可代表查询时的时间长短.
** 为了不让查询性能低,因此设置了α的临界值为0.75,即:DEFAULT_LOAD_FACTOR = 0.75
	临界值α如果选小了,那么数组的空间利用率会太低
	临界值α如果选大了,那么冲突就很多,如:数组长度为5,α=10,那平均每条链有10个元素,装满程度为1000%
	即使Hash函数设计的合理,基本上每次存放元素的时候就会冲突,鉴于两者之间,装载因子的取值应该在0.6 ~ 0.9之间,
    最终选择为0.75.
/**源码*/
//先用key求得hash值
static final int Hash(Object key){
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//利用上述函数取得hash值后,然后(n-1)& hash
int hash = hash(key);
index = (n-1) & hash;
# HashMap的数据结构和底层原理
	1.HashMap是非常常用的数据结构,由数组和链表组合构成的数据结构
	2.HashMap是一个用于key-value的集合,在Java7中叫Entry,Java8中叫Node
	3.HashMap数组中每一个元素的初始值都是null,所以HashMap最常用的两个方法:Get和Put
--Put方法的原理
比如: 调用HashMap.put("554",0),插入一个key为"554"的元素,这时候就需要利用一个hash函数来确定Entry的插入位置index:	index = Hash("554");
假定最后计算的index=2,那么结果如下:

在这里插入图片描述

但是HashMap的长度是有限的,当插入Entry越来越多,就会出现冲突的情况,如:

在这里插入图片描述

这时候只能使用链表来解决:(头插法)

在这里插入图片描述

# HashMap数据插入

在这里插入图片描述

--Get方法的原理
使用Get方法根据key来查找value,首先会把输入的key做一次Hash映射,得到对应的index
index = Hash("554");
由于有Hash冲突,同一个位置可能匹配多个Entry,这时候就需要顺着对应链表的头节点,一个一个向来查找,假设要找的key为"apple":

在这里插入图片描述

第一步:我们查看的是头节点Entry6,Entry6的key是banana,显然不是我们要找的结果
第二步:我们查看的是Next节点Entry1,Entry1的key是apple,是我们所需结果
之所以把Entry6放在头节点,是因为使用了头插法,第二次插入的banana将apple顺推到下一个节点,HashMap设计认为:后插入的Entry被查找的可能性更大.
# Java8之后HashMap为啥改为尾部插入
	首先先看HashMap的扩容机制:数组容量是有限的,数据多次插入,到达一定数量就会进行扩容,也就是resize
-- 那么时候进行reseze呢?
	有两个因素:
		Capacity:HashMap当前长度
		LoadFactor:负载因子,默认值0.75f

在这里插入图片描述

threshold(阈值) = capacity(数组长度/容量) * loadFactor(负载因子(0.75))
bucketIndex:该键值对最后被散列到hash表的table位置
--比如:
table的初始容量为4,加载因子为0.75,此时阈值为3,table已有三个元素,现在put一个元素(1,"A"),(1,"A")被散列到table[1]处,而table[1]!=null,此时蛮足扩容条件

在这里插入图片描述

**  						阈值 = 容量 * 加载因子
**					   threshold = capacity * loadFactor
# 扩容?它是怎么扩容的呢?
分为两步:
	扩容:创建一个新的Entry空数组,长度为原数组的2倍
	ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组中
# 为什么要重新Hash,直接复制不好嘛?
	因为数组长度扩大以后,Hash的规则也随之改变
	Hash的公式:index = HashCode(Key) & (Length - 1)
# 为啥Java8以前用头插法,之后改为尾部插入了呢?
--举例:现在往容量大小为2的容器put两个值,负载因子是0.75,当我们put第二个元素的时候就要进行resize
	现在我们在容量为2的容器里用不同的线程插入A,B,C.在我们resize之前打个断点,那还没进行resize之前是这样的:

在这里插入图片描述

	因为resize的赋值方式,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上.
就可能出现如下情况:B的下一个指针指向了A

在这里插入图片描述

	一旦几个线程都调整完成,就可能出现环形链表

在这里插入图片描述

	如果这个时候去取值,就会出现————Infinite Loop.
# 头插是JDK1.7的,那JDK1.8的尾插是怎么样的?
	因为java8之后链表有红黑树的部分,可以看到代码已经多了很多if else的逻辑判断了,红黑树的引入巧妙的将原本O(n)的时间复杂度降低到了O(logn).
	使用头插会改变链表的上的顺序,但是如果使用尾插,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了.
**也就是说原本是A->B,在扩容后那个链表还是A->B

在这里插入图片描述

	Java7在多线程操作HashMap时可能引起死循环,原因是扩容转移后前后链表顺序倒置,在转移过程中修改了原来链表中节点的引用关系.
	Java8在同样的前提下并不会引起死循环,原因是扩容转移后前后链表顺序不变,保持之前节点的引用关系.
# HashMap可以用在多线程嘛?
	在Java1.8以后,即使不会出现死循环,但是通过源码可知put/get方法都没有加同步锁,多线程最容易出现的就是:无法保证上一秒put的值,下一秒get到的值还是原值,所以线程无法保证安全
# HashMap的初始长度为多少?
	初始长度:DEFAULT_INITIAL_CAPACITY(16)
	自定义长度:但是如果指定长度时,容量不能超过,MAXIMUM_CAPACITY(2^30)
# 初始长度为什么是16呢?
	在JDK1.8的源码中,1<<4就是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;   //aka 16
# 为什么用位运算符,而不直接写16
	这样为了运算方便,位运算比算数计算效率高很多,之所以选16,是为了服务将Key映射到index的算法
# 为什么选择16作为初始长度?
	因为在使用不是2的幂的数字的时候,Length-1的值使所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值
--例如:HashMap的长度如果为10,计算值为"book"的hashCode,结果为十进制3029737,转为二进制:
10 1110 0011 1010 1110 1001

在这里插入图片描述

	单看结果没有发现问题
再试一个新的HashCode 10 1110 0011 1010 1110 1011

在这里插入图片描述

再是试一个新的HashCode 10 11100011 1010 1110 1111

在这里插入图片描述

	虽然HashCode的倒数第二第三位从0变为1,但运算结果都是1001.也就是说,当HashMap长度为10的时候,有些index的值出现几率会更大,而有些index的值永远不会出现(比如0111)!
	这样显然不符合Hash算法均匀分布的原则
** 反观长度为16或者其他2的幂,length-1的值是所有二进制位全为1,这种情况,index的结果就等同于HashCode后几位的值,只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的.这是为了实现分布均匀!
# 为什么我们要重写equals方法的时候需要重写hashCode方法?
	因为在java中,所有对象都继承Object类,Object类中有两个方法:equals、hashCode,这两个方法都是用来比较两个对象是否相等.
	在未重写equals之前,继承Object类中的equals方法:
		对于对象,==比较的是两个对象的值
		对于引用对象,比较的是;两个对象的地址
	HashMap是通过key的hashCode去寻找index的,那index一样也会形成链表,也就是说当"刘佳辉"和"554"的index都可能为2,在一个链表上.
# 我们去get的时候,他就是根据key去hash然后计算出index,找到了2,那我怎么找到具体的"刘佳辉"还是"554"呢?
	通过equals,所以如果我们对equals方法进行了重写,建议一定要对hashCode方法重写,以保证相同的对象返回相同的hash值,不同的对象返回不同的hash值.不然一个链表的对象,你哪里知道你要找的是哪个,到时候发现hashCode都一样,就没办法具体指出是哪个值了.
# 如何处理HashMap在线程安全的场景?
	一般都会使用Hashtable或者ConcurrentHashMap,但是因为前者的并发度的原因基本上没啥使用场景了,所以存在线程不安全的场景我们都使用的是ConcurrentHashMap.
	Hashtable的源码,很简单粗暴,直接在方法上锁,并发度很低,最多同时允许一个线程访问,ConcurrentHashMap就好很多了,1.7和1.8有较大的不同,不过并发度都比前者好太多了

在这里插入图片描述

# HashMap常见的面试题

** 1.HashMap的底层数据结构?
	答:HashMap底层数据结构是:JDK1.7由数组+链表组成的.
							 JDK1.8由数组+链表+红黑树结构组成
**2.HashMap的存取原理?
	存:
	1.如果table 数组为空时先创建数组,并且设置相关的加载因子,和扩容阈值;注意:HashMap不是一开始就初始化的,而是在首次put的时候初始化;
	2.如果 key 为空时,调用 putForNullKey 方法特殊处理,将null的key放到hash表的头部;
	3.计算 key 的哈希值;
	4.根据 key 的哈希值和当前数组的长度来计算得到该 key 在数组中的索引,其实索引最后的值就等于 hash&(table.length-1)和 hash%table.length类似;
	5.遍历该数组索引下的整条链表,如果通过key.equals()方法对比key一经存在,那么直接覆盖 value;
	6.如果该key之前没有,那么就进入addEntry方法.下面就来看一下addEntry方法.
	7.addEntry创建entry前,要判断当前容量是否达到阈值,如果是,那么要先扩容,然后重新计算hash值,并存储
	取:
	1.首先计算key的hashcode,通过hashCode%table.length找到数组中对应位置;
	2.然后遍历指定位置的链表,通过key的equals方法在对应位置的链表中找到需要的元素,部分情况需要自己重写equals方法.所以,hashcode与equals方法对于找到对应元素是两个关键方法
** 3.Java7和Java8的区别?
				1.8主要优化减少了Hash冲突 ,提高哈希表的存、取效率。
	1.JDK1.8中resize()方法在表为空时,创建表;在表不为空时,扩容;而JDK1.7中resize()方法负责扩容,inflateTable()负责创建表.
	2.层数据结构不一样,JDK1.7是数组+链表,JDK1.8则是数组+链表+红黑树结构(当链表长度大于8,转为红黑树)
	3.JDK1.7中新增节点采用头插法,JDK1.8中新增节点采用尾插法.这也是为什么JDK1.8不容易出现环型链表的原因
	4.JDK1.8 rehash 时保证原链表的顺序,而JDK1.7中rehash时有可能改变链表的顺序(头插法导致).
	5.在扩容的时候:JDK1.7在插入数据之前扩容,而JDK1.8插入数据成功之后扩容.

在这里插入图片描述

** 4.为啥会线程不安全?
	通过源码可知put/get方法都没有加同步锁,多线程最容易出现的就是:无法保证上一秒put的值,下一秒get到的值还是原值,所以线程无法保证安全.
** 5.有什么线程安全的类代替么?
	Hashtable/ConcurrentHashMap,但是因为前者的并发度的原因基本上没啥使用场景了,所以存在线程不安全的场景我们都使用的是ConcurrentHashMap.
** 6.默认初始化大小是多少?	   为啥是这么多?			为啥大小都是2的幂?
		初始化长度16 									                       使其Hash值分布更均匀
												   减少Hash碰撞			   	 
									   	 	分配过小使其频繁扩容
											  分配过大,浪费内存

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值