本文将针对jdk8中HashMap用到的几个知识做一个总结,主要涉及到源码及面试中相关的问题
jdk版本 | 实现 |
---|---|
1.8之前 | 数组+链表 |
1.8 | 数组+链表+红黑树 |
成员变量及常量
/**
*初始化容量(必须是二的n次幂)
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
*集合最大容量(必须是二的幂)
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
*默认负载因子常量
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
*放弃链表而使用红黑树的阈值,即链表转用红黑树的阈值(转红黑树条件之一 1/2)
*/
static final int TREEIFY_THRESHOLD = 8;
/**
*放弃红黑树而使用链表,即链表的值小于6会由红黑树转回链表
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
*当整个hashMap中元素数量大于64时,且链表的值超过8会转用红黑树(转红黑树条件之一 2/2)
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
*储存元素的数组(必须是二的幂)
*/
transient Node<K,V>[] table;
/**
*存放缓存
*/
transient Set<Map.Entry<K,V>> entrySet;
/**
*map中元素的数量
*/
transient int size;
/**
*该map修改的次数
*/
transient int modCount;
/**
*扩容的临界值
*/
int threshold;
/**
*hash表的负载因子
*/
final float loadFactor;
初始化
先来看看它的构造方法
- HashMap(int initialCapacity, float loadFactor);
自定义初始容量及负载因子
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);
}
- HashMap(int initialCapacity);
自定义初始容量,使用默认负载因子 0.75f
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
- HashMap();
最常用的构造方法,默认负载因为0.75f,默认初始化容量 1<<<4
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
- HashMap(Map<? extends K, ? extends V> m); 接收一个Map类型的集合
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
原来想着重看看使用最多的第三个构造方法,貌似它给负载因子赋了个值然后啥都没干,算了,跳过
重要内部类
链表
这个只有next
,并没有prev
,它是个单向链表
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;
}
}
红黑树
红黑树的实现,内容有点多,有兴趣的同学可以去研究下,我们只要知道它查询效率高于链表,以及红黑树与链表转换条件就行
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
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);
}
/**
* Returns root of tree containing this node.
*/
final TreeNode<K,V> root() {
for (TreeNode<K,V> r = this, p;;) {
if ((p = r.parent) == null)
return r;
r = p;
}
}
}
画图理解下:
HashMap ↓

我大致画了个图,里面有个hash桶(非要说它是个椭圆也行),到目前为止,可以大致了解HashMap的结构了,即:
==数组(transient Node<K,V>[] table)+链表(Node<K,V> )+红黑树(TreeNode<K,V>)==
当然,在一个HashMap实例中,同一个桶(也可以说同一个数组下标中)不会既存在链表又存在红黑树结构,他们会在==链表长度变化时互相转换==。
使用
存放 PUT
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
这里它用到了hash(key)这个方法,我们先来看看hash做了什么事
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
先给忘了java位运算的同学回忆下:
-
==^==:亦或运算,即==相同取0,不同则取1==
3^4: 0011 0100 --------- 0111 结果:7
-
==>>>==:无符号右移,即忽略符号右移
注意:无符号右移:高位补0; 有符号右移:正数高位补0,负数高位补1 3>>>2: 0011 0000 --------- 0000 结果:0 -3>>>2: 11111111 11111111 11111111 11111101 00111111 11111111 11111111 11111111 ----------------------------------- 1073741823
int是32位的,上面正数只写了4位是为了方便,不要纠结
可以看出,hash的结果就是==key的hashCode与key无符号右移16位的结果进行亦或运算==,为什么这么做呢?这样可以提高hash的离散性,减少hash碰撞,从而提高效率
扩容
final Node<K,V>[] resize() {
//旧表(旧数组)
Node<K,V>[] oldTab = table;
//旧数组长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//旧的扩容临界值 默认是负载因子 0.75*初始长度16
int oldThr = threshold;
//定义新的数组长度,新的扩容阈值
int newCap, newThr = 0;
//如果旧数组初始化过
if (oldCap > 0) {
//如果旧数组长度已经不小于集合最大容量,则不能扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//如果新的数组扩容一倍后小于设定的最大容量,且旧数组长度不小于16,则扩容一倍,且新扩容阈值也增大一倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//如果旧数组还未被初始化且有扩容阈值,则新的数组长度取旧的扩容阈值
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//旧数组、旧扩容阈值均为0,则取默认值(16,16*0.75)给新的数组
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//如果新的扩容阈值为0,则用数组长度*负载因子(默认0.75f)
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//给扩容阈值重新赋值
threshold = newThr;
//定义新的数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//给成员变量table赋值
table = newTab;
if (oldTab != null) {
//遍历旧数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//如果下标为j的桶节点不为null,赋值给e
if ((e = oldTab[j]) != null) {
//设置旧的节点指向null
oldTab[j] = null;
//如果只存在一个节点直接计算该节点中的元素在新的table中的位置并赋值
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;
/**
*e.hash & oldCap 保证了50%的几率将所有元素放置到高、低区
*e.hash xxxx xxxx xxxx xxxx
*oldCap 0000 0000 0100 0000 (由于它是2个幂,所以一定是0100这样的格式,即只存在一个1)
*结果 取决于e.hash本身与oldCap对应位置的数据为0还是1
/
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;
}
注意:链表数据扩容时,用(e.hash & oldCap) == 0
来将旧的数组数据==平均地==分布在新的数组中,分==高区==与==低区==
添加(put)
我们先来看看它的方法
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;
//如果容器尚未初始化,则初始化
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 {
//hash碰撞后的处理
Node<K,V> e; K k;
//如果之前存在与该元素key相同的元素,则替换之前的元素!!!暂时跳过,后面会替换(注意:这里要先判断hash码是否相等,所以两个元素equals时,放入同一个map不一定会被替换)
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果桶的数据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) {
//如果下一个节点为null,则直接放入
p.next = newNode(hash, key, value, null);
//如果此时节点长度达到了转红黑树的阈值(为什么是8-1=7?因为放入了当前这个元素,长度就达到了8),则进行红黑树的转换判断,是扩容?(MIN_TREEIFY_CAPACITY=64 记得这个参数吗?map长度如果小于64则扩容)还是转红黑树?(TREEIFY_THRESHOLD达到了8与map长度达到MIN_TREEIFY_CAPACITY 64)
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;
}
}
//遍历完后e还有值,说明遇到了需要替换的元素
if (e != null) { // existing mapping for key
V oldValue = e.value;
//如果onlyIfAbsent为false或者旧值为null,则替换旧值,所以参数onlyIfAbsent的意思就是,当旧值存在时,遇到一样的元素,是否进行替换,默认为false,就是要替换
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//将新put的元素移动到最后一个节点,我感觉这里应该是用来更新元素的年龄,有点像jvm中老年代的感觉
afterNodeAccess(e);
return oldValue;
}
}
//修改次数+1
++modCount;
//扩容判断
if (++size > threshold)
resize();
//根据传入参数判断是否移除该链表(红黑树)中最老的节点
afterNodeInsertion(evict);
return null;
}
大概就是这么回事儿,我觉得主要应该关注有一下几点
1.当发生hash碰撞时它做了什么事?
根据入参onlyIfAbsent(默认false)判断(如果旧值为null则直接替换)是否替换,如果为false则替换之前的元素
2.为什么要将新加入的元素放置在链表尾端?(红黑树也继承于Node)
我猜想应该是将新旧元素进行排序,最旧的在head,而最新的在tail
获取(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;
//判断map是否为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//检查第一个节点是否命中
if (first.hash == hash && // always check first node
((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;
}
移除(remove)
public V remove(Object key) {
Node<K,V> e;
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;
//判断该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;
//直接定位到下标 (n - 1) & hash 的桶,并判断头部节点是否命中
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
//红黑树遍历查找
if (p instanceof TreeNode)
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);
}
}
//根据传入参数判定值 value
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;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
hash
在hashMap中大量使用了hash
- 计算key的hash值时,==(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16)==;
- 计算数据存放位置时,==e.hash & (newCap - 1)==
相关问题
1.为什么HashMap容量需要2的幂?
在计算桶的位置时,会通过hash&(size-1)来确定该map在数组中的下标,这里设计size(HashMap)的容量,就是为了在运算时,保证size-1在做位运算时,永远以...1111结尾,即:
hash: xxxx xxxx 0101
size-1: 0000 0000 1111
结果: 0000 0000 0101 = 5
HashMap就是通过这种方式来保证元素的均匀分布
2.HashMap线程不安全主要是在哪个地方?
HashMap的不安全主要体现在两个地方:
ps:jdk1.7之前在多线程环境下,扩容的时候,链表可能会行成一个闭环,当我们在查询一个不存在的key调用get()时,遍历链表进入死循环,但是1.8已经不存在这个问题了,原因就是jdk1.7对链表元素进行了一次倒序处理,而jdk1.8中使用了正序处理。
1、数据丢失
2、size()不准确
原因的话,可以参考大佬写的:https://blog.csdn.net/fumitzuki/article/details/82760236
3.为什么重写equals时一定要重写hashCode?
其实从我们了解了HashMap源码后,就能从这个角度来解释这个问题,打个比方,如果一个对象O重写equals但是并未重写hashCode,在存放到HashMap时及操作HashMap时均会出现一系列问题;
put:由于未重写hashCode,在我们存入对象时,两个equals的对象hashCode可能是不等的,那么在通过 e.hash & (newCap - 1) 计算元素位置时,极有可能放置到不同的地方,即map中会存放两个我们认为是一样的对象;就算过了第一关,hash计算出来的位置相等,也会在插入同一个链表时,判断两个元素相等,HashMap中的逻辑是先判断hashCode是否相等,所以还是存放了两个我们认为一样的对象。
get:取的时候,也会根据hashCode计算位置,所以我们取出来的对象很可能不是我们需要的那一个,当然了remove也是一样的道理
作者能力有限,如果大家发现有不对的地方或者疑问,欢迎指正,大家一起进步!3q
网友评论