目的
面试准备, 方便后续复习方便.
资源
核心知识点
底层数据结构
- 1.7: 数组 + 链表
- 1.8: 数组 + (链表/红黑树)
// 链表
static class Node<K,V> implements Map.Entry<K,V> {...}
// 红黑树
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {...}
树化与退树化
为何要用红黑树
1.8为了解决链表过长, 而影响查询效率的问题, 引入了红黑树.
为何一上来不树化
- 红黑树用来避免DoS攻击, 防止链表超长时新能下降, 树化应当是偶然情况. (可以认为是对1.7的一次优化, 不用1.8不不会有多大的影响, 毕竟1.7多用了好多年了)
1.1 没有必要一上来就树化, 用链表应该也可以, 链表的元素也不多, 没必要用红黑树. 红黑树查找/更新的时间复杂度是O(logn), TreeNode占用空间比普通Node, 有着更多的成员变量, 更复杂, 如果非必要, 建议使用链表.
TreeNode 里面的代码很多, 其实还是非常复杂的, 元素旋转啥的.
树化阈值为何是8
hash值如果足够随机, 则在hash表内按泊松分布, 在负载因子0.75的情况下, 长度超过8的链表出现的概率是0.00000006, 选择8就是为了让树化的概率足够小.
何时树化
- 链表长度超过阈值8
// put 元素, 若果是链表的一段源码逻辑
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
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
- 数组容量>=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;
// 如果容量不够, 会先扩容, 不会去树化
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
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);
}
}
何时退化为链表
- 在扩容时如果拆分树时, 树元素个数<=6, 则会退化为链表
// 红黑树元素少于6, 退化
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
- remove树节点时, 如果root, root.left, root.right, root.left.left 有一个为null, 也会退化为链表
// 节点元素情况校验, 看是否满足退化条件
if (root == null || root.right == null ||
(rl = root.left) == null || rl.left == null) {
tab[index] = first.untreeify(map); // too small
return;
}
几个成员变量
// 负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 树化的其中一个条件
static final int TREEIFY_THRESHOLD = 8;
// 退树化的一种情况
static final int UNTREEIFY_THRESHOLD = 6;
// 树化: 最小的容量
static final int MIN_TREEIFY_CAPACITY = 64;
索引如何计算
- 计算对线的hashCode()
- 再调用HashMap的hash()方法进行二次哈希
static final int hash(Object key) {
int h;
// (对象原始code) 异或 (对象原始code高16位)
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 最后 & (capacity - 1) 得到索引
索引: i = (n - 1) & hash
hashCode都有了, 为何还要提供hash()方法?
二次hash()是为了综合高位数据, 让哈希分布更加均匀
数组容量为何是2的N次幂
计算索引时, 如果是2的N次幂, 可以使用位与运算代替取模, 效率更高; 扩容时 hash & oldCap == 0 的元素留在原来的位置, 否则新位置 = 旧位置 + oldCap.
1 取代取模的运算
2.扩容时, 元素迁移效率更高.
put方法流程
- HashMap是懒惰创建数组的, 首次使用才创建数组
- 计算索引(桶下标)
- 如果桶下标还没有占用, 创建Node占位返回
- 如果桶下标已经有人占用
4.1 已经是TreeNode走红黑树的添加或更新逻辑
4.2 是普通Node, 走链表的添加或更新逻辑
4.3 如果链表长度超过树化阈值, 走树化逻辑 - 返回前检查容量是否超过阈值, 一旦超过进行扩容
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 1. HashMap是懒惰创建数组的, 首次使用才创建数组
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2. 计算索引(桶下标): (n - 1) & hash
if ((p = tab[i = (n - 1) & hash]) == null)
// 3. 如果桶下标还没有占用, 创建Node占位返回
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
// 4.1 已经是TreeNode走红黑树的添加或更新逻辑
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 4.2 是普通Node, 走链表的添加或更新逻辑
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 4.3 如果链表长度超过树化阈值, 走树化逻辑
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 5. 返回前检查容量是否超过阈值, 一旦超过进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
1.7与1.8有何不同
- 链表插入节点时, 1.7是头插法, 1.8是尾插法
- 1.7是>= 阈值 && 没有空位才扩容, 而1.8是大于阈值就扩容
- 1.8 在扩容计算Node索引时, 会优化
加载因子为何默认是0.75f
- 在空间占用与查询时间之间取得比较好的权衡
- 大于这个值, 空间节省了, 但链表就会比较长影响性能
- 小于这个值, 冲突减少了, 但会频繁扩容, 空间占用多
多线程下会有啥问题
- 扩容死链(1.7)
- 数据错乱(1.7, 1.8)
key能否为null, 作为key的对象有什么要求
- HashMap 的key可以为null, 但是其他Map的其他实现则不然
- 作为key的对象, 必须实现 hashCode 和 equals, 并且 key 的内容不能修改. (不可变)
String对象的 hashCode 如果设计的, 为啥每次乘的是31(了解)
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
- 目标是达到较为均匀的散列效果, 每个字符串的hashCode足够独特