Java 基础篇之 Java HashMap
-
jdk1.7 和 jdk1.8 的HashMap的底层数据结构
jdk1.7:数组、链表
jdk1.8:数组、链表、红黑树
数组的特点:查询的效率高,插入、删除的效率低
链表的特点:查询的效率低,插入、删除的效率高
HashMap 使用两者的结构,使得查询和插入、删除效率都很高,jdk1.8引入红黑树解决链表过长效率低的问题。
🤔思考:为什么初始不使用红黑树?(空间和时间的考虑)
-
HashMap 的初始容量,加载因子
初始容量:16
加载因子:0.75
加载因子:是衡量哈希表密集程度的一个参数,如果加载因子越大,说明装载的数据越多,出现hash冲突的可能性越大。反之加载因子越小,说明装载的数据越少,出现hash冲突的可能性越小(其他空余空间不装数据)。该值考虑内存使用率和hash冲突的平衡。
-
链表转红黑树的阈值,红黑树转链表的阈值
数组长度大于64且链表(桶)的长度大于等于8时,链表转成红黑树
红黑树节点数小于6时,红黑树转成链表
当数组长度未达到64,但是链表长度大于8之后,会考虑进行数组扩容,而不是直接转红黑树
原因:如果数组比较小,应避免红黑树结构。红黑树数据结构比较复杂TreeNode的空间比数组节点大(约2倍),并且需要进行左旋、右旋、变色这些操作才能保持平衡,在数组容量较小时,操作数组扩容比操作红黑树更节省时间
static final int TREEIFY_THRESHOLD = 8; static final int MIN_TREEIFY_CAPACITY = 64; static final int UNTREEIFY_THRESHOLD = 6; final V putVal(K key, V value) { ... 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); //第一个条件,链表长度大于等于8 break; } ... } } 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) resize(); //扩容 else if ((e = tab[index = (n - 1) & hash]) != null) { //第二个条件,数组大小大于64 TreeNode<K,V> hd = null, tl = null; do { TreeNode<K,V> p = replacementTreeNode(e, null); if (tl == null) hd = p; else { p.prev = tl; tl.next = p; } tl = p; } while ((e = e.next) != null); if ((tab[index] = hd) != null) hd.treeify(tab); } } //resize方法会调用split final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) { ... if (loHead != null) { if (lc <= UNTREEIFY_THRESHOLD) //桶的长度小于等于6,红黑树转换未链表 tab[index] = loHead.untreeify(map); else { tab[index] = loHead; if (hiHead != null) // (else is already treeified) loHead.treeify(tab); } } if (hiHead != null) { if (hc <= UNTREEIFY_THRESHOLD) tab[index + bit] = hiHead.untreeify(map); else { tab[index + bit] = hiHead; if (loHead != null) hiHead.treeify(tab); } } }
-
为什么桶中的个数超过8才转为红黑树
① 红黑树的节点TreeNode所需要的空间约是普通链表节点node的2倍,且数据结构更复杂,要尽量避免红黑树结构
② 桶中的节点频率遵守泊松分布,从桶长度k的频率表可看出,桶长度超过8的概率不到千万分之一,概率很低。
-
HashMap的哈希函数怎么设计的
hash函数的作用:通过hash算法来决定每个元素的存储位置
hash函数的设计:hash函数先拿到key的hashcode(这个已存在,因为key都是String类型的),它是一个32位的int值,然后让hashcode的高16位和低16位进行异或操作。
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
-
为什么采用hashcode的高16位与低16位的异或操作
异或:二进制码同位进行比较,相同的取0,不相同的取1
① 高半区和低半区做异或,混合了原始哈希码的高位和低位,加大了低位的随机性,减少了哈希碰撞。
② 混合后的低位掺杂了高位的部分特征,高位的信息变相的保留下来。
//计算hash h = hashCode(); //1111 1111 1111 1111 1111 0000 1110 1010 h >>> 16; //0000 0000 0000 0000 1111 1111 1111 1111 hash = h ^ h>>>16; //1111 1111 1111 1111 0000 1111 0001 0101 //计算桶下标 (n-1)& hash; // 15 & h //15:0000 0000 0000 0000 0000 0000 0000 1111 //h:1111 1111 1111 1111 0000 1111 0001 0101 //结果:0000 0000 0000 0000 0000 0000 0101 = 5 &运算规则:0&0=0;0&1=0;1&0=0;1&1=1
-
两个键的hashcode相同,如何存储键值对
相同hashcode值算出来桶的位置是一样的,所以存放的都是同一个桶
① 如果键是一样的,则直接替换之前的值
② 如果键是不一样的,则插入进桶中,形成单链表(jdk1.7采用头插法,jdk1.8采用尾插法)
-
有哪些办法解决hash冲突,HashMap是怎么解决hash冲突的
① 有以下方法解决hash冲突
-
开放地址法(再散列法)
当发生hash冲突时,以当前地址为基准,通过一个探测算法,继续查找下一个可以使用的空的地址。
线性探测:冲突发生时,顺序查看表中下一单元,直到找出一个空单元或查遍全表
f i ( k e y ) = ( f ( k e y ) + d i ) M O D m ( d i = 1 , 2 , 3 , . . . , m − 1 ) fi(key) = (f(key) + di) MOD m (di = 1,2,3,...,m-1) fi(key)=(f(key)+di)MODm(di=1,2,3,...,m−1)
二次探测:冲突发生时,在表的左右进行跳跃式探测,比较灵活
f i ( k e y ) = ( f ( k e y ) + d i ) M O D m ( d i = 1 2 , − 1 2 , 2 2 , − 2 2 , . . . , n 2 , − n 2 , n < = m / 2 ) fi(key) = (f(key) + di) MOD m (di = 1^2,-1^2,2^2,-2^2,...,n^2,-n^2,n<=m/2) fi(key)=(f(key)+di)MODm(di=12,−12,22,−22,...,n2,−n2,n<=m/2)
伪随机探测:建立一个伪随机数发生器,生成一个位随机序列,并给定一个随机数做起点,每次加上这个伪随机数
f i ( k e y ) = ( f ( k e y ) + d i ) M O D m ( d i 是 一 个 随 机 数 ) fi(key) = (f(key) + di) MOD m (di 是一个随机数) fi(key)=(f(key)+di)MODm(di是一个随机数) -
再哈希法
当发生hash冲突时,再次hash算法一次,直至不发生冲突位置
-
链地址法(拉链法)
将hash值一样的数据组装成一个单链表
-
建立一个公共溢出区
所有冲突的关键字建立一个公共的溢出区来存放。在查找时,对给定的值通过hash计算出地址后,先与基表位置进行比对,
如果相同,则直接返回值,
如果不相同,则到溢出表中进行顺序查找
② HashMap 采用链地址法决绝hash冲突的
-
-
HashMap在什么条件下进行扩容,为什么扩容时2的次幂
① 在什么条件下进行扩容 第一种情况:当向HashMap中的新增元素,已有的元素个数已经达到了 容量 * 加载因子并且数组下标的值不为空时,就会触发扩容。
第二种情况:当向HashMap中的新增元素,HashMap的数组长度未达到64,但hash出来的结果目标桶的数据节点已有7个时,就会触发扩容。
② 为什么扩容是2的次幂
HashMap 计算元素需要存放在哪个桶时,是利用key的hashcode值进行高低位异或运算得到hash值,进而与(数组的长度-1 )&运算得到存放的桶下标, 通俗的讲就是取hash值的二进值后四位(数组默认为16,16-1=15即1111)。
1000 1111 0101 1010 1101 1101 0011 1010 //hash 值 & 1111 //数组长度-1,15 = 1010 //最后下标,10
当需要扩容时,为了保证扩容之后,一样可以高效的使用**&运算**,那么就是用 xxx1 1111来计算桶的下标,刚好对应之前值得2倍(位运算)。
-
为什么HashMap,jdk1.8版本使用尾插法,而弃用之前的头插法(为什么线程不安全)
① 头插法的实现逻辑,以及多线程下为何不安全
jdk1.7版本,先扩容后重新插入数据(因为要重新计算元素存放到扩容之后的数组里面的桶位置,所以先扩容)
void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; //遍历原数组table,用e指向每次遍历到的桶的头结点 for (Entry<K,V> e : table) { //当遍历到的e不为null的时候,此位置的所有元素(桶)全部移动到新数组 while(null != e) { //next指向当前元素e的下一个节点(链表) Entry<K,V> next = e.next; if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } //重新计算,新的下标 int i = indexFor(e.hash, newCapacity); //e的next指向新数组下标处的元素 e.next = newTable[i]; //e元素放在头部 newTable[i] = e; //赋值之前的下一个元素,循环 e = next; } } }
😫在多线程情况下
第一种情况put元素时:当有一个线程A先计算得到下标值,然后被挂起,另一个线程B也是计算得到下标值(同样的),然后执行完插入元素操作,当线程A再次调度时对过期的表头没有感知,于是覆盖了原有的线程B插入的数据,数据凭空消失。
第二种情况resize操作:当一个线程A执行到Entry<K,V> next = e.next;这一句时,然后被挂起,另一个线程B被调用然后完成了resize操作。这时A继续被调度时,就可能发生获取next元素时是环形链的情况。
② 尾插法的实现逻辑
jdk1.8版本,先插入数据,后扩容
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; //数组是否未初始化?若未初始化则进行初始化 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 若数组的这个位置还没有元素则直接将key-value放进去 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { // 若该下标位置已有元素 Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) // 已有元素的key值与新增元素的key判断是同一个,覆盖 e = p; else if (p instanceof TreeNode) // 如果已有元素是树节点 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { // 不是树节点则只能是链表节点了,还未转化为树 for (int binCount = 0; ; ++binCount) { // 遍历元素新增链表节点 if ((e = p.next) == null) { // 当节点的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; } final Node<K,V>[] resize() { ... @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) 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; 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; }
😫在多线程情况下
第一种情况put操作:当有一个线程A先计算得到下标值,进行了hash碰撞,发现此桶没有数据,准备直接插入数据时被挂起,另一个线程B也是计算得到下标值(同样的),然后因为此桶没有数据直接插入了数据,当线程A再次调度时对过期的表头没有感知,于是覆盖了原有的线程B插入的数据,数据凭空消失。第二种情况put操作:当一个线程A执行++size,然后被挂起,另一个线程B被调用然后完成了put操作。这时A继续被调度时,++size就比真实情况少了1。
总结:jdk1.8中HashMap没有重新计算元素的下标,而是利用其下标计算规则(&运算)以及扩容规则(2倍),直接判断hashcode在扩容之后新增位是0还是1,0则位置无变化,1则为原位置 + 数组原长度
例如:原数组长度为16,扩容之后是32 15:0000 0000 0000 0000 0000 0000 0000 1111 31:0000 0000 0000 0000 0000 0000 0001 1111 ① 有元素key算得hash值为52,&运算结果 原数组位置: 0000 0000 0000 0000 0000 0000 0011 0100 & 0000 0000 0000 0000 0000 0000 0000 1111 = 0100 //4 新数组位置 0000 0000 0000 0000 0000 0000 0011 0100 & 0000 0000 0000 0000 0000 0000 0001 1111 = 1 0100 //16 + 4 = 20 ② 有元素key算得hash值为36,&运算结果 原数组位置: 0000 0000 0000 0000 0000 0000 0010 0100 & 0000 0000 0000 0000 0000 0000 0000 1111 = 0100 //4 新数组位置 0000 0000 0000 0000 0000 0000 0010 0100 & 0000 0000 0000 0000 0000 0000 0001 1111 = 0100 //4