一、前言

面试过的人都知道,HashMap是Java程序员在面试中最最最经常被问到的一个点,可以说,不了解HashMap都不好意思说自己是做Java开发的。基本上你去面试十家公司,有七八家都会问到你HashMap。那么今天,就带着大家从源码的角度去分析一下,HashMap具体是怎么实现的。

二、HashMap的构造方法

1.HashMap构造方法

我们先来看HashMap的四个构造方法

2.构造方法里的putMapEntries方法

刚才构造方法中提到了putMapEntries这个方法,接下来就让我们一起看一下

//该函数用于将一个map赋值给新的HashMap
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
//定义变量接收旧hashmap的size
int s = m.size();
//判断s的容量是否大于0
if (s > 0) {
//判断当前数组有没有初始化
if (table == null) { // pre-size
求出以 旧hashmap数组容量为阈值 的数组容量赋值给ft
float ft = ((float)s / loadFactor) + 1.0F;
//判断是不是大于最大容量,如果是,赋值为最大容量,否则将ft赋值给t
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
//判断t是否大于threshold(数组扩容阈值)
if (t > threshold)
//通过tablesizefor方法求出大于等于t的最小2的次幂值赋值给threshold(数组扩容阈值)
threshold = tableSizeFor(t);
}
//如果数组长度大于扩容阈值,进行resize扩容操作
else if (s > threshold)
resize();
//循环遍历取出旧hashmap的值放入当前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);
}
}
}
/*
if (table == null)分支,是判断当前数组是否初始化,因为在jdk1.8之后,只有当你第一次放值时,才会帮你创建16位的数组。

float ft = ((float)s / loadFactor) + 1.0F经过除运算再加上1.0F是为了向上取整

if (t > threshold)注意一个细节,做判断的的时候,数组还没有初始化,这里的threshold的值还是给定的数组长度的值,也就是capacity的值

else if (s > threshold)说明传入的map集合大于当前的扩容阈值,需要进行resize扩容操作
*/

tableSizeFor方法

刚才putMapEntries方法中,调用了tableSizeFor方法,接下来我们看一下这个tableSizeFor方法。

作用:创建hashMap对象时调用有参构造,传入初始容量,需要保证hashMap的初始长度为2的n次幂。

tableSizeFor方法用来计算大于等于并离传入值最近的2的n次幂的数值,比如传入15,算出结果为16,传入17,算出结果为32。

通过二进制的位移,第一次右移一位,第二次右移两位,第三次右移四位。。。通过五次位移,将范围内所有的位数都变为1,高位补0,最后得出来的结果再加上1,最后算出来的结果一定是2的n次幂。

//这个方法的作用是找到大于等于给定容量的最小2的次幂值
//>>>表示无符号右移,也叫逻辑右移,即若该数为正,则高位补0,而若该数为负数,则右移后高位同样补0
7--》8
16--》16
static final int tableSizeFor(int cap) {
//先将数组长度减1,之所以在开始移位前先将容量-1,是为了避免给定容量已经是8,16这样2的幂时,不减一 直接移位会导致得到的结果比预期大。比如预期16得到应该是16,直接移位的话会得到32。
int n = cap - 1;
//右移一位,在进行或运算,这个时候最高位和次高位就已经都是1,此时是2个1
n |= n >>> 1;
//右移两位,在进行或运算,这个时候由上次运算得出的两个1,变成了四个1
n |= n >>> 2;
//右移四位
n |= n >>> 4;
//右移八位
n |= n >>> 8;
//右移十六位,这个时候所有的位数都变为了1
n |= n >>> 16;
//n+1操作,是为了进1,这个时候算出来的数值就一点是 2的n次幂
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

移位的思想

2的整数幂用二进制表示都是最高有效位为1,其余全是0。

HashMap源码分析_jvm

对任意十进制数转换为2的整数幂,结果是这个数本身的最高有效位的前一位变成1,最高有效位以及其后的位都变为0。

核心思想是,先将最高有效位以及其后的位都变为1,最后再+1,就进位到前一位变成1,其后所有的满2变0。所以关键是如何将最高有效位后面都变为1。

先移位,再或运算。

右移一位,再或运算,就有两位变为1;
右移两位,再或运算,就有四位变为1,,,
最后右移16位再或运算,保证32位的int类型整数最高有效位之后的位都能变为1.

以传入参数 24 为例:

24转换为二进制是11000,如下图所示:

HashMap源码分析_数组_02

经过一次位移如下图所示:

HashMap源码分析_jvm_03

经过第二次位移如下图:

HashMap源码分析_jvm_04

经过第三次位移如下图:

HashMap源码分析_链表_05

因为经过第三次位移运算,所有的位数都变成了1,后续还有第四次和第五次位移,但是已经右移到了最低位所以是无意义操作(针对当前数值24而言,如果数值很大,第四次和第五次操作可以保证所有位数都是1),这个时候所有的位数都是1,所以最后再给它+1,得出来的数值一定是2的n次幂,并且是离传入值最近的2的n次幂的数值。

得出来的数值是11111,在二进制+1,变为100000;将100000转换为十进制,结果 为32.

可以看出,不管传入的数值是多少,我们都能在二进制下将其所有位都转换为1。而且分别经过1,2,4,8,16次转换,不管这个int类型值多大,我们都会将其转换,只是值较小时,可能多做几次无意义操作。

三、HashMap的底层原理

先来看几个重要的参数:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认数组初始容量

static final int MAXIMUM_CAPACITY = 1 << 30;//数组最大容量

static final float DEFAULT_LOAD_FACTOR = 0.75f;//默认加载因子

static final int TREEIFY_THRESHOLD = 8;//树化的阈值

static final int UNTREEIFY_THRESHOLD = 6;//由树退化到链表的阈值

static final int MIN_TREEIFY_CAPACITY = 64;//树化最小数组容量

//node节点,继承了Map.entry,在Entry原有的K,V的基础上追加了hash和next字段
//分别表示key的hash值和下一个节点
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;

}
//重写了计算hash的方法
//将 Hash 值的高 16 位右移并与原 Hash 值取异或运算(^),混合高 16 位和低 16 位的值
//得到一个更加散列的低 16 位的 Hash 值。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

HashMap在JDK1.8之前的实现方式 数组+链表,

但是在JDK1.8后对HashMap进行了底层优化,改为了由 数组+链表或者数值+红黑树实现,主要的目的是提高查找效率

  1. Jdk8数组+链表或者数组+红黑树实现,当链表中的元素大于等于8 个并且数组长度大于等于64以后, 会将链表转换为红黑树,当红黑树节点 小于 等于6 时又会退化为链表。
  2. 当new HashMap()时,底层没有创建数组,首次调用put()方法示时,会调用resize方法,底层创建长度为16的数组,jdk8底层的数组是:Node[],而非Entry[],用数组容量大小乘以加载因子得到一个值,一旦数组中存储的元素个数超过该值就会调用resize方法将数组扩容到原来的两倍,在做扩容的时候会生成一个新的数组,原来的所有数据需要重新计算哈希码值重新分配到新的数组,所以扩容的操作非常消耗性能。

默认的负载因子大小为0.75,数组大小为16。也就是说,默认情况下,那么当HashMap中元素个数超过160.75=12的时候,就把数组的大小扩展为216=32,即扩大一倍。

  1. 在我们Java中任何对象都有hashcode,hash算法就是通过hashcode与自己进行向右位移16的异或运算。这样做是为了使高位的16位hash也能参与运算,使运算出来的hash值足够随机,足够分散,还有产生的数组下标足够随机。

map.put(k,v)实现原理

(1)首先将k,v封装到Node对象当中(节点)。 (2)先调用k的hashCode()方法得出哈希值,并通过哈希算法转换成数组的下标。 (3)下标位置上如果没有任何元素,就把Node添加到这个位置上。如果说下标对应的位置上有链表。此时,就会拿着k和链表上每个节点的k进行equal。如果所有的equals方法返回都是false,那么这个新的节点将被添加到链表的末尾。如其中有一个equals返回了true,那么这个节点的value将会被覆盖。

HashMap中的put()方法

public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

putVal()方法。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//判断数组是否未初始化
if ((tab = table) == null || (n = tab.length) == 0)
//如果未初始化,调用resize方法 进行初始化
n = (tab = resize()).length;
//通过 & 运算求出该数据(key)的数组下标并判断该下标位置是否有数据
if ((p = tab[i = (n - 1) & hash]) == null)
//如果没有,直接将数据放在该下标位置
tab[i] = newNode(hash, key, value, null);
//该数组下标有数据的情况
else {
Node<K,V> e; K k;
//判断该位置数据的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) {
//判断next节点,如果为空的话,证明遍历到链表尾部了
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是否为空(e值为修改操作存放原数据的变量)
if (e != null) { // existing mapping for key
//不为空的话证明是修改操作,取出老值
V oldValue = e.value;
//一定会执行 onlyIfAbsent传进来的是false
if (!onlyIfAbsent || oldValue == null)
//将新值赋值当前节点
e.value = value;
afterNodeAccess(e);
//返回老值
return oldValue;
}
}
//计数器,计算当前节点的修改次数
++modCount;
//当前数组中的数据数量如果大于扩容阈值
if (++size > threshold)
//进行扩容操作
resize();
//空方法
afterNodeInsertion(evict);
//添加操作时 返回空值
return null;
}

map中resize方法

//扩容、初始化数组
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//如果当前数组为null的时候,把oldCap老数组容量设置为0
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//老的扩容阈值
int oldThr = threshold;
int newCap, newThr = 0;
//判断数组容量是否大于0,大于0说明数组已经初始化
if (oldCap > 0) {
//判断当前数组长度是否大于最大数组长度
if (oldCap >= MAXIMUM_CAPACITY) {
//如果是,将扩容阈值直接设置为int类型的最大数值并直接返回
threshold = Integer.MAX_VALUE;
return oldTab;
}
//如果在最大长度范围内,则需要扩容 OldCap << 1等价于oldCap*2
//运算过后判断是不是最大值并且oldCap需要大于16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold 等价于oldThr*2
}
//如果oldCap<0,但是已经初始化了,像把元素删除完之后的情况,那么它的临界值肯定还存在, 如果是首次初始化,它的临界值则为0
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);
}
//初始化容量小于16的时候,扩容阈值是没有赋值的
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;
//判断当前下标为j的数组如果不为空的话赋值个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<K,V>)e).split(this, newTab, j, oldCap);
//有下个节点的情况,并且没有树化(链表形式)
else {
//比如老数组容量是16,那下标就为0-15
//扩容操作*2,容量就变为32,下标为0-31
//低位:0-15,高位16-31
//定义了四个变量
// 低位头 低位尾
Node<K,V> loHead = null, loTail = null;
// 高位头 高位尾
Node<K,V> hiHead = null, hiTail = null;
//下个节点
Node<K,V> next;
//循环遍历
do {
//取出next节点
next = e.next;
//通过 与操作 计算得出结果为0
if ((e.hash & oldCap) == 0) {
//如果低位尾为null,证明当前数组位置为空,没有任何数据
if (loTail == null)
//将e值放入低位头
loHead = e;
//低位尾不为null,证明已经有数据了
else
//将数据放入next节点
loTail.next = e;
//记录低位尾数据
loTail = e;
}
//通过 与操作 计算得出结果不为0
else {
//如果高位尾为null,证明当前数组位置为空,没有任何数据
if (hiTail == null)
//将e值放入高位头
hiHead = e;
//高位尾不为null,证明已经有数据了
else
//将数据放入next节点
hiTail.next = e;
//记录高位尾数据
hiTail = e;
}
//如果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;
}

map.get(k)实现原理

(1)、先调用k的hashCode()方法得出哈希值,并通过哈希算法转换成数组的下标。 (2)、在通过数组下标快速定位到某个位置上。重点理解如果这个位置上什么都没有,则返回null。如果这个位置上有单向链表,那么它就会拿着参数K和单向链表上的每一个节点的K进行equals,如果所有equals方法都返回false,则get方法返回null。如果其中一个节点的K和参数K进行equals返回true,那么此时该节点的value就是我们要找的value了,get方法最终返回这个要找的value。

HashMap中的get()方法

public V get(Object key) {
Node<K,V> e;
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;
//判断数组不为null并且长度大于0,并且通过hash算出来的数组下标的位置不为空,证明有数据
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//判断数组的位置的key的hash和内容是否等同与要查询的数据
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)
//进行红黑树查询操作
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//链表查询操作
do {
//循环链表,逐一判断
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
//发现key的话就返回
return e;
} while ((e = e.next) != null);
}
}
//没有查询到返回null
return null;
}

四、HashMap常见面试题分析

为何HashMap的数组长度一定是2的次幂?

首先,HashMap的初始化的数组长度一定是2的n次的,每次扩容仍是原来的2倍的话,就不会破坏这个规律,每次扩容后,原数据都会进行数据迁移,根据二进制的计算,扩容后数据要么在原来位置,要么在【原来位置+扩容长度】,这样就不需要重新hash,效率上更高。

HashMap中,如果想存入数据,首先它需要根据key的哈希值去定位落入哪个桶中

HashMap的做法,我总结的是,三步:>>>无符号右移、^异或、&与

具体是:拿着key的哈希值,先“>>>”无符号右移16位,然后“^”异或上key的哈希值,得到一个值,再拿着这个值去“&”上数组长度减一

最后得出一个数(如果数组长度是15的话,那这个数就是一个0-15之间的一个数),这个数就是得出的数组脚标位置,也就是存入的桶的位置。

由上边可以知道,定位桶的位置最后需要做一个 “&” 与运算,&完了得出一个数,就是桶的位置

知道了这些以后,再来说为什么HashMap的长度之所以一定是2的次幂?

至少有以下两个原因

1、HashMap的长度是2的次幂的话,可以让数据更散列更均匀的分布,更充分的利用数组的空间

怎么理解呢?下面举例子说一下如果不是2的次幂的数的话假设数组长度是一个奇数,那参与最后的&运算的肯定就是偶数,那偶数的话,它二进制的最后一个低位肯定是0,0做完&运算得到的肯定也是0,那意味着&完后得到的数的最低位一定是0最低位一定是0的话,那说明一定是一个偶数,换句话说就是:&完得到的数一定是一个偶数,所以&完获取到的脚标永远是偶数位,那意味着奇数位的脚标永远都没值,有一半的空间是浪费的奇数说完了,来说一下偶数,假设数组长度是一个偶数,比如6,那参与&运算的就是55的二进制 00000000 00000000 00000000 00000101发现任何一个数&上5,倒数第二低位永远是0,那就意味着&完以后,最起码肯定得不出2或者3(这点刚开始不好理解,但是好好想一下就能明白)意味着第二和第三脚标位肯定不会有值

虽然偶数的话,不会像奇数那么夸张会有一半的脚标位得不到,但是也总会有一些脚标位得不到的。所以不是2的次幂的话,不管是奇数还是偶数,就肯定注定了某些脚标位永远是没有值的,而某些脚标位永远是没有值的,就意味着浪费空间,会让数据散列的不充分,这对HashMap来说绝对是个灾难!

2、HashMap的长度一定是2的次幂,还有另外一个原因,那就是在扩容迁移的时候不需要再重新通过哈希定位新的位置了。扩容后,元素新的位置,要么在原脚标位,要么在原脚标位+扩容长度这么一个位置。

比如扩容前长度是8,扩容后长度是16

第一种情况:
扩容前:
00000000 00000000 00000000 00000101
&00000000 00000000 00000000 00000111 8-1=7
-------------------------------------
101 ===== 5 原来脚标位是5

扩容后:
00000000 00000000 00000000 00000101
&00000000 00000000 00000000 00001111 16-1=15
-------------------------------------
101 ===== 5 扩容后脚标位是5(原脚标位)


第二种情况:
扩容前:
00000000 00000000 00000000 00001101
&00000000 00000000 00000000 00000111 8-1=7
-------------------------------------
101 ===== 5 原来脚标位是5

扩容后:
00000000 00000000 00000000 00001101
&00000000 00000000 00000000 00001111 16-1=15
-------------------------------------
1101 ===== 13 扩容后脚标位是13(原脚标位+扩容长度)

扩容后到底是在原来位置还是在原脚标位+扩容长度的位置,主要是看新扩容最左边一个1对应的上方数字是0还是1如果是0则扩容后在原来位置,如果是1则扩容后在原脚标位+扩容长度的位置HashMap源码里扩容也是这么做的。

总结来说,就是hash&(n-1)这个计算有关。如果不为n不为2的n次方的话,那转换为二进制情况下,n-1就会有某一位为0,那与hash做了&运算后,该位置永远为0,那么计算出来的数组位置就永远会有某个下标的数组位置是空的,也就是这个位置永远不会有值。

JDK1.7中形成的环形链表

线程一:读取到当前的hashmap情况,在准备扩容时,线程二介入

HashMap源码分析_数组_06

线程二:读取hashmap,进行扩容

HashMap源码分析_链表_07

线程一:继续执行

此线程先将A移入新的链表,再将B插入到链头,由于另外一个线程的原因,B的next指向了A,所以B->A->B,形成循环。

HashMap源码分析_面试_08

JDK1.8中HashMap的性能优化

JDK1.8在1.7的基础上对一些操作进行了优化。

不同

JDK1.7

JDK1.8

存储结构

数组+链表

数组+链表+红黑树

单独函数inflateTable()

集成至扩容方法resize()

hash值计算方式

扰动处理=9次扰动=4次位运算+5次异或运算

扰动处理=2次扰动=1次位运算+1次异或运算

存放数据规则

无冲突时,存放数组;冲突时存放链表

无冲突时,存放数组;冲突&链表长度<8:c存放单链表;冲突&链表长度 >8:树化并存放红黑树

插入数据方式

头插法(将原位置的数据移动到后一位,再插入数据到该位置)

尾插法 (直接插入链表尾部/红黑树)

扩容后存储位置的计算方式

全部按照原来的方法进行运算(hashCode()->>扰动函数->>(h&length-1))

按照扩容后的规则计算(即扩容后位置=原位置或原位置+旧容量)

注意:JDK1.8里转换为红黑树的时候,数组长度必须大于64,如果数组长度小于64,链表长度达到8的话,会进行resize扩容操作。

五、总结

看到这里,我们已经HashMap的源码和实现有了清晰的理解,并且对它的构造方法再到它的一个put数据和get数据的一个源码解析,还有一些它里边比较精妙和重要的一些方法也都探索清楚了,希望这些知识点可以在大家以后的面试中也能够帮助到大家,斩获一个心仪的offer。