一、HashMap底层原理
要分为JDK1.8和之前的版本讲解。
我将从三个方面来介绍,分别是底层数据结构、存储结构、源码
1.1、HashMap原理
(1)从底层数据结构来说,JDK1.8之前底层是数组+链表(散列链表),1.8之后链表到达阈值长度就会变成红黑树存储;
(2)从存储结构来说,内部包含了一个Entry类型的数组table,Entry存储着键值对,数组中每一个位置被当成一个桶(Entry),一个桶存放一个链表;
(3)从源码角度来说,
向HashMap添加元素时调用put方法,put方法源码里通过hash函数得到key的hashCode值再来确定桶(Entry)下标;
——>该下标即为元素存放的位置,如果该位置没有元素则直接存该键值对对象(两个对象)在该位置;
——>如果当前位置存在元素,就发生了碰撞,就判断该位置原有元素与要存入的元素的key的hashCode值是否相等,那么需要通过key的equals方法判断这两个对象是否同一个key对象,如果是则直接覆盖,即新值覆盖旧值;
——>如果不同,就要通过拉链法解决冲突,就把 新的键值对对象保存到 旧的键值对对象的next变量中,构成链表。这个链表长度在JDK1.8后如果超过阈值8就会变为红黑树,以提高性能。
get()方法查找原理 简单可分为6步:
(1)定位键所在数组的下标索引,并获取索引位置;
(2)判断索引位置是否为null,为null则直接返回null;
(3)判断 索引位置的key 和要查找传进来的key是否相同(key相同指hashCode和equals都相同),若相同则返回 索引位置 并结束;
(4)判断是否有 后续节点,若没有则结束;
(5)判断 后续节点 是否为红黑树,若为红黑树则遍历红黑树,在遍历的过程中如果遇到一个节点与key要找的key相同,则返回该节点。若不是红黑树则遍历链表;
(6)遍历链表,若存在一个节点的key与要找的key相同则返回该节点。
注意:因为无法调用key为null的hashCode()方法,也就无法确定键值对的桶下标,只能通过指定一个桶下标来存放。HashMap使用第0个桶来存放键为null的键值对。
1.2、再介绍一下底层存储结构
拉链法解决冲突:
拉链法就是 将链表和数组结合。也就是创建一个链表数组,数组中的每一格就是一个链表。若遇到哈希冲突,得到数组下标,把数据放在对应下标元素的链表上即可。
(1)数组
HashMap是key-value键值对的集合,每一个键也叫一个Entry(桶),这些Entry分散存储在每一个数组中,该数组是HashMap的主干。
(2)链表
因为数组Table的长度是有限的,使用hash函数计算时可能会出现index冲突的情况,所以要链表来解决冲突;
数组Table的每一格元素不单纯只是一个Entry对象还有链表的头结点,每一格Entry对象通过Next指针指向下一个Entry节点;
当新来的Entry映射到冲突数组位置时,只需要插入对应的链表位置即可(一般采用头插法)。
(3)红黑二叉树
当链表长度超过阈值(8)会将链表转化为红黑树,用于提高性能
(注意:什么是红黑二叉树?)
关于详细JDK1.8版本HashMap知识,推荐阅读美团技术团队分享:https://zhuanlan.zhihu.com/p/21673805
二、HashMap的其余注意点
HashMap,默认大小为 16
2.1、举一个index冲突的例子
比如调用 hashMap.put("China", 0) ,插入一个Key为“China"的元素;这时候我们需要利用一个哈希函数来确定Entry的具体插入位置(index)。
(1)通过index = Hash("China"),假定最后计算出的index是2,那么Entry的插入结果如下:
(2)但是,因为HashMap的长度是有限的,当插入的Entry越来越多时,再完美的Hash函数也难免会出现index冲突的情况。比如下面这样:
(3)经过hash函数计算发现即将插入的Entry的index值也为2,这样就会与之前插入的Key为“China”的Entry起冲突;这时就可以用链表来解决冲突。当新来的Entry映射到冲突的数组位置时,只需要插入到对应的链表即可;此外,新来的Entry节点插入链表时使用的是“头插法”,即会插在链表的头部,因为HashMap的发明者认为后插入的Entry被查找的概率更大。
2.2、采用红黑树的集合都有哪些?
TreeMap、TreeSet、JDK1.8版HashMap底层都用了红黑二叉树,红黑二叉树是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况会退化成一个线性结构。
三、HashMap源码解刨(太血腥了!!)
3.1、get()方法操作源码
实际是根据输入节点的hash值和key值,底层利用getNode方法进行查找
get()方法查找原理 简单可分为6步:
(1)定位键所在数组的下标索引,并获取索引位置;
(2)判断索引位置是否为null,为null则直接返回null;
(3)判断 索引位置的key 和要查找传进来的key是否相同(key相同指hashCode和equals都相同),若相同则返回 索引位置 并结束;
(4)判断是否有 后续节点,若没有则结束;
(5)判断 后续节点 是否为红黑树,若为红黑树则遍历红黑树,在遍历的过程中如果遇到一个节点与key要找的key相同,则返回该节点。若不是红黑树则遍历链表;
(6)遍历链表,若存在一个节点的key与要找的key相同则返回该节点。
public V get(Object key) {
Node<K,V> e;
//实际上是根据输入节点的hash值和key值,利用getNode方法进行查找
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
在getNode()方法中,如果定位到的节点是TreeNode节点则在红黑树中查找,反之,在链表中查找
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
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)
//若定位到的节点是TreeNode节点,则在树中进行查找
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
//反之,在链表中查找
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
3.2、put操作源码
(1)主体源码
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// 键为 null 单独处理
if (key == null)
return putForNullKey(value);
int hash = hash(key);
// 确定桶下标
int i = indexFor(hash, table.length);
// 先找出是否已经存在键为 key 的键值对,如果存在的话就更新这个键值对的值为 value
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
// 插入新键值对
addEntry(hash, key, value, i);
return null;
}
(2)put操作时,遇到key为null的键值对
HashMap允许插入键为null的键值对,但是因为无法调用null的hashCode()方法,也就无法确定键值对的桶下标,只能通过指定一个桶下标来存放。HashMap使用第0个桶来存放键为null的键值对:
private V putForNullKey(V value) {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
(3)put操作时,使用链表的“头插法”,也就是新的键值对插在链表的头部,而不是尾部
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
// 头插法,链表头部指向新的键值对
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
3.3、确定桶下标源码
int hash = hash(key);
int i = indexFor(hash, table.length);
(1)计算 hash 值
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
(2)取模
确定桶下标的最后一步是将 key 的 hash 值对桶个数取模:hash%capacity,如果能保证 capacity 为 2 的 n 次方,那么就可以将这个操作转换为位运算:
static int indexFor(int h, int length) {
return h & (length-1);
}
3.4、扩容机制 源代码(JDK1.8)
什么是扩容机制:
扩容是为了防止HashMap中的元素个数超过了阈值,从而影响性能,而数组是无法自动扩容的,HashMap扩容是申请了一个容量为原来大小两倍的新数组,然后遍历旧数组,从新计算每个数组的索引位置,并复制到新数组中;哈希桶数组大小总是2的幂次方,所以重新计算后的索引位置要么在原来位置不变,要吗是“原来位置+就数组长度”。
参考:连接
设 HashMap 的 table 长度为 M,需要存储的键值对数量为 N,如果哈希函数满足均匀性的要求,那么每条链表的长度大约为 N/M,因此查找的复杂度为 O(N/M)。
为了让查找的成本降低,应该使 N/M 尽可能小,因此需要保证 M 尽可能大,也就是说 table 要尽可能大。HashMap 采用动态扩容来根据当前的 N 值来调整 M 值,使得空间效率和时间效率都能得到保证。
和扩容相关的参数主要有:capacity、size、threshold 和 load_factor。
参数 | 含义 |
---|---|
capacity | table 的容量大小,默认为 16。需要注意的是 capacity 必须保证为 2 的 n 次方。 |
size | 键值对数量。 |
threshold | size 的临界值,当 size 大于等于 threshold 就必须进行扩容操作。 |
loadFactor | 装载因子,table 能够使用的比例,threshold = (int)(capacity* loadFactor)。 |
static final int DEFAULT_INITIAL_CAPACITY = 16;
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
transient Entry[] table;
transient int size;
int threshold;
final float loadFactor;
transient int modCount;
(1)当需要扩容时,令 capacity 为原来的两倍
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
if (size++ >= threshold)
resize(2 * table.length);
}
(2)扩容使用 resize() 实现,需要注意的是,扩容操作同样需要把 oldTable 的所有键值对重新插入 newTable 中,因此这一步是很费时的。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
(3) 扩容-重新计算桶下标
在进行扩容时,需要把键值对重新计算桶下标,从而放到对应的桶上。在前面提到,HashMap 使用 hash%capacity 来确定桶下标。HashMap capacity 为 2 的 n 次方这一特点能够极大降低重新计算桶下标操作的复杂度
(4)计算数组容量
HashMap 构造函数允许用户传入的容量不是 2 的 n 次方,因为它可以自动地将传入的容量转换为 2 的 n 次方。
以下是 HashMap 中计算数组容量的代码:
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;
}