一、HashMap类图总览
HashMap是一种散列表,用以存储key-value键值对的数据结构,提供平均时间复杂度为O(1)的基于key级别的get/put操作
实现java.util.Map、Serializable、Cloneable接口并集成AbstractMap抽象类
二、构造方法
1.HashMap()
/**
* Constructs an empty {@code HashMap} with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
初始化loadFactor为DEFAULT_LOAD_FACTOR=0.75
构造方法中并没有table数组的初始化。HashMap中使用的是延迟初始化,在向HashMap中添加key-value时,在resize()方法中才开始真正的初始化
2.HashMap(int initialCapacity)
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
内部调用HashMap(int initialCapacity)方法,初始化容量为initialCapacity的HashMap对象。
3.HashMap(int initialCapacity, float loadFactor)
/**
* Constructs an empty {@code HashMap} with the specified initial
* capacity and load factor.
*
* @param initialCapacity the initial capacity
* @param loadFactor the load factor
* @throws IllegalArgumentException if the initial capacity is negative
* or the load factor is nonpositive
*/
public HashMap(int initialCapacity, float loadFactor) {
// 校验 initialCapacity 参数
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 避免 initialCapacity 超过 MAXIMUM_CAPACITY
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 校验 loadFactor 参数
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
// 设置 loadFactor 属性
this.loadFactor = loadFactor;
// 计算 threshold 阀值
this.threshold = tableSizeFor(initialCapacity);
}
该构造方法,初始化容量为initialCapacity、扩容因子为loadFactor的HashMap对象
4.HashMap(Map<? extends k, ? extends V> m)
/**
* Constructs a new {@code HashMap} with the same mappings as the
* specified {@code Map}. The {@code HashMap} is created with
* default load factor (0.75) and an initial capacity sufficient to
* hold the mappings in the specified {@code Map}.
*
* @param m the map whose mappings are to be placed in this map
* @throws NullPointerException if the specified map is null
*/
public HashMap(Map<? extends K, ? extends V> m) {
// 设置加载因子
this.loadFactor = DEFAULT_LOAD_FACTOR;
// 批量添加到 table 中
putMapEntries(m, false);
}
三、属性
- DEFAULT_INITIAL_CAPACITY:默认的初始化容量16。一般设置为2的n次幂,保证数据的离散性
/**
* 默认的初始化容量
*
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
- MAXIMUM_CAPACITY:最大容量,最大为2的30次方。HashMap底层数组的最大长度
/**
* 最大的容量为 2^30 。
*
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
- DEFAULT_LOAD_FACTOR:默认加载因子(数组中被占用数/数组长度),默认为0.75
/**
* 默认加载因子为 0.75
*
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
- TREEIFY_THRESHOLD:每个位置变化为红黑树,需要的链表最小长度
/**
* 每个位置链表树化成红黑树,需要的链表最小长度
*
* The bin count threshold for using a tree rather than list for a
* bin. Bins are converted to trees when adding an element to a
* bin with at least this many nodes. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon
* shrinkage.
*/
static final int TREEIFY_THRESHOLD = 8;
原理介绍: 在平时使用HashMap的时候我们都知道其平均查询时间复杂度基本为O(1),那它是怎么做到的呢?
HashMap其实底层也是个数组,只不过这个数组经过改造加强成为了超级数组。
HashMap存储的是k-v键值对,但是k可以是任意类型。所以我们可以使用hash,通过key的hash转成整数存储在数组中,但是得到的hash值可能非常大超过数组容量,于是我们可以通过hash(key)%size作为数组下标放到对应位置。
但是这样又会有其他问题?
- hash(key)计算出来的值一定能保证唯一性吗?如果不唯一怎么办?
- hash(key)%size操作后,即使不同哈希值也可能变成相同结果
这种问题就是我们通常所说的哈希冲突,那么如何解决这种问题呢?
- 开放寻址法:后续有时间研究补充,我也不是很懂
- 链表法:即数组中每个元素对应一个链表,将hash冲突的值放到对应下标的链表中解决hash冲突的问题。但是细想一下,如果n个key经过hash(key)%size计算得到的都是相同的值,那么链表长度就为n。这种情况下时间复杂度又退化到了O(n),那么如何解决极端情况下出现的问题呢?我们可以将数组中的链表换成其它数据结构,比如红黑树或者链表。这样查询平均时间复杂度可以变为O(logN)
需要注意的是:
- 在JDK7中,HashMap数据结构是使用数组+链表形式实现
- 在JDK8开始的版本中,HashMap采用数组+链表+红黑树的形式实现
四、哈希函数
HashMap是通过对key取hash值保证平均查询复杂度为O(1)的操作,那么一个高效的hash()函数至关重要
对于哈希函数来说,有两个方面特别重要
1.性能足够高,因为基本HashMap所有操作都需要用到哈希函数
2.哈希函数计算出的哈希值足够离散,这样就能够保证哈希冲突的概率足够小。
static final int hash(Object key) {
int h;
// h = key.hashCode() 计算哈希值
// ^ (h >>> 16) 高 16 位与自身进行异或计算,保证计算出来的 hash 更加离散
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 高效性:从整个计算过程来看,^(h >>> 16)只有这块的逻辑,两个位操作,性能肯定是有保证的。如果想要保证哈希函数的高效性,传入key自身的 hashCode()函数获取hashCode即可
- 离散型:这段代码保证hash更加离散,如果有兴趣可以深入研究《JDK 源码中 HashMap 的 hash 方法原理是什么?》
五、添加单个元素
public V put(K key, V value) {
// hash(key) 计算哈希值
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; // tables 数组
Node<K,V> p; // 对应位置的 Node 节点
int n; // 数组大小
int i; // 对应的 table 的位置
// 如果 table 未初始化,或者容量为 0 ,则进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize() /*扩容*/ ).length;
// 如果对应位置的 Node 节点为空,则直接创建 Node 节点即可。
if ((p = tab[i = (n - 1) & hash] /*获得对应位置的 Node 节点*/) == null)
tab[i] = newNode(hash, key, value, null);
// 如果对应位置的 Node 节点非空,则可能存在哈希冲突
else {
Node<K,V> e; // key 在 HashMap 对应的老节点
K k;
// 如果找到的 p 节点,就是要找的,则则直接使用即可
if (p.hash == hash && // 判断 hash 值相等
((k = p.key) == key || (key != null && key.equals(k)))) // 判断 key 真正相等
e = p;
// 如果找到的 p 节点,是红黑树 Node 节点,则直接添加到树中
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 如果找到的 p 是 Node 节点,则说明是链表,需要遍历查找
else {
// 顺序遍历链表
for (int binCount = 0; ; ++binCount) {
// `(e = p.next)`:e 指向下一个节点,因为上面我们已经判断了最开始的 p 节点。
// 如果已经遍历到链表的尾巴,则说明 key 在 HashMap 中不存在,则需要创建
if ((e = p.next) == null) {
// 创建新的 Node 节点
p.next = newNode(hash, key, value, null);
// 链表的长度如果数量达到 TREEIFY_THRESHOLD(8)时,则进行树化。
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break; // 结束
}
// 如果遍历的 e 节点,就是要找的,则则直接使用即可
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break; // 结束
// p 指向下一个节点
p = e;
}
}
// 如果找到了对应的节点
if (e != null) { // existing mapping for key
V oldValue = e.value;
// 修改节点的 value ,如果允许修改
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 节点被访问的回调
afterNodeAccess(e);
// 返回老的值
return oldValue;
}
}
// 增加修改次数
++modCount;
// 如果超过阀值,则进行扩容
if (++size > threshold)
resize();
// 添加节点后的回调
afterNodeInsertion(evict);
// 返回 null
return null;
}
- <1>处判断如果table未初始化或者容量为0,调用resize()方法进行扩容
- <2>处固若对应位置的Node节点为空,则直接创建Node节点即可
- <3>处如果对应位置的Node节点非空,则可能存在哈希冲突,需要分成Node节点是链表(<3.3>)或是红黑树(<3.2>)的情况
- <3.1>处如果找到的p节点就是要找的,则直接使用即可这是个优化操作,无论是Node节点是链表还是红黑树
- <3.2>处如果找到的p节点,是红黑树Node节点,则调用putTreeVal(HashMap<K,V> map, Node<K,V>[] tab, int h, K k, V v)方法,直接添加到树种
- <3.3>处如果找到的p节点是Node节点,则说明是链表,需要遍历查找。其中,binCount >= TREEIFY_THRESHOLD - 1代码段,在链表长度超过TREEIFY_THRESHOLD = 8的时候会调用treeifyBin(Node<K,V>[] tab, int hash)方法,将链表进行树化。
- <4>处根据是否在HashMap中已经存在key对应的节点有不同的处理
- <4.1>处如果存在的情况会有以下处理:
- 如果满足需要修改节点,则进行修改
- 如果节点被访问时,调用afterNodeAccess(Node<K,V> p)方法,节点被访问的回调,目前这是个空方法,用于HashMap的子类LinkedHashMap需要做的拓展逻辑
- 返回老的值
- <4.2>处如果不存在的情况,会有如下处理
- 增加修改次数
- 增加key-value键值对size数,并且size如果超过阈值调用resize()方法进行扩容
- 调用afterNodeInsertion(boolean evict)方法,添加节点后的回调,目前这是个空方法,用于HashMap的自雷LinkedHashMap需要做的拓展逻辑
- 返回null,因为老值不存在
六、扩容
在第五步中我们提到了resize()扩容方法。实际上在构造方法中我们能看到table数组并未初始化,它是在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;
// oldCap 大于 0 ,说明 table 非空
if (oldCap > 0) {
// 超过最大容量,则直接设置 threshold 阀值为 Integer.MAX_VALUE ,不再允许扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// newCap = oldCap << 1 ,目的是两倍扩容
// 如果 oldCap >= DEFAULT_INITIAL_CAPACITY 满足,说明当前容量大于默认值(16),则 2 倍阀值。
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 【非默认构造方法】oldThr 大于 0 ,则使用 oldThr 作为新的容量
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 【默认构造方法】oldThr 等于 0 ,则使用 DEFAULT_INITIAL_CAPACITY 作为新的容量,使用 DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY 作为新的容量
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果上述的逻辑,未计算新的阀值,则使用 newCap * loadFactor 作为新的阀值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 将 newThr 赋值给 threshold 属性
threshold = newThr;
// 创建新的 Node 数组,赋值给 table 属性
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 如果老的 table 数组非空,则需要进行一波搬运
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
// 获得老的 table 数组第 j 位置的 Node 节点 e
Node<K,V> e;
if ((e = oldTab[j]) != null) {
// 置空老的 table 数组第 j 位置
oldTab[j] = null;
// 如果 e 节点只有一个元素,直接赋值给新的 table 即可
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 如果 e 节点是红黑树节点,则通过红黑树分裂处理
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 如果 e 节点是链表
else { // preserve order
// HashMap 是成倍扩容,这样原来位置的链表的节点们,会被分散到新的 table 的两个位置中去
// 通过 e.hash & oldCap 计算,根据结果分到高位、和低位的位置中。
// 1. 如果结果为 0 时,则放置到低位
// 2. 如果结果非 1 时,则放置到高位
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 这里 do while 的原因是,e 已经非空,所以减少一次判断。细节~
do {
// next 指向下一个节点
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);
// 设置低位到新的 newTab 的 j 位置上
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 设置高位到新的 newTab 的 j + oldCap 位置上
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
总结起来两步
- 计算新的容量和扩容阈值并创建新的table数组
- 将老的table内容复制到新的table数组中
1)第一步
-
<1.1> 处,oldCap 大于 0 ,说明 table 非空,说明是两倍扩容的骚操作。
- <1.1.1> 处,超过最大容量,则直接设置 threshold阀值 为 Integer.MAX_VALUE,不再允许扩容。
- 【重要】<1.1.2> 处,两倍扩容,这个暗搓搓的 newCap = oldCap << 1)代码段,差点就看漏了。因为容量是两倍扩容,那么在 newCap * loadFactor 逻辑,相比直接 oldThr << 1 慢,所以直接使用 oldThr << 1 位运算的方案。
-
<1.2.1> 和 <1.2.2> 处,oldCap 等于 0 ,说明 table 为空,说明是初始化的骚操作。
- <1.2.1> 处,oldThr 大于 0 ,说明使用的是【非默认构造方法】,则使用 oldThr 作为新的容量。这里,我们结合 #tableSizeFor(int cap) 方法,发现 HashMap 容量一定会是 2 的 N 次方。
- <1.2.2> 处,oldThr 等于 0 ,说明使用的是【默认构造方法】,则使用 DEFAULT_INITIAL_CAPACITY 作为新的容量,然后计算新的 newThr 阀值。
-
<1.3> 处,如果上述的逻辑,未计算新的阀值,则使用 newCap * loadFactor 作为新的阀值。满足该情况的,有 <1.2.1> 和 <1.1.1> 的部分情况(胖友自己看下那个判断条件)
2)第二步
-
一共分成 <2.1>、<2.2>、<2.3> 的三种情况。相信看懂了 #put(K key, V value) 也是分成三种情况,就很容易明白是为什么了。
-
<2.1> 处,如果 e 节点只有一个元素,直接赋值给新的 table 即可。这是一个优化操作,无论 Node 节点是链表还是红黑树。
-
<2.2> 处,如果 e 节点是红黑树节点,则通过红黑树分裂处理。
-
<2.3> 处,如果 e 节点是链表,以为 HashMap 是成倍扩容,这样原来位置的链表的节点们,会被分散到新的 table 的两个位置中去。可能这里对于不熟悉位操作的胖友有点难理解,我们来一步一步看看:
为了方便举例,{} 中的数字,胖友记得是二进制表示哈。
- 1)我们在选择 hash & (cap - 1) 方式,来获得到在 table 的位置。那么经过计算,hash 在 cap 最高位(最左边)的 1 自然就被抹去了。例如说,11 & (4 - 1) = {1011 & 011} = {11} = 3 ,而 15 & (4 - 1) = {1111 & 011} = {11}= 3 。相当于 15 的 1[1]11 的 [1] 被抹去了。
- 2)HashMap 成倍扩容之后,我们在来看看示例。11 & (7 - 1) = {1011 & 0111} = {11} = 3 ,而 15 & (8 - 1) = {1111 & 0111} = {111}= 7 。相当于 15 的 1[1]11 的 [1] 被保留了。
- 3)那么怎么判断这 [1] 是否能够在扩容的时候被保留呢,那就使用 hash & oldCap 是否等于 1 即可得到。既然 [1] 被保留下来,那么其位置就会 j + oldCap ,因为 [1] 的价值就是 + oldCap 。
七、树化
/**
* 每个位置链表树化成红黑树,需要的链表最小长度
*
* The bin count threshold for using a tree rather than list for a
* bin. Bins are converted to trees when adding an element to a
* bin with at least this many nodes. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon
* shrinkage.
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* HashMap 允许树化最小 key-value 键值对数
*
* The smallest table capacity for which bins may be treeified.
* (Otherwise the table is resized if too many nodes in a bin.)
* Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
* between resizing and treeification thresholds.
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* Replaces all linked nodes in bin at index for given hash unless
* table is too small, in which case resizes instead.
*/
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 如果 table 容量小于 MIN_TREEIFY_CAPACITY(64) ,则选择扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 将 hash 对应位置进行树化
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 顺序遍历链表,逐个转换成 TreeNode 节点
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
// 树化
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
在第六步添加单个元素中我们看到,每个位置的链表如果要树化成红黑树,要求链表长度大于等于TREEIFY_THRESHOLD=8,那么我们思考个问题,为什么要求是8呢?
* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
- 首先参考泊松概率函数,当链表长度到达8的概率是0.00000006,不到千分之一。所以在hash算法正常时,不太会出现链表转红黑树的情况。
- 其次TreeNode相比普通Node,会有两倍的空间占用,并且在长度较小的情况下红黑树的性能和链表差别不大。例如,红黑树O(logN) = log8 = 3和链表的O(N)=8只相差5
- 比较HashMap是JDK提供的基础数据结构,必须在空间和时间做抉择。所以选择链表是空间复杂度优先,选择红黑树是时间复杂度优化。在绝大多数情况不会出现需要红黑树的情况
- <1>处如果table容量小于MIN_TREEIFY_CAPACITY=64时,调用resize()方法进行扩容。一般情况该链表可以分裂到两个位置上。当然极端情况下解决不了,这时候一般是hash算法有问题。
- <2>处,如果table容量大于等于MIN_TREEIFY_CAPACITY = 64时,则将hash对应位置进行树化。
八、总结
- HashMap默认容量为16(1<<4),每次超过阈值时,按照两倍大小自动扩容,所以容量总是2的N次方,且底层的table数组是延迟初始化,在首次添加key-value时才进行初始化。
- HashMap默认加载因子是0.75,如果我们一直HashMap大小,需要正确设置容量和加载因子
- HashMap的每个槽位在满足如下两个条件时可以进行树化成红黑树,避免槽位是链表数据结构时,链表过长导致查找性能慢。
- 条件一,HashMap的table数组大于等于64
- 条件二,槽位链表长度大于等于8时,选择8作为阈值的原因是,参考泊松概率函数
- 在槽位的红黑树节点数量小于等于6时会退化为链表
- HashMap的查找和添加key-value键值对的平均时间复杂度为O(1)
- 对于槽位是链表的节点,平均时间复杂度为O(k),其中k为链表长度
- 对于槽位是红黑树的节点,平均时间复杂度为O(logk),其中k为红黑树节点数量