java基础之hashmap及其衍生
HashMap及其衍生
0、流程图
首先上来直接上一下hashmap的put流程,然后按照put流程来进行重点分析
所有的重点所在的颜色区域都已经将其标注在了流程中。
下面来分别演示其中的变化
1、确定数组大小是2的n次幂
在使用构造函数来进行创建的时候,判断有没有指定对应的数组长度;如果没有,那么默认是16,加载因子默认是0.75;如果是自己指定的话,那么将会按照下面的规则来进行操作:
/**
* Returns a power of two size for the given target capacity.
*/
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
注释直接说明,返回的容量必须是2的n次幂。在对手动传入的cap容量来说,会进行五次位运算。这样子操作的目的就是为了得到距离当前传入数字最近的2的n次幂而已。
这样子操作的目的在这里先不来解释,下面将会来对其进行说明。
2、计算key的哈希值
在进行put的时候,首先会通过key的hash算法来对key进行计算hash值;
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这里的hashCode运算非常奇特,就是要将一个整形数字的32个比特位和低16位的比特位来进行比较,最终得到一个整形的哈希值。
为什么要这样子来进行操作呢?
首先对于一个Object类型的hashCode来说,是随机分布的,因为返回值是int类型,那么对应的范围应该在负的21亿到正的21亿之间,也就是说大概有42亿的数据,但是对于内存来说,很显然不可能会容纳下这么长的一个数组。所以唯一的办法就是尽可能地缩小数组的长度并且能够容纳更多的数据,这是设计的目标。
所以在链表中做了手段,要让每个桶下面的链表尽可能的一样长,但是链表过长,那么查询效率就会变低,所以引入了红黑树结构来降低查询的时间复杂度。由原来的O(n)降低为log2(n)。这是数组+链表+红黑树设计的初衷
那么为了让上面的设计实现,那么应该怎么样来进行操作?首先应该让每个桶中的分布尽可能的均匀,均匀起来尽可能的减少hash碰撞,尽管相对来说,在不同链表上的碰撞效果是类似的,但是这样子对于所有链表来说,是均匀散列的。
在JDK7中的设计:
bucketIndex = indexFor(hash, table.length);
static int indexFor(int h, int length) {
return h & (length-1);
}
这样子来进行设计的话,举个例子来进行说明:
11111111 11111111 11111111 00010100
00000000 00000000 00000000 00001111 &
00000000 00000000 00000000 00000100
那么如果只是这样子来进行计算的话,也就是说仅仅只是比较了一个key的hashcode的后四位数字而已,最终索引会是index=4。这样子的操作,一旦key的哈希值形成了等差数列之后,那么将会导致大部分的元素都落在了同一个位置。
这样子的操作是有可能发生的,但是为了避免,采用了新的操作方法来进行操作
还是上面的列子,如果采用了jdk8中的方式:
11111111 11111111 11111111 00010100 >>16
00000000 00000000 11111111 11111111
向右移动了16位之后,将会得到新的值,然后让32位的和低16的来做运算
11111111 11111111 11111111 00010100
00000000 00000000 11111111 11111111 &
00000000 00000000 11111111 00010100
让32位和低16位来进行运算的目的就是避免类似第一种情况中出现的问题
优化之后的好处:
1、尽可能在数组中均匀分布;
2、尽可能少的减少哈希碰撞;
当然,上述结构是有数学专门来进行证明过的。
注意:key为null的将会放在数组中下表为0的位置上,这是hash算法规定的。
3、确定在数组中的位置
当将计算好的哈希值获取得到之后,需要经过计算在数组中的索引位置,那么这里也是重头戏
(n - 1) & hash
n为table.length,hash为对象的哈希值。
上面对象的哈希值拿下来进行运算:
15 00000000 00000000 00000000 00001111
00000000 00000000 11111111 00010100 &
00000000 00000000 00000000 00000100
对应的索引为index=4,落在了数组中下标为4的位置。
那么从这里可以看到索引的位置是在0~15之间,而我们的数组长度大小刚刚是16
这里正好能够解释为什么数组的长度要是2的n次幂了。因为数组长度是n次幂来说,做了一次-1操作之后,就相当于是做了一次低位掩码,让"&"操作的时候,高位全部是0,而低位可以和key的hashCode来进行计算。
这里是和扰动函数更好的结合起来来做一下操作,目的就是为了让在hashmap中的元素分布变得更加均匀。
扰动函数也就是对对象key的hashCode算法进行优化之后,联合计算对象在数组中的位置,让其更加均匀而已。
4、jdk7和8的优化
总结起来有四点不同的地方
1、将7中的数组+链表改成8中的数组+链表+红黑树结构;
2、将7中的头插法改成了8中的尾插法;
3、扩容变动。7中的扩容是重新rehash一次重新散列在数组位置上;8中的扩容只有两步:要么在原来位置上,要么在原来位置上+旧容量大小;
4、插入操作中,7中是先判断,再插入;8中是先插入后判断;
下面分别来进行解释:
1)、增加了红黑树是为了将查询链表时候的时间复杂度由原来的O(n)变成log2(n),这是为了降低复杂度的;
2)、7中的头插法在扩容阶段容易形成环
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;//A线程如果执行到这一行挂起,B线程开始进行扩容
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
总结一下这里的过程:
0:获取得到每个桶,然后对每个桶来进行遍历;
1、获取得到第一个元素next,拿到key进行哈希运算,然后计算出来在数组中的位置;
2、然后newTable[i]中的元素指向了对应下标中的元素;
3、首先将数组中的第一个元素连接到新的数组上面去,然后将数组下标的元素对应上去;
画个图看下这里的流程:
上面演示的是在单线程下,扩容是没有问题的。
但是下面将会在多线程中来进行演示对应的效果:
第三轮循环将会导致形成循环链表的效果这里回头把图给补上。
这里最终导致的效果就是形成了循环链表,而再次进行put操作的时候,将会导致的是在for循环中出不来。
但是jdk8中的hashmap也会出现问题:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null) //多线程执行到这里
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold) // 多个线程走到这,可能重复resize()
resize();
afterNodeInsertion(evict);
return null;
}
3)第三点,扩容阶段jdk7采用的是重新进行哈希运算,而jdk8中只采用两种方式:原位置或者是原位置+旧数组长度
关于jdk7的这里就不再来进行说明,下面直接看jdk8的计算方式。
因为扩容会扩容到原来数组的二倍,而数组长度对应的二进制位只会向左边移动一位,那么就会导致有两种效果。
如果原来扩容之前的位置是1,将会导致位置发生改变;而原来的位置是0的话,将会导致位置不发生改变。
这里具体是由对应的算法导致的。
下面来一个例子来进行说明:
00000000 00000000 00000001 11110111
00000000 00000000 00000000 00001111 &
00000000 00000000 00000000 00000111 // 7
在原来的下标中Index=7,那么扩容之后
00000000 00000000 00000001 11110111
00000000 00000000 00000000 00011111 &
00000000 00000000 00000000 00010111 // 7+16=23
那么扩容之后的索引下标会为原来索引位置+旧的数组长度上
5、解决hashmap线程不安全的情况
使用ConcurrentHashmap来进行解决比较好。使用分段锁+syncronized和cas算法来进行操作,降低锁的节点的力度,在多线程条件下能够保证线程安装。
这里将会是我们进行查看的重点。
这里又涉及到了syncronized关键字以及对应的CAS原理等操作,是我们应该重点进行掌握的
6、有序的map
LinkedHashMap和TreeMap
但是两种方式又有点不同,对于LinkedHashMap来说,是由头结点和尾结点来结合来进行组成,其结果就是存取一致,
但是TreeMap中的有序按照排序规则有两种:
1、按照key默认的比较方式;
2、我们可以自定义对应的Compare接口来对自定义比较顺序;
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?