HashMap相关知识点

HashMap相关知识点(正在更新中…)

先从HashMap的组成:数组+链表 来分析:
分析时从

内存占用
访问查找
插入删除
数据缓存

这四个点来分析数组和链表的区别

数组:
1.内存占用

数组在内存中的存储是连续的,是一段连续的内存空间,在内存中使用比较高效。

2.访问查找

因为数组是一段连续的内存空间,所以查询时可以通过索引直接访问元素,其时间复杂度为O(1),为什么是O(1)?
举例为:
假设有5个整数的数组A:{10,20,30,40,50} ,每个整数占用4个字节的内存空间(32位字长机器下),
我们想获取第n个元素,只需要按照以下步骤计算:

  1. 获取 A数组 第一个元素内存地址,即A[0]的内存地址,假设为1000.
  2. 假设想获取第3个元素即A[2],其元素地址的计算方式即为:1000+(4×2)=1008
    原理为:先计算A[2]相对于A[0]的偏移量,也就是要跳过 2 个元素才能到达A[2],
    由于每个元素占4个字节,因此偏移量即为 4 × 2 = 8个字节,
    最后将 A[0]的内存地址加上想获取元素的偏移量就可以得到 A[2]的内存地址了。
    也就是 1000+(4×2)=1008
    因此,我们可以通过这种计算方式获取数组中任意元素内存地址,实现O(1)时间复杂度的随机访问。

然后,在ConcurrentHashMap中,用二进制的无符号左移的办法实现了上面的计算方式,也就是:

((long)i << ASHIFT) + ABASE)
其中: i 是数组的下标 也就是index
而 ASHIFT 是上面的 数组长度的二进制 左移位数的结果(表现形式)比如 数组元素大小是8 那这里结果就是 3  左移三位就是 * 8 ;
计算方式就是 :
ASHIFT = 31 - Integer.numberOfLeadingZeros(scale);
numberOfLeadingZeros 计算方式是计算 二进制的 高位开始连续的0的个数 
其中 假设(元素大小) scale = 8Integer.numberOfLeadingZeros(scale) = 31 - 28*0 1000 = 31 - 8 = 3
也就是 0100 = 8 ~
ABASE 是上面的 1000
0000 1000 = 4 
7 - 4 = 3
比如 0001 << 3 = 1000 = 8 (为啥8变成了7 因为你左移的时候, 结果就是 会少1,不然8会多一位)
*/
3.新增删除

数组的新增删除效率低于链表,原因是新增或删除时需要移动前后元素位置,效率很低。

4.数据缓存

由于数组的元素是连续存储的,内存是连续的,这有利于CPU进行缓存预读,提高访问效率。

链表:
1.内存占用

而链表在内存的存储是非连续的,每个节点除了存着自己的元素外还额外的存着下个元素指针的地址,故占内存大。

2.访问查找

链表查找元素的时候需要从头遍历,时间复杂度为O(n),因为其内存的存储是不连续的,查询的时候只能按照第一个元素去拿到第二个元素的指针位置,要一个一个找。

3.新增删除

链表的新增删除效率高于数组,原因是新增删除操作时只需要修改元素前后的指针。

4.数据缓存

而链表的内存不是连续的,故CPU无法预测下一个节点的位置,不利于CPU缓存预读,访问效率低。

然后是HashMap的面试:

1. HashMap能放入key和value都是Null的键值对吗?

答: 能,会放入第一个index中。
源码分析:
执行的代码:

        HashMap<String, String> stringStringHashMap = new HashMap<>();
        String result = stringStringHashMap.put(null, null);
        System.out.println("看看null:" + stringStringHashMap.get(null));
        // 看看null:null
put方法源码:
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
    // 1.在put的时候,会先把咱们传的key,也就是Null进行hash()计算:
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    // 2.在计算过程中会用三目运算符判断是否为null,若为null则直接赋值为0
于是,在index为0上(也就是第一个元素上)创建了key,value都为null的键值对。

在这里插入图片描述
且如果再Put一个key为null,value为:“西瓜”,key为null的值会被覆盖成“西瓜”。

在这里插入图片描述

2. 简单分析一下第一次put()时的一些流程.

首先,put()方法中会先经历一次对key的hash(方法),里面实质上就是做了个位运算(扰动函数):

	HashMap<String, String> stringStringHashMap = new HashMap<>();
	stringStringHashMap.put("啊啊", "西瓜");
	// 下面看put的操作:
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
    // 里面有个对key的hash方法,这里面就是个二进制位运算,也就是扰动函数
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    代码的return的意思就是前面1.如果put一个key和value都为null的键值对,会默认return hash=0的数值,
    然后做 & 按位与运算,不管什么数 & 0的时候,最终结果都是0,就只放到第一个index里 & 运算计算详情看下方的 & 运算的介绍.
    三目运算的后半部分就是指的 如果key不为null:
    则进行 扰动函数: 对key的hashCode做一个 按位异或运算^ 和 无符号右移位运算>>> 详解见下:
    
  • &(按位与):当两个相应的二进制位都为1时,结果为1,否则为0。例如,2(10进制)& 3(10进制)= 2(10进制),其二进制表示为 0010 & 0011 = 0010。

  • |(按位或):当两个相应的二进制位中有一个为1时,结果为1,否则为0。例如,2(10进制)| 3(10进制)= 3(10进制),其二进制表示为 0010 | 0011 = 0011。

  • ^(按位异或):当两个相应的二进制位相异时,结果为1,否则为0。例如,2(10进制)^ 3(10进制)= 1(10进制),其二进制表示为 0010 ^ 0011 = 0001。

问题来了:为什么要对key的hashCode做一个扰动函数(二进制位运算)的计算?

答:原因是为了解决 哈希冲突。

  • 哈希冲突 指的是在哈希表里的 不同的键值对(key)的出现了同样的hash值,导致存储的时候多个键值对 放到了同一个key里面,导致哈希表的查询性能降低。
  • 而扰动函数的作用是降低哈希冲突的概率,怎么实现的?
  • 首先,在Java里,解决哈希冲突的方案之一就是采取 拉链法,在这种解决方案里,哈希表的一个槽位(index)里面放了一个链表/红黑树,相同的hash值的键值对会放到同一个槽位下的链表/红黑树中。这样的话,当hash表里多个键值对有相同的hash值时,只需要遍历此槽位 的链表/红黑树就可以找到对应的键值对对象。
  • 而配合着拉链法使用的扰动函数目的是为了预防某些场景下,比如一个长度为8的hashMap,一共就放了8个键值对,而这8个键值对的hash值分别为3,11,19,27,35… 如果不经过扰动函数直接放到hashMap中,会导致这8个键值对全放到同槽位为3的链表中,导致hashMap的查询性能变差,变成了链表的慢查询。
  • 而扰动函数的存在就是为了解决此问题而生,它将上面传过来的 8个即将放到槽位为3的键值对的 key 的hash值进行了一个 按位异或运算^ 和 无符号右移位运算>>> 也就是:

(h = key.hashCode()) ^ (h >>> 16);

    // 里面有个对key的hash方法,这里面就是个二进制位运算,也就是扰动函数
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

这样的话,这些hash值经过扰动函数的计算后,会变成-1412916026,2117644378,-1593507462(类似于这种夸张地改变)。变成和原来大相径庭的hash值,这样做取模算法后再往槽位放键值对时,就能均匀随机的放到8个槽位中,优先实现接近数组的查询速度。

问题来了,扰动函数为什么要做 按位异或运算^ 和 无符号右移位运算>>>

先说无符号右位移运算,再说 按位异或运算:

(h = key.hashCode()) ^ (h >>> 16)
:(h >>> 16)
假设key是"啊啊",hashCode的值为698688,二进制表现为0000 0000 0000 1010  1010 1000 0100 0000
右移16位即:0000 0000 0000 1010  其十进制的值为10
此处的无符号右移16>>> 16 的目的是将32位的二进制的高位(16) 变为 低位(16)。

然后再做 按位异或 ^ 运算 ,最后就是将 699688的二进制 直接将 高位16位与低位16位 做了 按位异或 ^ 运算:
 	 
也就是 0000 0000 0000 1010  1010 1001 0100 00000000 0000 0000 0000  0000 0000 0000 1010 做按位异或 ^ 运算
结果为 0000 0000 0000 1010  1010 1001 0100 1010
十进制的结果是 698698 原来的hash值为698688经过扰动变为了698698

目的是最大化的将 前面这种 8个键值对的hash值分别为3,11,19 都存在槽位为3的index上的 情况给避免掉,
这样这样计算后,即便再遇到这种 规律的多个存在某个槽位的哈希值的时候,经过扰动函数的计算,
就大大降低了放在同一个槽位的可能性,就能降低hash冲突,为接下来的取模运算做准备。

取模(路由寻址算法):(table.length -1)& node.hash

也就是 (16 - 1) & 698698

为啥会有 length-1 的计算呢? 16 是 0001 0000 -1 就是 0000 1111 做& 运算, 这样谁来和他 & 都是在最后的这1111里也就是15以内去出结果
因为数组是从0的下标开始计算的 长度为16的数组,他的下标从0开始,也就是0-15
所以取模计算才会-1 ,不然直接算出个不存在的16 也就是17,直接数组越界

用二进制来算就是:

0000 0000 0000 0000  0000 0000 0000 1111
&
0000 0000 0000 1010  1010 1001 0100 1010
=
0000 0000 0000 0000  0000 0000 0001 1010
也就是10 即放在第10个槽位中

在后面的扩容方法里resize(),还有一个与此 路由寻址算法相对应的一个计算:

							if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
(e.hash & oldCap) == 0

这个计算和上面的 (table.length -1)& node.hash 有啥区别呢?
(e.hash & oldCap) == 0 缘由是在扩容的时候,判断这个hash值在扩容后,是否还在原来的index位置上。
比如 数组的长度由原来的16 扩容到了32 ,即二进制从 0001 0000 扩容到了 0010 0000
前面的寻址算法里,是把 长度-1 也就是 0000 1111去做了& 运算 ,
而当长度是32位的时候,长度-1 就变成 0001 1111 会继续做& 运算进行寻址公式,
注意,这里只是 数组的长度发生了变化(扩容了),而原来的 key的Hash值却从未改变。
于是乎:

展示一下 同一个hash值在面对两个不同长度的数组时,寻址算法出来的结果是多少:
# 1、展示 698714 & 15
0000 0000 0000 1010  1010 1001 0101 1010
&
0000 0000 0000 0000  0000 0000 0000 1111
=
0000 0000 0000 0000  0000 0000 0000 1010
也就是10

而当扩容后 698714 & 31
0000 0000 0000 1010  1010 1001 0101 1010
&
0000 0000 0000 0000  0000 0000 0001 1111
=
0000 0000 0000 0000  0000 0000 0001 1010
也就是26

这里的计算其实是为了说明, 当扩容后,2的倍数 由24次方变为25次方的时候, 
24次方扩容为5次方时导致二进制左边的那一位(从右数第5)0变成了1, 
当此时,上面的hash值, 刚好第5位也是1 ,本来16位长度-1 按位与运算& 的时候,
二进制只是1111,(只有两个与运算的二进制位都为1 的时候,才会算1,否则默认算0),这时候 hash值再大,
做按位与运算 & 的时候,hash值的其他高位,不参与运算,因为&的那个15 (0000 1111)其他高位对应都是0,最后结果也都是0。

但是现在现在高位(第五位)已然从0变成了1,也就是 0001 0000,再做&运算的时候,结果也会随之而改变, 
也就表示 这个hash值,不再放入这个index中,也就不再放到10里面,反而是去放到新的index为26的数组中去.

所以也就会有判断 (e.hash & oldCap) == 0 这里:
如果 为true 就说明即便扩容时,与hash值与新的 数组长度进行比对,也不会影响数组所在的位置。

举例: 32-1 = 31, 也就是 0001 111116, 也就是 0001 0000
	他们的第五位都是1(e.hash & oldCap) == 0 运算时(为true):
0000 0000 0000 0000  0000 0000 0001 1111
&
0000 0000 0000 1010  1010 1001 0100 1010
=
0000 0000 0000 0000  0000 0000 0000 1010
其结果,仍旧是0000 1010 也就是 10。
这样的话,在扩容后,继续做寻址操作时,此Hash值的数组位置就还是原来的10。
问题来了,怎么知道数组的扩容是怎么扩容的? 为啥本来是 24次方 = 16 下一次扩容就变成了 25次方 = 32?
原理在:
newCap = oldCap << 1
也就是 16 << 1 = 32 
0001 0000 << 1 (各二进制全部左移1)
=
0010 0000
也就是32

然后刚好配合了(e.hash & oldCap)这块儿代码去进行判断 e.hash &(table.length -1)
区别一个是hash值高位(第五位)1 ,但 length-1 只有0 1111 (高位为0),16)
而当扩容的时候: 扩容后的length - 1 就变成了 		 1 1111 ,32)
做寻址操作,因为hash值不变,这里扩容就看是否 hash值的高位(第五位) 是否也是为1 ,如果为0 ,那与 
0001 1111
&
1110 1010
结果仍旧是 101010。
然后为什么反而是去用oldCap也就是16去做计算呢,因为 
16 	  << 1 = 32 = 0010 0000   (扩容后)
length - 1 = 31 = 0001 1111   的第五位
=
16			    = 0001 0000 第五位与扩容后的length-1 的第五位是相同的(一定)
所以通过这个计算,其实等价于 扩容后的寻址计算的判断是否在当期index位置的条件 。
或者说 当 (e.hash & 16) == 0 时,第五位高位一定是 0 1010 然后再做后面的32-1&计算的时候 
0 1010 
1 1111
结果 一定是 0 1010 不变 10 
也就等同于 e.hash &32 -1)的值 = 10
为啥可以用 oldCap去做等价于 (oldCap << 1) -1 的事情呢?
本质就是 oldCap的最高位  与 (oldCap << 1) -1 相等(且长度也相等) 
而做与运算是只看hash值的那一位(oldCap/((oldCap << 1) -1)的最高位) 是否为0还是为1 
然后做 & 运算 ,只有都是1的时候结果值才会变.
只要为0 即和((oldCap << 1) -1)的寻址后的值相等
只要为1 则需要更换index的值了..

在这里插入图片描述

在这最终里插入图片描述
这样,就讲完了put()的将node放在哪个数组节点的逻辑了。

HashMap扩容的本质是以空间换时间来获取查询性能的提高。

数组创建时默认的大小为 16位

    /**
     * The default initial capacity - MUST be a power of two.
     * 0001 << 4 -> 1000 = 16
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

ps

1. 为啥数组的长度都要是2的n次方?

为了方便hash寻址
比如3 % 8 =3

公式: n % m = n & (m - 1) 也就是按位与运算 代替取模运算 提升hashMap的性能
0011
&
0111
=0011 =3

有个计算方式是如何判断参数是否是2的n次方
(n & (n-1)) == 0
怎么算的?

16 & 15 :
1 0000
&
0 1111
=0
倘若: 
15 & 14 =
1111
&
1110
=1110 = 14 不等于0 于是不是2的次方
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值