HashMap的底层源码
JDK 1.8 对 HashMap 进行了比较大的优化,底层实现由之前的 “数组+链表” 改为 “数组+链表+红黑树”,本文就 HashMap 的几个常用的重要方法和 JDK 1.8 之前的死循环问题展开学习讨论。
JDK 1.8 的 HashMap 的数据结构如下图所示,当链表节点较少时仍然是以链表存在,当链表节点较多时(大于8)会转为红黑树。
参数
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> 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;
boolean red;
}
属性
final float loadFactor;
transient int size;
transient int modCount;
transient Node<K,V>[] table;
构造方法
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
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;
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;
}
get 方法
返回指定键映射到的值,如果此映射不包含该键的映射,则返回null 。
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) {
if (first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
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;
}
如果是红黑树节点,则调用红黑树的查找目标节点方法 getTreeNode
final TreeNode<K,V> getTreeNode(int h, Object k) {
return ((parent != null) ? root() : this).find(h, k, null);
}
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
TreeNode<K,V> p = this;
do {
int ph, dir; K pk;
TreeNode<K,V> pl = p.left, pr = p.right, q;
if ((ph = p.hash) > h)
p = pl;
else if (ph < h)
p = pr;
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
else if (pl == null)
p = pr;
else if (pr == null)
p = pl;
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) &&
(dir = compareComparables(kc, k, pk)) != 0)
p = (dir < 0) ? pl : pr;
else if ((q = pr.find(h, k, kc)) != null)
return q;
else
p = pl;
} while (p != null);
return null;
}
put 方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
传进来的key会通过调用hash()方法来计算出哈希值(先用本地hashcode方法,与其右移16位进行异或操作,进行扰动扰动),目的是为了降低 hash 冲突的概率。
让高位也参加到运算当中来
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
真正的put方法
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;
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))))
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) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
resize 方法
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;
return oldTab;
}
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) {
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) {
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 {
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;
}
红黑树的重新hash方法 split()
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
if (loHead != null) {
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
if (hiHead != null)
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);
}
}
}
如果红黑树的结点小于等于6转回链表结构
final Node<K,V> untreeify(HashMap<K,V> map) {
Node<K,V> hd = null, tl = null;
for (Node<K,V> q = this; q != null; q = q.next) {
Node<K,V> p = map.replacementNode(q, null);
if (tl == null)
hd = p;
else
tl.next = p;
tl = p;
}
return hd;
}
HashMap常见问题
HashMap 和 Hashtable 的区别
-
HashMap 允许 key 和 value 为 null,Hashtable 不允许。 -
HashMap 的默认初始容量为 16,Hashtable 为 11。 -
HashMap 的扩容为原来的 2 倍,Hashtable 的扩容为原来的 2 倍加 1。 -
HashMap 是非线程安全的,Hashtable是线程安全的。 -
HashMap 的 hash 值重新计算过,Hashtable 直接使用 hashCode。 -
HashMap 去掉了 Hashtable 中的 contains 方法。 -
HashMap 继承自 AbstractMap 类,Hashtable 继承自 Dictionary 类。
HashMap的底层数据结构是什么
? 在jdk1.7之前,由数组加链表的形式组成,数组是主体,链表是为了解决哈希冲突存在的。
? 在jdk1.8中,它由数组+链表+红黑数组成,因为如果链表过程,会严重影响HashMap的性能,红黑数搜索的时间复杂度是O(logn),而链表是O(n),
转换条件
? 当链表长度大于8且数组长度(数据总量)超过64才会转换为红黑数
? 如果树立量小于64,那么先选择对数组进行扩容,而不是转换为红黑数,减少搜索时间。
说一下HashMap的特点
- 键是唯一的
解决哈希冲突的方法有哪些?HashMap用的哪种?
开放定址法,再哈希法,链地址法,简历公共溢出区
HashMap中采用的是链地址法
开放定址法也被称为再散列法,基本思想就是p=H(key)出现冲突时,以p为基础再次哈希,p1=H§,如果p1再次出现冲突,则以p1为基础,一次类推,知道一个不冲突的哈希地址pi。因此开放地址法需要hash表的长度大于所需要存放的元素。
因为存在再次哈希,只能在删除的结点上做标记,而不能真正删除结点。
再哈希法又称为双重散列,多重散列,提供了多个不同的hash函数,R1=H1(key1)发生冲突时,再计算R2=H2(key1),知道没有冲突为止,这样虽然不容易产生堆集,但增加了计算的时间。
链地址法也叫做拉链法,将哈希值相同的元素构成一个同义词的单链表,并将单链表的头指针存放在哈希表的第i个单元中,查找、插入和删除主要在同义词链表中进行,链表法适用于经常进行插入和删除的情况。
建立公共溢出区,将哈希表分别为公共表和溢出表,当溢出发生时,将所有溢出数据统一放到溢出区。
为什么负载因子设置为0.75,初始化临界值为12?
? 负载因子是什么时候触发扩容的一个条件,负载因子越大,它一次扩容所能容纳的容量就越多,loadFactory越趋近1,那么数组中存放的数据(entry也就越来越多)越多,数据也就越密集,也会有更多的链表长度处于更长的数据,我们的查询效率就会越低,当我们添加数据,产生hash冲突的概率也就越高。
? 默认的loadFactory是0.75,loadFactory越小,越趋近于0,数组中个存放的数据(entry)也就越少,表现得更加稀疏
? 0.75是对空间和时间效率的一种平衡选择。
如果负载因子小一些的话,比如是0.4,那么初始化长度16*0.4=6,数组占满6个空间就进行扩容,会造成大量空 间的浪费 。
如果大一些是0.9,这样会导致扩容之前查找元素的效率变低,
loadfactory设置为0.75是经过多重计算检验得到的可靠值,可以最大程度的减少rehash的次数,避免过多的性能消耗。
哈希表底层采用何种算法计算hash值?还有哪些算法可以计算出hash值?
? hashCode方法是Object中的额方法,所有的类都可以对其进行使用,首先底层调用hashCode方法生成的初始hash值h1,然后将h1无符号右移16位得到h2,只有将h1与h2进行按位异或(^)运算得到最终hash值h3,之后将h3与(length-1)进行按位与(&)运算得到hash表索引。
当两个hashCode相等时会怎么样?
? hsahCode相当于产生hash碰撞,hashCode相等会调用equals方法比较内容是否相等,内容相等则会进行覆盖,如果内容不相等则会连接到链表后方,当链表长度超过8且数组长度超过64会由链表转换为红黑数结点。
合适发生哈希碰撞和什么是哈希碰撞,如何解决哈希碰撞?
? 当两个元素的key计算出的哈希值相等时,就产生了哈希碰撞,如果元素相同则进行覆盖操作,如果不相同,jdk8之前使用的是数组+链表的方式,1.8之后使用的是数组+链表+红黑树方式解决哈希碰撞。
HashMap的put方法流程
以jdk8为例,简要流程如下:
- 首先根据key计算hash值,找到钙元素在数组中存储的下标
- 如果数组是空的吗,则调用resize进行初始化
- 如果没有哈希冲突则直接放进对对应的下标中
- 如果有冲突,且key已经存在,则进行覆盖操作
- 如果冲突后是链表,则判断链表是否大于8,如果大于8并且数组容量小于64,就进行扩容;如果链表数量大于8并且数组的容量大于64,则将这个结构转换成红黑树;否则,链表插入键值对,若key存在,就覆盖掉value
- 如果冲突发生后发现该节点是红黑树,就将该节点挂在树上。
HashMap的扩容方式
? HashMap的容量在超过负载因子所定义的容量后就会进行扩容操作。
? java中的数组是无法自己扩容的,将HashMap的大小扩大为原来数组的两倍。
一般用什么作为HashMap的key?
一般使用String、Integer这种不可变的类来当HashMap的key
- 因为String是不了可变的,当他创建字符串时,它的hashCode被缓存下来,不需要再次计算,相对于其他对象更快
- 因为获得对象时要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,这些类的规范的重写了hashCode()以及equals()方法8
为什么Map桶中结点个数超过8才转红黑树?
? 树结点占用的空间是普通Node的两倍,如果链表结点不够多却转换为红黑树,无疑会耗费大量的空间资源,并且在随机hash算法下的所有结点分布遵从泊松分布,概率很低,几乎不可能达到8。
- 从平均查找长度来看,红黑树的平均查找长度是logn,如果长度为8,则logn=3,而链表的平均查找长度为n/4,长度为8时,n/2=4,所以阈值8能大大提高搜索速度。
- 当长度为6时红黑树退化为链表是因为logn=log6约等于2.6,而n/2=6/2=3,两者相差不大,而红黑树结点占用更多的内存空间,所以此时转换最好。
什么HashMap线程不安全?
- 多线程下容易死循环。jdk1.7中的HashMap使用头插法插入元素,在多线程环境下,扩容的时候可能导致环形链表的产生,形成死循环。因此jdk1.8之后使用尾插法插入元素,在扩容时会保持元素的原本顺序买不回产生环形链表。
- 多线程的put可能会导致元素丢失。多线程同时执行put操作,如果计算出的索引位置相同,可能造成前一个key被后一个key覆盖,从而导致元素的丢失。此问题在1.7和1.8中都存在。
- put和get并发时,可能导致get为null。线程1执行put时,因为元素个数超出threshold而导致rehash,线程2此时执行get,有可能会导致这个问题。
计算hash值时为什么要让低16bit和高16bit进行异或处理?
? 如果数组很小,比如16,这样的值和hashCode做异或或实际上只有hashCode值的后4位进行运算,hash值是一个随机值,而如果产生的hashCode高位变化很大,而低位变化很小,那么有很大概率造成哈希冲突,所以我们为了使元素更好的散列,将hash值的高位也利用起来。
总结
借鉴博客:https://blog.csdn.net/v123411739/article/details/78996181
-
HashMap 的底层是个 Node 数组(Node<K,V>[] table),在数组的具体索引位置,如果存在多个节点,则可能是以链表或红黑树的形式存在。 -
增加、删除、查找键值对时,定位到哈希桶数组的位置是很关键的一步,源码中是通过下面3个操作来完成这一步:1)拿到 key 的 hashCode 值;2)将 hashCode 的高位参与运算,重新计算 hash 值;3)将计算出来的 hash 值与 “table.length - 1” 进行 & 运算。 -
HashMap 的默认初始容量(capacity)是 16,capacity 必须为 2 的幂次方;默认负载因子(load factor)是 0.75;实际能存放的节点个数(threshold,即触发扩容的阈值)= capacity * load factor。 -
HashMap 在触发扩容后,阈值会变为原来的 2 倍,并且会对所有节点进行重 hash 分布,重 hash 分布后节点的新分布位置只可能有两个:“原索引位置” 或 “原索引+oldCap位置”。例如 capacity 为16,索引位置 5 的节点扩容后,只可能分布在新表 “索引位置5” 和 “索引位置21(5+16)”。 -
导致 HashMap 扩容后,同一个索引位置的节点重 hash 最多分布在两个位置的根本原因是:1)table的长度始终为 2 的 n 次方;2)索引位置的计算方法为 “(table.length - 1) & hash”。HashMap 扩容是一个比较耗时的操作,定义 HashMap 时尽量给个接近的初始容量值。 -
HashMap 有 threshold 属性和 loadFactor 属性,但是没有 capacity 属性。初始化时,如果传了初始化容量值,该值是存在 threshold 变量,并且 Node 数组是在第一次 put 时才会进行初始化,初始化时会将此时的 threshold 值作为新表的 capacity 值,然后用 capacity 和 loadFactor 计算新表的真正 threshold 值。 -
当同一个索引位置的节点在增加后达到 9 个时,并且此时数组的长度大于等于 64,则会触发链表节点(Node)转红黑树节点(TreeNode),转成红黑树节点后,其实链表的结构还存在,通过 next 属性维持。链表节点转红黑树节点的具体方法为源码中的 treeifyBin 方法。而如果数组长度小于64,则不会触发链表转红黑树,而是会进行扩容。 -
当同一个索引位置的节点在移除后达到 6 个时,并且该索引位置的节点为红黑树节点,会触发红黑树节点转链表节点。红黑树节点转链表节点的具体方法为源码中的 untreeify 方法。 -
HashMap 在 JDK 1.8 之后不再有死循环的问题,JDK 1.8 之前存在死循环的根本原因是在扩容后同一索引位置的节点顺序会反掉。 -
HashMap 是非线程安全的,在并发场景下使用 ConcurrentHashMap 来代替。
|