小声哔哔HashMap

734 阅读5分钟

本人作为掘金社区的一个小萌新,在翻看各路大神写的技术博客时,佩服其知识储备之丰富,羡慕其文笔之潇洒,于是萌生了俺也试试的想法。鉴于初次撰笔,若有不足,烦请各位看官多多见谅,在评论区留下您宝贵的意见,不胜感激。

好了,废话不多说,直接进入今天的主题。。。。

HashMap

1)存储键值对(key-value)类型的数据

2)线程不安全

3)jdk1.2出现

在jdk1.7及之前HashMap使用的是数组+链表来实现的,在jdk1.8之后增加了一个红黑树的结构。

Node对象作为HashMap存储数据的一个基本单元,它里面的内容大概包括:

static class Node<K,V> implements Map.Entry<K,V> {    
final int hash;    //hash值
final K key;
V value;
Node<K,V> next;//指向下一个结点

所以这个hash值是个什么东西?

/*
散列函数
*/
static final int hash(Object key) { 
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

所谓的hash值不过是将key的hashCode和它自身的高十六位做异或运算(相同为0,不同为1)罢了,这样做的好处是让key的高位也参与到运算中,从而使得到的hash值更加散列,减少哈希冲突。

在HashMap的寻址过程中首先经过以上散列函数计算得到hash值,再将hash值和table.length -1 做与运算(只有同为1,才为1)得到在数组中存放的索引。

index =  hash & (table.length -1)

等价于:hash % table.length

但是&运算比%运算效率更高,所以选择&

在HashMap中数组table的长度为什么一定是2的幂次方?

/*
计算数组长度的函数
*/
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;
}

上述方法返回的结果一定是比传入的cap大且离它最近的一个2的幂次方的一个数,不信的小伙伴可以自己动手计算一下。。

链表何时转红黑树的?是不是像传闻中所说达到树化阈值8就转为红黑树?

/*
树化函数
*/
final void treeifyBin(Node<K,V>[] tab, int hash) {    
int n, index; 
Node<K,V> e;    
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)  //64      
    resize();    
else if...

在源码中可以看到,当链表节点数满8触发树化操作后,首先是进行一个判断(上述代码),只有在数组长度满MIN_TREEIFY_CAPACITY(最小树化容量64),才会进行树化,否则会进行扩容而不是树化。

为什么TREEIFY_THRESHOLD = 8 ,UNTREEIFY_THRESHOLD = 6  ?

红黑树的查找效率不是比链表高吗?为什么不干脆放弃链表结构?我觉得应该是在查找效率和转树结构的时间上做的一个权衡吧,太早转成树花费时间太多得不偿失,超过8继续使用链表又太拉胯,选择6和8,中间还有个差值7可以有效防止链表和树频繁转换,假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

put操作:

①.判断键值对数组table[i]是否为空(长度为0)或为null,否则执行resize()进行扩容

②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③

③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals

④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤

⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可

⑥.插入成功后,判断实际存在的键值对数量size是否超扩容阈值threshold,如果超过,执行resize()扩容

ps:在jdk1.7及之前,使用的是头插法,弊端:在转移数据、扩容后,容易出现链表逆序的情况,在高并发put的情况下,可能会出现环链,下次遍历会造成死循环;jdk8之后改用的尾插法,能避免这种情况。

resize():

触发条件:1.第一次进行put操作时 2.当元素数量达到扩容阈值(capacity * loadFactor)

过程: 1.判断,极端条件下(数组长度已达最大值 MAXIMUM_CAPACITY = 2^31 -1),则不再扩容 2.创建一个长度是原数组长度两倍的newTable。 3.保存旧数组 4.重新计算每个数据在新数组中的位置 5.将旧数组上的数字转移到新的数组中 6.重新设置扩容阈值。

ps:关于负载因子loadFactor,默认等于0.75,可以更改,但是不建议改,毕竟是官方给出的最优解。。。改大了,该扩容的时候不扩容,增加哈希冲突的次数;改小了,又会频繁扩容损失性能。

怎样让HashMap变得线程安全?

1.改用Hashtable,hahaha。。。开玩笑,这位老兄每个方法都用synchronized无脑加锁,效率实在太低。

2.使用Collections类的synchronizedMap方法返回一个线程安全的map。

3.改用ConcurrentHashMap(推荐)