由经典面试题引入,讲解一下HashMap的底层数据结构?这个面试题你当然可以只答,HashMap底层的数据结构是由(数组+链表+红黑树)实现的,但是显然面试官不太满意这个答案,毕竟这里有一个坑需要你去填,那就是在回答HashMap的底层数据结构时需要考虑JDK的版本,因为在JDK8中相较于之前的版本做了一些改进,不仅仅是增加了红黑树的数据结构、还包括了链表结点的插入由头插法改成了尾插法,这些都是底层数据结构的优化问题。
JDK8中HashMap的数据结构
从上面数据结构原理图中我们能看出数组和链表是如何组合使用的,数组不是实际保存数据的结构,数组保存的是Node
// 用于保存 Node 类型的数组
transient Node[] table;
// HashMap的默认初始化容量为16,1位运算左移4位
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// HashMap的负载因子用于计算阈值,超过阈值即负载过大需要数组扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 扩容的阈值(默认阈值 = 默认数组容量 * 负载因子),默认为12 = 16 * 0.75
int threshold;
// 树化的阈值,不是唯一条件,而是必须条件
static final int TREEIFY_THRESHOLD = 8;
// HashMap源码的静态内部类
static class Node implements Map.Entry {
final int hash; // 保存key计算出的hash码
final K key; // 保存key的值
V value; // 保存value的值
Node next; // 保存下一个结点的引用地址
Node(int hash, K key, V value, Node next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
HashMap的构造方法
// 此时只设置了默认的负载因子,即数组未初始化
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
// 可设置自定义负载因子和数组容量
public HashMap(int initialCapacity, float loadFactor) {
// 数组容量不能小于0
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +initialCapacity);
// 数组容量不能大于MAXIMUM_CAPACITY
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 负载因子不能小于等于0
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
this.loadFactor = loadFactor;
// 用于计算table数组的最终大小,因为数组大小必需为2的n次方数
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(Map extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
// 遍历Map取出集合的数据依次放入HashMap中
putMapEntries(m, false);
}
HashMap的put方法
public V put(K key, V value) {
// 计算key的哈希值,创建Node结点放入数组中
return putVal(hash(key), key, value, false, true);
}
// 计算key的哈希值的方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 425918570 对应的32位的二进制哈希值
// 高16位 低16位
0001 1001 0110 0011 --- 0000 0000 0110 1010 // 原始哈希值
0000 0000 0000 0000 --- 0001 1001 0110 0011 // 原始哈希值右移16位的值
0001 1001 0110 0011 --- 0001 1001 0000 1001 // 异或运算得到的哈希值
// HashMap添加数据的核心方法,
// onlyIfAbsent = false表示key相等时会覆盖旧value
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab; Node p; int n, i;
// table数组是第一次使用时才进行初始化,懒惰使用
if ((tab = table) == null || (n = tab.length) == 0)
// resize()包括了数组的初始化和扩容
n = (tab = resize()).length;
// table数组当前计算出的下标位置还未保存过元素
if ((p = tab[i = (n - 1) & hash]) == null)
// 创建新结点,直接放入计算出的数组下标位置中
tab[i] = newNode(hash, key, value, null);
else {
Node e; K k;
// 出现哈希冲突,需要判断是否是相同key,因为不同key也可能有相同的哈希值
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 当前计算的数组下标保存的是树结点,即红黑树结构
else if (p instanceof TreeNode)
// 采用红黑树的插入方法
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
else {
// 从数组下标所在的头结点开始遍历链表
for (int binCount = 0; ; ++binCount) {
// 找到尾结点,在尾结点后插入新结点,即尾插法
if ((e = p.next) == null) {
// 先插入结点,再判断阈值,故链表长度大于8时才是树化必须条件
p.next = newNode(hash, key, value, null);
// 链表的长度大于8时,走树化方法
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
// 如果当前遍历到的链表结点和需要添加的数据key相同,则无需插入,直接退出
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 添加结点数据时发现key已经存在,此时不是插入而是更新
if (e != null) {
V oldValue = e.value;
// put相同key的数据时会覆盖旧值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
// 返回当前key的旧值
return oldValue;
}
}
++modCount;
// HashMap中元素个数大于阈值,进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
// 32位的二进制哈希值
// 高16位 低16位
0001 1001 0110 0011 --- 0001 1001 0000 1001 // 最终计算出的哈希值
0000 0000 0000 0000 --- 0000 0000 0000 1111 // table数组的最大索引下标,使用默认大小就是15
0000 0000 0000 0000 --- 0000 0000 0000 1001 // 与运算(&)的结果 9,即下标位置
HashMap的数组长度为2的n次方数的原因
// 数组下标计算公式
int i = (n - 1) & hash
// 当hash值为10,数组长度n为9时
// 高16位 低16位
0000 0000 0000 0000 --- 0000 0000 0000 1010 // 影响&运算的有效位为1位,容易产生哈希冲突
0000 0000 0000 0000 --- 0000 0000 0000 1000 // (n - 1)的值为8
0000 0000 0000 0000 --- 0000 0000 0000 1000 // 计算出的数组下标为8
// 当hash值为10,数组长度n为16时
// 高16位 低16位
0000 0000 0000 0000 --- 0000 0000 0000 1010 // 影响&运算结果的有效位为4位,不易产生哈希冲突
0000 0000 0000 0000 --- 0000 0000 0000 1111 // (n - 1)的值为15
0000 0000 0000 0000 --- 0000 0000 0000 1010 // 计算出的数组下标为10
// 数组下标计算公式
int i = (n - 1) & hash
// 当hash值为10,扩容前,数组长度n为16
// 高16位 低16位
0000 0000 0000 0000 --- 0000 0000 0000 1010 // &运算结果的有效位为4位
0000 0000 0000 0000 --- 0000 0000 0000 1111 // (n - 1)的值为15
0000 0000 0000 0000 --- 0000 0000 0000 1010 // 计算出的数组下标为10
// 当hash值为10,扩容后,数组长度n为32
// 高16位 低16位
0000 0000 0000 0000 --- 0000 0000 0000 1010 // &运算结果的有效位为5位,增加一位有效位计算
0000 0000 0000 0000 --- 0000 0000 0001 1111 // (n - 1)的值为31
0000 0000 0000 0000 --- 0000 0000 0000 1010 // 计算出的数组下标还是10
HashMap的链表树化的条件
// 树化的阈值,不是唯一条件,而是必须条件
static final int TREEIFY_THRESHOLD = 8;
// 当遍历的结点数大于等于8时
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 进入是否需要树化的方法
treeifyBin(tab, hash);
// 将链表结点转换为红黑树,如果数组容量太小则先扩容数组
final void treeifyBin(Node[] tab, int hash) {
int n, index; Node e;
// 数组为空或容量小于64,扩容数组
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 找到当前链表的头结点
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode hd = null, tl = null;
// 遍历普通结点链表转换为树结点的双向链表
do {
// 链表结点替换成树结点返回
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);
}
}
JDK8的HashMap为什么在链表中使用尾插法代替了头插法
// 用于添加Entry结点的方法,jdk1.7叫Entry
void createEntry(int hash, K key, V value, int bucketIndex) {
// table数组保存的头结点Entry保存到e变量
Entry e = table[bucketIndex];
// 1. 把e元素作为新结点的next结点,即原头结点作为新结点的下一个结点
// 2. 新结点作为头结点保存到table[bucketIndex],即头插法
table[bucketIndex] = new Entry<>(hash, key, value, e);
// HashMap中保存的元素总数+1
size++;
}
// 从数组下标所在的头结点开始遍历链表
for (int binCount = 0; ; ++binCount) {
// 找到尾结点,在尾结点后插入新结点,即尾插法
if ((e = p.next) == null) {
// 新结点作为尾结点的next结点,并成为新尾结点
p.next = newNode(hash, key, value, null);
// 链表的长度大于8时,走树化方法
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
// 如果当前遍历到的链表结点和需要添加的数据key相同,则无需插入,直接退出
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
// jdk1.7的扩容过程
void transfer(Entry[] newTable, boolean rehash) {
// 获取新数组的容量
int newCapacity = newTable.length;
// 遍历旧数组,获取头结点,单节点也是链表
for (Entry e : table) {
// 遍历链表
while(null != e) {
// 取出当前结点的next结点
Entry next = e.next;
// 判断是否需要重新计算hash值
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
// 因为数组扩容了,重新计算元素存放的数组位置
int i = indexFor(e.hash, newCapacity);
// 当前添加的结点e作为头结点,所以它的e.next指向旧的头结点
e.next = newTable[i];
// 最新添加的结点成为新数组保存的头结点,每次更新头结点,即头插法
newTable[i] = e;
// 遍历的写法,取下一个节点,
e = next;
}
}
}
// 假设扩容前当前遍历的链表为: A > B > C
// 分析t1线程发生死链的情况,有两个线程 t1 和 t2,都在执行扩容操作
while(null != e) {
// t2线程未扩容成功时,t1线程执行,当 e = B,e.next一定为 C
Entry next = e.next;
// t1线程发生上下文切换,此时t2线程先完成了扩容
// 由于链表倒序为:C > B > A,当 e = B时,e.next的引用变为了 A, next还是引用 C
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
// B结点指向头结点C,即 B > c > B,发生死链
e.next = newTable[i];
// B结点变为头结点
newTable[i] = e;
// 继续遍历,下一个节点变为C,由于死链会变为死循环
e = next;
}
JDK8的HashMap为什么引入了红黑树的数据结构