深入理解HashMap
知识点:
- HashMap的几个重要组成?
- HashMap的存储结构?
- HashMap的扩容机制?
- HashMap的使用?
一、HashMap(https://www.bilibili.com/video/BV1Z54y1e7id?p=1学习视频)
1、概述
- HashMap以键值对的方式进行存储,键不能重复,值可以重复但一个键只能对应一个值。底层采用数组+链表+红黑树(JDK1.8及之后)的数据结构。
- 数组:底层实际是存储一个节点Node的地址;
- 链表:引入链表是为了解决哈希冲突问题;
- 红黑树:引入红黑树是为了解决当链表长度大于8且数组长度大于64时查询慢的问题。
- 哈希冲突:在HashMap要添加元素时会先调用底层本地的方法计算Key的hashCode(),当计算的hashCode一致时即为哈希冲突(计算的索引值也一样)。
二、Map继承图继承关系
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
public abstract class AbstractMap<K,V> implements Map<K,V>
与HashMap<K,V>同时实现Map<K,V>为什么呢?- 这是由于集合框架的创建者在编写的时候为了能够有一定的作用,后来发现没有什么用,但不删除也没什么影响就没删除了。仔细发现ArrayList、LinkedList也是这样。
三、源码剖析Hash的存储结构
1、HashMap数据结构
2、HashMap类成员变量
2.1、序列化版本号:集合可序列化
private static final long serialVersionUID = 362498820763181265L;
2.2、 默认初始化容量(构造方法中可修改):16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
- 创建时可指定初始容量,但必须是2的n次幂,如果不是则出现下面的处理方法:HashMap通过一通位移运算和或运算(tableSizeFor(int cap))得到的肯定是2的幂次数,并且是离指定容量数更大的、最近的(最小的)2的n次幂的数字。
索引 = (n - 1) & hash
这个算法计算索引值和索引 = (n - 1)%hash
(取余数)在n(容量)是2的m次幂时两个是相等的(计算机使用位移运算的速度比较快)。- 为什么要修改位为2的n次幂:
- 减少哈希碰撞,使空间更加均匀分配(可以试想当容量不为2的n次幂时,经过使用8和9对比,9时的哈希碰撞(计算出的索引值相同)比较多)。
- 使用时可以位运算,提高性能。
- 如何减少哈希碰撞的次数?:
- 容量为2的n次幂(求索引时减少碰撞)
- 使用hashCode()的返回值进行左移16位并异或运算(这样异或运算就结合的高、低位的特征得到的哈希值)
- HashMap的扩容操作会影响性能,在实际的开发中,我们如果把容量建小的话会导致多次扩容的操作,但如果建的太大又可能浪费,一般进行一下操作:
- 已知数据的容量:由阿里巴巴推荐的公式,
2.3、 集合的最大容量:2的30次幂
static final int MAXIMUM_CAPACITY = 1 << 30;
2.4、 默认负载因子(构造方法中可修改):决定已经使用容量达到多少时进行扩容,例如已使用的容量达到总容量的75%时进行扩容。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
- 负载因子大小的利与弊?
- 负载因子过大:负载因子过大,虽然数组的空间利用率会提升,但会数组比较拥挤,碰撞率会变高,导致产生较多的链表甚至红黑树结构,最终导致HashMap的效率低下。
- 负载因子过小:负载因子过小,即数组的使用都还没到总容量的75%时就扩容,碰撞率低且链表变短,数组的空间利用率低,扩容又开辟的内存而浪费资源,而且扩容操作同时也影响运行效率。
- 实际工程的负载因子确定?
- 负载因子0.75是经过开发者大量的测试得到的一个最佳值,实际中如果没有什么特别的需要可以不改变。
- 负载因子尽可能小:需求链表尽可能少,不考虑内存消耗的问题。
- 负载因子尽可能大:需求减少扩容次数,尽量使用完数组内存。
- 当负载因子大于1时会发生什么?
- 当负载因子大于1时,数组的容量小于临界值threshold,由于存储数据时是哈希值对数组取余数,所以数组的长度达不到临界值,如果一直添加元素,哈希表中就不断出现哈希冲突,导致链表不断增加,最后红黑树也不断增大,HashMap的效率变得极低。
2.5、 临界值threshold:当使用的容量达到该值时进行扩容。
int threshold;
- 计算公式:threshold = 容量 * 负载因子,注意在初始化时并不是这样计算的,通过tableSizeFor方法初始化,不过后面在扩容时会使用回来该公式,后面扩容时再介绍。
2.6、 红黑树节点小于6时转为链表
static final int UNTREEIFY_THRESHOLD = 6;
2.7、 数组大于该值且链表值大于阈值时将链表转为红黑树
static final int MIN_TREEIFY_CAPACITY = 64;
3、构造方法
HashMap的构造方法一共有4个,下面分别介绍:
3.1、空参构造(默认初始容量为16,默认负载因子为0.75)
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
- 解析:只是对负载因子loadFactor赋予默认值。
3.2、构造指定初始容量与默认负载因子。
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
- 解析:传递一个指定的容量,同时传递默认的负载因子。
3.3、构造指定容量和指定负载因子。
/**
* initialCapacity:指定的容量
* loadFactor:负载因子
*/
public HashMap(int initialCapacity, float loadFactor) {
//传递的初始容量小于0时则抛出异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//指定的容量大于HashMap的最大容量2的30次幂
if (initialCapacity > MAXIMUM_CAPACITY)
//超过最大容量则将最大容量赋予初始容量。
initialCapacity = MAXIMUM_CAPACITY;
//判断负载因子是否小于0或者负载因子是一个非数值,如果true则抛异常。
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//初始化负载因子
this.loadFactor = loadFactor;
//这个方法是个重点,下面展开介绍。(将指定容量变为2的n次幂)
this.threshold = tableSizeFor(initialCapacity);
//在tableSizeFor返回的值因为初始化容量,为什么传递给临界值threshold?
//解析:在jdk8以后的构造方法中,并没有对table这个成员变量进行初始化,table的初始化被推迟到了put方法中,在put方法中会对threshold重新计算,在put方法介绍。
}
this.threshold = tableSizeFor(initialCapacity);
:对不规范的指定容量进行初始化,这样做的原因是再计算索引index = (n-1)&hash
时尽量减少哈希碰撞,简单的举例初始容量为8和9(9不进行规范化)时即可对比出来。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; }
- 上面一顿的位移、或操作指定的容量,其实在自己找个数手动计算之后,即可知道其中的用途。总结起来就一句话:如果指定容量是2的n次幂,运算后依然是该数,如果指定容量不是2的n次幂,则运算将指定容量改为大于指定容量的最小的2的n次幂(例:如果是9,改为16;如果是25,改为32)。
3.4、构造参数为Map的实例。
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
- 解析:负载因子依然为默认值0.75,同时调用putMapEntries将原有的Map集合放入HashMap中。
putMapEntries(m, false);
:将原有的Map集合放入新的HashMap中,m为原Map集合,false代表放入的是初始化的Map集合中。/** * m :传递的原集合 * evict:使用该方法时是否是向新的(初始化)Map集合中操作数据。 final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) { //获取原集合的长度 int s = m.size(); //原集合中有元素是长度大于0 if (s > 0) { //判断table数组是否被初始化(第一次在构造方法中调用肯定没有被初始化,因为都是在put方法中构造数组的) if (table == null) { // pre-size //由已知元素的个数求容量,这个是阿里巴巴推荐的容量计算公式。 float ft = ((float)s / loadFactor) + 1.0F; int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY); if (t > threshold) threshold = tableSizeFor(t); } //如果已经有初始化好的table数组(这一步一般在put(Map map)中运行,而不是在构造的时候运行) //并且如果原集合的元素个数大于阈值,则进行扩容处理(resize();扩容后面单独介绍) else if (s > threshold) resize(); //增强for循环遍历m集合中的元素并将键值对存入新的集合中(hash值重新计算) 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); } } }
float ft = ((float)s / loadFactor) + 1.0F;
:该行为什么要加1.0F呢?直接s/loadFactor得到的容量不是够了吗?- 实际上是为了尽可能获取更大的容量,减少扩容的次数。试想一下,s为6,负载因子为0.75,则容量为8,当存储m的元素达到6个时又要去扩容一次到16,降低了效率。
4、put(key,value)增加方法(重点:HashMap的结构、哈希冲突、扩容、红黑树的处理)
4.1 增加方法的整体解析
HashMap的put方法中涉及的知识点比较多,下面简要说下流程:
- 如果是第一次插入则需要调用resize产生存储空间(第一次的threshold临界值就是在这里被改变为容量 * 负载因子)。
- 传递了key与value值以后,通过key计算hash值然后计算索引值。
- 如果没有哈希碰撞则直接插入。
- 如果发生碰撞(索引值一样),则需要对其进行处理:
- 先判断数组中的key是否一致,如果一致则替换数组(桶)中的元素。
- 如果是链表则采用传统的链式方法插入。如果链的长度达到临界值,则把链转变为红黑树;
- 如果该桶使用红黑树处理冲突,则调用红黑树的方法插入数据;
- 发现存入的元素个数达到临界值,则扩容。
- 下面是源码的详细解析:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* hash:key计算出的哈希值
* key:存储的键
* value:存储的值
* onlyIfAbsent:如果是true则不改变集合中已存在的元素
* evict:如果是false,该集合则处在创建/构造阶段。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//第一次插入:初始化桶数组 table,table 被延迟到插入新数据时再进行初始化。
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 {
Node<K,V> e; K k;
//如果与数组(桶)中的key相同,则e指向该元素。
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 {
//遍历链表记录链表的长度(便于考虑是否转为红黑树)
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//判断到链表最后一个节点仍没有相同的就创建一个节点存储
p.next = newNode(hash, key, value, null);
//如果链表长度大于指定的树化阈值,则进行树化操作。
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;
}
}
//判断如果HashMap中有相同的键时执行
//所以这里就是把该键的值变为新的值,并返回旧值
if (e != null) { // existing mapping for key
//记录旧值
V oldValue = e.value;
//onlyIfAbsent为false或者旧值为null
if (!onlyIfAbsent || oldValue == null)
//用新值替换旧值
e.value = value;
//访问后回调
afterNodeAccess(e);
//返回旧值
return oldValue;
}
}
//记录修改次数
++modCount;
//判断实际使用的大小是否大于threshold阈值,如果超过则扩容
if (++size > threshold)
resize();
// 插入后回调方法
afterNodeInsertion(evict);
return null;
}
总结:
- 插入操作的主要逻辑在于
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)
方法。上述操作主要完成的几件事:- 当桶数组 table 为空时,通过扩容的方式初始化 table。
- 查找要插入的键值对是否已经存在,存在的话根据条件判断是否用新值替换旧值。
- 如果不存在,则将键值对链入链表中,并根据链表长度决定是否将链表转为红黑树。
- 判断键值对数量是否大于阈值,大于的话则进行扩容操作。
- 以上操作的逻辑不难理解,主要查找对应键的操作和插入后的扩容、链表转为红黑树的操作。
4.2 扩容机制(resize())
HashMap在扩容时会按数组长度的2倍进行扩容,阈值同样也会变为原来的2倍。
4.2.1 HashMap什么时候会进行扩容?
- 当第一次使用put方法存储元素时进行扩容(数组的初始化)。
- 当HashMap中的元素个数超过数组大小(容量)*loadFactor(负载因子)时,就会进行数组扩容,扩大一倍容量。
- 当HashMap中存在一个链表的元素个数达到了8个且数组的容量小于64时进行扩容。
- 注意:HashMap进行扩容,JDK1.8之后元素的哈希值并不会重新计算,只是利用原有的哈希值计算出新的索引值。
4.2.2 HashMap怎样进行扩容?
HashMap进行扩容,会伴随着一次重新hash分配(1.8之后的源码并没有这样做的),同时计算索引值,并且会遍历hash表中所有的元素,是非常耗时的。在编写程序中,要尽量避免resize。下面是resize()扩容源码解读:
final Node<K,V>[] resize() {
/**
* 第一步:计算新数组(桶)的新容量(newCap)和新阈值(newThr)
*/
Node<K,V>[] oldTab = table;
//判断当前数组是否为空并得到旧数组容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//旧数组容量是否大于0
if (oldCap > 0) {
//判断如果旧数组的长度大于最大容量,说明该数组不用扩容了,直接返回旧数组就行了,但threshold阈值为Integer.MAX_VALUE。
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//使新容量增大一倍、新阈值增大一倍
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
//这个是在构造方法时指定了容量时运行,构造方法中tableSizeFor(int cap)才会出现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;
/**
* 第三步:如果旧的数组不为空,则遍历数组,重新计算旧数组各个元素在新数组里的新位置(索引)。
*/
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
//如果节点是 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;
Node<K,V> next;
//遍历链表重新计算元素索引值,并将旧链表分成两个链表(此do..while已经拆分了链表成两个链表)。
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;
}
resize()源码的解读主要分成三个步骤:
- 第一步:计算新数组(桶)的新容量(newCap)和新阈值(newThr)。
- 第二步:根据新容量创建新的数组。
- 第三步:如果旧的数组不为空,则遍历数组,重新计算旧数组各个元素在新数组里的新位置(索引)。
4.2.3 下面对三个步骤难点部分的补充
- 第一步:计算新数组(桶)的新容量(newCap)和新阈值(newThr)的条件语句。
条件 | 作用 | 覆盖情况 | 备注 |
---|---|---|---|
oldCap > 0 | 计算并赋值给新容量和新阈值 | 数组已经被初始化 | 已经使用过put方法的情况 |
oldThr > 0 | 获取新的容量 | threshold > 0,且桶数组未被初始化 | 调用 HashMap(int) 和 HashMap(int, float) 构造方法时会产生这种情况 |
oldCap == 0 && oldThr == 0 | 赋予容量和阈值默认值 | 桶数组未被初始化,且 threshold 为 0 | 调用 HashMap() 构造方法会产生这种情况。 |
oldCap > 0
分成两部分:
条件 | 作用 | 覆盖情况 | 备注 |
---|---|---|---|
oldCap >= 2的30次幂(oldCap >= MAXIMUM_CAPACITY) | 阈值修改为最大值且返回旧数组 | 桶数组容量大于或等于最大桶容量 2的30次幂 | 这种情况下不再扩容 |
newCap < 230 && oldCap > 16 | 扩大容量、阈值为原来的2倍 | 新桶数组容量小于最大值,且旧桶数组容量大于 16 |
-
oldThr > 0
就解释了我们使用指定容量后,为什么是threshold = tableSizeFor(initialCapacity)?原来是在构造方法中先保存在threshold中,在put方法再进行计算threshold和容量的值。 -
第三步:怎样重新计算旧数组各个元素在新数组里的新位置(索引)?
- 如果只有数组中的一个元素就直接计算(索引 = hash&(新容量-1) )插入即可。
- 对于树形节点,需先拆分红黑树再插入。(待分析)
- 对于链表类型节点,则需先对链表进行分组(就是索引分成两组(原位置或者原位置+旧容量两条链表)),然后再插入。
- 举个例子:当容量=8变为16时计算索引,
经过上面的计算将链表(包括数组的的那个元素)分成不同的链表,
由于哈希值与旧容量进行与运算,并且元素的哈希值的第四位是0或者1都是随机的,所以计算分链表可能只有一条,也可能有两条。
4.2.4 红黑树与链表的拆分与转化学习中…学完再来总结吧!!!
5、删除方法(remove)
HashMap的主要知识点在于put方法中,学完put方法再来学remove方法就比较好理解了,remove方法主要分成下面三步完成:
- 第一:找到对应数组的位置(索引)。
- 第二:如果是链表就遍历链表找到对应的元素删除。
- 第三:如果是红黑树就遍历树找到对应的节点删除,当树节点小于6时再转化为链表。
5.1、 源码解析
public V remove(Object key) {
Node<K,V> e;
//如果删除了对应的节点就返回该节点的值value
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;
//第一步:根据hash找到位置,如果当前key映射到的桶不为空。--start
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
//定义一个node,这个node指向最后要删除的节点。
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;
else if ((e = p.next) != null) {
if (p instanceof TreeNode)
// 如果是 TreeNode 类型,调用红黑树的查找逻辑定位待删除节点
//注意:TreeNode是Node的子类(HashMap.TreeNode -> LinkedHashMap.Entery ->HashMap.Node)
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);
}
}
//第二步:删除对应元素
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;
}
四、遍历HashMap的方式
HashMap的使用方法有很多,其中的遍历方式需要重点掌握:
4.1、 分别遍历Key和Values
已定义HashMap集合(map)
1. 获取所有键的Set集合:map.keySet();
2. 获取所有值的Set集合:map.values();
最后使用增强for循环遍历即可取出各个键与值。
4.2、 使用Iterator迭代器
//获取键值对Set集合的迭代器
Iterator<Map.Entry<String, String>> iterator = hashMap.entrySet().iterator();
while (iterator.hasNext()) {
//获取其中一个键值对
Map.Entry<String, String> next = iterator.next();
//获取键
next.getKey();
//获取值
next.getValue();
}
4.3、 通过get方式(不建议使用)
因为迭代两次,keySet获取Iterator一次,还有通过get又迭代一次。降低性能。
//先获取全部的键的Set集合
Set<String> set1 = hashMap.keySet();
for (String string : set1) {
//根据键获取对应的值
hashMap.get(string);
}
4.jdk8以后使用Map接口中的默认方法forEach():
default void forEach(BiConsumer<? super K,? super V> action)
BiConsumer接口中的方法:
void accept(T t, U u) 对给定的参数执行此操作。
参数 :
t - 第一个输入参数
u - 第二个输入参数
- 遍历实例:
public class Demo02 {
public static void main(String[] args) {
HashMap<String,String> m1 = new HashMap();
m1.put("001", "zhangsan");
m1.put("002", "lisi");
m1.forEach((key,value)->{
System.out.println(key+"---"+value);
});
}
}
五、HashMap的细节问题
5.1、初始化容量问题
问题:
我们都知道HashMap的扩容操作是比较影响性能的,所以在开发过程中我们如果想要提高HashMap的性能可以尽量减少HashMap的扩容操作。我们怎样能够减少扩容操作呢?
解析:而减少扩容操作可以从初始化容量入手;
- 举个例子:当我们的数据个数为6时,6/0.75=8,我们设置容量为2的n次幂:8,这时的临界值为6,当我们插入第6个值时竟然又要扩容了,这就很影响性能。当我们使用公式 (需求容量/0.75 +1)向上取整时,我们的容量就是6/0.75+1=9,经过处理后就会设置初始容量为16,存储过程就不用扩容了。
- 因此,在开发过程中当我们知道了存储数据的大小时,建议把默认容量的数字设置成initialCapacity/ 0.75F + 1.0F。
5.2、 被 transient 所修饰 table 变量
问题:
细心阅读 HashMap 的源码,会发现桶数组 table 被申明为 transient。transient 表示易变的意思,在 Java 中,被该关键字修饰的变量不会被默认的序列化机制序列化。回到源码中考虑一个问题:桶数组 table 是 HashMap 底层重要的数据结构,不能够序列化怎么能够进行还原呢?
解析:
HashMap 并没有使用默认的序列化机制,而是通过实现readObject/writeObject两个方法自定义了序列化的内容。HashMap的重要信息是键值对,认为只要直接序列化键值对就可以了,这样是错误的想法。序列化table数组有两个问题待解决:
- HashMap的数组大多数情况下是不可能装满的,序列化未存储的空间同样浪费资源。
- 同一个键值对在不同的JVM下所处的数组位置不一样,在不同JVM下反序列化可能出现错误。
- HashMap需要根据计算哈希值来找到索引值,如果键的hashCode方法没有覆盖重写,计算时会调用Object的hashCode方法,而Object的hashCode方法是调用了本地的(即JVM中),不同的JVM对该方法有不同的实现,因此可能产生不同的hash值,所以如果对table序列化的话可能出现不同的存储哈希表导致错误。
- 因此,HashMap不能序列化数组table,采用了自定义的其他方法。
5.3、 HashMap不能在多线程中使用?(可能要结合扩容机制讲解)
问题:HashMap为什么是线程不安全的,主要问题出现在哪里?
HashMap在多线程中在put操作时会引起扩容,在扩容过程后将元素放到新的HashMap中容易出现环形链表,出现了环形链表时,因为我们在遍历该Map时使用判断一个链表的完整是结尾为null,出现环形就没有了该null值了,无限循环下去。
解析:
假设在put方法时出现了扩容,而且假设其中的一个链表从旧map到新的map时不会分成两条链(多了可能有两条)。
六、参考文献
1.黑马程序员出品的教学视频资料 深入解读大厂java面试必考基本功-HashMap集合https://www.bilibili.com/video/BV1Z54y1e7id?from=search&seid=12428668374426906310
2.segmentfault的coolblog:HashMap 源码详细分析(JDK1.8) https://segmentfault.com/a/1190000012926722#
终于对HashMap有新的理解,花了我一个多星期呀!!!我太难了!!记录不易,先留个赞吧!!!!