Map随笔:最常用的Map——HashMap

2022-12-07,,,

目录
Map随笔最常用的Map——HashMap
前言:
1,HashMap的结构
2,HashMap的一些属性(JDK8)
3,HashMap的构造函数(JDK8)
4,HashMap的一些方法(JDK8)
1,计算hash表容量
2,计算key的hash值
3,面试被问最频繁的方法put()
4,核心扩容方法resize()
结束语:

Map随笔:最常用的Map——HashMap

前言:

​ HashMap作为我们工作中最常用的一个容器,去了解它的一些原理是非常有必要的,同时,HashMap也是面试中被问起的常客。所以接下来我就源码并穿插一些面试中最常被问起的面试题和大家分享一下HashMap相关的知识来避免以后在面试中再被HashMap相关知识所难住。

1,HashMap的结构

2,HashMap的一些属性(JDK8)

先看看源码中的几个常量属性

    /**
* 默认的数组容量
*/
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; /**
* java8优化了HashMap的数据结构,在链上的结构数据超过固定数量便会从链表转换成红黑树存储
* 链表转树的默认值 8
*/
static final int TREEIFY_THRESHOLD = 8; /**
* 树转链表的默认值 6,当数据个数小于该值时,结构由红黑树转换成链表结构
*/
static final int UNTREEIFY_THRESHOLD = 6;

再看看一些其他属性

    /**
* HashMap底层有一部分是hash表结构,用数组承载数据
*/
transient Node<K,V>[] table; /**
* HashMap的entrySet()方法的返回值
*/
transient Set<Map.Entry<K,V>> entrySet; /**
* 数据个数
*/
transient int size; /**
* 用来记录HashMap结构改变次数,因为HashMap是有Fast-Fail机制的
*/
transient int modCount; /**
* 阈值,如果数组中数据个数大于等于这个值,便会触发扩容
*/ int threshold; /**
* HashMap的加载因子,这个属性权衡哈希冲突和空间使用率的数值
*/
final float loadFactor;

最后看一看HashMap中第一个内部类,也就是承载数据的载体Node(Java8)

    static class Node<K,V> implements Map.Entry<K,V> {
//key的hash值
final int hash;
//key
final K key;
//value值
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;
}
..................................
}

HashMap在Java8以前呢是一个数组链表双结构数据类型,在Java8以后增加了红黑树结构。但是这个链表于LinkedList中的双向链表不同,HashMap中的链表是一个单向链表,只有指向下一个节点的指针(当年我被问过一道面试题便是HashMap中链表的“链”是什么?)。

值得说的是Java8之前,这个Node叫做Entry,这个也算是一个别人判断你是不是真的看过源码的一个依据,这个小细节要注意。


static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
final int hash;
..................................
}

相关面试题:

1,HashMap的有没有默认容量,是多少?

源码中已经写的很清楚,HashMap是有默认容量的,值是16,这个容量指的是数组的容量

2,HashMap中的加载因子是什么?为什么默认值是0.75F?大于默认值或者小于默认值有什么影响吗?

熟悉HashMap的人都知道,HashMap在存储数据时,是通过key的哈希值进行计算后将数据放到hash桶中,但是如此会出现hash冲突(不同的key在计算后值相同),hash冲突后带来的问题便是查找数据时效率收到影响。而加载因子又和阈值threshold有直接关系(threshold = table.length * loadFactor),HashMap在hash表中数据超过阈值时会扩容,但是会造成空间浪费,比如,默认jvm给HashMap分了存放16个数据内存大小,但是 只能放14(16*0.75)个数据便会触发扩容,剩余2个存放数据的空间便浪费掉了,所以加载因子时平衡hash冲突和空间利用率的一个折中率。过大,空间利用率高了,但是hash冲突变大了,过小,hash冲突变低了,但是空间利用率也变低了,之所以默认是0.75f,这也也不是什么固定公式算出来的,这完全前辈们大数据测试下来的一个经验值(详细可参考源码注释)以及这里:HashMap为什么默认加载因子是0.75

3,HashMap什么时候就变成红黑树结构了?

key值经过计算后得值相同的key放在同一个hash桶中,并以单向链表的形式连接起来,如果链上的数据量大于8(默认),便转换成红黑树结构,当数据量小于6时,又变回链表。树结构的查找效率比链表快

3,HashMap的构造函数(JDK8)

    /**
* 自定义容量和加载因子,如果没有特殊要求,不建议自定义加载因子
*/
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);
} /**
* 自定义容量
*/
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
} /**
* 无参构造函数,默认容量16,加载因子0.75f
* java8 源码有部分改变,默认设置源码部分逻辑挪到了resize()
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
} /**
* 添加一个Map
*/
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}

4,HashMap的一些方法(JDK8)

1,计算hash表容量

    /**
* Returns a power of two size for the given target capacity.
*/
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;
}

HashMap要求hash表的容量必须是2的指数幂(原因后面解释),该方法返回的是大于指定容量cap的最小2的指数幂。

2,计算key的hash值

    /**
* jdk7
*/
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
} /**
* jdk8
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

在获取key的hash值时,Java7和8都做了相关的位与运算目的都是为了近一步对key的hash值在插入HashMap时计算的混淆,增加它的随机度,降低hash冲突。不同的点是,7 进行了4次位与计算,而8只进行了一次,这种计算都叫做”扰动函数“,之所以8只进行一次,可能是因为多次的计算效果并不是很明显,反而影响效率,所以只做一次扰动就够了,而8 利用key 的hash值的高低位 异或运算 近一步 增加 key在HashMap中hash表的随机度,之所以右移16,是因为hash值是int数值,64位系统上是32位,所以右移16位,使得其高低位混淆,即混淆了低位的”随机度“又保留了高位的特征,这种设计就很巧妙。

3,面试被问最频繁的方法put()

put方法 Java8对put方法进行了新的逻辑和优化,所以一个一个分开来看。先看8中的put方法

 public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// onlyIfAbsent:当key重复时,是否覆盖旧的数据 false:覆盖 true:不覆盖
// evict : 留给 LinkedHashMap扩展用的
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 旧表数据为0,则新建一个表,初始容量和加载因子是默认值
// Java8之前,初始化表是在构造方法里,8以后优化成put操作时,原因个人觉得是为了节约内存空间(new了不一定用)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 这个key在hash表中所对应下标上没有数据,直接插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 这个key在hash表中所对应下标上有数据,数据添加到链上
else {
//e用来判断是本次put操作时新增还是修改
Node<K,V> e; K k;
//表中已经有该key,
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果是LinkedHashMap的树节点,则以树结构去添加
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);
//链上节点超过8个,则把这个链表结构变成红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//链上有相同的key,停止遍历查找
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//e != null,说明本次put操作时修改
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
//用新的value替换旧的value值
e.value = value;
//LinkedHashMap扩展用
afterNodeAccess(e);
//返回旧值 (面试题)
return oldValue;
}
}
// -----------------如果进入以下代码,说明本次操作时新增操作 // 记录HashMap内部变化次数,触发Fast-Fail机制
++modCount;
// 判断,如果当前大小大于等于阈值,则触发扩容
if (++size > threshold)
resize();
// LinkedHashMap扩展点
afterNodeInsertion(evict);
// 如果是新增操作,则返回null(面试题)
return null;
}

再看看7中的源码

    public V put(K key, V value) {
// HashMap允许有且只有一个key为null的数据
if (key == null)
return putForNullKey(value);
int hash = hash(key.hashCode());
//计算下标
int i = indexFor(hash, table.length);
//判断key是否已经存在
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
//同8
modCount++;
//新增新节点
addEntry(hash, key, value, i);
return null;
} void addEntry(int hash, K key, V value, int bucketIndex) {
//获取旧的节点
Entry<K,V> e = table[bucketIndex];
//生成新的节点,其中构造方法中的e对应的是Entry的next属性,所以8之前,HashMap采用的是头插法,新的节点插入到链的头部
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
//判断是否需要扩容,扩容大小为原来的2倍
if (size++ >= threshold)
resize(2 * table.length);
}

​ Java7和8的put方法有较大的改变,首先,8添加了红黑树结构,在链上长度超过8(默认)时,会将两边转换成红黑树结构。其次,在链表上添加数据时,8采用的是尾插法,7采用的时头插法,所以在扩容时,7再扩容后,链上的数据顺序变成原来的倒序,8保持原来的数据,这样一来,在多线程环境下,7之前的HashMap

再扩容时发生的死锁问题的打了改善。最后,8添加了一些LinkedHashMap的扩展方法入口

​ 值得说的是,为什么HashMap不直接用key的hash值作为下标呢,而是将key的hash值经过 “(table.length- 1) & hash”计算后的值作为下标呢?原因其实不难想到,首先hash值是一个int数值,int数值的范围时 -231~231-1,而HashMap要求的最大容量MAXIMUM_CAPACITY值时1<<30,显然直接用hash值是不合适的,另一方便也不可能new一个HashMap就给分配这么大的空间。因为HashMap要求容量必须时2的指数幂,所以 “(table.length- 1) & hash“ 就等价于 hash % table.length 的值,这样以来便能确保任何一个key都能分配在HashMap的容量内,这也是为什么要求HashMap的容量为什么时2的指数幂的原因。

4,核心扩容方法resize()

先看看8中的源码

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) {
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)
//此处用旧的阈值缓存了程序员指定的容量值,而这块也不一定是直接使用程序员指定的容量,而是经过tableSizeFor()计算得来的值,此举目的是为了保证容量是2的指数幂
newCap = oldThr;
else {
//无参构造函数的初始化,默认初始容量16,默认加载因子0.75f
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) {
//原来的表中的元素设为null,方便gc
oldTab[j] = null;
//如果旧表的该节点上没有形成链表,则直接将该节点迁移到新表中
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
//如果是树节点,则采用split方法迁移
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
//迁移链表上的数据
//8的迁移方式与7不用,采用两条链表做迁移,原因后面详解
//判断(e.hash & oldCap) == 0 放在lo链
// 判断(e.hash & oldCap) != 0 放在hi链
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) {
//新链的尾部没有数据,则说明新链还没有数据,所以把该节点放在loHead指正
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);
//lo链放在新表中与老表下标相同的地方
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//hi链放在新表中下标是老表中(下标+旧表容量)处
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}

​ 8在迁移数据时采用两条链去实现,一方面省去像7一样,重新计算索引的时间,另一方面,将原表中在同一个hash桶中的数据再次打散分配到其他桶中,因为HashMap容量都被固定为2的指数幂,所以,e.hash & oldCap 计算出的值 要么是0 要么是 oldCap ,这样一来,旧表中的数据在迁移到新表中时,所处位置要么是在旧表中的位置,要么就是表中 下标为 (旧表位置+oldCap )的地方。

Java7中的源码

   void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
// 同8,判断是否可以扩容
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//创建新表,容量是原来的两倍
Entry[] newTable = new Entry[newCapacity];
//迁移数据的方法,死锁问题也在此处出现
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
} void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
//获取旧表每个节点
Entry<K,V> e = src[j];
if (e != null) {
//元表中元素置为null,便于gc
src[j] = null;
do {
//第1步
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
//第2步
e.next = newTable[i];
//第3步
newTable[i] = e;
//第4步
e = next;
} while (e != null);//7是头插法,所以链上最后一个节点的next为null,一次作为循环条件
}
}
}

​ 下来逐步分析一下,7中多线程环境下扩容死锁的问题(看图说话)

            旧表结构如图,当线程1开始扩容,执行第1不 Entry<K,V> next = e.next; 后,e指向A,next指向B,此时线程1的时间片用完了,线程2开始扩容,如果不巧的是A、B、C扩容后又是同一个位置,线程2开始移动节点,移动完后节点如下图

            因为,7采用的是头插法,所以链的顺序反了过来,此时线程2的时间片用完,但是还没有执行 table = newTable; 所以还没有生成新表,此时线程1重现获得执行权,继续扩容:

            因为标量e和next是局部变量,所以线程独享,此时的e和next指向的还是线程2执行前的值:e=A,next=B,线程1继续执行循环体内逻辑:

            此时,当第一次循环执行完后关系如下图:

            此时,e指向B节点,而B节点不是null,所以循环继续,执行后newTable[i]指向节点B,B的next是a(线程2执行的结果),此时引用如图:

            此时,变量e由重新回到了节点A,以为A不等于null,所以循环继续,a的next是null,newTable[i]指向节点B,所以在执行e.next = newTable[i]后,a的next指向了B,这样一来,A的next指向B(线程1的结果),B的next指向A(线程2的结果),如此便形成一个环,newTable[i]指向e也就是节点A,这样便形成了如图的结构:

            A和B的next相互引用,在get操作时便会发生死锁问题,造成内存溢出错误。另外,如果线程1最终生成新表,那么新表的链表中,C节点被丢失,这也时HashMap为什么不能在多线程环境下使用的原因,1,会发生死锁,2,丢失数据。

            所以多线程情况下,HashMap线程不安全,建议使用ConcurrentHashmap,当然HashTable也是线程安全的,但是HashTable过于粗暴,它给每个方法加了synchronized锁,而synchronized是重量级锁,性能低下,而且不允许有null,所以基本上已经没有人使用。而ConcurrentHashmap在8之前采用的时分段锁技术,关于ConcurrentHashmap的知识会有专门的一个博客来和大家分享。

结束语:

HashMap就和大家分享到这里,大家有什么问题或者文中有错误之处望大家提出来大家共同学习。

Map随笔:最常用的Map——HashMap的相关教程结束。

《Map随笔:最常用的Map——HashMap.doc》

下载本文的Word格式文档,以方便收藏与打印。