引言
唉!
金九银十转眼已过,
面试现场不知所措;
不懂原理是我过错,
今日弥补只为求过。
======================================================
小学弟呀,小学弟!
平时不努力,
全来学押韵;
近来找工作,
处处不如意;
闲来送你一篇原理文,
让你对HashMap不再愁。
=======================================================
话不多说,就让我们开始HashMap的原理讲解吧。
正文
Hash表
HashMap其实就是用Hash表这种数据结构来实现的;如果你对Hash表了如指掌,我相信,对于HashMap你已经成功一半了。那我们就先来说说什么是Hash表吧。
先来举个例子:
在上学时我们都排过队,当老师没有做要求的时候,大家都会随便站,关系好的站在一起等等没有统一规则,甚至还有插班的行为。当一个老师从这个长长的队列时找人时,往往都是从第一个开始找,直到找到他想找的人为止。这种方式就可以看做链表或者数组,每次都需要逐个遍历筛选。
这时候教导主任来了,说这样排队太乱了,于是制定了排队规则,必须按照年级+班级+自己的序号顺序站队并记住这个序号。当老师再次找一个陌生的同学时,只要对教导主任说我要找二年级五班006同学,教导主任通过信息就可以直接找到张三同学站的位置,并不需要遍历了。这种方式就是Hash表的方式进行添加或者查找的。
我们来解释一下上面的例子:
例子中所提到的二年级五班006就是Hash值:根据key值(例子中的某个学生)计算得出一个固定长度的目标值,而这个目标值就叫做Hash值;
教导主任所制定的排队规则就是所制定的一个Hash函数:用key怎么计算Hash值的呢?就是通过Hash函数的计算;在项目中每一个hash函数的实现都是不一样的,而大部分hash函数的具体实现是非公开的,因为可能会造成安全问题。
简单的来说,哈希表是一种表结构,我们可以直接根据给定的key值计算出目标位置。在工程中这一表结构实现通常采用数组。
那么一个好的Hash函数具备什么样的特性呢?
1,同一性:也可以叫做散列性,即每一个Hash值都要尽可能的均匀;因为存储在数组时要尽可能的均匀存储。(也可以说每个数组下标的命中率尽可能相同。)
2,遵循雪崩效应:当有微小的key值输入时,Hash值要发生巨大的变化;有些Hash函数是为了进行加密使用的(如https协议)遵循这条可以保证更高的安全性。
3,尽量避免Hash冲突:当我们要把十个苹果放到九个抽屉里的时候,总会有至少两个苹果放到同一个抽屉里(抽屉原理),存在两个苹果的抽屉,我们就可以叫它Hash冲突,或者Hash碰撞;同样因为我们Hash值的长度是固定的,而key可以是任意值,所以总会造成两个key值的hash值相同,这种情况叫做Hash冲突,Hash冲突是不可避免的,但我们要尽可能的减少冲突。
现在看看我们例子中教导主任制定的Hash函数是不是可以被我们吐槽一番呢。
所以,Hash表他的查询速度快,一个良好的Hash表时间复杂度可以达到o(1),对于庞大的海量数据,这种查找速度还是非常惊人的。
HashMap原理
JDK1.7
了解了Hash表,我们再来看HashMap,我们先来分析JDK1.7中的HashMap的实现:
JDK1.7中的HashMap采用的是数组加链表的形式进行存储的,数组每个下标所对应的位置我们都可以叫做一个hash桶。
put过程:
public V put(K key, V value) {
if(key == null) //1
returnputForNullKey(value);
inthash=hash(key); //2
int i = indexFor(hash, table.length); //3
for(Entry e = table[i]; e != null; e = e.next) {//4
Object k;if(e.hash ==hash&& ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value; e.value = value; e.recordAccess(this);returnoldValue;
} } modCount++; addEntry(hash, key, value, i); //5
returnnull;
}
1,key不能为空:
在存储元素前,HashMap将判断元素的key是够为空,如果为空,直接返回。
2,计算Hash值:
finalinthash(Object k){
inth =0;
if(useAltHashing) {
if(kinstanceofString) {//2.1
return sun.misc.Hashing.stringHash32((String) k);
}
h = hashSeed;
}
h ^= k.hashCode();
// 2.2
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
通过hash源码我们看到:
2.1,这里JDK1.7对String类型的元素进行了重新hash的过程,为什么String要重新hash呢?
要从一篇邮件开始说起...在JDK1.7出现后,Tomcat发布了一篇邮件,里边写到了JDK1.7中HashMap的一个潜在安全漏洞。
mail-archives.apache.org/mod_mbox/to…4EFB9800.5010106@apache.org
这篇邮件中,因为Tomcat使用了Hashtable存储了HTTP请求参数,由于String的hash算法是公开的,可能会导致一些专业人员可以获取到无数个hashcode都相同的key值,当这些请求参数同时请求后,就会使hash表退化成链表,使cpu存在大量的链表,由于链表查找的时间复杂度O(n),查找速度会受到很大的影响,所以会遭受DDos攻击。
当然Tomcat也给出了解决方案,而随后在JDK1.7的版本后也修复了这个bug。
2.2,我们看在2.2处进行了一堆的>>>和^操作,原因就是为了要增加它的同一性,让命中每个数组下标的概率相同。
3,获得数组下标:
staticintindexFor(inth,intlength){
returnh & (length-1);
}
在步骤2中,我们得到了Hash值,那么怎么通过hash值来决定存在哪个桶里呢?
可能有些人我们会想到取余操作,但是取余操作有两个缺点:1,相比于java的&运算速度相对慢一点;2,负数取余还是负数,数组下标(桶的位置)不能为负数。
所以Java采用的是按位与,这样就可以根据数组长度和Hash值计算出该元素桶的具体位置。
采用这种方式计算数组下标也是有损失的,如果length不为2的次幂,那么每次按位与时,为零的那一位永远为零,导致有些下标永远无法得到,如下图。
HashMap为了解决这个问题,如果你的值不为2的幂,那么在构造函数中HashMap将找到大于等于我们赋给HashMap的初始容量的最小2的幂。所以为了减少这次转换,我们初始化HashMap的容量时一定要为2的幂。
4,我们接着向下看注释4的地方:这里就是做了一个循环链表,去插入元素这么一个操作。
5,我们能看到这里调用了一个addEntry()函数,这个函数是干什么的呢,我们点击去:
voidaddEntry(inthash, K key, Vvalue,intbucketIndex){
if((size >= threshold) && (null!= table[bucketIndex])) {
resize(2* table.length);
hash = (null!= key) ? hash(key) :0;
bucketIndex = indexFor(hash, table.length); } createEntry(hash, key,value, bucketIndex);
}
我们大致能了解到,这个其实就是扩容机制,知道这些就可以了,接着我们讲讲具体是怎么扩容的。
threshold:即阔值,当数组的使用大小大于等于这个值时,则将数组扩容。threshold = capacity(数组容量)* load_factor(负载因子)
load_factor(负载因子):默认为0.75,官方解释说:在时间和空间成本之间做了权衡后,得出的0.75这个值。
进入到if后,执行了resize()这个函数,这个函数可以说是一切根源的罪魁祸首。
我们点resize()进去看看
voidresize(intnewCapacity){
Entry[] oldTable = table;intoldCapacity = oldTable.length;
if(oldCapacity == MAXIMUM_CAPACITY) {//MAXIMUM_CAPACITY默认是2的30次幂,所以基本不会到达这一容量
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM.isBooted() &&
(newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean rehash = oldAltHashing ^ useAltHashing;
transfer(newTable, rehash);
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
voidtransfer(Entry[] newTable,booleanrehash){
intnewCapacity = newTable.length;
for(Entry e : table) {
while(null!= e) {
Entry next = e.next;if(rehash) {
e.hash =null== e.key ?0: hash(e.key);
}inti = indexFor(e.hash, newCapacity);
e.next = newTable[i]; newTable[i] = e; e = next; } } }
这两个方法主要是rehash操作,然后将数组中的链表使用头插法重新插入到了新的数组中,其实这在单线程中是没有问题的,但是如果多个线程同时操作的时候,将会出现循环链表的情况。当再次访问这个循环链表时,就会出现死循环,cpu100%的情况,虽然HashMap中明确表示它并不是线程安全的,但还是有人会用到它,造成这样的问题。
这里给大家分享一篇文章,里边明确说明了为什么会产生循环链表:coolshell.cn/articles/96…
这就是JDK1.7中的HashMap的put过程,接下来我们简单说一说get过程:
publicVget(Object key) {
if(key ==null)
returngetForNullKey();
Entry entry = getEntry(key);returnnull== entry ?null: entry.getValue();
}
这是JDK1.7的get方法,当key为空时返回null,否则调用getEntry()函数:
final EntrygetEntry(Object key){
inthash = (key ==null) ?0: hash(key);
for(Entry e = table[indexFor(hash, table.length)];
e !=null;
e = e.next) { Object k;if(e.hash == hash &&
((k = e.key) == key || (key !=null&& key.equals(k))))
returne;
}returnnull;
}
就是计算key的hash值,然后循环该桶下的链表,查找数据。
JDK1.8
上面我们说到,JDK1.7的HashMap存在着安全隐患,如多线程下会可能出现循环链表(这完全是用户自己的锅,因为Java团队明确表示HashMap不是线程安全的)。
所以JDK1.8的HashMap在结构上使用了数组加链表加红黑树的数据结构,防止当链表过长时导致搜索时间退化到o(n)。
当链表的元素达到了8的时候,将会使用红黑树代替链表,选择8的原因是因为Java团队解释说:
大致上说,他们采用了泊松分布的原理(大学数学我们应该都学过),当链表达到8时只有0.00000006的概率。
在hash()函数上也做了改进:
staticfinalinthash(Object key){
inth;
return(key ==null) ?0: (h = key.hashCode()) ^ (h >>>16);
}
另外一个比较明显的改进是在resize()这个函数中从之前的头插法改为了尾插法,这样在多线程的情况下就不会存在循环链表,进而导致死循环的情况了。但是值得注意的是,HashMap任然不是线程安全的,这在HashMap注释中就已经明确说明了。
结束
这就是HashMap的比较重要的部分,也是面试过程中在问到HashMap时比较常见的问题。如果还有什么没有提到的,大家可以在评论中告诉我,我会尽量补充的。
作者:张子江
链接:https://juejin.im/post/6880712401143595021
来源:掘金
网友评论