HashMap底层原理分析笔记
文章目录
先贴出HashMap的一些属性
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
// 默认初始化容量 - 必须是2的幂次方.
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 阈值:链表转化为红黑树的条件之一(链表长度大于等于8)
static final int TREEIFY_THRESHOLD = 8;
// 阈值:当长度小于等于6的时候红黑树转化为链表
static final int UNTREEIFY_THRESHOLD = 6;
// 链表转化为红黑树的条件之一(数组容量大于等于64)
static final int MIN_TREEIFY_CAPACITY = 64;
HashMap里数组和链表是怎么样存储键值对的?
首先我们知道Hashmap集合存储的是一对键值对<key,value>
,然后HashMap底层数据结构是数组+链表。(在jdk1.8的时候加入了红黑树),那么数组和链表是怎么样存储这个键值对的呢?其实HashMap底层是把这个键值对封装成了一个对象。然后再把这个对象存储在了数组里。源码分析:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
可以看到HashMap的put方法返回了一个叫putVal的方法,点进去再看
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//这里声明了一个Node<K,V>数组,也就是说数组里放的是Node对象
Node<K,V>[] tab; Node<K,V> p; int n, i;
//put方法的逻辑代码
...
}
通过putVal这个方法知道了这是一个Node数组,Node对象还可以再点,点进去看看
/**
* Basic hash bin node, used for most entries. (See below for
* TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
*/
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
这里可以看到Node类实现了一个叫Map.Entry<K,V>的接口,其实这个Entry接口就是Map接口的一个内部接口。然后Node还有一个属性叫next
,这里是代表指向下一个节点,也就是链表的下一个节点。在这里可以得出结论:HashMap存储的键值对是以Node对象(也可以说是Entry对象)的形式存储在Node数组和链表里面的。
HashMap的put方法是如何确定put的键值对元素应该存放到数组哪个位置的呢(定位到数组下标的)?
贴出源码分析:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
/**
*首先判断这个tab数组是不是为空或者长度是不是为0,如果是空或者长度为0的话,调用resize方法进行扩容。
*所以jdk1.8是先put元素再扩容。1.8之前是先扩容
*/
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
/**
*这里如果p这个Node对象=tab[某个下标] == null时,把一个新的Node对象赋值给tab[i]。
*/
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
}
由以上源码可以知道,在获取tab数组下标时,是这样一段代码tab[i = (n - 1) & hash]
这里n = tab.length,那这个hash是什么东西?其实如果看下源码的话,源码的注释已经告诉我们了
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
hash就是key的hashCode,所以HashMap判断元素存放到数组哪个位置其实就是先获取key,然后利用hash函数得出key的hashCode,再用key的hashCode与数组的长度-1做&运算。(tab.length - 1) & hash
。那么这样怎么保证获取的这个下标值不会越界呢?就比如说tab.length是16,利用(tab.length - 1) & hash
它是怎么保证获取的值的范围是0-15的呢?这个就跟&运算符和为什么hashMap的容量必须是2的幂次方有关系了,这里引用B站“@dilidili王老桔”的评论
Q6:容量大小为什么要取2的指数倍?
A:两个原因:1,提升计算效率:因为2的指数倍的二进制都是只有一个1,而2的指数倍-1的二进制就都是左全0右全1。那么跟(2^n - 1)做按位与运算的话,得到的值就一定在【0,(2^n - 1)】区间内,这样的数就刚合适可以用来作为哈希表的容量大小,因为往哈希表里插入数据,就是要对其容量大小取余,从而得到下标。所以用2^n做为容量大小的话,就可以用按位与操作替代取余操作,提升计算效率。2.便于动态扩容后的重新计算哈希位置时能均匀分布元素:因为动态扩容仍然是按照2的指数倍,所以按位与操作的值的变化就是二进制高位+1,比如16扩容到32,二进制变化就是从0000 1111(即15)到0001 1111(即31),那么这种变化就会使得需要扩容的元素的哈希值重新按位与操作之后所得的下标值要么不变,要么+16(即挪动扩容后容量的一半的位置),这样就能使得原本在同一个链表上的元素均匀(相隔扩容后的容量的一半)分布到新的哈希表中。(注意:原因2(也可以理解成优点2),在jdk1.8之后才被发现并使用)
分析到这里我们解答了两个问题,
-
HashMap里数组和链表是怎么样存储键值对的?
HashMap存储的键值对是以Node对象的形式存储在Node数组和链表里面的 -
HashMap的put方法是如何确定put的键值对元素应该存放到数组哪个位置的呢(定位到数组下标的)?
HashMap判断元素存放到数组哪个位置其实就是先获取key,然后利用hash函数得出key的hashCode,再用key的hashCode与数组的长度-1做&运算得到数组下标。数组长度-1 & hash
haspmap扩容机制(简述)
那么接下来,当我们put元素越来越多的时候,HashMap就需要扩容了,那么HashMap的扩容机制又是怎么样的呢?,前面给出了一个默认的加载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f;
而hashmap的扩容就跟这个加载因子有关,当第一次往hashmap里面put元素的时候,使用resize()方法对数组进行扩容(其实就是new了一个新数组)
/**
* The number of key-value mappings contained in this map.
*/
transient int size;
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果tab ==null的时候,tab = resize(),调用resize方法进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
...
}
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;//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;//16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//12
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
return newTab;
}
可以看到,如果在new一个hashmap对象的时候不指定容量大小
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;//16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//12
}
那么新的数组大小会等于DEFAULT_INITIAL_CAPACITY=16,newThr就是一个扩容条件,即当hashmap里面的元素数量达到这个值时会进行下一次扩容。可以看到这个newThr是DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY,也就是12,
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
}
继续看这一段源码,当数组第一次扩容之后数组里存放了元素,那么这个oldCap就是当前数组的容量大小(也就是16),那么新数组(newCap)的大小是当前数组(oldCap)左移一位,就是乘2,newThr也相应的左移一位。继而得出结论,当hashMap第一次put元素的时候,hashMap里数组大小是默认初始化大小16,当元素个数大于 数组长度*默认加载因子(也就是12)的时候,hashMap发生扩容,扩容后的数组大小是原来的两倍
总结一下:
- 当调用默认构造方法新建一个hashMap对象的时候,hashMap里数组大小为初始容量16,默认加载因子0.75,当hashmap里数组大小为
数组长度 * 默认加载因子
时,发生扩容,扩容后大小为原来的2倍