HashMap面试题
- 一、介绍下 HashMap 的底层数据结构
- 二、1.8 为什么要改成“数组+链表/红黑树”?
- 三、那在什么时候用链表?什么时候用红黑树?
- 四、为什么链表转红黑树的阈值是8?
- 五、为什么转回链表节点是用的6而不是复用8?
- 六、HashMap 的默认初始容量是多少?HashMap 的容量有什么限制吗?
- 七、HashMap 的容量必须是 2 的 N 次方,这是为什么?
- 八、负载因子为什么默认值是0.75。
- 九、HashMap 的插入流程是怎么样的?
- 十、图里刚开始有个计算 key 的 hash 值,是怎么设计的?
- 十一、为什么要将 hashCode 的高16位参与运算?
- 十二、扩容(resize)流程介绍下?
- 十三、对比JDK1.7 JDK 1.8 主要进行了哪些优化?
- 十四、扩容重新计算下标
- 十五、JDK1.7和1.8的改进
- 十六、ConcurrentHashMap
一、介绍下 HashMap 的底层数据结构
- JDK1.8之前的HashMap数据结构:数组 + 链表
- JDK1.8之后的HashMap数据结构:数组 + 链表 / 红黑树
二、1.8 为什么要改成“数组+链表/红黑树”?
主要是为了提升在hash 冲突严重时(链表过长)的查找性能,使用链表的查找性能是 O(n),而使用红黑树是 O(logn)。
三、那在什么时候用链表?什么时候用红黑树?
- 插入:默认情况下是使用链表节点。当同一个索引位置的节点在新增后达到9个(阈值8):如果此时数组长度大于等于 64,则会触发链表节点转红黑树节点;而如果数组长度小于64,则不会触发链表转红黑树,而是会进行扩容,因为此时的数据量还比较小。
- 移除:当同一个索引位置的节点在移除后达到 6 个,并且该索引位置的节点为红黑树节点,会触发红黑树节点转链表节点
四、为什么链表转红黑树的阈值是8?
阈值为8是在时间和空间上权衡的结果
- 红黑树节点大小约为链表节点的2倍,在节点太少时,红黑树的查找性能优势并不明显,付出2倍空间的代价不值得。
- 理想情况下,使用随机的哈希码,节点分布在 hash 桶中的频率遵循泊松分布,按照泊松分布的公式计算,链表中节点个数为8时的概率为 0.00000006,这个概率足够低了,并且到8个节点时,红黑树的性能优势也会开始展现出来,因此8是一个较合理的数字。
五、为什么转回链表节点是用的6而不是复用8?
如果我们设置节点多于8个转红黑树,少于8个就马上转链表,当节点个数在8徘徊时,就会频繁进行红黑树和链表的转换,造成性能的损耗
六、HashMap 的默认初始容量是多少?HashMap 的容量有什么限制吗?
默认初始容量是16。HashMap 的容量必须是2的N次方,HashMap 会根据我们传入的容量计算一个大于等于该容量的最小的2的N次方,例如传 9,容量为16。
七、HashMap 的容量必须是 2 的 N 次方,这是为什么?
- 原因1:降低发生碰撞的概率,使散列更均匀。根据 key 的 hash 值计算 bucket 的下标位置时,使用 “与”运算公式:h & (length-1),当哈希表长度为 2 的次幂时,等同于使用表长度对 hash 值取模,散列更均匀;
- 原因2:表的长度为 2 的次幂,那么(length-1)的二进制最后一位一定是 1,在对 hash 值做“与”运算时,最后一位就可能为 1,也可能为 0,换句话说,取模的结果既有偶数,又有奇数。设想若(length-1)为偶数,那么“与”运算后的值只能是 0,奇数下标的 bucket 就永远散列不到,会浪费一半的空间。
八、负载因子为什么默认值是0.75。
答:在时间和空间上权衡的结果。如果值较高,例如1,此时会减少空间开销,但是 hash 冲突的概率会增大,增加查找成本;而如果值较低,例如 0.5 ,此时 hash 冲突会降低,但是有一半的空间会被浪费,所以折衷考虑 0.75 似乎是一个合理的值。
九、HashMap 的插入流程是怎么样的?
十、图里刚开始有个计算 key 的 hash 值,是怎么设计的?
拿到 key 的 hashCode,并将 hashCode 的高16位和 hashCode 进行异或(XOR)运算,得到最终的 hash 值。
JDK1.7进行了9次扰动,JDK1.8进行了2次扰动。
JDK1.7代码
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
JDK1.8代码
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
十一、为什么要将 hashCode 的高16位参与运算?
减少哈希碰撞
十二、扩容(resize)流程介绍下?
十三、对比JDK1.7 JDK 1.8 主要进行了哪些优化?
- 底层数据结构从“数组+链表”改成“数组+链表+红黑树”,主要是优化了 hash 冲突较严重时,链表过长的查找性能:O(n) -> O(logn)。
- 优化了 hash 值的计算方式,新的只是简单的让高16位参与了运算。
- 扩容时插入方式从“头插法”改成“尾插法”,避免了并发下的
死循环
。 - 扩容时计算节点在新表的索引位置方式从“h & (length-1)”改成“hash & oldCap”,性能可能提升不大,但设计更巧妙、更优雅。
hash扰动
// JDK 1.7 -- 9次
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
// JDK 1.8 -- 2次
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
十四、扩容重新计算下标
扩容:是根据之前计算过的hashcode重新计算下标,不会重新计算hashcode。扩容后,元素到新数组的位置会出现两种情况
- 下标位置不变
- 下标 + 原数组的长度
所以原来一个比较长的链表,经过扩容重新计算下标,就会分布到新数组的两个位置中,那么就会缩短链表的长度。
扩容利用左移一位计算,相当于*2。
1.7
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
1.8
// 如果e节点不为空,则代表目标节点存在,使用传入的value覆盖该节点的value,并返回oldValue
if (++size > threshold)
resize();
十五、JDK1.7和1.8的改进
1.7采用的是Entry数组
table = new Entry[capacity];
1.8采用的是Node数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
1.7采用的是头插(并发下会导致死循环)
table[bucketIndex] = new Entry<>(hash, key, value, e);
1.8采用的是尾插
tab[i] = newNode(hash, key, value, null);
十六、ConcurrentHashMap
JDK1.7构造
static final int DEFAULT_INITIAL_CAPACITY = 16;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
public ConcurrentHashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0) // 参数校验
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// Find power-of-two sizes best matching arguments
int sshift = 0;
int ssize = 1;
while (ssize < concurrencyLevel) { // 这个循环可以找到 concurrencyLevel 之上最近的 2的次方值
++sshift; //代表ssize左移的次数
ssize <<= 1;
}
this.segmentShift = 32 - sshift; // 记录段偏移量
this.segmentMask = ssize - 1; // 记录段掩码
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c) //Segment 中的类似于 HashMap 的容量至少是2或者2的倍数
cap <<= 1;
// create segments and segments[0] // 创建 Segment 数组,设置 segments[0] 只创建了segments[0]
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}
JDK1.8构造
//什么也没做
public ConcurrentHashMap() {
}
JDK1.7 put
使用Segment和CAS保证线程安全
//value 不能为null
if (value == null)
throw new NullPointerException();
//非第一次添加 HashEntry数组取的是Segment[0]的长度
Segment<K,V> proto = ss[0]; // use segment 0 as prototype
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
private Segment<K,V> ensureSegment(int k) {
final Segment<K,V>[] ss = this.segments;
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K,V> seg;
//第一次判断
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
Segment<K,V> proto = ss[0]; // use segment 0 as prototype
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
//第二次判断
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // recheck
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
//使用CAS(比较并交换)保证线程安全
//参数:Segment 要操作的值 期望值 赋值
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
//真正的put
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash; // 计算要put的数据位置
HashEntry<K,V> first = entryAt(tab, index); // CAS 获取 index 坐标的值
for (HashEntry<K,V> e = first;;) {
if (e != null) { // 检查是否 key 已经存在,如果存在,则遍历链表寻找位置,找到后替换 value
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
}
else {
if (node != null) // first 有值没说明 index 位置已经有值了,有冲突,链表头插法。
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
if (c > threshold && tab.length < MAXIMUM_CAPACITY) // 容量大于扩容阀值,小于最大容量,进行扩容
rehash(node);
else
setEntryAt(tab, index, node); // index 位置赋值 node,node 可能是一个元素,也可能是一个链表的表头
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
JDK1.8 put
public V put(K key, V value) {
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
//使用synchroized和cas锁保证线程安全
final V putVal(K key, V value, boolean onlyIfAbsent) {
//key和value都不允许为null
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh; // f = 目标位置元素
if (tab == null || (n = tab.length) == 0)
tab = initTable(); // 数组桶为空,初始化数组桶(自旋+CAS)
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null))) // 桶内为空,CAS 放入,不加锁,成功了就直接 break 跳出
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) { // 使用 synchronized 加锁加入节点
if (tabAt(tab, i) == f) {
if (fh >= 0) { // 说明是链表
binCount = 1;
for (Node<K,V> e = f;; ++binCount) { // 循环加入新的或者覆盖节点
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
CurrentHashMap1.7和1.8
- JDK1.7中
ConcurrentHashMap
采用了数组+分段锁的方式实现。 - JDK1.8中
ConcurrentHashMap
相对于 Java7 来说变化比较大,不再是之前的 Segment 数组+ HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。当冲突链表达到一定长度时,链表会转换成红黑树。
总结
- Java7 中
ConcurrentHashMap
使用的分段锁,也就是每一个Segment
上同时只有一个线程可以操作,每一个 Segment 都是一个类似HashMap
数组的结构,它可以扩容,它的冲突会转化为链表。但是Segment
的个数一但初始化就不能改变。 - Java8 中的
ConcurrentHashMap
使用的 Synchronized 锁加 CAS 的机制。结构也由 Java7 中的 Segment 数组 + HashEntry 数组 + 链表 进化成了 Node 数组 + 链表 / 红黑树,Node 是类似于一个HashEntry 的结构。它的冲突再达到一定大小时会转化成红黑树,在冲突小于一定数量时又退回链表。