虽然前面通过HashMap源码分析了解了HashMap的原理,但是很多时候,面试官换种方式提问的时候,仍然一脸懵逼。
这应该有两个原因:1)对原理理解的仍然不够深刻;2)没有提前分析过,面试官可能会问哪些问题,没有想在面试官的前面。
所以接下来通过一些面试题的分析,促进对HashMap的理解。
HashMap的实现原理
你看过HashMap源码吗,知道底层的原理吗
(或者问:知道HashMapput元素的过程是什么样吗?)
很宽泛的问题,需要提前梳理一下,从哪开始讲起,以及哪些是要讲的。
- 首先说一下底层原理概述
-
JDK8之前的HashMap由数组+链表组成,用Entry数组来存储数据(Entry对象包括:key、value、hash和指向下一个entry的引用变量),使用链表来解决哈希冲突。
-
JDK8之后的HashMap由数组+链表+红黑树组成。用Node数组来存储数据(Node对象包括的属性:key、value、hash和指向下一个node的引用变量),链表和红黑树是为了解决哈希冲突的。
-
(描述的时候过渡一下:主要通过put元素的流程来说一下现在主流JDK8版本的底层原理)
- put元素的流程
- 往数组中put第1个元素:
-
1)首先会触发
resize()方法进行扩容。如果new HashMap的时候调用的是空的构造方法,那么在resize方法里面,会将新数组的容量设为DEFAULT_INITIAL_CAPACITY,threshold用DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY计算,然后直接new一个空Node数组,长度是DEFAULT_INITIAL_CAPACITY。 -
2)扩容结束之后,通过Node数组长度和传入的hash值,计算元素应该存放的位置,生成Node结点放在计算的位置上
-
- 数组中已有元素,再put元素:
-
1)计算元素应该存放的位置。如果计算的位置上没有元素,则直接将传入的元素生成一个Node结点,放在计算的位置上;
-
2)如果计算的位置上有元素,则说明出现了hash冲突
-
首先判断这个位置上的key是否与传入的key相同,如果相同,则直接用传入的值替换旧值。
-
如果不相同,则要转链表或转红黑树
-
如果这个位置上的元素是红黑树结点,那么需要调用红黑树的插值方法
-
如果这个位置上的元素是链表结点,就使用尾插法在链表尾部插入新结点。插入的过程中会判断链表上的结点数量是否达到阈值。
-
1)如果链表长度大于等于8,会调用转红黑树的方法,这个方法里面会先判断存储数据的数组长度是否大于等于64,如果大于等于64,则会将链表转红黑树(后续如果由于删除或者其他原因调整了大小,当红黑树的节点小于或等于 6 个以后,又会恢复为链表形态。);否则会对数组进行扩容;
-
2)如果链表长度小于8,则直接插入链表。
-
-
-
-
- 往数组中put第1个元素:
类似的问题如下:
- HashMap怎么解决hash冲突的。
- HashMap是怎么扩容的。
- HashMap是先插入元素还是先扩容?为什么这么设计。
- HashMap有没有容量限制
为什么使用数组+链表/红黑树
既然问了为什么用xx,那就需要对比才能体现优势,所以回答的时候可以用一些常见的数据结构组合,来进行对比。
比如:用LinkedList + 链表组合 或者 ArrayList + 链表组合
-
首先说用链表和红黑树的目的是为了解决hash冲突
-
再说为什么采用数组
-
和LinkedList进行对比:查找效率比LinkedList(底层是链表结构)高。
-
和ArrayList进行对比:数组的扩容可以自己定义,目前HashMap的扩容机制是原数组长度的2的次幂,ArrayList是固定1.5倍扩容。
知道get过程是是什么样吗?
(HashMap的get方法逻辑是怎样的)
-
根据传入key的hash值,计算所在位置,如果该位置上第一个结点不为空,则
-
先判断这个元素的hash值是否与传入的hash值相同,以及该元素的key是否与传入的key相同,如果都相同,直接返回这个元素的值。
-
如果不相同,说明后面有链表或者红黑树,就需要判断这个元素是红黑树节点还是链表节点。
-
如果是红黑树,则调用红黑树获取节点的方法,然后返回节点的值。
-
如果是链表,则循环遍历链表,找到和传入key、hash都相同的元素,并返回。
-
-
你还知道哪些hash算法?(可忽略)
先说一下hash算法干嘛的,Hash函数是指把⼀个⼤范围映射到⼀个⼩范围。把⼤范围映射到⼀个⼩范围的⽬的往往是为了 节省空间,使得数据容易保存。
比较出名的有MurmurHash、MD4、MD5等等
String中hashcode的实现
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;
}
String类中的hashCode计算⽅法还是⽐较简单的,就是以31为权,每⼀位为字符的ASCII值进⾏运算,⽤⾃然溢出来等效 取模。
哈希计算公式可以计为ss[[00]]3311^^((nn–11)) ++ ss[[11]]3311^^((nn–22)) ++ …… ++ ss[[nn–11]]
那为什么以31为质数呢? 主要是因为31是⼀个奇质数,所以31i=32i-i=(i<<5)-i,这种位移与减法结合的计算相⽐⼀般的运算快很多
知道jdk1.8中hashmap改了什么吗
- 由数组+链表的结构改为数组+链表+红黑树(当链表元素数量大于等于8,而且数组的容量大于等于64的时候,链表会转为红黑树。)
- 优化了hash方法,将key进行hashCode得到h之后,再将h的16位与低16位进行异或运算,这样可以有效降低冲突概率。???
- 扩容后,元素要么是在原位置,要么是在原位置+旧容量。
为什么hashmap在链表元素数量超过8时候改为红黑树。
链表元素数量超过8的时候,查询速度比较慢,需要红黑树来加快查询速度。
为什么在解决hash冲突时候,不直接用红黑树,而是先用链表,再用红黑树
因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。
- 当元素小于8个的时候,此时做查询操作,链表结构已经能保证查询性能。
- 当元素大于8个的时候,此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。
因此,如果一开始就⽤红黑树结构,元素太少,新增效率又比较慢,是会浪费性能的。
类似的问题还有很多:
- JDK8中,HashMap什么情况会用红黑树。
- JDK8中,HashMap链表转红黑树,为啥是链表长度达到8才转,为什么是8。
- JDK8中,HashMap为啥不直接用红黑树。
当链表转为红黑树,什么时候退化为链表
为6的时候退化为链表。中间有个差值7可以防止链表和树之间频繁的转换。
假设:如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表, 如果一个HashMap不停的插⼊、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
HashMap的负载因子为什么是0.75
官方的回答是说:0.75是在空间和时间复杂度之间的良好平衡。
负载因子太低会导致大量的空桶浪费空间,负载因子太高会导致大量的碰撞,降低性能。0.75刚刚好。
HashMap的长度为什么是2的幂次方
-
结论:为了能够高效的计算元素索引位置,并且让位置均匀分布。
-
具体来说:往HashMap中put元素的时候,需要计算索引位置,为了减少hash碰撞,可以使用将hash值对数组长度取模(hash%length)的方式来计算,但是取模运算的效率不如位运算。要使用位运算来代替取模运算,就必须满足数组长度为2的幂次方。
JDK8中,HashMap为什么引入红黑树,而不是AVL树
为什么用A,而不是B,这类问题本质上是比较两者的区别,看看分别有什么优势和劣势。
红黑树是弱平衡二叉树,AVL树是严格平衡二叉树(所有节点的左右子树高度差的绝对值不超过1)。
所以:红黑树的插入和删除操作可能需要进行1到2次的旋转来维护,而AVL树为了保持严格平衡可能需要进行多次旋转。因此,红黑树的插入和删除操作性能更高。
HashMap的并发问题
可参考文章:一文看懂 jdk8 中的 ConcurrentHashMap
HashMap在并发环境下会有什么问题
-
在JDK1.7中,并发环境下可能会出现查询时死循环的问题。 因为在JDK1.7里面,出现hash冲突的时候采用头插法,在进行扩容,将旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,所以最后的结果打乱了插入的顺序,就可能发生环形链和数据丢失的问题。环形链会引起死循环,导致CPU利用率接近100%。
-
在JDK1.8中对HashMap进行了优化,发生hash碰撞,不再采用头插法方式,而是直接插入链表尾部,因此不会出现环形链表的情况,但是在多线程环境下,会发生数据覆盖的情况。 如果没有hash碰撞的时候,它会直接插入元素。如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,线程A会把线程B插入的数据给覆盖,导致数据发生覆盖的情况,发生线程不安全。
并发环境下,推荐使用ConcurrentHashMap
参考文章:HashMap并发修改异常
说一下为什么会出现线程的不安全性
同上
一般是如何解决的
或者问:HashMap如何实现同步?
- 使用Collections.synchronizedMap()方法
Map<Integer, String> map = new HashMap<>();
Map<Integer, String> synchronizedMap = Collections.synchronizedMap(map);
synchronizedMap实现线程安全的原理也很简单,它首先基于当前的map对象生成一个新的map类型,它的所有操作(get/put/resize等方法)都用了synchronized加了一个对象锁。
- 使用ConcurrentHashMap;
ConcurrentHashMap
ConcurrentHashMap线程安全的实现原理
JDK1.7(可作为了解)
底层数据结构
1.7中的ConcurrentHashMap 是由 Segment 数组和 HashEntry 数组组成。 1个ConcurrentHashMap有多个Segment(初始貌似是16个),一个Segment有多个HashEntry。
Segment 实现了 ReentrantLock,是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。
实现线程安全的原理详解
ConcurrentHashMap 在 put 方法中进行了两次 hash 计算去定位数据的存储位置,尽可能的减小哈希冲突的可能性,然后再根据 hash 值以 Unsafe 调用方式,直接获取相应的 Segment,最终将数据添加到[容器]中是由 segment对象的 put 方法来完成。由于 Segment 对象本身就是一把锁,所以在新增数据的时候,相应的 Segment对象块是被锁住的,其他线程并不能操作这个 Segment 对象,这样就保证了数据的安全性。在扩容时也是这样的,在 JDK1.7 中的 ConcurrentHashMap扩容只是针对 Segment 对象中的 HashEntry 数组进行扩容,还是因为 Segment 对象是一把锁,所以在 rehash 的过程中,其他线程无法对 segment 的 hash 表做操作,这就解决了 HashMap 中 put 数据引起的闭环问题。
以下是put元素的源码,用于理解原理。
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 在往该 Segment 写入前,先确保获取到锁
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
V oldValue;
try {
// Segment 内部数组
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
// 更新已有值...
}
else {
// 放置 HashEntry 到特定位置,如果超过阈值则进行 rehash
// 忽略其他代码...
}
}
} finally {
// 释放锁
unlock();
}
return oldValue;
}
JDK1.8 之后
Java8 ConcurrentHashMap 存储结构
Java 8 几乎完全重写了 ConcurrentHashMap,代码量从原来 Java 7 中的 1000 多行,变成了现在的 6000 多行。
ConcurrentHashMap 取消了 Segment 分段锁,采用 Node + CAS + synchronized 来保证并发安全。数据结构跟 HashMap 1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。
Java 8 中,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。
final V putVal(K key, V value, boolean onlyIfAbsent) {
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;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {
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;
}
ConcurrentHashMap 能保证复合操作的原子性吗?
ConcurrentHashMap 是线程安全的,意味着它可以保证多个线程同时对它进行读写操作时,不会出现数据不一致的情况,也不会导致 JDK1.7 及之前版本的 HashMap 多线程操作导致死循环问题。但是,这并不意味着它可以保证所有的复合操作都是原子性的,一定不要搞混了!
复合操作是指由多个基本操作(如put、get、remove、containsKey等)组成的操作,例如先判断某个键是否存在containsKey(key),然后根据结果进行插入或更新put(key, value)。这种操作在执行过程中可能会被其他线程打断,导致结果不符合预期。
例如,有两个线程 A 和 B 同时对 ConcurrentHashMap 进行复合操作,如下:
// 线程 A
if (!map.containsKey(key)) {
map.put(key, value);
}
// 线程 B
if (!map.containsKey(key)) {
map.put(key, anotherValue);
}
如果线程 A 和 B 的执行顺序是这样:
- 线程 A 判断 map 中不存在 key
- 线程 B 判断 map 中不存在 key
- 线程 B 将 (key, anotherValue) 插入 map
- 线程 A 将 (key, value) 插入 map
那么最终的结果是 (key, value),而不是预期的 (key, anotherValue)。这就是复合操作的非原子性导致的问题。
那如何保证 ConcurrentHashMap 复合操作的原子性呢?
ConcurrentHashMap 提供了一些原子性的复合操作,如 putIfAbsent、compute、computeIfAbsent 、computeIfPresent、merge等。这些方法都可以接受一个函数作为参数,根据给定的 key 和 value 来计算一个新的 value,并且将其更新到 map 中。
上面的代码可以改写为:
// 线程 A
map.putIfAbsent(key, value);
// 线程 B
map.putIfAbsent(key, anotherValue);
或者
// 线程 A
map.computeIfAbsent(key, k -> value);
// 线程 B
map.computeIfAbsent(key, k -> anotherValue);
ConcurrentHashMap和HashTable的区别
底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
实现线程安全的方式(重要):
- 在 JDK1.7 的时候,
ConcurrentHashMap对整个桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 - 到了 JDK1.8 的时候,
ConcurrentHashMap已经摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用synchronized和 CAS 来操作。(JDK1.6 以后synchronized锁做了很多优化) 整个看起来就像是优化过且线程安全的HashMap,虽然在 JDK1.8 中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本; Hashtable(同一把锁) :使用synchronized来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
ConcurrentHashMap 为什么 key 和 value 不能为 null
HashMap的使用
key可以是null吗,value可以是null吗
可以,不会报错
一般用什么作为key值
-
基本类型的包装类
-
String类
用可变类当HashMap的Key会有什么问题
当类中的属性变化时,会导致key的hash值发生变化,进而导致找不到之前的key对应的value。
让你实现一个自定义的class作为HashMap的Key该如何实现
对于这个问题考查到了下面的两个知识点
-
重写hashcode和equals方法需要注意什么?
- 两个对象相等,hashcode一定相等
- 两个对象不等,hashcode不一定不等
- hashcode相等,两个对象不一定相等
- hashcode不等,两个对象一定不等
-
如何设计一个不变的类。
- 类添加final修饰符,保证类不被继承。
- 保证所有成员变量必须私有,并且加上final修饰 通过这种方式保证成员变量不可改变。但只做到这一步还不够,因为如果是对象成员变量有可能再外部改变其值。所以第4 点弥补这个不⾜。
- 不提供改变成员变量的方法,包括setter。避免通过其他接⼝改变成员变量的值,破坏不可变特性。
- 通过构造器初始化所有成员,进行深拷贝(deep copy)
- 在
getter方法中,不要直接返回对象本⾝,⽽是克隆对象,并返回对象的拷贝。这种做法也是防⽌对象外泄,防⽌通过getter获得内部可变成员对象后对成员变量直接操作,导致成员变量发生编程。
HashMap与Hashtable的区别
-
线程是否安全:HashMap是非线程安全的,Hashtable是线程安全的。因为Hashtable内部的方法基本都是synchronized关键字修饰的。但实际工作中,保证线程安全使用的是ConcurrentHashMap。Hashtable已经基本淘汰了。
-
空键和空值:HashMap:key和value都可以是NULL;Hashtable:不允许空的key和空值,否则会抛空指针异常
-
初始容量大小和每次扩容大小不同:
- 创建时如果不指定容量,Hashtable默认的是11,之后每次扩容,容量变为原来的2n+1;HashMap默认的初始大小是16,每次扩容,容量是原来的2倍
- 创建时如果指定了容量,Hashtable会使用指定的大小,HashMap会将其扩充为2的幂次方大小。
-
底层数据结构:HashMap底层是Node数组+链表+红黑树的结构。链表是为了解决Hash冲突,红黑树是为了解决链表长度过长时搜索性能较差的问题。Hashtable没有这样的机制,底层就一个Entry数组。
HashMap和TreeMap如何选择
- 源码上的区别:两者都继承自AbstractMap,但是TreeMap还实现了Navigable接口和SortedMap接口。
- 实现Navigable接口,让TreeMap有了对集合内元素的搜索的能力
- 实现SortMap接口,让TreeMap有了对集合中的元素根据键排序的能力。默认是按照key的升序排序,但是也可以自己指定排序比较器。
所以两者分别有以下特性
-
插入和查找性能
- HashMap:提供常数时间的平均性能O(1)来添加和检索键值对。但在某些情况下可能会退化到线性时间O(n)。
- TreeMap:添加、删除、查找的时间复杂度都是O(logn)。
-
键的顺序
- HashMap:key的插入顺序和遍历时的顺序不一致
- TreeMap:根据插入时的顺序或者构造函数传入的Comparator来维护键的顺序
public class Person {
private Integer age;
public Person(Integer age) {
this.age = age;
}
public Integer getAge() {
return age;
}
public static void main(String[] args) {
TreeMap<Person, String> treeMap = new TreeMap<>(new Comparator<Person>() {
@Override
public int compare(Person person1, Person person2) {
int num = person1.getAge() - person2.getAge();
return Integer.compare(num, 0);
}
});
treeMap.put(new Person(3), "person1");
treeMap.put(new Person(18), "person2");
treeMap.put(new Person(35), "person3");
treeMap.put(new Person(16), "person4");
treeMap.entrySet().stream().forEach(personStringEntry -> {
System.out.println(personStringEntry.getValue());
});
}
}
-
空键和空值
- HashMap:key和value都可以是NULL
- TreeMap:不允许空的key,允许空值。
-
总结:
- 如果需要快速的插入、删除和查找操作,且不需要任何排序功能,那么HashMap是一个很好的选择
- 如果需要有序的键、或者特定的排序要求,那么可以使用TreeMap。
HashMap和HashSet的区别
如果你看过 HashSet 源码的话就应该知道:HashSet 底层就是基于 HashMap 实现的。(HashSet 的源码非常非常少,因为除了 clone()、writeObject()、readObject()是 HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法。
HashMap | HashSet |
|---|---|
实现了 Map 接口 | 实现 Set 接口 |
| 存储键值对 | 仅存储对象 |
调用 put()向 map 中添加元素 | 调用 add()方法向 Set 中添加元素 |
HashMap 使用键(Key)计算 hashcode | HashSet 使用成员对象来计算 hashcode 值,对于两个对象来说 hashcode 可能相同,所以equals()方法用来判断对象的相等性 |
HashSet如何检查重复
HashSet加入一个元素的时候,是直接调用HashMap的put方法,规则是:
- 计算加入元素的hashcode是否与已有元素的hashcode相同,如果不相同,则加入成功,add方法会返回true;
- 如果相同,会再调用equals方法比较两个对象是否相同,如果不相同,则重新计算hascode值?如果相同,则加入失败,add方法会返回false
当把对象加入
HashSet时,HashSet会先计算对象的hashcode值来判断对象加入的位置,同时也会与其他加入的对象的hashcode值作比较,如果没有相符的hashcode,HashSet会假设对象没有重复出现。但是如果发现有相同hashcode值的对象,这时会调用equals()方法来检查hashcode相等的对象是否真的相同。如果两者相同,HashSet就不会让加入操作成功。 在 JDK1.8 中,HashSet的add()方法只是简单的调用了HashMap的put()方法,并且判断了一下返回值以确保是否有重复元素。直接看一下HashSet中的源码:
// Returns: true if this set did not already contain the specified element
// 返回值:当 set 中没有包含 add 的元素时返回真
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
而在HashMap的putVal()方法中也能看到如下说明:
// Returns : previous value, or null if none
// 返回值:如果插入位置没有元素返回null,否则返回上一个元素
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
...
}
也就是说,在 JDK1.8 中,实际上无论HashSet中是否已经存在了某元素,HashSet都会直接插入,只是会在add()方法的返回值处告诉我们插入前是否存在相同元素。