HashMap的底层原理实现
hashmap继承了abstractMap,实现了map、cloneable接口,也就说hashmap实现了map相关的接口,比如put、get等接口。
数据结构
红黑树
数组
链表
put
put方法实现是putval方法,通过hash(key),key,value进行插入,也就是咱们的key会给我们通过hashmap自定义实现的hash进行hash计算,
hash(key)
如果说key为空,则返回0
获取key的hash值异或该hash值右移16位,主要是避免理想状态下的hash碰撞,右量16位也是hashmap能将性能发挥到极致的原因
1.首次创建hashmap,插入数据时候,回调用resize分配一个数组长度为16个的node数组,扩容也是在resize方法扩容
2.threshold需要调整的下一个大小值(容量*负载因子)(容量DEFAULT_INITIAL_CAPACITY=16,负载因子load_factor=0.75)
3.数组|红黑树|链表插入完成后,会去判断当前数组的长度是否大于当前已有的容量*0.75,如果大于,会调用resize方法进行扩容2次方
4.putVal涉及两次扩容,没数据的时候扩容,当前数据>下一次需要调整容量的大小值后扩容
5.resize方法,主要是对当前容量、下一次的容量进行扩容,节点重新排序,排序后还是原来的位置,最主要的是如果当前的容量超过1>>30位后,会给下一次需要扩展的容量分配int的最大值,如果下一次需要扩容的容量超过1>>30位后,会赋值一个最大的int值,不满足的话,当前下一次的容量就重新赋值为下一次的容量x2。如果下一次要分配的容量为0,也就是咱们创建hashmap的时候未设置初始容量和负载因子,那这一块会给我们的容量赋值为16,下一次扩容的容量会用这个容量x0.75,最后将原数据的值赋值到当前的新容器中,链表的话进行重排序,但是不会影响索引位置。
6.回到putval方法,找到当前hash计算后数据的位置 没找到就创建一个新的,有的话就赋值给p,下一步,如果key和value都是相同的就替换,如果不是就判断下是不是红黑树的节点,如果是,就创建个新的节点,把数据扔进去
7。如果不是树节点,那可能就是链表了,然后进行遍历,数据就放到当前遍历节点的后面,如果节点的长度超过8个,就将这个链表的数据转化为红黑树
10。红黑树转链表,当链表的阙值低于6个时,红黑树转链表,也就是resize方法内,咱们对当前node进行重排序的时候,会调用split方法,这个方法在重排序的同时,会对红黑树和链表进行转换,阙值低于6,转链表,高于8,转红黑树
get
我们都知道,key的hash是不会变的,就算最终通过hash(key)最终得到的结果也和插入的结果是一样的
1.先去根据当前table表结构的长度和hash进行&(与)计算去获取元素,如果获取成功,获取失败直接return,获取成功则会进行key比对,比对成功则直接返回,比对失败则进入下一个点,其实这一块主要就是表的node结构
2.去寻找当前点上的下一级,也就是next,如果next 结构是TreeNode红黑树结构,那么就会进行find查找,查到后直接返回
3.如果是非红黑树,则去咱们的链表里查找,最终查到后返回
1 HashMap为什么异或原数右移16位计算哈希值?
源码
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
简单的说如果key为null,返回0。否则返回key的hash值异或一个key的hash值右移16位。
我们看一下效果(异或^ 拆解为二进制数 相同为0 不同为1 与符号&同为1时为1 不同则为0,同为0也是0)
0000 1010 1000 1000 1010 0011 0111 0100 `原数`
0000 0000 0000 0000 0000 1010 1000 1000 `右移16`
0000 1010 1000 1000 1010 1001 1111 1100 `异或结果`
我们发现,高位16没有发生变化,因为右移16位之后高位都是补0,1异或0还是1,0异或0还是0。
到此我们不能明确的知道,这个异或右移16位有什么作用,我们看一下HashMap如何计算插入位置的。
源码
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
n为容量大小,假设我们现在的容量是起始容量16,则这里的算式就是15&hash值
我们看一下效果
1101 0011 0010 1110 0110 0100 0010 1011 `原数`
0000 0000 0000 0000 0000 0000 0000 1111 `15的二进制`
0000 0000 0000 0000 0000 0000 0000 1011 `结果`
仔细观察上文不难发现,高区的16位很有可能会被数组槽位数的二进制码锁屏蔽,如果我们不做刚才移位异或运算,那么在计算槽位时将丢失高区特征(虽然高区特性不同hashcode也可以计算出不同的槽位)
也许你可能会说,即使丢失了高区特征不同hashcode也可以计算出不同的槽位来,但是你设想如果两个哈希值的低位差异极小而高位差异很大,导致这两个哈希值计算出来的桶位比较接近,会插入到HashMap的两个位置比较相邻的位置,这样哈希碰撞的概率就变高了!
我们认为一个健壮的哈希算法应该在hash比较接近的时候,计算出来的结果应该也要天差地别,足够的散列,所以这个高位右移16位的异或运算也是HashMap将性能做到极致的一种体现。
2 HashMap的hash算法为什么使用异或?
异或运算能更好的保留各部分的特征,如果采用&(与)运算计算出来的值会向0靠拢,采用 |(或) 运算计算出来的值会向1靠拢。这样的话得到的hashCode基本相近,容易产生重复的hash值,碰撞的概率就高了,而使用异或,无论值是否相近,得到的hash值都是差别很大的,也就是说使用异或可以使hash碰撞的概率降低,理想状态下是不会出现hash碰撞
举例:(此处截图只是展示得到的公式为 (n-1)&hash,通过整合最终得到公式为(n-1)&(n=Objects.hash(value))^(n>>>16))
异或
当我们的默认长度为16的时候,值为4.5得到的结果为13,值为4.6得到的结果为11
与
当我们的默认长度为16的时候,值为4.5得到的结果为2,值为4.6得到的结果为4
或
当我们的默认长度为16的时候,值为4.5得到的结果为15,值为4.6得到的结果为15
3 可以用%取余运算吗?
&(与)运算是二进制逻辑运算符,是计算机能直接执行的操作符,而%是Java处理整形浮点型所定义的操作符,底层也是这些逻辑运算符的实现,效率的差别可想而知,效率相差大概10倍。
HashMap的加载因子
加载因子为什么是0.75?
很多人说HashMap的DEFAULT_LOAD_FACTOR = 0.75f是因为这样做满足泊松分布,这就是典型的半知半解、误人子弟、以其昏昏使人昭昭。实际上设置默认DEFAULT_LOAD_FACTOR为0.75和泊松分布没有关系,而是我们一个随机的key计算hash之后要存放到HashMap的时候,这个存放进Map的位置是随机的,满足泊松分布。泊松分布是一种概率,也就是让我们放入map的位置随机,减少hash碰撞。
我们来看一下官方对这个加载因子的解释:
简单翻译一下:理想情况下,在随机hashCodes下,bin中节点的频率遵循Poisson分布(http://en.wikipedia.org/wiki/Poisson_distribution),默认调整大小阈值0.75的平均参数约为0.5,尽管由于调整粒度而差异很大。忽略方差,列表大小k的预期出现次数是(exp(-0.5)* pow(0.5,k)/ * factorial(k))。第一个值是:
0:0.60653066
1:0.30326533
2:0.07581633
3:0.01263606
4:0.00157952
5:0.00015795
6:0.00001316
7:0.00000094
8:0.00000006
其他:少于一百万分之十
也就是说,我们单个Entry的链表长度为0,1的概率非常高,而链表长度很大,比8还要大的概率忽略不计了。
加载因子可以调整吗??
可以调整,hashmap运行用户输入一个加载因子
public HashMap(int initialCapacity, float loadFactor) {
}
加载因子为0.5或者1,会怎么样?能大于1吗
我们凭借逻辑思考,如果加载因子非常的小,比如0.5,那么我们是不是扩容的频率就会变高,但是hash碰撞的概率会低很多,相应的链表长度就普遍很低,那么我们的查询速度是不是快多了?但是内存消耗确实大了。
那么加载因子很大呢?我们想象一下,如果加载因子很大,我们是不是扩容的条件就变的更加苛刻了,hash碰撞的概率变高,每个链表长度都很长,查询速度变慢,但是由于我们不怎么扩容,内存是节省了不少,毕竟扩容一次就翻一倍。
那么加载因子大于1会怎么样,我们加载因子是10,初始容量是16,当桶数达到160时扩容,平均每个链表长度为10,链表并没有长度限制,所以,加载因子可以大于1,但是我们的HashMap如果查询速度取决于链表的长度,那么HashMap就失去了自身的优势,尽管JDK1.8引入了红黑树,但是这只是补救操作。
如果在实际开发中,内存非常充裕,可以将加载因子调小。如果内存非常吃紧,可以稍微调大一点。
HashMap的初始容量
为什么HashMap的初始容量是16?
我们知道扩容是个耗时的过程,有大量链表操作,16作为一个折中的值,即不会存入极少的内容就扩容,也不会在加入大量数据而扩容太多次。16扩容3次就达到128的长度。
其实还有一个很重要的地方,16是2的4次方,我们在看HashMap的源码时,可以看到初始容量的定义方式如下:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
为什么初始容量是2的多次方比较好?
这是我们计算插入位置的算法,n代表的就是容量。假设我们没有设置容量,也没扩容过,那么这个n就是16,n-1=15
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
演示计算过程
1101 0011 0010 1110 0110 0100 0010 1011 `原数`
0000 0000 0000 0000 0000 0000 0000 1111 `15的二进制`
0000 0000 0000 0000 0000 0000 0000 0011 `结果`
我们发现,插入位置实际上又由原数的最低的4位决定的,每个位置都有插入的可能。
初始容量如果不是2的次方呢?
HashMap确实提供我们手动设置初始容量
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
假如我们设置为17,我们看一下计算插入位置的过程,hash & 16
1101 0011 0010 1110 0110 0100 0010 1011 `原数`
0000 0000 0000 0000 0000 0000 0001 0000 `16的二进制`
0000 0000 0000 0000 0000 0000 0000 0000 `结果`
我们发现16的二进制只有一个为1其他都是0,其他数字与上它,不是16就是0。也就是说,这简直是Hash冲突的噩梦。
你将会得到一个Java双单向链表
再举例,初始长度是15 , hash & 14
1101 0011 0010 1110 0110 0100 0010 1011 `原数`
0000 0000 0000 0000 0000 0000 0000 1110 `14的二进制`
0000 0000 0000 0000 0000 0000 0000 0000 `结果`
结果发现,最后一位永远是0,那么0,2,4,6,8,10,12,14这几位就无法插入上了。
这也是2N的性质,2N-1,结果为全是1,插入的位置由原数决定,每个点都有机会插入。
HashMap对于你输入非2的次方的数,会怎么样?
当然HashMap不会让你们这么做的,实际上你给定的初始容量,HashMap还会判断是不是2的次幂,如果不是,则给出一个大于给定容量的最小2的次幂的值作为新的容量。
public HashMap(int initialCapacity, float loadFactor) {
...
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
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;
}
这也验证了一个重要的编程思想:永远要把客户当成傻子。
HashMap树化
为什么要进行树化?
我们看一下官方的描述
简单翻译一下:
由于TreeNodes的大小大约是常规节点的两倍,因此我们仅在容器包含足够的节点以保证使用时才使用它们(参见 TREEIFY_THRESHOLD 值)。当它们变得太小(由于移除或调整大小)时,它们会被转换回普通的bin。理想情况下,在随机哈希代码下,bin中的节点频率遵循泊松分布,下面就是list size k 的频率表。
0:0.60653066
1:0.30326533
2:0.07581633
3:0.01263606
4:0.00157952
5:0.00015795
6:0.00001316
7:0.00000094
8:0.00000006
其他:少于一百万分之十
为什么链表长度为8的概率如此之低,还要去树化?
这里科普一个东西:Hash碰撞攻击,就是说,有人恶意的向服务器发送一些hash值计算出来一样,但是又不相同的数据,用我们的Java语言来理解就是:
a.hash()==b.hash() , a.equals(b)==false
这样,我们的HashMap会把这些数据全部加入到同一个位置,即一条链表上,倘若我们的链表长度达到了100,那么可想而知,性能急剧下降。这时我们的红黑树可以缓解这种性能急剧下降的问题,但是最好的解决方案是去拦截这些恶意的攻击。
为什么不选择6进行树化?
我们看一下TreeNode的源码
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
........
}
这是node节点,继承了Map.Entry
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
对比发现:TreeNode每一个数都是一个TreeNode,正如官方所说的,TreeNode大概是普通的2倍,所以我们转换成树结构时会加大内存开销的。
我们发现在加载因子没有修改的前提下,单一条链表的长度大于等于8的概率是非常的低的,所以我们选择8才树化,树化的频率还是很低的,HashMap整体性能受到影响还是比较小的。
如果选择6进行树化,虽然概率也很低,但是也比8大了一千倍,遇到组合Hash攻击时(让你每个链表都进行树化),也会遇到性能下降的问题。
为什么树化之后,当长度减至6的时候,还要进行反树化?
长度为6时我们查询次数是6,而红黑树是3次,但是消耗了一倍的内存空间,所以我们认为,转换回链表是有必要的。
维护一颗红黑树比维护一个链表要复杂,红黑树有一些左旋右旋等操作来维护顺序,而链表只有一个插入操作,不考虑顺序,所以链表的内存开销和耗时在数据少的情况下是更优的选择。
为什么在JDK1.8中进行对HashMap优化的时候,把链表转化为红黑树的阈值是8,而不是7或者不是20呢?
如果选择6和8(如果链表小于等于6树还原转为链表,大于等于8转为树),中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
还有一点重要的就是由于treenodes的大小大约是常规节点的两倍,因此我们仅在容器包含足够的节点以保证使用时才使用它们,当它们变得太小(由于移除或调整大小)时,它们会被转换回普通的node节点,容器中节点分布在hash桶中的频率遵循泊松分布,桶的长度超过8的概率非常非常小。所以作者应该是根据概率统计而选择了8作为阀值
总结
本篇文章主要讲了一下HashMap那几个为什么?希望能对你有帮助!
如果实际面试的时候,你能提出一些对HashMap的优化的一些思路,也是加分项!比如你说我觉得hash算法可以优化,hash散列种子可以优化,等等。
=============源码在这儿=======================
final HashMap.Node<K,V>[] resize() {
HashMap.Node<K,V>[] oldTab = table; //table为当前已存的数据 包含分配的空的数据
int oldCap = (oldTab == null) ? 0 : oldTab.length; //当前已存的数据的容量 包含空数据
int oldThr = threshold; //下一次的容量
int newCap , newThr = 0; //新的容量 新的下一次容量
if (oldCap > 0) { //当前数据的容量超过0
if (oldCap >= MAXIMUM_CAPACITY) { //是否大于最大容量 1 << 30 左移30位 计算出来 1073741824
threshold = Integer.MAX_VALUE; //当前容量扩容为int最大值
return oldTab; //然后返回当前数据
}
//如果不大于hashmap设定的最大容量 左偏移1位 x2 也就是新容量就等于分配容量x2
// 新容量就等于当前分配容量x2 < hashmap的最大容量 && 当前分配的容量>=默认的初始容量16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
//老的容量x2 就得到一个新的容量
newThr = oldThr << 1; // double threshold
}//不满足当前容量>0 看看下一次分配的容量是否大于0 如果大于0 下一次分配的容量就赋值给当前的新容量
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { //如果下一次要分配的容量不大于0 就将默认的容量赋值给新容量 // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); //新的容量 = 负载因子0.75*默认容量16
}
//新的下一次的容量如果等于0
if (newThr == 0) {
float ft = (float)newCap * loadFactor; //当前新的容量*负载因子0.75
//新的下一次容量
//如果新的容量>hashmap最大容量&&新的容量*负载因子小于最大容量限制。
//新的下一次容量就不变 如果不满足以上条件 则赋值为int最大
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE);
}
threshold = newThr; //将新的下一次容量赋值给下一次容量变量值
@SuppressWarnings({"rawtypes","unchecked"})
HashMap.Node<K,V>[] newTab = (HashMap.Node<K,V>[])new HashMap.Node[newCap]; //重新分配大小为当前新的容量node数组
table = newTab; //将这个新的容器赋值给原table容器
if (oldTab != null) { //如果oldTab不为空 也就是原本咱们容器就没得数据
for (int j = 0; j < oldCap; ++j) { //循环一个node 因为是一个数组
HashMap.Node<K,V> e; //创建个空的node
if ((e = oldTab[j]) != null) { //如果当前当前下标存在数据 并且赋值给e
oldTab[j] = null; //将当前下标的数据清空
if (e.next == null) //获取e下的链表next是否有数据 但是e目前来说是有数据的 就是没有next指向
//没有数据 就将当前e的 hash与容量-1 作为下标进行e数据赋值
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof HashMap.TreeNode) //如果e是树节点类型 就通过split重排节点
((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
HashMap.Node<K,V> loHead = null, loTail = null;
HashMap.Node<K,V> hiHead = null, hiTail = null;
HashMap.Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//声明了一个局部变量 tab,局部变量 Node 类型的数据 p,int 类型 n,i
Node<K,V>[] tab; Node<K,V> p; int n, i;
//首先将当前 hashmap 中的 table(哈希表)赋值给当前的局部变量 tab,然后判断tab 是不是空或者长度是不是 0,实际上就是判断当前 hashmap 中的哈希表是不是空或者长度等于 0
if ((tab = table) == null || (n = tab.length) == 0)
//如果是空的或者长度等于0,代表现在还没哈希表,所以需要创建新的哈希表,默认就是创建了一个长度为 16 的哈希表
n = (tab = resize()).length;
//将当前哈希表中与要插入的数据位置对应的数据取出来,(n - 1) & hash])就是找当前要插入的数据应该在哈希表中的位置,如果没找到,代表哈希表中当前的位置是空的,否则就代表找到数据了, 并赋值给变量 p
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);//创建一个新的数据,这个数据没有下一条,并将数据放到当前这个位置
else {//代表要插入的数据所在的位置是有内容的
//声明了一个节点 e, 一个 key k
Node<K,V> e; K k;
if (p.hash == hash && //如果当前位置上的那个数据的 hash 和我们要插入的 hash 是一样,代表没有放错位置
//如果当前这个数据的 key 和我们要放的 key 是一样的,实际操作应该是就替换值
((k = p.key) == key || (key != null && key.equals(k))))
//将当前的节点赋值给局部变量 e
e = p;
else if (p instanceof TreeNode)//如果当前节点的 key 和要插入的 key 不一样,然后要判断当前节点是不是一个红黑色类型的节点
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) // 重新计算当前链表的长度是不是超出了限制 TREEIFY_THRESHOLD = 8
treeifyBin(tab, hash);//超出了之后就将当前链表转换为树,注意转换树的时候,如果当前数组的长度小于MIN_TREEIFY_CAPACITY(默认 64),会触发扩容,我个人感觉可能是因为觉得一个节点下面的数据都超过8 了,说明 hash寻址重复的厉害(比如数组长度为 16 ,hash 值刚好是 0或者 16 的倍数,导致都去同一个位置),需要重新扩容重新 hash
break;
}
//如果当前遍历到的数据和要插入的数据的 key 是一样,和上面之前的一样,赋值给变量 e,下面替换内容
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { //如果当前的节点不等于空,
V oldValue = e.value;//将当前节点的值赋值给 oldvalue
if (!onlyIfAbsent || oldValue == null)
e.value = value; //将当前要插入的 value 替换当前的节点里面值
afterNodeAccess(e);
return oldValue;
}
}
++modCount;//增加长度
if (++size > threshold)
resize();//如果当前的 hash表的长度已经超过了当前 hash 需要扩容的长度, 重新扩容,条件是 haspmap 中存放的数据超过了临界值(经过测试),而不是数组中被使用的下标
afterNodeInsertion(evict);
return null;
}