HashMap学习
HashMap知识整理
最近在看jdk8之后的hashmap的源码(主要是引入红黑树),写下博客记录学习。
具体看Gitee地址:https://gitee.com/dz138598/hash-map/tree/master
Hash定义
把任意长度的输入,通过一个hash算法,映射成固定的长度的输出。
Hash冲突
定义
当两个不同的输入值,根据同一散列函数计算出相同的散列值的现象。即两个对象调用hasCode方法计算得到的hash相同。
hash冲突在理论上是没有办法避免的,多映射到少的时候那肯定会存在一个冲突的问题。也就是我们常说的鸽笼原理。
解决的几种办法
- 开放定址法
- 线性探查法
- 平方探查法
- 双散列探查法
- 链地址法
- 再哈希法
- 建立公共溢出区
具体分析
线行探查法:
最简单的冲突处理方法,它从发生冲突的单元起,依次判断下一个单元是否为空,当达到最后一个单元时,再从表首依次判断。直到碰到空闲的单元或者探查完全部单元为止。
平方探查法
发生冲突时,用发生冲突的单元d[i], 加上 1²、 2²等,直到找到空闲单元。平方探查法可能不能探查到全部剩余的单元。
链接地址法
将哈希值相同的元素构成一个同义词的单链表,并将单链表的头指针存放在哈希表的第i个单元中,查找、插入和删除主要在同义词链表中进行。
再哈希法
就是同时构造多个不同的哈希函数,第一个哈希函数当H1 = RH1(key) 发生冲突时,再用H2 = RH2(key) 进行计算,直到冲突不再产生,这种方法不易产生聚集,但是增加了计算时间。
建立公共溢出区
将哈希表分为公共表和溢出表,当溢出发生时,将所有溢出数据统一放到溢出区。
HashMap
存储规则说明
-
HashMap<String,Object> map = new HashMap();
创建HashMap对象后,并没有在创建集合对象的创建数组,而是首次调用put方法时,底层创建长度是16的
Node[] table
数组。 -
假设向哈希表存储数据
name:zhangsan
,根据name
,调用String类的hasCode()方法计算出哈希值,此哈希值经过某种算法计算之后,得到在Node数组中存放的位置(是2),如果此位置是空,就直接添加到该位置。
HashMap中hash函数是如何实现的?
对key的hashCode做hash操作,无符号右移16位做异或运算。
下标计算方式:hashCode%length
,而计算机中求余的效率不如位运算。而当length是2的n次幂,hashCode()%length
等价于hashCode()&(length-1)
- 假设向哈希表又中存储了
sex:男
,根据sex
,计算出hash值和通过算法得到下标(是2)。但是2的位置已经存在了name:zhangsan
。如果哈希值不相同,那么sex:男
会在此空间划出一个节点变为链表来存储。 - 假设向哈希表又中存储了
grade:2020
,根据grade
,计算出hash值和通过算法得到下标(是2)。且hash值和sex
的一样。那么继续调用equals
方法,比较内容是否相等,不相等的话,划出一个节点变为链表来存储。
当两个key的哈希值hashCode相同的时候,会怎么样?
-
会发生哈希碰撞
-
JDK8之前使用数组+链表解决,而JDK8使用了数组+链表+红黑树解决
-
若key值的内容部相同
equals
则会替换旧的value值。否则连接到链表后面。如果链表长度超过8,并且数组长度大于64就会转变红黑树存储-
size表示HashMap中K-V的实时数量,注意不等于数组的长度
-
阈值定义:当前已经占用数组长度的最大值
-
threshold(阈值)= capacity(容量)*loadFactor(加载因子0.75)
-
size>threshold就需要resize(扩容),扩容后的容量是之前的容量的2倍。
-
我们在实际开发中,如果对效率要求很高,应当尽可能避免hashmap的扩容。
-
-
源码分析
import java.uthil.HashMap
HashMap继承关系
public interface Map<K,V> {...
public abstract class AbstractMap<K,V> implements Map<K,V> {..
public class HashMap<K, V> extends AbstractMap<K, V>
implements Map<K, V>, Cloneable, Serializable {...
上面有一个很奇怪的现象:就是HashMap已经继承了AbstractMap,而AbstractMap类实现了Map接口,那为什么HashMap还要在实现Map接口?
这样的写法,其实就是一个失误。
成员属性分析
静态值
/*
序列化版本号
*/
private static final long serialVersionUID = 362498820763181265L;
-
概念
- 把对象转换为字节序列的过程称为对象的序列化
- 把字节序列恢复为对象的过程称为对象的反序列化
-
在序列化对象时,为保证在被反序列化时仍然具有唯一性,就需要给每个参与序列化的类发一个唯一的“身份证号码”——序列化版本号,那么这个类在后期怎么修改,它的终身代码的版本号都是这个序列化版本。如果不加,JVM给定义的默认序列化版本就会发生变化。此时的序列化版本号是JVM虚拟机自动计算出来的,此时进行反序列化,会因为版本不一致而出现错误。
/**
* 默认的初始容量(数组长度) ,=1<<4=16。HashMap的容量必须是2的n次幂
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
- 我们在定义HashMap的时候也可以去指定一个HashMap容量
/**
* 指定容量去初始化一个HashMap
*/
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
当如果传入的参数不是2的n次幂,HashMap的tableSizeFor()
会通过一系列的位移运算和或运算得到一个2的n次幂的结果。
这个数字是离指定容量最近且改数字大于等于指定容量。
static final int tableSizeFor(int cap) {
int n = cap - 1;
/*
>>> 表示符号位也会跟着移动,比如 -1 的最高位是1,表示是个负数,然后右移之后,最高位就是0表示当前是个正数。
所以 -1 >>>1 = 2147483647
>> 表示无符号右移,也就是符号位不变。那么-1 无论移动多少次都是-1
原理就是将最高位 1 右边的所有比特位全置为 1,然后再加 1,最高位进 1,右边的比特位全变成 0,从而得出一个 2 的次幂的值
*/
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;
}
上述算法的分析:
cap-1
是为了防止cap已经是2的n次幂的情况。假设传入的值为8,没有进行减一的操作,那么得到的结果就是16。- 如果n=0,即cap=1。最后返回的结果是1(n+1)
/**
* 默认负载因子
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* TreeNode临界值
*/
static final int TREEIFY_THRESHOLD = 8;
网上一种说法解释8
-
红黑树的平均查找长度是log(n)如果长度为8,平均查找长度是log(8) = 3
-
链表平均查找长度是 n/2,如果长度是8的情况下,8/2=4,效率低于红黑树,所以需要转换为红黑树
-
如果链表长度小于等于6, 6/2=3.而log(6) ≈ 2.6,虽然比链表快,但是效率差距并不大
- 而且,链表转换为红黑树也需要一定的时间,所以这时候并不会转换为红黑树
/**
* 链表值小于6会从红黑树转回链表
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 当数组长度大于这个数时才会转红黑树,否则只是扩容
*/
static final int MIN_TREEIFY_CAPACITY = 64;
变量
/**
* 实际存储的数组 Entry数组。jdk中称其为 hash桶
*/
transient Node<K, V>[] table;
/**
* 实际存储的个数,这里的size是key-value的长度,而不是数组的长度
*/
transient int size;
/**
* 临界值,与HashMap扩容相关
* 计算方式:数组长度 * 负载因子
* 当HashMap中元素个数超过这个值的时候
* 就会进行扩容
*
* @serial
*/
int threshold;
/**
* 负载因子,初始值=0.75,与扩容有关
*
* @serial
*/
final float loadFactor;
- 默认的负载因子是0.75,并且这个负载因子的作用是计算扩容阈值用的,比如说使用无参构造方法创建的hashmap对象,他默认情况下扩容阈值就是16*0.75,即12是扩容阈值(在第一次的情况下)
- 负载因子是用来衡量HashMap满的程度,计算HashMap实时加载因子的方法是:
size/capacity
- loadFactor太大会 导致查找元素效率低,太小会导致数组利用率低
- 当HashMap中容纳的元素超过边界值,认为HashMap太挤了,需要扩容。扩容的过程涉及到rehash、复制等操作,非常消耗性能,所以开发中尽量减少扩容的次数,可以通过创建HashMap时指定初始化容量来避免
- 比如:我们需要存储1000个元素到HashMap中,那么我们如果
new HashMap(1024)
,但是1024*0.75=768<1000
,就会发生扩容。所以我们应该new HashMap(2048),因为2048*0.75=1536>1000
- 比如:我们需要存储1000个元素到HashMap中,那么我们如果
核心方法
构造方法
空参构造,默认负载因子是0.75,在new HashMap时,并不会创建数组,而是在第一次调用put方法的时候创建
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
指定容量大小和默认负载因子
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
指定容量大小和指定负载因子(不建议改变负载因子)
public HashMap(int initialCapacity, float loadFactor) {
// 判断初始化容量 initialCapacity 是否小于0
if (initialCapacity < 0) {
// 如果小于 0,抛出非法的参数异常
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
}
// 判断初始化容量 initialCapacity 是否大于集合的最大容量 MAXIMUM_CAPACITY
if (initialCapacity > MAXIMUM_CAPACITY) {
// 如果超过最大容量,将最大容量赋值给 initialCapacity
initialCapacity = MAXIMUM_CAPACITY;
}
// 判断加载因子 是否小于等于0,或者是否是一个非法数值(NAN not a number)
if (loadFactor <= 0 || Float.isNaN(loadFactor)) {
// 如果满足上面条件,抛出非法参数异常
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
}
// 将指定的负载因子赋值给 loadFactor
this.loadFactor = loadFactor;
/*
tableSizeFor 判断指定的初始化容量是否为 2 的n次幂,
如果不是,那就变为比指定容量大的最小的2的n次幂。
但是注意,这里计算出初始化容量之后,直接赋值给了threshold
有人认为这是个bug(原因主要是赋给边界值,要乘一个0.75)
事实上,在put方法中,会对threshold重新计算
*/
this.threshold = tableSizeFor(initialCapacity);
}
参数是Map的构造方法
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
// 获取map的元素个数
int s = m.size();
if (s > 0) {
// 判断 table是否已经初始化
if (table == null) {
float ft = ((float) s / loadFactor) + 1.0F;
int t = ((ft < (float) MAXIMUM_CAPACITY) ?
(int) ft : MAXIMUM_CAPACITY);
// 判断得到的值是否大于阈值,如果大于阈值,则初始化阈值
if (t > threshold) {
threshold = tableSizeFor(t);
}
} else if (s > threshold) {
// 已初始化,并且元素个数大于阈值,进行扩容
resize();
}
// 将m中所有的元素添加到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);
}
}
}
put方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
hash方法
注意:HashMap允许key为空,但是 Hashtable 不支持 key =null
为什么要右移16位?
举一个例子
00001111 00001111 00001111 11111111 //h=keyCode()
00000000 00000000 00001111 00001111 //h>>>16
00001111 00001111 00000000 11110000 //^
00000000 00000000 00000000 00001111 //table.length-1
00000000 00000000 00000000 00000000 //下标
假设length的长度很小,(是16),那么length-1->1111,如果直接和hasCode()进行&操作,实际上只使用了hasCode()的四位。特别是当hasCode()的高位变化很大,低位变化很小,就很容易造成hash冲突。即为了减少hash冲突。
static final int hash(Object key) {
int h;
/*
如果key为null
可以看到当key为null的时候也是有哈希值的,返回值是0
如果key不为null
首先计算出key的hashCode,然后赋值给h,接着,h进行无符号右移16位,再进行异或运算
*/
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
putVal方法
/**
* @param hash key的hash值
* @param key 原始key
* @param value key对应的value
* @param onlyIfAbsent 如果为true代表不更改现有的值
* @param evict 如果为false,表示table为创建状态
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K, V>[] tab;
Node<K, V> p;
int n, i;// n存放数组长度。i存放下标
if ((tab = table) == null || (n = tab.length) == 0) {
// 如果为空就通过resize实例化一个数组(前面谈到的在put中新建tab)
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;
/*
比较桶中第一个元素的hash值和key是否相等。
1. p.hash == hash :判断第一个元素的hash与我们传进来的hash是否相等
2. ((k = p.key) == key || (key != null && key.equals(k)))
2.1 (k = p.key) == key ==是地址比较,如果==都相等equals肯定也相等
2.2 (key != null && key.equals(k))) 值比较
上面如果都满足的情况下,说明第一个元素的key和我们传进来的key值是相等的
*/
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))) {
// 将该位置的节点赋值给e
e = p;
} else if (p instanceof TreeNode) {
// 判断当前下标位置的数据类型是否为红黑树
e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
} else {
// 说明当前元素是个链表
// 遍历链表
for (int binCount = 0; ; ++binCount) {
// 进入,说明e是表尾
if ((e = p.next) == null) {
// 直接将数据写到下一个节点
p.next = newNode(hash, key, value, null);
/*
1. 节点添加完成之后判断此时节点个数是否大于临界值 8,如果大于则将链表转为红黑树。
2. int binCount = 0,表示for循环的初始化值,从0开始计算,记录遍历节点的个数
|- 0表示第一个节点
|- 1表示第二个节点
|- 。。。。
|- 7表示第八个节点
因此这里TREEIFY_THRESHOLD需要-1
*/
if (binCount >= TREEIFY_THRESHOLD - 1) {
// 将链表转为红黑树
treeifyBin(tab, hash);
}
break;
}
// 如果当前位置的key与要存放位置的key相同,直接跳出
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {
/*
要添加的元素和链表中存在的元素相等了,则跳出for循环,不需要再比较后面的元素了
直接进入下面的if语句去替换e的值
*/
break;
}
// 说明新添加的元素和当前节点不相同,继续找下一个元素。
p = e;
}
}
// e不为空,说明上面找到了一个去存储Key-Value的Node
if (e != null) {
// 拿到旧Value
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null) {
// 新的值赋值给节点
e.value = value;
}
afterNodeAccess(e);
// 返回旧value
return oldValue;
}
}
// 统计数据改变次数
++modCount;
// 当最后一次调整之后的Size大于临界值,就需要调整数组容量
if (++size > threshold) {
resize();
}
afterNodeInsertion(evict);
return null;
}
resize方法
什么时候需要扩容?
- HashMap中元素超过临界值(数组长度*负载因子)就会进行扩容。
比如原数组长度是16,16*0.75=12
,当元素个数大于12,则会进行扩容,变成32(扩大2倍)。所以当我们已知size的时候,应该要指定数组大小,避免扩容,消耗新能。
- 当HashMap其中一个链表对象个数达到8个,此时如果数组长度没有达到64,HashMap也会进行扩容。
HashMap的扩容是什么?
HashMap在进行扩容的时候,使用rehash
非常的巧妙。因为,每次扩容都是翻倍,与原来的(n-1)&hash的结果相比,只是多了一个二进制位,所以节点要么在原来的位置,要么就被分配到 原位置+原容量 这个位置。
正是因为这样巧妙地rehash方式,既省去了重新计算hash的时间,而且同时,因为新增的1bit是0还是1可以认为是随机的,在resize的过程中保证了rehash之后每一个桶上的节点数一定小于等于原来桶上的节点数,保证了rehash之后不会出现更严重的hash冲突,均匀的把之前的冲突的节点分散到新的桶中。
/**
* 数组扩容
*/
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;
}
/*
没超过最大值,数组扩容为原来的2倍
1.(newCap = oldCap << 1) < MAXIMUM_CAPACITY 扩大到2倍之后赋值给newCap,判断newCap是否小于最大容量
2.oldCap >= DEFAULT_INITIAL_CAPACITY 原数组长度大于等于数组初始化长度
*/
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY) {
// 当前容量在默认值和最大值的一半之间
// 新的临界值为当前临界值的2倍
newThr = oldThr << 1; // double threshold
}
} else if (oldThr > 0) // initial capacity was placed in threshold
{
// 旧容量为0,当前临界值不为0,让新的临界值等于当前临界值
newCap = oldThr;
} else {
// 当前容量和临界值都为0,让新的容量等于默认值,临界值=初始容量*加载因子
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int) (DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 经过上面对新临界值的计算后如果还是0
if (newThr == 0) {
// 计算临界值为新容量 * 加载因子
float ft = (float) newCap * loadFactor;
// 判断新容量小于最大值,并且计算出的临界值也小于最大值
// 那么就把计算出的临界值赋值给新临界值。否则新临界值默认为Integer最大值
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) {
// 遍历旧桶,把旧桶中的元素重新计算下标位置,赋值给新桶
// j 表示数组下标位置
for (int j = 0; j < oldCap; ++j) {
Node<K, V> e;
/*
(e = oldTab[j]) != null 将旧桶的当前下标位置元素赋值给e,并且e不为null
*/
if ((e = oldTab[j]) != null) {
// 置空,置空之后原本的这个数据就可以被gc回收(*)
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 { // preserve order
// 到这里说明该位置的元素是链表
/*
loHead:链表头结点
loTail:数据链表
hiHead:新位置链表头结点
hiTail:新位置数据链表
*/
Node<K, V> loHead = null, loTail = null;
Node<K, V> hiHead = null, hiTail = null;
Node<K, V> next;
// 循环链表,直到链表末再无节点
do {
// 获取下一个节点
next = e.next;
// 如果这里为true,说明e这个节点在resize之后不需要移动位置
if ((e.hash & oldCap) == 0) {
if (loTail != null) {
loTail.next = e;
} else {
loHead = 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;
}
remove 方法
/**
* 根据key删除元素
* 删除是有返回值的
* 并且返回值是被删除key所对应的value
*/
@Override
public V remove(Object key) {
Node<K, V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
方法中主要的方法是removeNode(hash(key), key, null, false, true)
,
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;
/*
1. (tab = table) != null 把hash桶赋值给tab,并且判断tab是否为nul
2. (n = tab.length) > 0 获取tab的长度,赋值给n,判断n是否大于0
3. (p = tab[index = (n - 1) & hash]) != null 根据hash计算索引位置,赋值给index
并从tab中取出该位置的元素,赋值给p,并判断,p不为null
*/
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;
}
// 取出p的下一个节点赋值给e,并且e不为空
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);
}
}
// 判断node不为空,
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) {
// node==p,说明node是第一个节点,那么直接将下一个节点赋值给当前下标
tab[index] = node.next;
} else {
p.next = node.next;
}
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
HashMap遍历方式
- 分别遍历key和value
@Test
public void testMap1() {
HashMap<String, Integer> map = getMap();
for (String key : map.keySet()) {
System.out.println(key);
}
for (Integer value : map.values()) {
System.out.println(value);
}
}
- 使用iterator迭代器迭代
@Test
public void testIterator() {
HashMap<String, Integer> map = getMap();
Iterator<Map.Entry<String, Integer>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Integer> entry = iterator.next();
System.out.println(entry.getKey() + ":" + entry.getValue());
}
}
- 通过get方式
说明:根据阿里开发手册,不建议这种方式,因为要迭代多次。keySet一次,get一次。
@Test
public void testGet() {
HashMap<String, Integer> map = getMap();
Set<String> keySet = map.keySet();
for (String key : keySet) {
System.out.println(key + ":" + map.get(key));
}
}
- Jdk8以后使用Map接口中的一个默认方法
forEach
@Test
public void testForeach() {
HashMap<String, Integer> map = getMap();
map.forEach((key, value) -> {
System.out.println(key + ":" + value);
});
}
参考链接:
https://blog.csdn.net/Elizabeth_ZSY/article/details/113434571
https://blog.csdn.net/jdliyao/article/details/79826526
https://ke.qq.com/course/1645879?taid=7384371633397047
https://blog.csdn.net/chengqiuming/article/details/96692290
https://www.iteye.com/blog/yananay-910460
https://www.cnblogs.com/zhisuoyu/archive/2016/03/24/5314541.html