“我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情”
Map接口
Map是一种双列集合的顶级接口,在Java中用于实现K,V的双列存储,子类实现常用的有: HashMap、LinkedHashMap,不常用的有: TreeMap,等。
一、HashMap
HashMap是Map接口的其中一个实现,在日常开发中运用的最为广泛,它是基于哈希表的一种实现,允许键值都为null。HashMap不保证元素插入的顺序,它会在插入时对key进行hash操作,以保证插入的元素在存储的数组中分布均匀。HashMap的默认大小(在进行第一次resize()后)为16,默认的负载因子为0.75,默认的扩容阈值为(16 * 0.75) = 12。当Node节点后连接的元素过多时,会将此Node进行树化操作,变更为红黑树结构进行存储,已优化性能。
1. 静态常量
-
DEFAULT_INITIAL_CAPACITY默认初始化容量,必须是2的n次幂,否则进行&操作时会出现问题!-
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 值为 161向左位移4位,得到的值为16。 相当于 1 * 2 * 2 * 2 * 2 左移几位就相当于乘几个2
-
-
MAXIMUM_CAPACITY最大容量-
static final int MAXIMUM_CAPACITY = 1 << 30; // 值为 1073741824如果通过构造器指定的最大容量大于此容量的话,则会使用次容量覆盖指定的初始化容量。
-
-
DEFAULT_LOAD_FACTOR默认的负载因子-
static final float DEFAULT_LOAD_FACTOR = 0.75f;负载因子,通俗的来讲:就是HashMap可以接受的最大扩容阈值是多少。
计算公式为:当前 size * DEFAULT_LOAD_FACTOR
-
-
TREEIFY_THRESHOLD树化阈值-
static final int TREEIFY_THRESHOLD = 8;当数组中某个下边下存储的
Node节点长度等于8的时候,会将这个Node进行树化,变更为红黑树,进行存储。
-
-
MIN_TREEIFY_CAPACITY树化table大小阈值-
static final int MIN_TREEIFY_CAPACITY = 64;当
Node长度满足TREEIFY_THRESHOLD(树化阈值) 并且table长度满足,MIN_TREEIFY_CAPACITY(树化table大小阈值)时,才会将Node进行树化,也就是说判断一个Node是否需要树化,必须要同时满足这两个条件。
-
2. 内部属性
-
table存储数据的数组-
transient Node<K,V>[] table;用于存储数据用的数组,
transient修饰,表示不需要被序列化。
-
-
entrySet用于缓存所有的元素-
transient Set<Map.Entry<K,V>> entrySet;用于缓存添加到
HashMap中的所有元素,当调用entrySet()方法时,将会返回内部存储的Entey引用。
-
-
size当前Map长度-
transient int size;集合长度
-
-
modCount用于记录被修改的次数-
transient int modCount;
-
-
threshold扩容阈值-
int threshold;当
table中存储的元素数量 > 扩容阈值的时候,将触发扩容。
-
3. 构造方法
HashMap共有4个构造方法,分别为:
-
无参构造
-
public HashMap() { // 将负载因子初始化为默认的 0.75 this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted }
-
-
指定容量
-
public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); }构造一个带有指定初始容量和默认负载因子(0.75)的空HashMap。
-
-
指定容量 和 负载因子
-
public HashMap(int initialCapacity, float loadFactor) { // 如果容量小于0 抛出参数不合法异常 if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); // 如果容量大于最大容量 抛出参数不合法异常 if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; // 如果负载因子小于=0 或者是个无效的值 抛出参数不合法异常 if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); // 初始化数据 this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); }构造一个具有指定初始容量和加载因子的空HashMap。
-
-
指定
Map实现-
public HashMap(Map<? extends K, ? extends V> m) { // 初始化默认的负载因子 this.loadFactor = DEFAULT_LOAD_FACTOR; // 将指定的Map实现中的元素赋值给自身 putMapEntries(m, false); }构造一个新的HashMap,具有与指定Map相同的映射。HashMap使用默认负载因子(0.75)和足够容纳指定Map中的映射的初始容量创建。
-
4. put()流程
简介
HashMap 添加一个元素的流程极其的复杂,首先会获取key的hash值,当key为null的时候,会返回0,否则的话,会调用key的hashCode()方法获取该对象的hashCode,然后在将得到的hashCode的高16为右移到低16位,用于减少hash碰撞,使得到的hash可以更均匀的分布,在将hashCode和右移16位的值进行异或操作,从而得到最终的值。得到hash值以后才是真正的put操作。
首先会判断当前的table是否为空,如果为空则代表是第一次进入,会直接调用resize()方法进行扩容操作,扩容完成后直接创建一个新的Node节点,放在指定的位置下,如果table不为空,则会先计算该hash所对应的数组槽位,判断传入的hash值是否与table指定位置下的头结点是否相等,如果相等,直接跳过。如果与头部节点不相等,则会判断这个Node是否为,红黑树结构,如果是的话则会进行红黑树结构的操作,如果不是的话,则会去改Node中进行循环比对,如果找到的hash一致的Node,则会跳过,如果没找到则会在该Node的尾部插入一个新的Node,并判断长度是否满足树化阈值,满足的话则会进行树化操作,不满足则会再次判断table长度,是否达到扩容阈值,达到的话就进行扩容,否则就返回null,代表put()元素成功!
源码
首先添加一个元素需要调用put()方法,在put()内部会调用putVal()方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
在执行之前需要计算一下传入key所对应的位置信息。也就是hash()方法。
hash()方法
/**
* 为什么不直接返回hashCode,而是先 h >>> 16 在 ^ 异或操作呢?
* 原因是因为,因为hashCode是int类型,范围有42亿左右,也不可能在数组初始化的时候全都放在内存当中,所以不能直接返回hashCode,直接返回的话hashCode很可能会超过数组的长度,造成下标越界
* 此方法实际为一个扰动函数,目的就是为了将hashCode处理的更加松散,减少hash碰撞的概率
* 流程如下:
* 1. 将hashCode方法返回的10进制数字转换为32位2进制数,位数不足则会进行补码操作,即将空余的位补码为0
* 2. 将32位2进制的hashCode的高16位无符号右移16位,并当做异或运算的参数。
* 3. 将低16位与原值(hashCode原值)进行异或运算(相同位置的位,不同为1 相同为0)。
* 3.1 本来位移操作并不会保留低16位的信息,但是在此处进行异或操作就相当于是把自身的高16位与低16位进行运算,也是变相的保留了低16位的信息,可以是hash更加的散列
* 4. 得到异或的结果后,再将其转换为10进制返回,也就是这个元素在table中存储的下标
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
putVal()方法,真正执行put元素的方法。
/**
* @param hash hash方法计算得出的hash信息
* @param key 要添加元素的key
* @param value 要添加元素的value
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none:上一个值,如果没有,则为空值
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// 初始化辅助变量 tab、p、n、i
Node<K, V>[] tab;
Node<K, V> p;
int n, i;
// 判断table是否为空,如果为空的话则代表是第一次进入的,首先初始化一下存放数据的数组
if ((tab = table) == null || (n = tab.length) == 0) {
// 初始化数组为16
n = (tab = resize()).length;
}
// 因为table的长度大部分时候都是小于2^16即65536,所以也只会用hash的低16位进行运算(甚至再大部分时候,根本用不到hash的低16位,而是用的更低位进行运算)
// 那高16位不就用不到了吗,但是要想得到的结果更加的随机,那么就只能将hash自身的高16位与低16位进行异或运算,使其得到的hash更加的随机(妙哇~),这也刚好对应hash(Object key)方法
// 从上方的默认初始化变量可以得知,数组的长度必须是2的n次幂,之所以是这样是因为,当某个数是2的n次幂时,&位与运算 等于 %取模运算,在计算机里 &要比%性能高,所以必须要保证数组长度是2的n次幂
// (n - 1) & hash 的意思就是,n-1 是因为数组下标是0开始的,防止越界所以要-1,&运算相当于%运算(参考上方解释),就是将hashCode平均分成数组长度-1份,然后取出余数以此确认改hashCode所对应的下标
// 如果得出的下标内,没有值的话
if ((p = tab[i = (n - 1) & hash]) == null) {
// 就直接给对应的下标创建一个新的Node节点
tab[i] = newNode(hash, key, value, null);
}
// 对应的下标不为空,里边有值
else {
Node<K, V> e; // 辅助变量
K k; //辅助变量
// 如果头部节点的hash值等于传入数据的hash值,并且key和传入的key一致
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 {
// 就搞一个死循环,binCount用于记录当前循环到Node下的第几个了
for (int binCount = 0; ; ++binCount) {
// 如果 p.next == null 头部节点的下一个节点指针是个空的话,就说明当前下标下的链表内只有一个头部节点
if ((e = p.next) == null) {
// 就直接创建一个新的元素,挂到头部节点的屁股后边
p.next = newNode(hash, key, value, null);
// 如果当前循环的指针大于了树化阈值 8
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=p.next)) 这行代码,即赋值又判断(秒哇~)
p = e;
}
}
// 如果传入的元素在当前下标中的链表中存在的话,救记录一下值是啥,然后给你返回出去
if (e != null) { // existing mapping for key
V oldValue = e.value;
// 如果这个参数为true的话,就改一下值
if (!onlyIfAbsent || oldValue == null) {
e.value = value;
}
// LinkedHashMap 使用到的回调函数,处理自己的逻辑,默认是个空实现,啥都不干。
afterNodeAccess(e);
// 把旧值返回出去,也就代表添加失败!因为已经存在了。
return oldValue;
}
}
// 修改操作次数
++modCount;
// 判断是否需要扩容 数组长度是否超过了扩容阈值 threshold = table.size * 0.75
if (++size > threshold) {
// 扩容
resize();
}
// 完成添加后续回调,空实现,LinkedHashMap用到了。
afterNodeInsertion(evict);
// 返回空代表添加成功
return null;
}
resize()方法,这个方法是HashMap的精髓,复杂的扩容操作都在里边。
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) {
// 如果超过的HashMap定义的最大容量
if (oldCap >= MAXIMUM_CAPACITY) {
// 将扩容阈值设置为int最大值
threshold = Integer.MAX_VALUE;
// 不扩容了,再扩就要出问题了,内存oom了
return oldTab;
}
// 如果没有超过最大值,并且老容量大于初始容量,就扩容至原来的两倍 左移一位就是×2
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;
}
// 第一次进来扩容,就初始化一下,附原始的值
else { // zero initial threshold signifies using defaults
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;
// 因为resize()方法在很多地方都有调用,所以要判断一下,如果不是第一次进来,老数组是有数据的,就将数据移动到新数组内
if (oldTab != null) {
// 循环老数组内所有的元素
for (int j = 0; j < oldCap; ++j) {
// 当前循环到的元素(辅助变量)
Node<K, V> e;
// 赋值,并判断当前下标元素是否不为空,如果为空则不进行任何操作
if ((e = oldTab[j]) != null) {
// 将原有的数据清掉,可能是为了让gc回收
oldTab[j] = null;
// 如果当前位置只有1个头结点元素
if (e.next == null) {
// 就直接移动到新数组内,并重新计算位置
// todo: 原有位置 * 2 -1
newTab[e.hash & (newCap - 1)] = e;
}
// 如果当前下标是红黑树
else if (e instanceof TreeNode) {
// 对红黑树进行拆分操作
((TreeNode<K, V>) e).split(this, newTab, j, oldCap);
}
// 如果当前位置有多个链表节点
// 此流程会将原有节点转换为两个链表
else { // preserve order
// 声明低位头节点 低位尾节点
Node<K, V> loHead = null, loTail = null;
// 声明高位头节点 高位尾节点
Node<K, V> hiHead = null, hiTail = null;
// 下一个元素的指针 用于do-while 判断
Node<K, V> next;
// 其实这个do-while就是一致在操作 e这个指针,通过不断更新指针的指向,从而将不同的数据挂载到 loXXXX 或 hiXXXX 中去
// 这个地方在一直循环变更e的指向,并且不断的赋值给辅助变量,所以理解起来很困难。
// tips:这地方有一个问题,如果链表内第一位的数据在条件:if ((e.hash & oldCap) == 0) 中成立,且整个循环流程中仅仅成立一次,
// 那么这时loHead将会保存完整的链表(因为此时e的指向是自身第一位元素,所以包含所有next节点),并且在这个链表loHead中肯定是包含hiHead中所有的元素的,那么它最终为什么不会重复呢?
// 原因是在下边执行了:loTail.next = null; 将尾巴后的元素全都清空掉了,所以不会重复
do {
// 更新下次循环的指针
next = e.next;
// 拿当前节点的hash值的高位去进行 & 操作,如果得到的值是0
// 就代表要把当前元素放置到低位中去
if ((e.hash & oldCap) == 0) {
// 判断是否是第一次进入,低位尾巴等于空,说明是第一次进入
if (loTail == null) {
// 将第一个元素赋值给低位头部
// 此时,这个元素是个完整的链表
loHead = e;
}
// 不是第一次进入,将此节点挂载到低位的后边
else {
// 因为 loHead等于e的指针 loTail也等于e的指针,所以操作这两个对象的其中一个,就相当于在同时操作这两个对象
loTail.next = e;
}
// 低位
loTail = e;
} else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 赋值loHead至新数组下标
if (loTail != null) {
// 去重,打断原有链表的引用关系
loTail.next = null;
newTab[j] = loHead;
}
// 赋值hiHead至新数组下标
if (hiTail != null) {
// 去重,打断原有链表的引用关系
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
// 返回新数组
return newTab;
}
至此,HashMap添加的全流程就结束了。(红黑树的操作暂时不讲解…)
原文地址: 传送门
5. get()流程
get流程与remove流程类似,都是先计算下标,然后找到对应的元素后返回,查找元素方法有:头部对比、循环对比、红黑树查找。
get()方法
public V get(Object key) {
Node<K, V> e;
// 寻找Node 如果找到了就返回这个Node的value,否则就是null
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
getNode()方法
final Node<K, V> getNode(int hash, Object key) {
// 辅助变量
Node<K, V>[] tab;
Node<K, V> first, e;
int n;
K k;
// table是否为空 hash对应的下标是否有值
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;
// 如果头部没对上,就判断是否存在next节点,存在就循环
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;
}
6. remove()流程
移除一个元素相对简单,就是先计算一下元素所在的位置,然后只针对这个位置下的链表进行:头部比对、循环比对、红黑树方式比对,然后删除这个元素。
源码如下:
remove()方法
public V remove(Object key) {
Node<K, V> e;
// 判断removeNode是否存在返回值,存在就是成功,不存在就是没找到这个key
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
removeNode()方法
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;
// table不为null && lengtn>0 并且 (计算并赋值p为hash对应元素的下标) 不为null
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;
// 如果头部的元素等于传入的元素,就不需要循环找了,找到了,直接赋值给node,等待删除
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) {
node = p;
}
// 循环比对table对应下标下链表的所有元素
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等于当前元素
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)))) {
// 红黑树方式删除
if (node instanceof TreeNode) {
((TreeNode<K, V>) node).removeTreeNode(this, tab, movable);
}
// 如果在头部节点找到了,就把头删掉,数组直接指向下一个元素就好
else if (node == p) {
tab[index] = node.next;
}
// 将node的上一个节点和node的下一个节点绑定,node就是要删除的元素,绑定完成之后就相当于node被删掉了
else {
p.next = node.next;
}
// 修改操作数和总数
++modCount;
--size;
// 删除完了的回调
afterNodeRemoval(node);
return node;
}
}
return null;
}
二、面试题
HashMap简介
HashMap是Map接口下的一个实现类,它是线程不安全的,在日常开发中使用非常频繁,它是以key、value形式存储数据,具体使用数组+链表进行存储,在特殊情况下会转换为红黑树优化性能,其中存储时,key可以为null。
HashMap添加流程
HashMap添加一个元素可以总结为以下几个步骤:计算存储位置,扩容,添加元素,返回是否成功。
首先说下计算存储位置:计算的流程是:先判断传入的key是否为null,如果为null的话,则该元素永远在下标0的位置存放,如果不为null则获取该key的hashCode,保存到变量h,在将h右移16位,与原先的hashCode进行异或操作,就是相当于将自身的高16位与低16位进行运算,本身右移是不保留高位的,而这个地方又和自身进行异或,就是相当于变相的保留了高位的信息,可以使hash更加的散列。
计算完hash后,会进行(n-1) & hash的操作,其实就相当于hash值模取数组的length,得到的值永远不可能大于数组长度,之所以可以这么干是因为hashMap的长度永远都是2的n次幂,所以(n-1)&hash这个操作就永远满足公式:值等于hash模取数组长度取余。这也是为什么规定hashMap长度永远都是2的n次幂的原因。
第二步就是扩容操作,扩容只发生在第一次进入HashMap、或容量小于扩容阈值时,扩容会先记录一下老数组的内容,和计算新数组的长度,扩容阈值等参数,将其容量扩大两倍,此时在创建一个新的数组,长度就是刚才计算的值,在将原数组中的所有参数存放在新数组中去,最后返回扩容完成的新数组。
第三步就是添加元素操作,如果该下标是个null,就创建一个新的Node节点,把值放进去就完活了。如果不是null,则会判断这个key是否存在,存在就跳过,不存在则判断是否是红黑树节点,是的话进行红黑树节点添加数据的操作。如果不是红黑树节点,就进行普通节点的添加操作,其实就是循环一下这个Node下的所有节点,看看有没有一样的,有一样的就跳过,不一样就在尾巴上添加新的节点,如果循环的次数小于树化阈值,就直接添加,否则就将这个节点尝试变更为红黑树的方式进行存储,变更时会判断数组长度是否大于64,如果是小于64的则进行扩容操作,下次再添加的时候再次尝试树化,如果满足树化阈值,就将这个Node转换为红黑树放方式进行存储。
添加完成之后,再次判断是否需要扩容,最终返回结果,null即代表成功!
音频版
掘金不支持音频标签,可以自己下载播放:地址
插入逻辑图片版
扩容逻辑图片版