【摘要】 主要成员变量
节点数组 table 默认容器大小capacity为16节点 ,节点成员有hash,key,value,next扩容因子loadFactor ,默认为0.75扩容节点大小threshold 计算公式为capacity*loadFactor
构造函数
HashMap提供四个构造函数
public HashMap(int initialCapacity…
主要成员变量
- 节点数组 table 默认容器大小capacity为16
- 节点 ,节点成员有hash,key,value,next
- 扩容因子loadFactor ,默认为0.75
- 扩容节点大小threshold 计算公式为capacity*loadFactor
构造函数
HashMap提供四个构造函数
public HashMap(int initialCapacity, float loadFactor);
//此构造函数传入初始容器大小和扩容因子
public HashMap(int initialCapacity) ;
//此构造函数传入初始容器大小,扩容因子就会使用默认的0.75
public HashMap();
//此构造函数是无参的,只会设置扩容因子为默认的0.75,而容器大小是不会初始化的,等PUT的时候再扩容,下文介绍。
public HashMap(Map<? extends K, ? extends V> m);
//此构造函数是传入一个初始map,容器大小会初始化为大于此map大小的最小的2的幂次方的数,如传入map的大小为10,则容器大小为16.
PUT方法
1、判断容器是否为空,如果为空,则进行扩容(扩容方法详解下面介绍)。
2、根据key获取的hash值(下面介绍)跟容器大小-1(n-1)取模获取到容器下标,根据下标在节点容器中查找。
a.如果没有找到,则直接新建一个节点放入容器
b.如果有找到,且这个节点是红黑树节点,则以红黑树的方式进行插入新节点。且这个节点的hash,key都一致,则将旧值替换为新值,且返回旧值。如果在这个节点链表中没找到这个key,且已有的节点链表长度小于7,则直接在这个节点末尾直接插入这个新节点(尾插法),如果已有的节点长度等于7(加上新加的这个节点等于8),则会将这个节点链表变为红黑树。以红黑树的方式插入。
3、如果加入后的节点数量大于扩容节点数量(默认为12)则需要扩容。
流程图如下:
key的hash算法
//扰动函数
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } tab[i = (n - 1) & hash]
这是用了二次hash运算。
第一次是通过key的hashcode无符号右移16位与key的hashcode做异或运算,这样能保证高16位能参与第二次hash(&)运算(如果直接用hashcode与n-1进行&运算,因为n-1的前16位基本都是0,则hashcode的高位基本不影响最终的结果),混合原始哈希码的高位和低位,以此来加大低位的随机性,使最终二次hash计算出来的hash值碰撞减少。
第二次是为了能使计算的hash值能落在容器table中,所以需要对table的长度取模 hash%n;
计算机中,&运算比%运算高很多,所以基于以下的公式:
当 lenth = 2^n 时,X % length = X & (length – 1)
为了满足这个公式,让&取代%,我们容器的大小必须是2的幂次方,而且每次扩容都是扩大一倍。这也说明了为啥上面构造函数,如果你传的容器大小是18的话,他也会把你设为32。
扩容(resize)
在上面,我们知道,在put的时候有多次地方用到扩容。扩容源码如下(已添加了注释)
final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; //不是第一次扩容 if (oldCap > 0) { //如果已有的tabledable大小已经是最大的,无法扩容,直接返回 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } //newCap = oldCap << 1这里把容器扩大两倍,在上面有说明为啥扩大两倍 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // 相应的扩容节点大小也扩大两倍 } else if (oldThr > 0) //是第一次扩容,且是通过自己传入容器大小来初始化的,则直接把初始容器大小设为自己传入的容器大小 newCap = oldThr; else { // 是第一次扩容,且是无参构造函数初始化的,用默认的大小 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr == 0) {//是第一次扩容,且是通过自己传入容器大小来初始化的,因为此时oldThr是等于容器大小,不能直接赋值,则需要再根据公式容器大小*加载因子 计算一遍 float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; if (oldTab != null) {//不是第一次扩容,则需要将原来的节点重新进行hash计算 for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { oldTab[j] = null; if (e.next == null)//节点链表就一个节点,则直接计算hash存储 newTab[e.hash & (newCap - 1)] = e; else if (e instanceof TreeNode)//红黑树节点,则需要将红黑树分组,重新计算 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { // 分组链表 Node<K,V> loHead = null, loTail = null;//重新计算后的hash下标值不变 Node<K,V> hiHead = null, hiTail = null;//重新计算后的hash下标值等于原来的hash下标+oldCap 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; }
每次扩容都是以两倍的大小扩容,且如果不是第一次扩容,则需要重新计算hash值排列,这是很耗费资源的,所以如果初始大概知道需要用到的容器大小,我们可以先初始设置容器大小,这样就能减少扩容的次数了。
多线程下put引发的线程不安全问题
在put方法中有一个判断是否hash碰撞
if ((p = tab[i = (n - 1) & hash]) == null)// A tab[i] = newNode(hash, key, value, null);
假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完上面第A行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所以此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
if (++size > threshold)//B resize();
除此之前,还有就是代码中最后有一个判断是否需要扩容的第B行处有个++size,我们这样想,还是线程A、B,这两个线程同时进行put操作时,假设当前HashMap的zise大小为10,当线程A执行到第B行代码时,从主内存中获得size的值为10后准备进行+1操作,但是由于时间片耗尽只好让出CPU,线程B快乐的拿到CPU还是从主内存中拿到size的值10进行+1操作,完成了put操作并将size=11写回主内存,然后线程A再次拿到CPU并继续执行(此时size的值仍为10),当执行完put操作后,还是将size=11写回内存,此时,线程A、B都执行了一次put操作,但是size的值只增加了1,所以说还是由于数据覆盖又导致了线程不安全。
GET方法
源码如下,已添加注释,比较简单,只是涉及到链表查找以及红黑树查找。
public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; }
final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { //hash碰撞有值 if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k))))//hash跟key都一样,则返回 return first; if ((e = first.next) != null) { if (first instanceof TreeNode)//红黑树节点,则需要再红黑树中查找节点 return ((TreeNode<K,V>)first).getTreeNode(hash, key); do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null);//循环在单链表中查找 } } return null; }
红黑树与链表转换
1、hashMap并不是在链表元素个数大于8就一定会转换为红黑树,而是先考虑扩容,扩容达到默认限制后才转换。
2、hashMap的红黑树不一定小于6的时候才会转换为链表,而是只有在resize的时候才会根据 UNTREEIFY_THRESHOLD(6) 进行转换。
文章来源: blog.csdn.net,作者:迷路的小羊,版权归原作者所有,如需转载,请联系作者。
原文链接:blog.csdn.net/weixin_40114067/article/details/115706536