时间长了总是会容易遗忘的知识点
几乎是每次面试必问的问题,虽然别人已经写的很好了,但是自己整理总结一下可以加深印象。以JDK1.8以上版本为主介绍。
Java 为数据结构中的映射定义了一个接口 java.util.Map,此接口主要有四个常用的实现类。
咱们主要是看HashMap。
对于上述四种Map类型的类,要求映射中的key是不可变对象。不可变对象是该对象在创建后它的哈希值不会被改变。如果对象的哈希值发生变化,Map对象很可能就定位不到映射的位置了。
使用 数组 + 链表 + 红黑树 的方式存储键值对。
在 JDK1.7 及之前,HashMap是 数组 + 链表 实现的存储结构。
在 JDK1.8 添加了红黑树部分。众所周知,当链表的长度特别长的时候,查询效率将直线下降,查询的时间复杂度为 O(n),因此,JDK1.8 把它设计为达到一个特定的阈值 (链表长度> 8 且 数据大小 >= 64) 之后,就将链表转化为红黑树。由于红黑树,是一个自平衡的二叉搜索树,因此可以使查询的时间复杂度降为 O(logn)。
HashMap 使用哈希表来存储,并且用链地址法解决冲突。
哈希表为解决冲突,可以采用开放地址法和链地址法等来解决问题,HashMap采用了链地址法。
链地址法,简单来说,就是数组加链表的结合。在每个数组元素上都一个链表结构,当数据被Hash后,得到数组下标,把数据放在对应下标元素的链表上。
public class HashMap extends AbstractMap
implements Map, Cloneable, Serializable {
// 哈希桶数组,放所有Node节点
transient Node[] table;
// 普通单向链表节点类
static class Node implements Map.Entry {
final int hash; // key的hash值,用来定位数组索引位置
final K key;
V value;
Node next; // 链表的下一个node
Node(int hash, K key, V value, Node next) {... }
public final K getKey(){ ... }
public final V getValue() { ... }
public final String toString() { ... }
public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); }
public final V setValue(V newValue) { ... }
public final boolean equals(Object o) { ... }
}
// 红黑树节点
static final class TreeNode extends LinkedHashMap.LinkedHashMapEntry {
TreeNode parent; // red-black tree links
TreeNode left;
TreeNode right;
TreeNode prev; // needed to unlink next upon deletion
boolean red; //当前节点是红色或者黑色的标识
TreeNode(int hash, K key, V val, Node next) { ... }
final TreeNode root() { ... }
static void moveRootToFront(Node[] tab, TreeNode root) { ... }
final TreeNode find(int h, Object k, Class> kc) { ... }
final TreeNode getTreeNode(int h, Object k) { ... }
static int tieBreakOrder(Object a, Object b) { ... }
final void treeify(Node[] tab) { ... }
final Node untreeify(HashMap map) { ... }
final TreeNode putTreeVal(HashMap map, Node[] tab, int h, K k, V v) { ... }
final void removeTreeNode(HashMap map, Node[] tab, boolean movable) { ... }
final void split(HashMap map, Node[] tab, int index, int bit) { ... }
static TreeNode rotateLeft(TreeNode root, TreeNode p) { ... }
static TreeNode rotateRight(TreeNode root, TreeNode p) { ... }
static TreeNode balanceInsertion(TreeNode root, TreeNode x) { ... }
static TreeNode balanceDeletion(TreeNode root, TreeNode x) { ... }
static boolean checkInvariants(TreeNode t) { ... }
}
}
// 默认的初始化容量为16,必须是2的n次幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 最大容量为 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的加载因子0.75,乘以数组容量得到的值,用来表示元素个数达到多少时,需要扩容。
// 为什么设置 0.75 这个值呢,简单来说就是时间和空间的权衡。
// 若小于0.75如0.5,则数组长度达到一半大小就需要扩容,空间使用率大大降低,
// 若大于0.75如0.8,则会增大hash冲突的概率,影响查询效率。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// map中的实际键值对个数,即数组中元素个数
transient int size;
// 每次结构改变时,都会自增,fail-fast机制,这是一种错误检测机制。
// 当迭代集合的时候,如果结构发生改变,则会发生 fail-fast,抛出异常。
transient int modCount;
// 数组扩容阈值
// 要调整大小的下一个大小值(容量*加载因子)。
int threshold;
// 加载因子
final float loadFactor;
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(Map extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
// 可指定期望的初始容量。
// 为什么是期望?
// 因为容量要求一定是2的n次幂,如果传入的不符合,后面会计算使容量确定为仅大于该值的最小2次幂数
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 可指定期望容量和加载因子,不建议自己手动指定非0.75的加载因子
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;
// 通过 tableSizeFor 方法保证容量总是2的n次幂。
// 这里没有将值直接赋值给 capacity,而是赋值给扩容阈值threshold
// threshold 是要调整大小的下一个容量阈值
// 因为扩容的存在,capacity是会变化的,
// 所以,容器扩展到指定大小的任务交给resize方法即可。
// 这里只要指定下一次所需要扩容的容量大小,
// 在put第一次初始化哈希桶的时候,直接通过resize方法扩展至指定容量大小。
this.threshold = tableSizeFor(initialCapacity);
// 思考 ?
// 这里的 threshold 为什么没有考虑加载因子。 详见resize
}
// 最大容量为 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 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;
}
我们以随意选一个数值运算来看下具体变化。
// 初始数值 37 ,仅以后八位示例
0010 0101 cap = 37
-----------
0010 0100 n = cap - 1 = 36
0001 0010 n >>> 1
-----------
0011 0110 按位或 |=
0000 1001 n >>> 2
-----------
0011 1111 按位或 |=
0011 1111 之后 n >>> 4, n >>> 8, n >>> 16 按位或的结果都是0011 1111
判断是否超过最大值,是则返回最大值 2^30。不是则返回n+1
0100 0000 未超过 , n + 1 = 2^6 = 64
return (n < 0) ? DEFAULT_INITIAL_CAPACITY : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
不行。因为是可以设置容量小于16的,比如1,2,4,8。默认容量为16并不代表最小容量为16。不要把默认值和最小值搞混了。虽然,不建议设置这么小的值,会导致频繁扩容。
刚才说容量的时候有提到扩容,扩容就是重新计算哈希桶的容量。
当然,Java中数组是无法自动扩容的。
HashMap 使用了一个新的哈希桶数组代替已有的容量小的数组,并将已存储的值重新计算位置放入新的哈希桶中。
// 默认的加载因子0.75,乘以数组容量得到的值,用来表示元素个数达到多少时,需要扩容。
// 为什么设置 0.75 这个值呢,简单来说就是时间和空间的权衡。
// 若小于0.75如0.5,则数组长度达到一半大小就需要扩容,空间使用率大大降低,
// 若大于0.75如0.8,则会增大hash冲突的概率,影响查询效率。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 数组扩容阈值
// 要调整大小的下一个大小值(容量*加载因子)。
int threshold;
// 加载因子
final float loadFactor;
final Node[] resize() {
// 旧数组
Node[] oldTab = table;
// 旧数组的容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 旧的扩容阈值
int oldThr = threshold;
// 新的容量及扩容阈值
int newCap, newThr = 0;
// 1.当旧数组的容量大于0时,直接扩容。
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 新容量为旧容量的2倍 newCap = oldCap << 1
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 扩容阈值也扩大两倍
newThr = oldThr << 1; // double threshold
}
// 2. 反之小于0 说明还未进行过初始化 。
// 但是oldThr > 0 说明设置了期望的初始容量 => public HashMap(int initialCapacity)
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
// 3. 均小于0,说明均未初始化,设置默认值即可。
newCap = DEFAULT_INITIAL_CAPACITY; // 1 << 4 aka 16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 16 * 0.75
}
// 来自于上面的第2中情景,需要重新计算正确的扩容阈值
// 结合上方的情况2 和 public HashMap(int initialCapacity, float loadFactor)方法理解
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 赋予 threshold 正确的值,表示数组下次需要扩容的阈值
threshold = newThr;
// 创建扩容后的哈希桶数组
@SuppressWarnings({"rawtypes","unchecked"})
Node[] newTab = (Node[])new Node[newCap];
table = newTab;
// 如果原来的数组不为空,那么我们就需要把原来哈希桶数组中的元素重新分配到新的数组中
// 如果是空,则是第一次调用resize初始化数组,因此也就不需要重新分配元素
if (oldTab != null) {
// 遍历旧数组
for (int j = 0; j < oldCap; ++j) {
Node e;
// 取得桶中的链表 Node e;
if ((e = oldTab[j]) != null) {
// 将旧数组中的引用置空,释放引用。
oldTab[j] = null;
// 1. 如果当前元素的下一个元素为空,则说明此处只有一个元素
// 则直接用它的hash()值和新数组的容量取模就可以了,得到新的下标位置。
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 2. 如果是红黑树结构,则拆分红黑树,必要情况下可能退化为链表
((TreeNode)e).split(this, newTab, j, oldCap);
else { // preserve order
// 3. 当前是链表结构,判断是否需要移动链表元素的位置
// 区别于JDK1.7,这里使用尾插法
// loHead loTail 下标位置不变(低位)的链表头尾节点
Node loHead = null, loTail = null;
// hiHead hiTail 需要移动位置(高位)的链表头尾节点
Node hiHead = null, hiTail = null;
Node next;
do {
next = e.next;
// 这里不需要重新在计算hash值,
// 将原有 hash 和 oldCap 与运算为 0 ,表示位置不变。
// 详见 hash() 方法分析 结合 resize ,put的下标计算
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
// 需要移动到新位置的Node
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;
// 新位置下标为 旧位置索引 + oldCap
// 详见 hash() 方法 结合 resize 和 put 方法。
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
首先,我们从 putVal() 方法中可以看到,哈希桶计算 index 的时候使用的
tab[i = (n - 1) & hash],而 n = tab.length。
所以
i = (tab.length - 1) & hash
其实相当于对哈希桶长度取模操作,但是二进制算法效率更高。
例:
// 假设 n = 16 , hash 为 14
14 % 16 = 14
14 & (16 - 1)= 14
0000 1110 14
0000 1111 & 15
------------------
0000 1110 14
===================
// 假设 n = 16 , hash 为 18
18 % 16 = 2
18 & (16 - 1)= 2
0001 0010 18
0000 1111 & 15
------------------
0000 0010 2
确认完这个逻辑后,我们从 resize 方法了解到,HashMap 每次扩容都是2倍扩容。从二进制考虑,就是tabLength多了1位。在 hash 不变的情况下,低位计算不会变化,那么只要考虑只计算这多出来的1位高位就行。
// 假设 n = 16 , hash 为 18
18 % 16 = 2
18 & (16 - 1)= 2
0001 0010 18
0000 1111 & 15
------------------
0000 0010 2
扩容后。 hash不变,n = 16 * 2 = 32
18 % 32 = 18
18 & (32 - 1)= 18
0001 | 0010 18
0001 | 1111 & 31
------------------
0001 | 0010 18 = 2 + 16 = index + oldCap
计算结果低位未变,新增高位计算结果不为 0
需要移位,且移位大小为 index + oldCap
这个方法又被称作:扰动函数
static final int hash(Object key) {
int h;
// h = key.hashCode() 取hashCode值
// h ^ (h >>> 16) 高位参与运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
为什么这样操作可以减少哈希碰撞?
我们之前的内容也了解到了,哈希桶的插入位置是类似取模的操作。其实就是只有hash值的后n位有效,n是由哈希桶长度决定的。大部分时候,哈希桶的长度都是比较小的,可能只有涉及到4到6位左右的计算。
那么,如果两个hash值只要低位相同就会发生碰撞,哪怕高位不同也不行。而扰动函数进行高位右移与低位异或,等于将高位特征参与到低位中去,就能避免部分hash值低位相同,高位不同的碰撞问题。
在JDK1.7中,扰动函数是经过多次右移操作的,但是JDK1.8考虑到效率问题,多次右移并未有较为显著的提升效果,故一次右移已满足需求,更多考虑到效率及性能的一个交换。
如果我讲的不够明白,大家可以参考文章尾部的几篇链接,有大佬进行图文并茂的讲解。
说了这么多,终于来到了这个环节,很多人在博客中可能喜欢先讲这部分,再补充其它点。但就我个人而言,可能上面的内容更加吸引我吧~ 哈哈。
下面请结合源码再过一遍。
// 当链表长度超过阈值8 就会尝试转化为红黑树
static final int TREEIFY_THRESHOLD = 8;
// 链表转化为红黑树,除了有上面阈值的限制
// 还需要数组容量至少达到64,才会真正树化,否则只会扩容
static final int MIN_TREEIFY_CAPACITY = 64;
// 通过扰动函数计算key的 hash 值,然后进行存储
// 如果当前 key 已经有 value ,则替换并返回被替换的value ,如果没有,则返回 null。
public V put(K key, V value) {
// 先调用了 hash(key)
return putVal(hash(key), key, value, false, true);
}
/**
* Implements Map.put and related methods
*
* @param hash hash for key 经过扰动函数处理过的hash值
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value 如果是true,则不替换value
* @param evict if false, the table is in creation mode. 如果是false,则表示处于创建模式。
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node[] tab; Node p; int n, i;
// 判断哈希桶是否为空,如果是空,通过resize()方法进行初次初始化。
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 通过 (n - 1) & hash 确认哈希桶中的位置
// 如果当前位置节点为空,则直接存入K,V创建Node存入。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 当前节点有值,分成三种情况分析
Node e; K k;
// 1. 比较首节点的hash值,key值,如果相同,则替换
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
// 2. 当前节点为红黑树,将值加入红黑树中
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
else {
// 3. 当前结构为链表,使用尾插法将值添加至链表尾部
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 减一是因此处从首节点之后开始便利
// 插入之后,链表长度达到树化阈值 8,尝试转化为红黑树结构
// treeifyBin() 中会判断容量是否达到 MIN_TREEIFY_CAPACITY (64)
// 达到了才会真正树化,否则只会扩容
treeifyBin(tab, hash);
break;
}
// 遍历中,找到了相同的Key,跳出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// e 不为空,表示当前HashMap已经存在这个key,判断是否需要替换
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
// onlyIfAbsent 为 false,替换旧值,并返回被替换的旧值
e.value = value;
// 空实现,在LinkedHashMap中有实现,用于LinkedHashMap的排序问题
afterNodeAccess(e);
return oldValue;
}
}
// fail - fast 机制
++modCount;
// 插入完毕,size + 1
// 判断插入后的大小是否达到扩容阈值,达到则进行扩容
if (++size > threshold)
resize();
// 空实现
afterNodeInsertion(evict);
return null;
}
// 当链表长度超过阈值8 就会尝试转化为红黑树
static final int TREEIFY_THRESHOLD = 8;
// 链表转化为红黑树,除了有上面阈值的限制
// 还需要数组容量至少达到64,才会真正树化,否则只会扩容
static final int MIN_TREEIFY_CAPACITY = 64;
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
...
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 减一是因此处从首节点之后开始便利
// 插入之后,链表长度达到树化阈值 8,尝试转化为红黑树结构
// treeifyBin() 中会判断容量是否达到 MIN_TREEIFY_CAPACITY (64)
// 达到了才会真正树化,否则只会扩容
treeifyBin(tab, hash);
...
}
final void treeifyBin(Node[] tab, int hash) {
int n, index; Node e;
// 确认容量是否达到树化阈值 MIN_TREEIFY_CAPACITY (64)
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
// 未满足树化要求,通过resize() 初始化扩容
resize();
// 确认需要转化的节点不为 null
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 开始转化为红黑树结构
TreeNode hd = null, tl = null;
do {
// 链表节点Node 转化为 红黑树节点TreeNode
TreeNode 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);
}
}
反树化主要发生在两个过程中
If the current tree appears to have too few nodes, the bin is converted back to a plain bin. (The test triggers somewhere between 2 and 6 nodes, depending on tree structure).
// 如果红黑树种的元素减少至 6 个时,退化为链表
static final int UNTREEIFY_THRESHOLD = 6;
final Node[] resize() {
...
else if (e instanceof TreeNode)
// 2. 如果是红黑树结构,则拆分红黑树,必要情况下可能退化为链表
((TreeNode)e).split(this, newTab, j, oldCap);
...
}
static final class TreeNode extends LinkedHashMap.LinkedHashMapEntry {
...
final void split(HashMap map, Node[] tab, int index, int bit) {
...
if (hc <= UNTREEIFY_THRESHOLD)
// 拆分过程中元素减少到6个,触发反树化,退化为链表
tab[index + bit] = hiHead.untreeify(map);
...
}
/**
* Removes the given node, that must be present before this call.
* This is messier than typical red-black deletion code because we
* cannot swap the contents of an interior node with a leaf
* successor that is pinned by "next" pointers that are accessible
* independently during traversal. So instead we swap the tree
* linkages. If the current tree appears to have too few nodes,
* the bin is converted back to a plain bin. (The test triggers
* somewhere between 2 and 6 nodes, depending on tree structure).
*/
final void removeTreeNode(HashMap map, Node[] tab, boolean movable) {
...
if (root == null || root.right == null ||
(rl = root.left) == null || rl.left == null) {
// 红黑树太小了,退化为链表,大概发生于 2 到 6 nodes
tab[index] = first.untreeify(map); // too small
return;
}
...
}
final Node untreeify(HashMap map) {
Node hd = null, tl = null;
for (Node q = this; q != null; q = q.next) {
Node p = map.replacementNode(q, null);
if (tl == null)
hd = p;
else
tl.next = p;
tl = p;
}
return hd;
}
...
其实前面说了那么多,所有的设计都是为最终 get 服务的。
然而到 get(K) 方法反而简单了很多。
内心:废话,不简单,怎么做到快狠准!
哈哈哈,咱们接着聊
咱们在跟着源码简单过一遍
public V get(Object key) {
Node e;
// 通过扰动函数 hash(key) 拿到那个要找的hash
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node getNode(int hash, Object key) {
Node[] tab; Node first, e; int n; K k;
// 哈希桶有值,并且 (n - 1) & hash 位置上也有值,就进去
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 先瞅瞅首节点呗。你看,注释都说always了。
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
// 就是它!返回
return first;
// 首节点不是,接着往下找
if ((e = first.next) != null) {
// next节点存在,分两种情况
// 1. 当前桶里是红黑树
if (first instanceof TreeNode)
// 从红黑树里取
return ((TreeNode)first).getTreeNode(hash, key);
// 2. 当前桶里是个链表
do {
// 遍历链表
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
// 啥都没找着,返回 null
return null;
}
文中有大量的参考内容,大佬写的是真的好,有部分是我结合自己的理解,做了不少改动之处。
大家可以尝试阅读以下原创,加深理解,或者指正我理解错误的地方。
欢迎指导和交流。
https://docs.oracle.com/javase/8/docs/api/
Java 8系列之重新认识HashMap <美团技术团队>
面试官再问你 HashMap 底层原理,就把这篇文章甩给他看
一个HashMap跟面试官扯了半个小时
知乎 - 解读HashMap中hash算法的巧妙设计
知乎 - 胖君 - HashMap 的 hash 方法原理是什么
An introduction to optimising a hashing strategy