
基于 JDK1.8 进行分析
首先说几个问题,然后根据问题去分析具体情况
Q1. 默认容量是多少,负载因子是多少,扩容倍数?
Q2. 底层的存储数据结构?
Q3. 如何处理 hash 冲突?
Q4. 如何计算一个 key 的 hash 值?
Q5. 数组的长度为何是 2 的幂次方?
Q6. 扩容查找过程?
开始之前,先看一下 HashMap 的几个基本属性。
/**
* 默认的初始化容量,必须是 2 的 幂次方
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 二进制 1 向左移四位
/**
* 最大容量,左移三十位,也就是 2 的 30 次方
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认的负载因子
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f; //不指定的话,默认为 0.75f
/**
* Node 节点数组, 用来表示一个 key-value
* 内部类
*/
transient Node<K,V>[] table;
/**
* map 中包含键值对映射的数量
*/
transient int size;
节点数组的存储使用了一个内部类 Node 来存储,该 Node 对象包含了当前对象 key 的 hash 值,key 值,value,以及下一个对象的指针。源码如下
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //节点 hash
final K key; // key
V value; // 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;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
由上面的源码可以看出,每一个节点都存储了下一个节点,是一种链表形式的存储。注意到它重写了 hashCode 方法和 equals 方法。
HashMap 的初始化
在 hashMap 中,我们最常用的初始化方式就是使用默认构造方法 Map<String, Object> map = new HashMap<>(); 和使用已经定义好的初始化容量的构造方法 Map<String, Object> map = new HashMap<>(10);,先看一下不带容量的构造方法。
/**
* 给定了一个默认的负载因子
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
该构造方法啥也没做,只是指定了一个负载因子,默认为 0.75f。
再看一下指定了容量的构造方法。源码如下:
/**
* @param initialCapacity 传进来的初始化容量
* 指定了默认的负载因子
*/
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
发现它调用的实际上是自身的另一个构造方法,传入了两个参数,一个是我们指定的初始化容量,另一个是默认的负载因子(0.75f)。那就接着往下看它另一个构造方法
/**
* 构造了一个空的 HashMap
* @params initialCapacity 传进来的初始化容量
* @params loadFactor 负载因子
*/
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)) // 负载因子小于 0,或者非浮点数则抛出非法参数异常
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor; //负载因子
// threshold 阈值
this.threshold = tableSizeFor(initialCapacity);
}
- 对传入的容量做了基本的判断,如果小于 0,则抛出非法参数异常。如果大于最大容量(230-8),则将最大容量赋值给初始化容量
- 对负载因子做了基本的判断,如果负载因子小于等于 0、或者不是浮点数。则抛出非法参数异常。
- 负载因子赋值
- 调用
tableSizeFor方法计算阈值
先不管阈值是啥意思,先看看 tableSizeFor 方法做的事情。源码如下
/**
* 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;
}
这里做了位运算,该方法的作用是为了返回一个比给定容量最接近的 2n 的值
举个例子
- 如果给定容量是 3,最接近的值就是 22 = 4
- 如果给定容量是 5,最接近的值就是 23 = 8
- 如果给定容量是 13,最接近的值就是 24 = 16
由此可以得出规律:该算法的作用就是把最高位 1 后面的数值全部变成 1,最后将计算出来的结果 + 1。
例如 3 二进制 为 11,经过计算 21 + 20 = 3 + 1
例如 7 二进制为 111,经过计算 22 + 21 + 20 = 7 + 1
例如 13 二进制为 1101,转换后 1111,经过计算 23 + 22 + 21 + 20 = 8 + 4 + 2 + 1 = 15 + 1 = 16
依次类推,这里把阈值设置成与给定容量最接近的一个 2 的幂次方(包含等于),也就是说 HashMap 的容量始终都是 2 的幂次方。
HashMap 初始化时机
到这里,构造方法做的事情就算完了,但是存在一个问题,就是我们以上的分析计算了阈值(更何况阈值是啥作用目前还不清除),但是我们发现构造方法里面并没有对 Map 进行初始化的动作。
那么初始化的动作在哪里呢?不难想到,我们 new HashMap<>() 这个对象,下面的操作应该就是要对 Map 进行 crud 操作了。那么它的初始化动作最有可能发生在哪里呢?我们说了 HashMap 可以动态扩容,如果要触发扩容,必然是在添加元素的时候检测发现不够存储了,才会去触发扩容。显而易见,Map 的初始化放在了 put 方法里面,源码如下:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
发现它调用了一个 putVal 方法,那我们接着往下看 putVal 方法。源码如下:
/**
* @param hash // key 的 hash 值
* @param key // key 值
* @param value // key 对应的 value
* @param onlyIfAbsent if true, don't change existing value // 如果设置为 true,则不会改变已经存在的值,put 方法传进来时,默认传入的是 false,如果放进来的值已经存在,它就会对值进行覆盖
* @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) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length; //这里调用了 resize 方法
if ((p = tab[i = (n - 1) & hash]) == null) //判断要存放的位置的值是否为 null,说明该位置没有元素,可以插入
tab[i] = newNode(hash, key, value, null); // 向尾部插入一个节点
else { //如果有元素
Node<K,V> e; K k;
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;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
首次调用 putVal 方法的时候,table 这个数组一定为空,那么它一定会进入到 n = (tab = resize()).length; 中去。

在 resize 方法里面,应该是做了初始化可扩容的操作。进 resize 方法看看,源码如下:
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) // 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;
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<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 {
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;
}
代码有点长,我们分两部分来看(为了方便分析,这里截图说明)
先看第一部分

这里有两组数据,一组是旧容量(oldCap)和旧阈值(oldThr),另外会根据旧容量和旧阈值计算出一组新容量(oldCap)和新阈值(newThr)出来
在我们未指定容量的时候,table 最开始是为 null 的,所以旧容量和旧阈值都是 0,所以会进入第二步中,第二步将默认的容量(16)赋值给了新容量(newCap),将默认容量 x 默认负载因子计算出来的值赋值给了新阈值(Thr)。
所以:如果我们指定容量,HashMap 默认容量为 16,默认阈值为 16 * 0.75f = 12
第三步只是做了一个保守的判断,预防计算出出来的新阈值(newThr)为 0, 如果为 0 ,则重新计算新阈值。
重点来了,在第一次进入的时候,执行到第四步出现了我们心心念念的初始化动作 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];,
看到这里,基本上 HashMap 的初始化已经搞清楚了。
第一个问题基本上可以回答一半了。剩下就差扩容倍数了。
扩容时机
到这里,可能有同学会会有一问,这个 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; 不是每次进来都会被调用吗?
是的,这个 new 动作确实是每次 put 的时候都会被调用,也就是每执行一次 put 动作的时候,HashMap 都会重新生成一个新的 Node[] 数组,然后把数据从旧的数组中拷贝过去。
拷贝的原理这篇文章先不展开讲,重点还是关注如何触发扩容。
回到我们的 putVal 方法中

我们发现他 resize 方法在 putVal 里面除了初始化时会被,在方法的最底部也会被调用一次(上图中的第二步),注意看条件
if(++size > threshold){
resize();
}
size 是 HashMap 的 key value 的数量,也就是说当 size + 1 > threshold 的时候触发了扩容,这个时候我们就能理解阈值的作用了。
阈值 = 容量 * 负载因子
当添加元素时,会先去预判断添加之后的 size 是否大于阈值(threshold),如果大于,就扩容。
回到我们上面截图 resize 方法的上半部分
之前我们说了 resize 方法的第一步如果是第一次 put,则第一步不会执行。
那么如果是非第一次 put,那么就会进入第一步的 判断中去。
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
}
只看这一部分代码,旧容量显然大于 0,
- 判断旧容量是否达到了最大容量,达到了就会把 Integer.MAX_VALUE 值赋值给阈值,返回旧的 oldTab
- 如果没达到,那么就判断旧容量左移一位(乘以 2)之后是否小于最大容量 && 旧容量是否大于默认初始化容量
- 满足条件则新阈值 = 旧阈值左移一位 (乘以 2),新容量也等于旧容量左移一位(乘以 2)
至此,第一个问题的所有答案都出来了
总结
- HashMap 默认容量是 16,默认的负载因子是 0.75f
- 构造方法只会初始化一个空的 Node 数组,真正的初始化过程是在 put 方法中的 putVal 里面调用的,主要核心方法是 resize 方法
- HashMap 的容量固定为 2n 次方,也就是如果我们传入了初始化容量,HashMap 不会使用我们传入的容量,而是会帮我们计算与我们传进去的参数最接近(大于等于)的一个 2n 的值作为容量
- HashMap 内部会存在一个阈值(threshold),该值为容量和负载因子的乘积,当 HashMap 里面的容量大于等于阈值时,会触发扩容。
- 因为扩容是一个拷贝数组的过程,比较耗费资源。建议在初始化的时候指定容量,才能最大限度的利用 HashMap 的性能。
面试题扩展
如果初始化传入容量为 1w,在存储第 1w 的键值对时,会触发扩容吗?
答:需要考虑两种情况,使用默认负载因子和自定义的负载因子
- 如果使用默认负载因子 0.75,初始化容量为 1w,那么 HashMap 帮我们生成的容量就是 214 次方为 16384,
根据默认负载因子计算出来的扩容阈值为 12288
也就是说,只有当 put 到第 12288 个元素的时候,才会触发 HashMap 的扩容,而存储第 1w 个元素时,还未到达阈值,不会触发扩容机制。 - 如果使用自定义的负载因子,那么就要根据具体情况来分析了,比如使用 0.5 作为负载因子,那么阈值计算出来就是 8192,显然会触发扩容
如果初始化传入容量为 1k 呢?会触发扩容吗?
答:根据上面的分析,按照默认负载因子来计算:初始化容量为 1k,HashMap 生成的容量就是 210 = 1024,阈值就是 1024 * 0.75 = 768,实际上不用 put 到 1000 个元素,put 到 768 个元素时就会触发扩容。
HashMap 初始化详解:JDK1.8 版本
本文深入分析了JDK1.8中HashMap的初始化过程,包括默认容量、负载因子、扩容策略等。HashMap的容量始终为2的幂次方,初始化在put方法中进行,通过resize方法完成。当size超过阈值(容量*负载因子)时,HashMap会触发扩容。同时,文章探讨了不同负载因子和初始容量对扩容的影响,对于面试问题提供了详细解答。
1359

被折叠的 条评论
为什么被折叠?



