1、HashMap的概述
1.1 什么是HashMap
Hashmap是一种非常常用的、应用广泛的数据类型。HashMap 是基于哈希表的 Map 接口是实现的。此实现提供所有可选操作,并允许使用 null 做为值(key)和键(value)。HashMap 不保证映射的顺序,特别是它不保证该顺序恒久不变。此实现假定哈希函数将元素适当的分布在各个桶之间,可作为基本操作(get 和 put)提供稳定的性能。
1.2 HashMap的继承关系
代码继承关系
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
}
(1)AbstractMap:Map的骨架实现,用来减少子类实现Map接口要做的工作
(2)Map:值可重复,键不能重复,允许键为null
(3)Cloneable:一种标记接口,实现改接口能够实现克隆,若不实现该接口,用Object.clone会报错
(4)Serializable:一种标记接口,实现改接口,他支持序列化,能够通过序列化进行传输
2、HashMap的数据结构
(1)jdk1.8之前:数组+链表
链表的节点存储的是一个 Entry 对象,每个Entry 对象存储四个属性(hash,key,value,next),若遇到哈希冲突,则将冲突的值加到链表中即可。
(2)jdk1.8之后:数组+链表|红黑树
当连表长度大于阈值(或者红黑树的边界值,默认为8)并且当前的数组长度大于64时,此时此索引位置上的所有数据改为使用红黑树存储。
数组:采用一段连续的存储单元来存储数据。是HashMap的主题
链表:主要为了解决哈希冲突
红黑树:一种平衡的二叉树
哈希冲突:当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了(两个对象调用的 hashCode 方法计算的哈希码值一致导致计算的数组索引值相同)
3、源码分析
3.1 成员变量
// 默认初始容量 jdk1.7结果直接是16,没有位移
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认装载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 链表转红黑树的一个界限值(后面会进行介绍) jdk1.8之前无
static final int TREEIFY_THRESHOLD = 8;
// 红黑树转链表的一个界限值(后面会进行介绍) jdk1.8之前无
static final int UNTREEIFY_THRESHOLD = 6;
// 链表转红黑树的另一个界限值(后面会进行介绍) jdk1.8之前无
static final int MIN_TREEIFY_CAPACITY = 64;
// 底层数组
transient Node<K,V>[] table;
// 存放具体元素的集,可用于遍历map集合
transient Set<Map.Entry<K,V>> entrySet;
// 元素个数
transient int size;
// 修改次数
transient int modCount;
// 阈值,当size大于该值时会进行扩容
int threshold;
// 装载因子,用来计算threshold
final float loadFactor;
(1)<<:表示左移,左移几位就是乘以2的几次幂
(2)>>:表示右移,右移几位就是除以2的几次幂
3.2 构造方法
(1)无参构造
public HashMap() {
// 初始化this.loadFactor为0.75
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
(2) HashMap(int)
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
(3)HashMap(int, float)
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);
// 初始化loadFactor和threshold
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
(4)HashMap(Map)
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
// 将m中的所有元素添加到该HashMap中
putMapEntries(m, false);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
// 获取元素的个数
int s = m.size();
// 当m中有元素的时候,需要将Map中的元素放入此HashMap实例中
if (s > 0) {
// 判断 table是否已经初始化
if (table == null) { // pre-size
// 未初始化,s是m的元素个数
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
// 判断得到的值是否大于阈值,如果大于阈值,则初始化阈值
if (t > threshold)
threshold = tableSizeFor(t);
}
//判断待插入的 Map 的 大小(size),如果size > threshold ,则先进行 resize() 扩容
else if (s > threshold)
resize();
//然后开始遍历,待插入的 Map ,将每一个<Key,Value> 插入到该HashMap实例中
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);
}
}
}
//对于给定的目标容量,返回两倍大小的幂
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;
}
1、>>与 >>> 的区别
(1)>>:带符号右移。正数右移高位补0,负数右移高位补1。比如:
4 >> 1,结果是2;-4 >> 1,结果是-2。-2 >> 1,结果是-1。
(2)>>>:无符号右移。无论是正数还是负数,高位通通补0。
逐步分析以上代码
假如传入的值为10
- int n = cap - 1; ------> n=9
- n |= n >>> 1; ------> 13
n = 9
n ---------- 0000 1001
n >>> 1 ---------- 0000 0100
n |= n >>> 1 ---------- 0000 1101
|=:只要有一个为1,|=的结果就为1
- n |= n >>> 2; ------> 15
n ---------- 0000 1101
n >>> 2 ---------- 0000 0011
n |= n >>> 2 ---------- 0000 1111
此时低位全部为1,后面再进行或运算,低位也不会变成其他数
- return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
通过三目运算得到最终值为16
关于为什么cap首先减1?
防止本来就是2的次幂数返回2倍,比如cap是8,若不减1,则返回16而不是8了
因为 HashMap 要保证容量是 2 的整数次幂, 该方法实现的效果就是如果你输入的 cap 本身就是偶数,那么就返回 cap 本身,如果输入的 cap 是奇数,返回的就是比 cap 大的最小的 2 的整数幂
为什么要右移和或运算那么多次
由于int类型为32位,所有即使除符号为之外只有第一位为1的情况,也能将所有的位全部变成1
为什么要在前面减1,然后在后面加回来呢?
当输入为0的时候就会发现,方法的输出为1,HashMap的容量只有大于0时才有意义。
3.3 增加元素
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
HashMap提供了put用于添加元素,put方法调用了putVal方法。我们可以看到在 putVal() 方法中 key 在这里执行了一下hash()算法,我们分析一下怎么实现的
static final int hash(Object key) {
int h;
// 如果key为null,返回0, 可以看到当key为null的时候也是有哈希值的
// 如果key不为null, 首先计算出key的hashCode,然后赋值给h,接着,h进行无符号右移16位,再进行异或运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
// 这里用 h^(h>>>16)使得h的高16位也参与运算,增强散列性
}
^ 异或运算符,运算规则:相同的二进制数位上,数字相同,结果为0,否则为1
①首先获取对象的hashCode()值,然后赋值给h,
②再将hashCode值右移16位
③将右移后的值与原来的hashCode做异或运算,返回结果
// onlyIfAbsent为false,表示条目已存在则用新值覆盖旧值;
// onlyIfAbsent为true,表示条目存在不进行覆盖
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab;
Node<K,V> p;
// n存放数组长度。i存放key的hash计算后的值
int n, i;
// table 表示存储在Map中的元素的数组,把table赋值给tab,再判断是否为空
// 判断数组的长度是否为0
if ((tab = table) == null || (n = tab.length) == 0)
// 如果为空就实例化一个数组
n = (tab = resize()).length;
// i = (n - 1) & hash 计算当前key所在下标,确定在哪个桶中,并将下标赋值给i
// n是数组的长度,hash是在hash算法中返回的hash值
// p = tab(i) 将该位置的元素赋值给p,并且判断是否为null
if ((p = tab[i = (n - 1) & hash]) == null)
// 直接新建一个Node节点,并作为桶的第一个元素放在该下标的位置
tab[i] = newNode(hash, key, value, null);
else { // 桶不为空
Node<K,V> e; K k;
// p.hash == hash:判断第一个元素的hash与我们传进来的hash是否相等
// (k = p.key) == key 将第一个元素的key赋值给k,并且判断是否和我们传进来的key相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 第一个元素的key和我们传进来的key值是相等的,初始化e
e = p;
// p是TreeNode的实例
else if (p instanceof TreeNode)
// 若key在红黑树中存在,直接返回已存在的节点,不做处理(下面会用新值替换旧值)
// 若key在红黑树中不存在,putTreeVal中会创建新节点、添加到红黑树中,并返回null
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else { // 桶是单向链表
// 遍历链表
for (int binCount = 0; ; ++binCount) {
// 该条件成立时,p.next就是新节点的位置
if ((e = p.next) == null) {
// 创建新节点并连接到p.next处
p.next = newNode(hash, key, value, null);
//超过了链表的设置长度(默认为8)则转换为红黑树
// binCount从0开始计算,记录遍历节点的个数,所以后面减一
if (binCount >= TREEIFY_THRESHOLD - 1) 5
// 将链表转为红黑树
treeifyBin(tab, hash);
break;
}
// 判断key对应的节点是否已经存在,若存在则直接跳出
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
// 说明新添加的元素和当前节点不相同,继续找下一个元素。
p = e;
}
}
// e不为null表示添加的键已存在,用新值替换旧值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 当最后一次调整之后的Size大于临界值,就需要调整数组容量
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
hash & (length - 1) 来得到该对象的保存位,等价于 hash % length,但是 & 效率比 % 要高
3.4 数组扩容
final Node<K,V>[] resize() {
// 先拿到旧的hash桶
Node<K,V>[] oldTab = table;
// 获取未扩容前的数组容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 旧的临界值
int oldThr = threshold;
// 定义新的容量和临界值
int newCap, newThr = 0;
// 旧容量大于0
if (oldCap > 0) {
// 旧的容量如果超过了最大容量
if (oldCap >= MAXIMUM_CAPACITY) {
// 临界值就等于Integer类型最大值
threshold = Integer.MAX_VALUE;
// 不扩容,直接返回旧数组
return oldTab;
}
// oldCap扩大到2倍之后赋值给newCap,判断newCap是否小于最大容量并且原数组长度大于等于数组初始化长度
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 进行2倍的扩容
newThr = oldThr << 1; // double threshold
}
// 旧容量为0,当前临界值不为0,让新的临界值等于当前临界值
else if (oldThr > 0)
newCap = oldThr;
// 当前容量和临界值都为0
else {
//使用默认的加载因子(0.75)
newCap = DEFAULT_INITIAL_CAPACITY;
//新增的临界值也就为 16 * 0.75 = 12
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 经过上面对新临界值的计算后如果还是0
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];
// 赋值给hash桶
table = newTab;
// 如果数组不为空,将原数组中的元素放入扩容后的数组中
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 将旧桶的当前下标位置元素赋值给e,并且e不为null
if ((e = oldTab[j]) != null) {
// 置空
oldTab[j] = null;
// 下一个节点如果为空
if (e.next == null)
// 如果没有下一个节点,说明不是链表,当前桶上只有一个键值对,直接计算下标后插入
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)// 桶内是红黑树
// 将以e为根节点的红黑树分裂成两部分,这两部分可能会退化为链表
((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;
}
什么时候需要扩容
当Hashmap中元素个数超过 数组长度*负载因子 就会进行扩容。也就是说,当HashMap数组长度是16的时候,如果元素个数超过 16 * 0.75=12 的时候,就会给数组扩容,扩容方式就是将原数组扩大2倍,也就是16 * 2=32
当HashMap中的其中一个链表的对象个数达到了8个,此时如果数组长度没有达到64,
那么HashMap也会进行扩容。如果达到了64,那么这个链表会变成红黑树,
节点类型由Node变成TreeNode。
1.7和1.8的扩容有什么区别
(1)在1.7中,扩容之后需要重新去计算其Hash值,因为每个元素放在哪个位置计算(hash值 & length-1),然后Hash值对其进行分发(扩容之后的length是扩容之前的二倍)
(2)在1.8版本中,则是根据在同一个桶的位置中进行判断(e.hash & oldCap)是0还是1,重新进行hash分配后,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上
3.5 删除数据
remove(key) 方法 和 remove(key, value) 方法都是通过调用removeNode的方法来实现删除元素的
// 根据key删除元素 删除的返回值是被删除key所对应的value
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;
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
// 进入这里面,说明hash桶不为空,并且当前key所在位置的元素不为空
Node<K,V> node = null, e; K k; V v;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 当前第一个位置的元素就是我们要找的元素
node = p;
// 如果存在next节点,就说明该数组位置上发生了hash碰撞,此时可能存在一个链表,也可能是一颗红黑树
else if ((e = p.next) != null) {
// 是一个红黑树,那么调用getTreeNode方法从树结构中查找满足条件的节点
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
// 是一个链表,只需要从头到尾逐个节点比对即可
else {
do {
// 如果e节点的键和key相等,e节点就是要删除的节点,赋值给node变量,跳出循环
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
// 更新p
p = e;
// 如果e存在下一个节点,那么继续去匹配下一个节点。直到匹配到某个节点跳出 或者 遍历完链表所有节点
} while ((e = e.next) != null);
}
}
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
// 是个红黑树,调用removeTreeNode进行删除
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
// 该node节点就是首节点
else if (node == p)
// 直接将节点数组对应位置指向到下一个节点即可
tab[index] = node.next;
// p是node的父节点,由于要删除node,所有只需要把p的下一个节点指向到node的下一个节点即可把node从链表中删除了
else
p.next = node.next;
++modCount; // 修改次数递增
--size; // 个数递减
afterNodeRemoval(node);
return node;
}
}
return null;
}
3.6 替换数据
(1)replace(K,V)
public V replace(K key, V value) {
Node<K,V> e;
// 获取key对应的节点
if ((e = getNode(hash(key), key)) != null) {
// 进行替换
V oldValue = e.value;
e.value = value;
afterNodeAccess(e);
return oldValue;
}
return null;
}
(2)replace(K,V,V)
public boolean replace(K key, V oldValue, V newValue) {
Node<K,V> e; V v;
// 获取key对应的节点,当e.value与oldValue相等时才newValue替换原值
if ((e = getNode(hash(key), key)) != null &&
((v = e.value) == oldValue || (v != null && v.equals(oldValue)))) {
e.value = newValue;
afterNodeAccess(e);
return true;
}
return false;
}
3.7 获取数据
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;
// table不为空且桶不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// key对应的节点与桶内第一个节点一致,直接返回第一个节点first
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)
// 从红黑树中获取key对应的节点
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
// 桶仍然是单向链表 遍历链表,寻找key对应的节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
// 未找到,返回null
return null;
}
3.8 转红黑树方法
在前面分析put方法的时候,节点添加完成之后就会判断此时节点个数是否大于8,如果大于则将链表转换为红黑树。
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 如果当前数组为空,或者数组长度小于进行树形化的阈值(64)就去扩容,而不是转换为红黑树。
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 链表长度达到9且数组长度不小于64,将链表转为红黑树
else if ((e = tab[index = (n - 1) & hash]) != null) {
// hd:红黑树的头结点。tl:红黑树的尾结点
TreeNode<K,V> hd = null, tl = null;
// 遍历(单向)链表
do {
// 重新创建一个树节点,内容和当前链表节点e一致
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
// 将新创建的p节点赋值给红黑树的头结点
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
// 让桶中第一个元素即数组中的元素指向新建的红黑树的节点,以后这个桶里的元素就是红黑树,而不是链表
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
为什么数组的长度达到64才进行转为红黑树
如果数组很小,那么转换为红黑树然后遍历效率要低一些,这时候进行扩容,那么重新计算哈希值链表的长度就有可能变短了,数据会放到数组中,这样相对来说效率高一些
4、其他
jdk1.7的HashMap与1.8的HashMap有什么不同?
(1)扩容计算方式不一样(前面已提)
(2)结构不同(前面已提)
(3)扩容后移动链表数据不同
JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法
因为JDK1.7是用单链表进行的纵向延伸,当采用头插法就是能够提高插入的效率,但是也会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题
最后,谢谢泥萌的观看,文中借用了别的博主的图,参考了很多的博客和视频,若有不正确欢迎指正,大家相互学习,嘻嘻