梳理HahsMap知识,文章结构及思路如下:
目录
讲一下几个主要函数的逻辑思路吧 get(),put(),resize(),replace(),remove()
一、主要特点
- 底层实现是 链表数组+红黑树,拉链法
- key 用 Set 存放,不允许重复,key 如果用对象则需要重写 hashCode 和 equals 方法
- 允许空键和空值,但空键只有一个
- 元素是无序的,而且顺序会不定时改变
- 插入、获取的时间复杂度基本是 O(1)(前提是有适当的哈希函数,让元素分布在均匀的位置)
- 两个关键因子:初始容量、加载因子
二、继承关系
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
三、数据结构
数组+链表+红黑树(JDK1.8增加了红黑树部分)
主要元素
/**
* 默认初始容量16——必须是2的幂
* 01向左补四位,2的四次方
* hashCode & (length-1); 15位与14位相比,与hashcode相与会有更多的结果,且不浪费空间
* 所以将length定位二次幂,在进行hash运算时,不同的key算得index相同的几率较小,那么数据在数组上分布就比较均匀,
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* 最大容量,必须是2的幂 2的30次方
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 载荷因子
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* treeify_threshold由链表转化为红黑书的阀值
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 红黑树节点转换链表节点的阈值
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 转红黑树时数组应该满足的长度
* 至少是 4 * TREEIFY_THRESHOLD ,节省效率
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* 基本的哈希节点,链表节点, 继承自Entry
* k,v是Map<k,v>传入的数据类型
*/
static class Node<K,V> implements Map.Entry<K,V> {
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;
}
@Override
public final K getKey() { return key; }
@Override
public final V getValue() { return value; }
@Override
public final String toString() { return key + "=" + value; }
@Override
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
@Override
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
@Override
public final boolean equals(Object o) {
//存储位置相同
if (o == this) {
return true;
}
//instanceof是Java中的一个双目运算符,用来测试一个对象是否为一个类的实例
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
return Objects.equals(key, e.getKey()) && Objects.equals(value,
e.getValue());
}
return false;
}
}
//将不需要序列化的属性前添加关键字transient,序列化对象的时候,这个属性就不会被序列化。
//table数组
transient Node<K,V>[] table;
/**
* Holds cached entrySet(). Note that AbstractMap fields are used
* for keySet() and values().
*/
transient Set<Map.Entry<K,V>> entrySet;
// 大小
transient int size;
transient int modCount;
/**
* 转化为红黑树的阀值
*/
int threshold;
/**
* 哈希表的负载系数。
*/
final float loadFactor;
好的Hash算法和扩容机制,可以使Hash碰撞的概率又小,哈希桶数组(Node[] table)占用空间又少
四、核心方法解析
hash()
第一步,拿到key.hashCode()
第二步高16位异或运算
(>>> 表示无符号右移,也叫逻辑右移,即若该数为正,则高位补0,而若该数为负数,则右移后高位同样补0)
Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这里借用一个图来表示整个key.hashcode()变化到下标的处理过程
通过这个计算过程可以看出,生成的数组下标会因为‘扰动’的增加而减少碰撞的机率。
comparableClassFor()
/**
* Returns x's Class if it is of the form "class C implements
* Comparable<C>", else null.
*/
static Class<?> comparableClassFor(Object x) {
if (x instanceof Comparable) {
Class<?> c; Type[] ts, as; Type t; ParameterizedType p;
if ((c = x.getClass()) == String.class) // bypass checks
return c;
if ((ts = c.getGenericInterfaces()) != null) {
for (int i = 0; i < ts.length; ++i) {
if (((t = ts[i]) instanceof ParameterizedType) &&
((p = (ParameterizedType)t).getRawType() ==
Comparable.class) &&
(as = p.getActualTypeArguments()) != null &&
as.length == 1 && as[0] == c) // type arg is c
return c;
}
}
}
return null;
}
/**
* Returns k.compareTo(x) if x matches kc (k's screened comparable
* class), else 0.
*/
@SuppressWarnings({"rawtypes","unchecked"}) // for cast to Comparable
static int compareComparables(Class<?> kc, Object k, Object x) {
return (x == null || x.getClass() != kc ? 0 :
((Comparable)k).compareTo(x));
}
tableSizeFor()
【作用】返回给定目标容量的2倍幂。将我们传入的容量设置为大于并最接近的2^N
【解读】
//补位,将原本为0的空位填补为1,最后加1时,最高有效位进1,其余变为0,如此就可以取到最近的2的幂
static final int tableSizeFor(int cap) {
//减一后,最右一位肯定和cap的最右一位不同,即一个为0,一个为1
int n = cap - 1;
//(>>>)无符号右移一位,(|)按位或
n |= n >>> 1;
//(>>>)无符号右移两位,(|)按位或
n |= n >>> 2;
//(>>>)无符号右移四位,(|)按位或
n |= n >>> 4;
//(>>>)无符号右移八位,(|)按位或
n |= n >>> 8;
//(>>>)无符号右移十六位,(|)按位或,为何到16呢,存疑
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
HashMap()
指定了初始容量和加载因子,会对参数进行校验
初始容量不能为负数,不能大于最大容量 1 << 30 (2^30)
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);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
/**
*
* @param m the map whose mappings are to be placed in this map
* @throws NullPointerException if the specified map is null
*/
public HashMap1(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
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;
//tab 指向哈希表,n 为哈希表的长度,first 为 (n - 1) & hash 位置处的桶中的头一个节点
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)
//如果是树形节点,就调用树形节点的 get 方法
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
//do-while 遍历链表的所有节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
put()
这里看到知乎一个大神的相关回答,图片描述非常形象,引用如下:
(大神回答链接如下:https://zhuanlan.zhihu.com/p/21673805)
如果定位到的数组位置没有元素就直接插入;
如果定位到的数组位置有元素就要与插入的key比较,
如果key相同就直接覆盖,
如果key不相同,就判断p是否是一个树节点,
如果是就调用e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value)将元素添加进入;
如果不是就遍历链表尾部插入。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 1.校验table是否为空或者length等于0,如果是则调用resize方法进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2.通过hash值计算索引位置,将该索引位置的头节点赋值给p,如果p为空则直接在该索引位置新增一个节点即可
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// table表该索引位置不为空,则进行查找
Node<K,V> e; K k;
// 3.判断p节点的key和hash值是否跟传入的相等,如果相等, 则p节点即为要查找的目标节点,将p节点赋值给e节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 4.判断p节点是否为TreeNode, 如果是则调用红黑树的putTreeVal方法查找目标节点
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 5.走到这代表p节点为普通链表节点,则调用普通的链表方法进行查找,使用binCount统计链表的节点数
for (int binCount = 0; ; ++binCount) {
// 6.如果p的next节点为空时,则代表找不到目标节点,则新增一个节点并插入链表尾部
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 7.校验节点数是否超过8个,如果超过则调用treeifyBin方法将链表节点转为红黑树节点,
// 减一是因为循环是从p节点的下一个节点开始的
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
// 8.如果e节点存在hash值和key值都与传入的相同,则e节点即为目标节点,跳出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e; // 将p指向下一个节点
}
}
// 9.如果e节点不为空,则代表目标节点存在,使用传入的value覆盖该节点的value,并返回oldValue
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e); // 用于LinkedHashMap
return oldValue;
}
}
++modCount;
// 10.如果插入节点后节点数超过阈值,则调用resize方法进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict); // 用于LinkedHashMap
return null;
}
resize()
扩容,1.7的扩容里,变化长度后所有的hashcode都要重新计算,消耗较大。1.8的扩容里,机制变得更加巧妙,节省计算,具体变化以后再进行学习。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 1.老表的容量不为0,即老表不为空
if (oldCap > 0) {
// 1.1 判断老表的容量是否超过最大容量值:如果超过则将阈值设置为Integer.MAX_VALUE,并直接返回老表,
// 此时oldCap * 2比Integer.MAX_VALUE大,因此无法进行重新分布,只是单纯的将阈值扩容到最大
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 1.2 将newCap赋值为oldCap的2倍,如果newCap<最大容量并且oldCap>=16, 则将新阈值设置为原来的两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 2.如果老表的容量为0, 老表的阈值大于0, 是因为初始容量被放入阈值,则将新表的容量设置为老表的阈值
else if (oldThr > 0)
newCap = oldThr;
else {
// 3.老表的容量为0, 老表的阈值为0,这种情况是没有传初始容量的new方法创建的空表,将阈值和容量设置为默认值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 4.如果新表的阈值为空, 则通过新的容量*负载因子获得阈值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 5.将当前阈值设置为刚计算出来的新的阈值,定义新表,容量为刚计算出来的新容量,将table设置为新定义的表。
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 6.如果老表不为空,则需遍历所有节点,将节点赋值给新表
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) { // 将索引值为j的老表头节点赋值给e
oldTab[j] = null; // 将老表的节点设置为空, 以便垃圾收集器回收空间
// 7.如果e.next为空, 则代表老表的该位置只有1个节点,计算新表的索引位置, 直接将该节点放在该位置
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 8.如果是红黑树节点,则进行红黑树的重hash分布(跟链表的hash分布基本相同)
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 9.如果是普通的链表节点,则进行普通的重hash分布
Node<K,V> loHead = null, loTail = null; // 存储索引位置为:“原索引位置”的节点
Node<K,V> hiHead = null, hiTail = null; // 存储索引位置为:“原索引位置+oldCap”的节点
Node<K,V> next;
do {
next = e.next;
// 9.1 如果e的hash值与老表的容量进行与运算为0,则扩容后的索引位置跟老表的索引位置一样
if ((e.hash & oldCap) == 0) {
if (loTail == null) // 如果loTail为空, 代表该节点为第一个节点
loHead = e; // 则将loHead赋值为第一个节点
else
loTail.next = e; // 否则将节点添加在loTail后面
loTail = e; // 并将loTail赋值为新增的节点
}
// 9.2 如果e的hash值与老表的容量进行与运算为非0,则扩容后的索引位置为:老表的索引位置+oldCap
else {
if (hiTail == null) // 如果hiTail为空, 代表该节点为第一个节点
hiHead = e; // 则将hiHead赋值为第一个节点
else
hiTail.next = e; // 否则将节点添加在hiTail后面
hiTail = e; // 并将hiTail赋值为新增的节点
}
} while ((e = next) != null);
// 10.如果loTail不为空(说明老表的数据有分布到新表上“原索引位置”的节点),则将最后一个节点
// 的next设为空,并将新表上索引位置为“原索引位置”的节点设置为对应的头节点
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 11.如果hiTail不为空(说明老表的数据有分布到新表上“原索引+oldCap位置”的节点),则将最后
// 一个节点的next设为空,并将新表上索引位置为“原索引+oldCap”的节点设置为对应的头节点
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
// 12.返回新表
return newTab;
}
treeifyBin()
/**
* 将链表节点转为红黑树节点
*/
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 1.如果table为空或者table的长度小于64, 调用resize方法进行扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 2.根据hash值计算索引值,将该索引位置的节点赋值给e,从e开始遍历该索引位置的链表
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
// 3.将链表节点转红黑树节点
TreeNode<K,V> p = replacementTreeNode(e, null);
// 4.如果是第一次遍历,将头节点赋值给hd
if (tl == null) // tl为空代表为第一次循环
hd = p;
else {
// 5.如果不是第一次遍历,则处理当前节点的prev属性和上一个节点的next属性
p.prev = tl; // 当前节点的prev属性设为上一个节点
tl.next = p; // 上一个节点的next属性设置为当前节点
}
// 6.将p节点赋值给tl,用于在下一次循环中作为上一个节点进行一些链表的关联操作(p.prev = tl 和 tl.next = p)
tl = p;
} while ((e = e.next) != null);
// 7.将table该索引位置赋值为新转的TreeNode的头节点,如果该节点不为空,则以以头节点(hd)为根节点, 构建红黑树
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
compute()
@Override
public V compute(K key,
BiFunction<? super K, ? super V, ? extends V> remappingFunction) {
if (remappingFunction == null)
throw new NullPointerException();
int hash = hash(key);
Node<K,V>[] tab; Node<K,V> first; int n, i;
int binCount = 0;
TreeNode<K,V> t = null;
Node<K,V> old = null;
if (size > threshold || (tab = table) == null ||
(n = tab.length) == 0)
n = (tab = resize()).length;
if ((first = tab[i = (n - 1) & hash]) != null) {
if (first instanceof TreeNode)
old = (t = (TreeNode<K,V>)first).getTreeNode(hash, key);
else {
Node<K,V> e = first; K k;
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {
old = e;
break;
}
++binCount;
} while ((e = e.next) != null);
}
}
V oldValue = (old == null) ? null : old.value;
V v = remappingFunction.apply(key, oldValue);
if (old != null) {
if (v != null) {
old.value = v;
afterNodeAccess(old);
}
else
removeNode(hash, key, null, false, true);
}
else if (v != null) {
if (t != null)
t.putTreeVal(this, tab, hash, key, v);
else {
tab[i] = newNode(hash, key, v, first);
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
}
++modCount;
++size;
afterNodeInsertion(true);
}
return v;
}
五、面试题
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 类。
谈一下1.7和1.8中hashMap的优化
- 底层数据结构,增加了红黑树这一结构,默认变化阀值是数组长度>64,链表长度>8,查找操作需要遍历node数组节点下的链表,当桶的长度过长时,会降低查询效率,所以1.8中增加红黑树,这种结构在数据量大的时候增删改查操作更快,当链表长度降低<=6时,红黑树重新转化为链表,链表O(n),红黑树O(logn)
- 优化高位运算的hash算法,h^(h>>>16),扰动四次变为扰动一次,提高效率
- 添加时,头插变为尾插,头插法会使链表发生反转,多线程环境下头插会产生环,尾插则不会发生反转,更安全
- 优化扩容机制,resize()扩容机制,扩容的阀值=设定容量*载荷因子(16*0.75),达到阀值时,扩容成当前的2倍。这中间消耗性能最大的就是扩容后index值的重新计算,index=h&(length-1),1.8中进行了优化,扩容后,元素要么是在原位置,要么是在原位置再移动2次幂的位置,且链表顺序不变
HashMap是线程安全的吗?有什么办法线程安全吗
- 不是线程安全,1.7中会出现链表循环,插入重复,在源码中没有关于锁的操作。
- 实现线程安全,有三种方法,Tablemap,ConcerrentHashMap,以及Collections.synchronizedMap,tableMap对整个操作方法进行锁定,粒度过大,基本不存在合适的使用场景,Collections.synchronizedMap是Collections里面的内部类,把map传入内部定义的带锁的synchronizedMap对象,可以实现线程安全,ConcerrentHashMap使用分段锁(锁住当前节点),CAS+synchronized,降低锁粒度,提高并发量
hash函数是怎么设计的
先拿到key的hashcode(32位)然后让hashcode的高16与低16位进行异或运算
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
能详细说一下嘛,这样做有什么好处
这个有人叫它扰动函数,对hashcode的高十六位与低十六位进行了异或运算
好处有两个:
- 位运算,可以提高算法的运算效率
- 不会造成因为高位没有参与下标的计算而增加的碰撞,增加hashcode的散列性,
为什么这样设计可以增加散列性
key.hashCode()是来自key本身hash值,本身太大,所以要与数组length-1做与运算(有0则0)。
这样操作固然可以让数变得很小,但也产生了一个问题,那就是只会取到最后几位,碰撞的几率会增大。
所以这就需要一个方法来增加要取模的数据的变动性,上面的hash中高位异或的操作就是为了解决这个问题而设计,
它将原始哈希码的高位和低位做异或混合,以此来加大低位的随机性。
LinkedHashMap怎么实现有序的
LinkedHashMap内部维护了一个单链表,有头尾节点,同时LinkedHashMap节点Entry内部除了继承HashMap的Node属性,
还有before 和 after用于标识前置节点和后置节点。可以实现按插入的顺序或访问顺序排序。
讲一下几个主要函数的逻辑思路吧 get(),put(),resize(),replace(),remove()
涉及put&replace、remove等发生变动的操作,需要如下验证及处理:
数组是否为空——put&replace操作创建,remove操作返回
目标数组是否存在、key是否存在——数组存在则判断节点,put&replace操作(节点存在则替换,节点不存在则创建), remove操作(节点存在则删除,节点不存在则返回)
节点存储形式是链表还是红黑树
操作完成后,是否达到扩容/缩减阀值 ——扩容/红黑树与链表转换
其他详细分析见上面主要方法分析
六、参考资料
https://blog.csdn.net/java_wxid/article/details/106896221?utm_source=app
https://zhuanlan.zhihu.com/p/21673805
https://blog.csdn.net/v123411739/article/details/78996181
https://blog.csdn.net/u012211603/article/details/79879944
https://blog.csdn.net/qq_41345773/article/details/92066554