所有人都知道,HashMap底层的数据结构是数组+链表+红黑树
HashMap<String, Objeck> map = new HashMap<>();
map.put(“1”,“zhangsan”);
当我们在执行put的过程中,它的底层是怎样运作的呢?
基本执行流程:
HashMap在初始化的时候,默认是一个数组,他在put 的过程中会进行计算hash值,按照hash值进行存储,
如果计算之后,hash值相同,他会判断内容是否相同,如果相同则覆盖,如果不同则在数组下面以链表的形式进行添加,当链表的长度达到一定的阀值的时候,链表会自动转换为红黑树。
上图中,每一个小格子都代表了hashmap中一个内部类对象,表示一个节点 包含【key,value】,存储了键和值
class Node{
private String key;
private String value;
}
数组的表示形式:
Node []node = new node[16] ------->表示了数组
链表的表示形式:
class Node{
private String key;
private String value;
private Node next; //表示了一个单向的链表
}
红黑树的表示形式:
class TreeNode extends Node{
Node parent;
Node left ; //这三个属性表示树的三个节点
Node right;
}
hash值的计算过程
HashMap在存储过程中,最重要的无非就是获取到要存储的数组位置下标
要想确定存储的位置,首先需要确定2点
1.数组的长度length
2.下标范围(0到length-1)
下标范围在计算的时候,他首先会根据int hash = hashCode()函数得到一个32位的值
如果在我们的想法里面,用 hash值%length不就是这个范围吗?
所以在Node类中,还应该有一个int hash; 用来存储hash值
因为在hash值确定了,对应的下标也就确定了。
但实际上上述的算法并不合适,因为这样造成的重复率太高了,假设length是16,hash值为16,32,64…所计算出的下标就会完全一致,所以他重复的概率太高了,在java中则是运用下面的方式来计算的。
在HashMap的算法中,使用的&(与运算)来代替%运算,这种方式造成的重复率更低,也就更加安全
举例:
hash1: 0000 1111 0000 1111 1010
hash2:0001 1111 0001 1101 1101
假设hashmap容量为16 length -1 值为(16-1):0000 1111
hash1 & (length - 1) = 0000 0000 0000 0000 1010 = 10
hash2 &(length - 1) = 0000 0000 0000 0000 1101 = 13
这样可以保证hash值得到的结果肯定是数组的下标值,并且尽量减少碰撞
这时候又会抛出一个问题,为什么HashMap的容量总是 “2的几次方” 呢?
做一个假设:
假设HashMap的长度为15 ( length-1) = 1110
hash1 = 0000 1111
hash2 = 1111 1110
hash1 % ( length-1) = 1110
hash2 % ( length-1) = 1110
这样的话,造成最后一位不管什么情况下都等于0,增大了碰撞的概率,而长度如果正好是2的n次方,
则可以解决这个问题
例:长度为16: (length -1)= 1111
例:长度为32:(length -1 ) = 11111
所以这个时候就可以保证,下标位置最终计算结果依赖的是hash值,与计算公式的(length -1)的结果无关
但是上述的结果还是有很大的重复率,例如:
hash1 = 0000 1010
hash2 = 1100 1010
虽然hash值并不相同,但是结果还是会重复,所以这时候java又在计算hash值之前增加了一个“ ^ ”运算
先将hash值>>>16(低16位运算) 然后用原先的hash ^ 位移的hash,最终算出hash值,然后在进行上面的&(与)运算,这样,最终就最大程度的减少了下标重复的概率
所以indexx下标的完整算法是:key.hashCode() 然后高16位和低16为进行异或运算 ,然后再和(length -1)进行(&)运算 这样最终得到index结果值,这样得到的重复的可能性大大降低。
put的过程
在调用put的时候,首先会调用hash()方法得到hash值
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 判断map是否是null
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 计算index位置,并判断这个位置是否有节点,如果没有直接赋值
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 如果key值相同,直接替换
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 {
//key值不同,按照链表方式存储----->循环遍历找到最后一个节点,并存储
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();
afterNodeInsertion(evict);
return null;
}
根据hash结果,计算node的位置,
判断index这个位置有没有节点,如果没有,就直接放入这个位置
index这个位置如果有元素,判断key
key相同,直接替换value值
key不同,按照链表存储
key不同,按照红黑树存储
HashMap扩容
数组长度不够用?
本质上是创建一个新的数组,然后将老的数组中【链表,红黑树】,迁移到新的数组中去
什么情况会进行扩容呢?
整个数据结构中节点超过length*0.75就会进行扩容
源码:
if (++size > threshold)
resize();
他就会执行扩容操作
resize()方法内:
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
他扩容的方式是进行位移
原来是1<<4 10000 <<1 100000 2^5 充分利用了位移的高效率
这里,也就保证了HashMap的长度永远是2的n次方
创建成功后,就会对数据进行迁移
1.循环遍历旧数组下标
2.当下标位置有元素,进行迁移,
3.如果有元素,并且下面没有元素
@SuppressWarnings({"rawtypes","unchecked"}
// 首先创建新的数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 循环遍历所有旧节点
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//判断当前下标是否有元素,没有元素不值得迁移
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 该节点下面如果没有后续节点
if (e.next == null)
// 再次用新的算法得到index下标值,并进行赋值
newTab[e.hash & (newCap - 1)] = e;
//如果后续节点是红黑树
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
//如果下面是链表
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
//循环链表
do {
next = e.next;
//老数组链表中的node元素,会保存到新数组对应位置
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//老数组的node元素,保存到index+老长度的位置
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;
}
}
}
}
}
线程安全问题
jvm在运行时,会有一个主内存
每个线程在运行时,也会有每个线程的工作内存,线程在访问主线程的HashMap时候,会先将数据拷贝到工作内存,然后再将数据写回去,这个时候如果有其他线程在这个过程中读取数据,就会造成数据不一致的情况
多线程执行操作和单线程执行操作,最终的数据不一致,线程非安全
一般情况下,我们可以通过为它加锁保证线程的安全性
put方法---------> synchronized :一个线程操作完成,其他线程才能进入操作
HashMap的线程安全解决方案,Hashtable
put方法加了synchronized
但是这样由于方法加了锁,所以对于效率来说比较低下,因为一旦有一个线程在操作,那么整个map其他线程都不能操作,只能等待,这个时候效率太低下了
java中也给出了对应的优化方案:
我们可以只给他在你指定需要操作的数组下标加锁,意思是,某个hash值对应的下标加锁了,表示你正在操作这个下标,但对于其他的下标别人也可以正常操作,并不受影响,只是别人不能操作你这个下标下面的链表和红黑树了,
这就是HashMap的线程安全问题优化的一个解决方案,ConcurrentHashMap类,只在当前下标区域控制线程安全