【❂Java集合】HashMap源码及底层数据结构分析
HashMap结构图
HashMap底层数据结构:Entry数组+链表+红黑树(JDK1.8版本) Entry+链表(JDK1.7版本)
这里简单说下红黑树的特点:
-
每个节点只有两种颜色:红色或者黑色 -
根节点必须是黑色 -
每个叶子节点(NIL)都是黑色的空节点 -
从根节点到叶子节点,不能出现两个连续的红色节点 -
从任一节点出发,到它下边的子节点的路径包含的黑色节点数目都相同
由于红黑树,是一个自平衡的二叉搜索树,因此可以使查询的时间复杂度降为O(logn)。
HashMap源码分析(JDK1.8版本)
常见的参数
//默认的Hash表的长度
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//Hash表的最大长度
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//当链表的长度为8的时候转化为红黑树
static final int TREEIFY_THRESHOLD = 8;
//桶中元素个数小于6的时候红黑树转换为链表
static final int UNTREEIFY_THRESHOLD = 6;
//只有当数组的长度大于等于64并且链表个数大于8才会转换为红黑树
static final int MIN_TREEIFY_CAPACITY = 64;
//Hash表
transient Node<K,V>[] table;
//遍历的时候使用返回一个K-V集合
transient Set<Map.Entry<K,V>> entrySet;
//表中K-V的个数
transient int size;
//对集合的修改次数,主要是后面出现的集合校验
transient int modCount;
//阈值当size大于threshold时就会进行resize
int threshold;
//加载因子
final float loadFactor;
//普通单向链表节点类
static class Node<K,V> implements Map.Entry<K,V> {
//key的hash值,put和get的时候都需要用到它来确定元素在数组中的位置
final int hash;
final K key;
V value;
//指向单链表的下一个节点
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
//转化为红黑树的节点类
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
//当前节点的父节点
TreeNode<K,V> parent;
//左孩子节点
TreeNode<K,V> left;
//右孩子节点
TreeNode<K,V> right;
//指向前一个节点
TreeNode<K,V> prev; // needed to unlink next upon deletion
//当前节点是红色或者黑色的标识
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
}
构造方法
//传入初始化容量,和指定的加载因子
public HashMap(int initialCapacity, float loadFactor) {
//参数校验
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//如果传入的值大于最大容量,就将最大的值赋给他
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//参数校验
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
//返回的是2的整数次幂
this.threshold = tableSizeFor(initialCapacity);
}
//指定HashMap的容量
public HashMap(int initialCapacity) {
//调用如上的双参构造函数
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//无参构造函数
public HashMap() {
//初始化加载因子为默认的加载因子
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
//构造一个映射关系与指定 Map 相同的新 HashMap。
public HashMap(Map<? extends K, ? extends V> m) {
//初始化加载因子为默认的加载因子
this.loadFactor = DEFAULT_LOAD_FACTOR;
//构造的过程函数
putMapEntries(m, false);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
//获取m集合中元素个数
int s = m.size();
//如果m集合元素个数是0个那么下面这些操作也就没有必要了
if (s > 0) {
if (table == null) { //表示的拷贝构造函数调用putMapEntries函数,或者是构造了HashMap但是还没有存放元素
//计算的值存在小数所以+1.0F向上取整
float ft = ((float)s / loadFactor) + 1.0F;
//将ft强制转换为整形
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
//如果计算出来的值大于当前HashMap的阈值更新新的阈值为2次方
if (t > threshold)
threshold = tableSizeFor(t);
}
else if (s > threshold)//如果Map集合元素大于当前集合HashMap的阈值则进行扩容
resize();
//将Map集合中元素存放到当前集合中
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
size函数
//返回key-val的数量
public int size() {
return size;
}
isEmpty函数
//当前的集合是否为null
public boolean isEmpty() {
return size == 0;
}
get具体过程函数
//根据key获取对应的val
public V get(Object key) {
Node<K,V> e;
//通过hash值,key找到目标节点再返回对应的val
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
//获取key对应的节点
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//如果集合为空和对应的下标数组中的值为空直接返回null
//first = tab[(n - 1) & hash]数组的长度是2的n次方减1后对应位全部变为1,这样做,与操作永远都会在数组下标范围内不会越界
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // 如果第一个节点hash与对应hash相等,并且key也相等则返回当前节点
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//第一个节点的下一个节点不为null
if ((e = first.next) != null) {
//判断节点是否为树形
if (first instanceof TreeNode)
//在树形结构中查找节点并返回
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {//通过do...while结构遍历找对应key的节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
//找到节点并返回
return e;
} while ((e = e.next) != null);
}
}
//未找到对应的节点
return null;
}
containsKey函数
//查看是否包含指定key
public boolean containsKey(Object key) {
//通过getNode返回是否为null判断是否存在key
return getNode(hash(key), key) != null;
}
put函数
//调用putVal向当前集合中存放元素并返回对应的val
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
//存放对应的key-val
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果当前集合为null则将集合扩容并且将新的存放结构赋值给tab
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//找到key存放的链表,如果为空直接将当前节点存放链表在第一个位置
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else { //当前为链表不为null
Node<K,V> e; K k;
//表示当前链表第一个位置key已经存在,将当前节点赋值给e
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//查看当前的节点是否属于树形结构如果是则在TreeNode中查找并将赋值给e
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
//找到当前存放位置节点的最后一个节点的next并将当前要插入的节点插入
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // 链表的长度为8的时候转化为红黑树减一是因为元素从0开始
treeifyBin(tab, hash);
//跳出死循环
break;
}
//表示的是当前链表已经存在当前要插入的key,HashMap不存在重复的key
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//将节点后移
p = e;
}
}
if (e != null) { // 当前节点不为null将e.val存放在oldValue
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)//不管oldValue是否为null都会发生value赋值给e.value
//当出现重复的key之后上面会将节点保存给e并未修改新的val值,在此更新
e.value = value;
//将结点向后调整到最后面
afterNodeAccess(e);
//如果为null返回null,不为null返回对应的val
return oldValue;
}
}
//++modCount对其集合操作的次数+1
++modCount;
if (++size > threshold)//如果在放入元素以后大于阈值则进行2倍扩容
resize();
afterNodeInsertion(evict);
return null;
}
resize函数
//扩容
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) {
if (oldCap >= MAXIMUM_CAPACITY) {//如果集合现在数组的长度大于等于最大容量
threshold = Integer.MAX_VALUE;//将整型最大的值赋值给threshold
return oldTab;
}
//当前集合数组长度扩大二倍赋值给newCap小于MAXIMUM_CAPACITY
//并且集合的容量大于等于默认容量将当前阈值扩大二倍赋值给新的阈值
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//若没有经历过初始化,通过构造函数指定了initialCapcity,将当前容量设置为大于它最小的2的n次方
else if (oldThr > 0)
newCap = oldThr;
else { // 初始的时候长度和阈值都使用默认值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//重新计算threshold
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//更新当前集合阈值
threshold = newThr;
//从这里开始便是将oldTab数据重新hash放入扩容后的newTab
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//将table指向的oldTab指向newTab
table = newTab;
if (oldTab != null) {
//遍历哈希表
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//当前链表是否为null、并且将就链表赋值给e
if ((e = oldTab[j]) != null) {
oldTab[j] = null;//将原来位置的链表置为null方便垃圾回收
if (e.next == null)//链表的长度为1直接将链表中的一个节点重新hash存放到相应的位置
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode) //表示节点类型为树形结构
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { //链表是非树形结构,并且节点数量是大于1
//将链表拆分为两个子链表
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do { //通过do...while遍历链表
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) {//在新表的j位置存放链表
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {//在新表的j+oldCap位置存放链表
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
上边还有一个非常重要的运算,就是下边这个判断,它用于把原来的普通链表拆分为两条链表,位置不变或者放在新的位置。
if ((e.hash & oldCap) == 0) {} else {}
我们以原数组容量16为例,扩容之后容量为32。说明下为什么这样计算。
//e.hash值
0110 1101 0110 1111 0110 1110 0010 1000
//oldCap值,即16
0000 0000 0000 0000 0000 0000 0001 0000
//做与运算,我们会发现结果不是0就是非0,
//而且它取决于 e.hash 二进制位的倒数第五位是 0 还是 1,
//若倒数第五位为0,则结果为0,若倒数第五位为1,则结果为非0。
//那这个和新数组有什么关系呢?
//别着急,我们看下新数组的容量是32,如果求当前hash值在新数组中的下标,则为
// e.hash &( 32 - 1) 这样的运算 ,即 hash 与 31 进行与运算,
0110 1101 0110 1111 0110 1110 0010 1000
&
0000 0000 0000 0000 0000 0000 0001 1111
=
0000 0000 0000 0000 0000 0000 0000 1000
接下来,我们对比原来的下标计算结果和新的下标结果,看下图,我们观察,hash值和旧数组进行与运算的结果 ,跟新数组的与运算结果有什么不同。
会发现一个规律:
若hash值的倒数第五位是0,则新下标与旧下标结果相同,都为 0000 1000
若hash值的倒数第五位是1,则新下标(0001 1000)与旧下标(0000 1000)结果值相差了 16 。
因此,我们就可以根据 (e.hash & oldCap == 0) 这个判断的真假来决定,当前元素应该在原来的位置不变,还是在新的位置(原位置 + 16)。
如果,上边的推理还是不明白的话,我再举个简单的例子。
18%16=2 18%32=18
34%16=2 34%32=2
50%16=2 50%32=18
计算中的18,34 ,50 其实就相当于 e.hash 值,和新旧数组做取模运算,得到的结果,要么就是原来的位置不变,要么就是原来的位置加上旧数组的长度。
remove函数
// 移除指向key返回对应的val
public V remove(Object key) {
Node<K,V> e;
//返回如果为空返回null否则返回e.val
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
//常规的判断表不为null,key有对应的存储位置
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//表示的是key存储在当前链表的第一个位置
node = p;
else if ((e = p.next) != null) {//表示的是链表的长度大于1
if (p instanceof TreeNode)//判断是否是树的实列
//返回对应key在红黑树存储的位置
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {//当前结构为链表
do {//遍历链表
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {//找到对应的节点保存并跳出循环
node = e;
break;
}
//将节点后移
p = e;
} while ((e = e.next) != null);
}
}
//表示要删除的key存在并且找到
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)//如果是树形在树型结构中移除当前节点
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)//表示的链表中的第一个节点
tab[index] = node.next;
else
p.next = node.next;//移除节点
++modCount;//操作+1
--size;//长度-1
afterNodeRemoval(node);
//返回节点
return node;
}
}
return null;
}
clear函数
//清除集合中的所有key-value
public void clear() {
Node<K,V>[] tab;
//集合操作+1
modCount++;
if ((tab = table) != null && size > 0) {//表不为null才进行遍历
size = 0;
for (int i = 0; i < tab.length; ++i)//遍历集合所有元素都置为null,方便垃圾回收
tab[i] = null;
}
}
containsValue函数
//查看集合是否包含指定value
public boolean containsValue(Object value) {
Node<K,V>[] tab; V v;
if ((tab = table) != null && size > 0) {//表不为null
for (int i = 0; i < tab.length; ++i) {//遍历数组
for (Node<K,V> e = tab[i]; e != null; e = e.next) {//遍历链表
if ((v = e.value) == value ||
(value != null && value.equals(v)))
//存在指定的value直接返回true
return true;
}
}
}
//集合中不存在指定value返回false
return false;
}
keySet函数
//返回key的所有集合set
public Set<K> keySet() {
Set<K> ks = keySet;
if (ks == null) {
ks = new KeySet();
keySet = ks;
}
return ks;
}
values函数
//返回所有的value集合
public Collection<V> values() {
Collection<V> vs = values;
if (vs == null) {
vs = new Values();
values = vs;
}
return vs;
}
entrySet函数
// 返回所有的key-value集合
public Set<Map.Entry<K,V>> entrySet() {
Set<Map.Entry<K,V>> es;
return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}
tableSizeFor()
上边的一个构造函数中,调用了 tableSizeFor 方法,这个方法是怎么实现的呢?
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;
}
我们以传入参数为14 来举例,计算这个过程。
首先,14传进去之后先减1,n此时为13。然后是一系列的无符号右移运算。
//13的二进制
0000 0000 0000 0000 0000 0000 0000 1101
//无右移1位,高位补0
0000 0000 0000 0000 0000 0000 0000 0110
//然后把它和原来的13做或运算得到,此时的n值
0000 0000 0000 0000 0000 0000 0000 1111
//再以上边的值,右移2位
0000 0000 0000 0000 0000 0000 0000 0011
//然后和第一次或运算之后的 n 值再做或运算,此时得到的n值
0000 0000 0000 0000 0000 0000 0000 1111
...
//我们会发现,再执行右移 4,8,16位,同样n的值不变
//当n小于0时,返回1,否则判断是否大于最大容量,是的话返回最大容量,否则返回 n+1
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
//很明显我们这里返回的是 n+1 的值,
0000 0000 0000 0000 0000 0000 0000 1111
+ 1
0000 0000 0000 0000 0000 0000 0001 0000
将它转为十进制,就是 2^4 = 16 。我们会发现一个规律,以上的右移运算,最终会把最低位的值都转化为 1111 这样的结构,然后再加1,就是1 0000 这样的结构,它一定是 2的n次幂。因此,这个方法返回的就是大于当前传入值的最小(最接近当前值)的一个2的n次幂的值。
hash()计算原理
前面 put 方法中说到,需要先把当前key进行哈希处理,我们看下这个方法是怎么实现的。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这里,会先判断key是否为空,若为空则返回0。这也说明了hashMap是支持key传 null 的。若非空,则先计算key的hashCode值,赋值给h,然后把h右移16位,并与原来的h进行异或处理。为什么要这样做,这样做有什么好处呢?
这里,会先判断key是否为空,若为空则返回0。这也说明了hashMap是支持key传 null 的。若非空,则先计算key的hashCode值,赋值给h,然后把h右移16位,并与原来的h进行异或处理。为什么要这样做,这样做有什么好处呢?
我们知道,hashCode()方法继承自父类Object,它返回的是一个 int 类型的数值,可以保证同一个应用单次执行的每次调用,返回结果都是相同的(这个说明可以在hashCode源码上找到),这就保证了hash的确定性。在此基础上,再进行某些固定的运算,肯定结果也是可以确定的。
我随便运行一段程序,把它的 hashCode的二进制打印出来,如下。
public static void main(String[] args) {
Object o = new Object();
int hash = o.hashCode();
System.out.println(hash);
System.out.println(Integer.toBinaryString(hash));
}
//1836019240
//1101101011011110110111000101000
然后,进行 (h = key.hashCode()) ^ (h >>> 16) 这一段运算。
//h原来的值
0110 1101 0110 1111 0110 1110 0010 1000
//无符号右移16位,其实相当于把低位16位舍去,只保留高16位
0000 0000 0000 0000 0110 1101 0110 1111
//然后高16位和原 h进行异或运算
0110 1101 0110 1111 0110 1110 0010 1000
^
0000 0000 0000 0000 0110 1101 0110 1111
=
0110 1101 0110 1111 0000 0011 0100 0111
可以看到,其实相当于,我们把高16位值和当前h的低16位进行了混合,这样可以尽量保留高16位的特征,从而降低哈希碰撞的概率。
思考一下,为什么这样做,就可以降低哈希碰撞的概率呢?先别着急,我们需要结合 i = (n - 1) & hash 这一段运算来理解。
(n-1) & hash 作用
//这是 put 方法中用来根据hash()值寻找在数组中的下标的逻辑,
//n为数组长度, hash为调用 hash()方法混合处理之后的hash值。
i = (n - 1) & hash
我们知道,如果给定某个数值,去找它在某个数组中的下标位置时,直接用模运算就可以了(假设数组值从0开始递增)。如,我找 14 在数组长度为16的数组中的下标,即为 14 % 16,等于14 。18的位置即为 18%16,等于2。
其实上面的就是取模运算的位运算形式。以18%16为例
//18的二进制
0001 0010
//16 -1 即 15的二进制
0000 1111
//与运算之后的结果为
0000 0010
// 可以看到,上边的结果转化为十进制就是 2 。
//其实我们会发现一个规律,因为n是2的n次幂,因此它的二进制表现形式肯定是类似于
0001 0000
//这样的形式,只有一个位是1,其他位都是0。而它减 1 之后的形式就是类似于
0000 1111
//这样的形式,高位都是0,低位都是1,因此它和任意值进行与运算,结果值肯定在这个区间内
0000 0000 ~ 0000 1111
//也就是0到15之间,(以n为16为例)
//因此,这个运算就可以实现取模运算,而且位运算还有个好处,就是速度比较快。
为什么高低位异或运算可以减少哈希碰撞
我们想象一下,假如用 key 原来的hashCode值,直接和 (n-1) 进行与运算来求数组下标,而不进行高低位混合运算,会产生什么样的结果。
//例如我有另外一个h2,和原来的 h相比较,高16位有很大的不同,但是低16位相似度很高,甚至相同的话。
//原h值
0110 1101 0110 1111 0110 1110 0010 1000
//另外一个h2值
0100 0101 1110 1011 0110 0110 0010 1000
// n -1 ,即 15 的二进制
0000 0000 0000 0000 0000 0000 0000 1111
//可以发现 h2 和 h 的高位不相同,但是低位相似度非常高。
//他们分别和 n -1 进行与运算时,得到的结果却是相同的。(此处n假设为16)
//因为 n-1 的高16位都是0,不管 h 的高 16 位是什么,与运算之后,都不影响最终结果,高位一定全是 0
//因此,哈希碰撞的概率就大大增加了,并且 h 的高16 位特征全都丢失了。
爱思考的同学可能就会有疑问了,我进行高低16位混合运算,是可以的,这样可以保证尽量减少高区位的特征丢失。那么,为什么选择用异或运算呢,我用与、或运算不行吗?
这是有一定的道理的。我们看一个表格,就能明白了。
可以看到两个值进行与运算,结果会趋向于0;或运算,结果会趋向于1;而只有异或运算,0和1的比例可以达到1:1的平衡状态。
所以,异或运算之后,可以让结果的随机性更大,而随机性大了之后,哈希碰撞的概率当然就更小了。
以上,就是为什么要对一个hash值进行高低位混合,并且选择异或运算来混合的原因。
为什么HashMap链表会形成死循环
准确的讲应该是 JDK1.7 的 HashMap 链表会有死循环的可能,因为JDK1.7是采用的头插法,在多线程环境下有可能会使链表形成环状,从而导致死循环。JDK1.8做了改进,用的是尾插法,不会产生死循环。
那么,链表是怎么形成环状的呢?
我们从 put()方法开始,最终找到线程不安全的那个方法。这里省略中间不重要的过程,我只把方法的跳转流程贴出来:
//添加元素方法 -> 添加新节点方法 -> 扩容方法 -> 把原数组元素重新分配到新数组中
put() --> addEntry() --> resize() --> transfer()
问题就发生在 transfer 这个方法中。
我们假设,原数组容量只有2,其中一条链表上有两个元素 A,B,如下图:
现在,有两个线程都执行 transfer 方法。每个线程都会在它们自己的工作内存生成一个newTable 的数组,用于存储变化后的链表,它们互不影响(这里互不影响,指的是两个新数组本身互不影响)。但是,需要注意的是,它们操作的数据却是同一份。
因为,真正的数组中的内容在堆中存储,它们指向的是同一份数据内容。就相当于,有两个不同的引用 X,Y,但是它们都指向同一个对象 Z。这里 X、Y就是两个线程不同的新数组,Z就是堆中的A,B 等元素对象。
假设线程一执行到了上图中所指的代码①处,恰好 CPU 时间片到了,线程被挂起,不能继续执行了。记住此时,线程一中记录的 e = A , e.next = B。
然后线程二正常执行,扩容后的数组长度为 4, 假设 A,B两个元素又碰撞到了同一个桶中。然后,通过几次 while 循环后,采用头插法,最终呈现的结构如下:
此时,线程一解挂,继续往下执行。注意,此时线程一,记录的还是 e = A,e.next = B,因为它还未感知到最新的变化。
我们主要关注图中标注的①②③④处的变量变化:
/**
* next = e.next
* e.next = newTable[i]
* newTable[i] = e;
* e = next;
*/
//第一次循环,(伪代码)
e=A;next=B;
e.next=null //此时线程一的新数组刚初始化完成,还没有元素
newTab[i] = A->null //把A节点头插到新数组中
e=B; //下次循环的e值
第一次循环结束后,线程一新数组的结构如下图:
然后,由于 e=B,不为空,进入第二次循环(此时看到的链表是线程二修改后的链表关系)。
//第二次循环
e=B;next=A; //此时A,B的内容已经被线程二修改为 B->A->null,然后被线程一读到,所以B的下一个节点指向A
e.next=A->null // A->null 为第一次循环后线程一新数组的结构
newTab[i] = B->A->null //新节点B插入之后,线程一新数组的结构
e=A; //下次循环的 e 值
第二次循环结束后,线程一新数组的结构如下图:
此时,由于 e=A,不为空,继续循环。
//第三次循环
e=A;next=null; // A节点后边已经没有节点了
e.next= B->A->null // B->A->null 为第二次循环后线程一新数组的结构
//我们把A插入后,抽象的表达为 A->B->A->null,但是,A只能是一个,不能分身啊
//因此实际上是 e(A).next指向发生了变化,A的 next 由指向 null 改为指向了 B,
//而 B 本身又指向A,因此A和B互相指向,成环
newTab[i] = A->B 且 B->A
e=next=null; //e此时为空,结束循环
第三次循环结束后,看下图,A的指向由 null ,改为指向为 B,因此 A 和 B 之间成环。
这时,有的同学可能就会问了,就它们成环了,又怎样,跟死循环有什么关系?
我们看下 get() 方法(最终调用 getEntry 方法),
可以看到查找元素时,只要 e 不为空,就会一直循环查找下去。若有某个元素 C 的 hash 值也落在了和 A,B元素同一个桶中,则会由于, A,B互相指向,e.next 永远不为空,就会形成死循环。
HashMap的数据覆盖问题
JDK1.7的数据覆盖
最开始HashMap的状态如下:
下面假设在多线程环境下,有两个线程A和B都在进行put操作。线程A在第一次执行到transfer函数中第11行代码处挂起(还未执行第十一行代码),因为该函数在这里分析的地位非常重要,因此再次贴出来。注意这里假定线程A刚刚执行完第十一行代码,在工作区完成了对e.next的修改,但是还没有将修改结果同步回主线程,也就第此时在工作区7.next = null,但是在主内存中,7的next指向的还是最初的5节点。
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != 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 = newTable[i];
newTable[i] = e; //线程A刚执行到这里就挂起,还未执行该行代码
e = next;
}
}
}
线程A在挂起前的执行步骤如下:
1)在线程A挂起之前,它已经遍历到了旧数组第一个不为空的桶,也就是旧数组的table[1],它先去遍历挂在这个桶上的链表。此时代码中的变量e = 7节点,它是链表的头节点,开始以它为起始点遍历链表。
2)第一次执行完第五行的代码后,将线程自己的局部变量next设置成了e.next,也就是局部变量next = 5
3)然后第九行代码计算出当前遍历到的节点e = 7,在新数组中对应的下标位置,i = 3 (扩容后数组大小为4)
4)第一次执行完第十行代码后,将e.next指向了新数组下标位置为3的桶,也就是 7.next = newTable[3] = null。在这一步的时候,就将旧数组中的链表拆分成了7和5、3两部分,7的next指向了新数组对应的桶位置,5、3链表脱离了旧数组,用next局部变量指向5来保证可达性
此时线程A中运行结果如下:
注意上述的结果,仅仅是在线程A的工作空间中如此,但是在主内存中,并不是这样的,因为线程A对主内存中拷贝下来的数据进行修改操作之后,还没来得及将修改结果同步给主内存就被挂起了。
此时线程B已获得CPU时间片,并完成resize扩容操作,线程B的运行结果如下:
此时线程B已经完成扩容,并且将修改结果都同步给了主内存,此时主内存的数据也是这样的。
此时切换回线程A,在线程A挂起时:e=7,next=5,newTable[3]=null。
这里注意一下,e本身是线程A的局部变量,但是它其中存储的地址是主内存的地址,也就是7节点,在HashMap的操作过程中,每个数据节点在主内存的地址都是不会改变的,改变的只是其next的指向,进而改变不同节点的连接顺序,但节点本身在主内存中的位置是不变的。所以此时e指向的7节点就是在主内存中真正的7节点,修改e后再将修改数据同步回主内存就相当于修改了节点7。
切换回线程A后,主内存中的数据已经被线程B修改了,线程A后续在工作空间中使用到被修改的主内存数据时,会得到总线的通知,告诉线程A此时在它工作空间中的数据已经过时了,需要从主内存中更新最新数据,然后线程A就会把相应的数据进行更新。线程A对已修改数据的更新应该不是同时发生的,而是线程A用到哪个数据的时候,才会去更新,但是这里为了方面后面的讲解,我们就一次性把所有的数据都更新好,这并不影响最后的分析结果。
线程A被唤醒后,线程A所面临的数据情况如下图:
唤醒线程A后,首先线程A会将之前在工作内存中修改的7.next = newTable[3]回写到主内存中,将主内存的7.next = null
1)执行第十一行newtable[i]=e:就将7放在了newTable[3]的位置,Java对象是地址传递,在数组中存储的都是地址。注意此时没有改变7.next的指向,7.next仍然指向null。此时局部变量next=5,然后将e = next = 5。接着进行下一次循环。在这一步中,原本在newTable[3]上的3节点就被7给覆盖了,3节点就丢失了。
2)进入下一轮循环,执行第五行 Entry<K,V> next = e.next:首先将局部变量next=e.next,也就是将next=null,此时e.next是从主内存中获取的,在主内存中5.next == null,所以局部变量next设置成了null。
3)执行第十行 e.next = newTable[i]:接着将e.next=newTable[1],此时newTable[1]也是从主内存中获取到的最新值,所以e.next=5,也就是5.next = 5,形成了一个环路。
4)执行第十一行 newTable[i] = e:然后将newTable[1]=e,也就是newTable[1]=5。
5)执行第十二行 e = next:最后e=next ,也就是 e=null,至此e==null,就会结束循环。
3元素丢失,并形成环形链表,在后续操作hashmap时会造成死循环。
JDK1.8的数据覆盖
在jdk1.8中对HashMap进行了优化,采用的不是头插法,而是直接插入链表尾部,因此不会出现环形链表的情况,但是在多线程的情况下仍然不安全。
/**
* Implements Map.put and related methods.
* 实现了map的put和相关方法
* @param hash key的hash值(key的hash高16位+高16位与低16位的异或运算)
* @param key 键
* @param value 值
* @param onlyIfAbsent onlyIfAbsent为true的时候不要修改已经存在的值,如果onlyIfAbsent为false,当插入的元素已经在HashMap中已经拥有了与其key值和hash值相同的元素,仍然需要把新插入的value值覆盖到旧value上。如果onlyIfAbsent为true,则不需要修改
* @param evict evict如果为false表示构造函数调用
* @return 返回旧的value值(在数组桶或链表或红黑树中找到存在与插入元素key值和hash值相等的元素,就返回这个旧元素的value值),如果没有发现相同key和hash的元素则返回null
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// tab用来临时存放数组table引用 p用来临时存放数组table桶中的bin
// n存放HashMap容量大小 i存放当前put进HashMap的元素在数组中的位置下标
Node<K,V>[] tab; Node<K,V> p; int n, i;
// table未初始化或者长度为0,进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
if ((p = tab[i = (n - 1) & hash]) == null) // 如果没有hash碰撞,则直接插入元素
tab[i] = newNode(hash, key, value, null);
// 桶中已经存在元素
else {
// e记录当前节点 k记录key值
Node<K,V> e; K k;
// 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 将第一个元素赋值给e,用e来记录。直接将插入的新元素覆盖旧元素
e = p;
// hash值不相等,即key不相等并且该节点为红黑树结点,将元素插入红黑树
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) {
// 在尾部插入新结点
p.next = newNode(hash, key, value, null);
// 结点数量达到阈值(默认为 8 ),执行 treeifyBin 方法
// 这个treeifyBin()方法会根据 HashMap 数组情况来决定是否转换为红黑树。
// 只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少执行效率。否则,就是只是对数组扩容。
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 树化操作
treeifyBin(tab, hash);
// 跳出循环 此时e=null,表示没有在链表中找到与插入元素key和hash值相同的节点
break;
}
// 判断链表中结点的key值和Hash值与插入的元素的key值和Hash值是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 若相等,则不用将其插入了,直接跳出循环
break;
// 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
p = e;
}
}
// 当e!=null时,表示在数组桶或链表或红黑树中存在key值、hash值与插入元素相等的结点。此时就直接用原有的节点就可以了,不用插入新的元素了。此时e就代表原本就存在于HashMap中的元素
if (e != null) {
// 记录e的value,也就是旧value值
V oldValue = e.value;
// onlyIfAbsent为false或者旧值为null,则需要用新的value值对旧value值进行覆盖
if (!onlyIfAbsent || oldValue == null)
//用新值替换旧值
e.value = value;
// 替换旧值时会调用的方法(默认实现为空)
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// 结构性修改,记录HashMap被修改的次数,主要用于多线程并发时候
++modCount;
// 实际大小大于阈值则扩容 ++size只有在插入新元素才会执行,如果发现HashMap中已经存在了相同key和hash的元素,就不会插入新的元素,在上面就已经执行return了,也就不会改变size大小
if (++size > threshold)
resize();
// 插入成功时会调用的方法(默认实现为空)
afterNodeInsertion(evict);
// 没有找到原有相同key和hash的元素,则直接返回Null
return null;
}
这是jdk1.8中HashMap中put操作的主函数, 注意这一行代码if ((p = tab[i = (n - 1) & hash]) == null),如果没有hash碰撞则会直接插入元素。如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null,所以这线程A、B都会进入这行代码中。
假设一种情况,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,问题出现:线程A会把线程B插入的数据给覆盖,发生线程不安全。
并发环境下红黑树结构上的死循环问题(JDK1.8)
JDK1.7的时候,讲过扩容导致的死循环问题,其原因为扩容迁移数据的时候采用的是头插法,因为如果采用头插法,必然会涉及到让遍历到节点的next指向数组桶位置的节点(也就是链表头节点),但是标识当前遍历到的节点的标识e和标识其下一个节点next这两个变量都是线程自己独有的私有变量,所以当有多个线程同时操作同一个对象时,就可能出现不同线程之间内部属性不一致的情况,进而导致让某一个节点e的next指向数组桶上的节点,但是此时数组桶上的节点已经指向了e了,这就造成了一个链表上的死循环,导致了并发错误。
但是JDK1.8扩容迁移数据的时候,采用的是尾插法,直接将数据插入到链表或者红黑树的尾部。这样每次将节点加入到链表的时候,都是先遍历到链表尾部,这个过程中肯定遍历的是正确的链表,一直遍历到链表尾部null节点时才回去执行插入操作,也就不会出现那种链表死循环的情况了。
但是!!需要注意的一点是,JDK1.8虽然解决了并发环境下链表上出现死循环的问题,但是如果是将数据加入到红黑树中,在红黑树结构上还是有可能出现死循环问题的。
出现循环的是balanceInsertion()方法,这个方法是插入红黑树节点的方法,在插入红黑树节点的同时,还需要重新维护红黑树结构,使其继续符合红黑树的性质,保证其相对平衡。
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root, TreeNode<K,V> x) {
// 新插入的节点标为红色
x.red = true;
//无限for循环,定义xp、xpp、xppl、xppr变量,在循环体进行赋值,p就是parents
//- root:当前根节点
//- x :新插入的节点
//- xp :新插入节点的父节点
//- xpp :新插入节点的祖父节点
//- xppl:新插入节点的左叔叔节点
//- xppr:新插入节点的右叔叔节点
for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {
// 为定义的各个变量赋值的过程
if ((xp = x.parent) == null) {
x.red = false;
return x;
}
else if (!xp.red || (xpp = xp.parent) == null)
return root;
// 重点看这里
// 如果父节点是爷爷节点的左孩子
if (xp == (xppl = xpp.left)) {
// 如果右叔叔不为空且为红色
if ((xppr = xpp.right) != null && xppr.red) {
// 右叔叔变为黑色
xppr.red = false;
// 父节点变为黑色
xp.red = false;
// 爷爷节点变为黑色
xpp.red = true;
// 将爷爷节点当作起始节点,再次循环,请注意再次循环!!!
x = xpp;
}
// 省略其他代码
}
// 省略其他代码
}
}
总结一下上边的源码就是,新插入一个节点,该方法要保持红黑树的五个性质:
- 节点是红色或黑色。
- 根节点是黑色。
- 每个叶节点(NIL节点,空节点)是黑色的。
- 每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
- 从任一节点到其每个叶子的路径上包含的黑色节点数量都相同。
常见面试题
为什么HashMap默认的长度为2的整数次幂?
因为获取索引h&(length-1)可以保证散列的均匀,避免不必要的hash冲突。
为什么加载因子是0.75?大了会怎么样?小了会怎么样?
首先加载因子是表示hash表的填满程度,当为0.75的时候是在对提高空间利用率和减少查询成本的折中,当大于0.75的时候填满的元素越多,空间利用率越高,但是冲突的概率变大;当小于0.75的时候填满的元素越少,空间利用率越低,但是冲突的概率变小。
什么是哈希冲突?如何解决?
哈希冲突是指hash出来的地址被其他元素所占用;
解决的方法
链地址法:当出现冲突的时候将冲突的元素加入当前的链表之中
开放地址法:如果映射的地址被占用了,在哈希函数值的基础上加上指定数值,这样就可以把冲突的地址给错开,然后重新开辟新的地址用来存储。根据增量值的不同,分为线性探测再散列和二次探测再散列
再哈希法:构造多个不同的哈希函数,当哈希地址Hi=RH1(Key)发生冲突时,再计算Hi=RH2(Key)…直到哈希不冲突,这样的方法增加了计算的时间。
建立公共溢区:哈希表分成了两个表:一个是基础表,另外一个则是溢出表,凡是与基础表发生冲突的数据都会被添加到溢出表。
什么是扰动函数?怎么设计的?为什么这个设计?
扰动函数是hash函数拿到k的hashcode值,这个值是一个32位的int,让高16位与低16位进行异或。
理论上来说字符串的hashCode是一个int类型值,那可以直接作为数组下标了,且不会出现碰撞。但是这个hashCode的取值范围是[-2147483648, 2147483647],有将近40亿的长度,谁也不能把数组初始化的这么大,内存也是放不下的。
混合原始哈希码的高位和低位,以此来加大低位的随机性,这样设计在一定的程度上减少了hash碰撞,优化了散列的效果 。
JDK1.8在对HashMap较1.7有什么优化?
- 首先是最重要的就是底层的数据结构,1.7的底层数据结构是数组+链表;而在1.8的时候变成了数组+链表+红黑树
- 在哈希上1.7扰动四次,1.8做了一次扰动,可以提高效率
- 1.7在进行resize扩容的时候是重新哈希,1.8的时候采用的是索引位置不变或者就是就哈希表的容量+当前索引。
- 1.7采用插入方式是头插法,1.8采用的是尾插法。
为什么1.8扩容不用重新哈希?
HashMap线程安全吗?为什么不安全?怎么解决不安全?
首先HashMap是线程不安全的。JDK1.7的时候采用头插法,多线程同时插入的时候,A线程在插入节点B,B线程也在插入,遇到容量不够开始扩容,重新hash,放置元素,采用头插法,后遍历到的B节点放入了头部,这样形成了环。JDK1.8采用尾插法,会造成两种情况两个线程同时插入只有一个成功插入(即发生数据覆盖),还有就是可能会造成两次resize(++size > threshold) 。
解决的方案:
- 使用Hashtable,但是效率比较差
- 使用ConcurrentHashMap,这也是比较常用的。
- 使用Collections.synchronizedMap()
以上三种线程安全。
HashMap内部节点是有序的吗?
不是有序的。有序的Map集合有LinkedHashMap、TreeMap。
HashMap一般采用什么作为key?
HashMap一般采用String作为key、因为这些类底层已经重写了hashcode、equals方法,用的是final修饰类在多线程情况下相对安全。
为什么重写equals还要重写hashcode?
比如HashMap中不允许存在相同的key,当重写了equals方法没有重写hashcode方法,当两个对象中的值相同,但是它们们hashcode不同会造成比如
class newInstance1 = new class(1);
class newInstabce2 = new class(2);
以上的比较对象的时候hashcode不同,equal方法比较返回false;但是重写Hashcode后,可以达到返回true。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!