面试题深入思考02-----万恶的HashMap
对于HashMap的面试题属于热点问题了,今天来简单记录一下。
1.基本的底层原理
对于HashMap来说,底层就是数组,链表和红黑树。在JDK1.8之前是没有红黑树的,基本就是所谓的拉链法解决Hash冲突。
1.1基本的参数设置:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;//默认初始大小
static final int MAXIMUM_CAPACITY = 1 << 30 //最大容量
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;//最小树化容量
对于基本参数就有几个重要的问题:
- 负载因子为什么是0.75?
- 树化为什么是8而退化是6?
- 64的容量怎么来的?
这几个问题将在扩容中进行讨论。
1.2 hash()
对于hashmap来说,hash()是最重要的一部分:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
因此看出,对于一个key来说。他的hash值为其hashcode的高16位与低16位做异或可以减少hash冲突,保留高16位的特征。除此之外,Key为空是放入0桶的。
1.3 基本的节点Node与其table
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //hash值
final K key;
V value;
Node<K,V> next; //链表指针
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
return o instanceof Map.Entry<?, ?> e
&& Objects.equals(key, e.getKey())
&& Objects.equals(value, e.getValue());
}
}
构成的数组table:
/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node<K,V>[] table;
2. 基本操作get()与put():
2.1 简单解释一下get()流程?
public V get(Object key) {
Node<K,V> e;
return (e = getNode(key)) == null ? null : e.value;
}
通过getnode方法获取key的节点:
final Node<K,V> getNode(Object key) {
Node<K,V>[] tab; //取出数组
Node<K,V> first, e; //数组的头节点以及next后继节点
int n, hash;
K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & (hash = hash(key))]) != 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)
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;
}
**总结:**按照key的hash映射找索引,走数组,数组咩有走链表或者树,走完没有返回null
2.2 简单解释一下put流程?
//通过putVal方法实现插入
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* Implements Map.put and related methods.
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//数组为空 进行扩充
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//数组对应位置为空,说明可以插入,直接在索引处建立一个Node
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//进入此时意味着出现冲突
Node<K,V> e; K k;
//代表在数组处出现的冲突,用e存储这个p
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//数组处是树节点,要进入树里操作
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//走这里说明是进入链表操作
else {
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;
}
}
//走到这里去更改value
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;//记录操作次数
//比较大小考虑是否扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
总结:
- 求hash值,在数组中定位索引。
- 索引为空,咩有哈希冲突,直接把node新建在索引处。
- 存在冲突,表头的key与插入key相同,那么直接覆盖value。
- 不是的话,考虑是树还是链表,继续寻找。
- 如果链表中咩有,直接尾插,有就正常覆盖。
3.了解扩容机制吗?
基本按照每次扩容二倍,负载因子0.75方式扩容
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//正式开始扩容
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;//拿出新数组
if (oldTab != null) {
//遍历老数组,开始取值移入新数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;//方便GC
//没有哈希冲突,直接将数组上的节点移入新数组
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//有哈希,且为树,走树的方法
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
//对于拉链数组进行操作
//分为高低链表放在新数组不同位置
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
//精髓判断,考虑有效hash值高一位是1还是0,为依据将原数组下链表分为两部分
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);
//将完成的两个新链表插入新数组
if (loTail != null) {
loTail.next = null;
//存在原索引处
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
//新索引自然在原数组大小加原索引处
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
总结:
触发扩容后,按照位置不同的节点,采用不同操作:
- 没有链表或者红黑树的节点,按照新的数组大小求索引。
- 链表处的rehash采用分配的方式分高低链表转入新数组,取决于 (e.hash & oldCap) == 0 与否,这样可以分散hash冲突。
3. 哪些参数为何如此设计?
3.1 为什么扩容取2次幂?
根据我们对源码的了解后,不难看到2次幂的好处:
- 对于拉链法解决的哈希冲突,求余操作在整个数组大小n为2次幂的情况下,等同于hash&(n-1)。对于计算机来说位运算是更加快速的。
- 对于扩容来说,不需要再次求hash值来重新分配数组中的节点。因为其节点在扩容后满足位置不变或者其右移至原数组大小个的位置,即:index为old_index或者index+n。
3.2 为什么链表要转化红黑树?而树化的阈值为什么为8?退化又为什么是6?
对于链表来说,其查询的时间复杂度是链表长度的,在链表长度过长的情况下,是及其耗时的。采用红黑树可以提高查询效率,但随之而来的树化操作也需要时间。
* 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是是亿分之六,因而无需红黑树来提高效率。
那么退化为什么是6呢?
其实很好理解,当你刚刚树化后是8的大小,那么我要是删除这个节点,阈值为7,又要立即退化,那么会造成频繁的操作。
3.3 扩容的负载因子是如何设置的?为什么是0.75?0.5和1可以吗?那0.8呢?
首先如果采用0.5为扩容大小的负载因子,那么会造成大量的空间被浪费,反过来设置为1那么会造成大量的哈希冲突。
0.75是权衡两者的结果。源码的注释大概是这个意思。
然鹅,那天京东面试官问我,那为啥是0.75不是0.8吗?不可以吗?
。。。
只查到如下解释:
对于开放定址法,加载因子是特别重要因素,应严格限制在0.7-0.8以下。超过0.8,查表时的CPU缓存不命中(cache missing)按照指数曲线上升。因此,一些采用开放定址法的hash库,如Java的系统库限制了加载因子为0.75,超过此值将resize散列表。