HashMap与ConcurrentHashMap⾯试要点

版权声明:本文为CSDN博主「LeslieGuGu」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/weixin_44050144/article/details/114855602

1.HashMap

1.1 HashMap底层数据结构

  • JDK7:数组+链表
  • JDK8: 数组+链表+红⿊树(看过源码的同学应该知道JDK8中即使⽤了单向链表,也使⽤了双向链表,双向链表主要是为了链表操作⽅便,应该在插⼊,扩容,链表转红⿊树,红⿊树转链表的过程中都要操作链表)

1.2 JDK8中的HashMap为什么要使⽤红⿊树?

  • 当元素个数⼩于⼀个阈值时,链表整体的插⼊查询效率要⾼于红⿊树
  • 当元素个数⼤于此阈值时,链表整体的插⼊查询效率要低于红⿊树。 此阈值在HashMap中为8

1.3 JDK8中的HashMap什么时候将链表转化为红⿊树?

这个题很容易答错,⼤部分答案就是:当链表中的元素个数⼤于8时就会把链表转化为红⿊树。 但是其实还有另外⼀个限制:当发现链表中的元素个数⼤于8之后,还会判断⼀下当前数组的⻓度,如果数组⻓度⼩于64时,此时并不会转化为红⿊树,⽽是进⾏扩容。

只有当链表中的元素个数⼤于8,并且数组的⻓度⼤于等于64时才会将链表转为红⿊树。

上⾯扩容的原因是,如果数组⻓度还⽐较⼩,就先利⽤扩容来缩⼩链表的⻓度。

1.4 JDK8中HashMap的put⽅法的实现过程?

  1. 根据key⽣成hashcode

  2. 判断当前HashMap对象中的数组是否为空,如果为空则初始化该数组

  3. 根据逻辑与运算,算出hashcode基于当前数组对应的数组下标i

  4. 判断数组的第i个位置的元素(tab[i])是否为空

    a. 如果为空,则将key,value封装为Node对象赋值给tab[i]
    b. 如果不为空:

    b.i. 如果put⽅法传⼊进来的key等于tab[i].key,那么证明存在相同的key
    b.ii. 如果不等于tab[i].key,则:

    1. 如果tab[i]的类型是TreeNode,则表示数组的第i位置上是⼀颗红⿊树,那么将key和value插⼊到红⿊树中,并且在插⼊之前会判断在红⿊树中是否存在相同的key
    2. 如果tab[i]的类型不是TreeNode,则表示数组的第i位置上是⼀个链表,那么遍历链表寻找是否存在相同的key,并且在遍历的过程中会对链表中的结点数进⾏计数,当遍历到最后⼀个结点时,会将key,value封装为Node插⼊到链表的尾部,同时判断在插⼊新结点之前的链表结点个数是不是⼤于等于8,如果是,则将链表改为红⿊树。

    b.iii. 如果上述步骤中发现存在相同的key,则根据onlyIfAbsent标记来判断是否需要更新value值,然后返回oldValue

  5. modCount++

  6. HashMap的元素个数size加1

  7. 如果size⼤于扩容的阈值,则进⾏扩容

1.5 JDK8中HashMap的get⽅法的实现过程

  1. 根据key⽣成hashcode
  2. 如果数组为空,则直接返回空
  3. 如果数组不为空,则利⽤hashcode和数组⻓度通过逻辑与操作算出key所对应的数组下标i
  4. 如果数组的第i个位置上没有元素,则直接返回空
  5. 如果数组的第1个位上的元素的key等于get⽅法所传进来的key,则返回该元素,并获取该元素的value
  6. 如果不等于则判断该元素还有没有下⼀个元素,如果没有,返回空
  7. 如果有则判断该元素的类型是链表结点还是红⿊树结点
    a. 如果是链表则遍历链表
    b. 如果是红⿊树则遍历红⿊树
  8. 找到即返回元素,没找到的则返回空

1.6 JDK7与JDK8中HashMap的不同点

  1. JDK8中使⽤了红⿊树
  2. JDK7中链表的插⼊使⽤的头插法(扩容转移元素的时候也是使⽤的头插法,头插法速度更快,⽆需遍历链表,但是在多线程扩容的情况下使⽤头插法会出现循环链表的问题,导致CPU飙升),JDK8中链表使⽤的尾插法(JDK8中反正要去计算链表当前结点的个数,反正要遍历的链表的,所以直接使⽤尾插法)
  3. JDK7的Hash算法⽐JDK8中的更复杂,Hash算法越复杂,⽣成的hashcode则更散列,那么hashmap 中的元素则更散列,更散列则hashmap的查询性能更好,JDK7中没有红⿊树,所以只能优化Hash算 法使得元素更散列,⽽JDK8中增加了红⿊树,查询性能得到了保障,所以可以简化⼀下Hash算法,毕竟Hash算法越复杂就越消耗CPU
  4. 扩容的过程中JDK7中有可能会重新对key进⾏哈希(重新Hash跟哈希种⼦有关系),⽽JDK8中没有这部分逻辑
  5. JDK8中扩容的条件和JDK7中不⼀样,除开判断size是否⼤于阈值之外,JDK7中还判断了tab[i]是否为空,不为空的时候才会进⾏扩容,⽽JDK8中则没有该条件了
  6. JDK8中还多了⼀个API:putIfAbsent(key,value)
  7. JDK7和JDK8扩容过程中转移元素的逻辑不⼀样,JDK7是每次转移⼀个元素,JDK8是先算出来当前位置上哪些元素在新数组的低位上,哪些在新数组的⾼位上,然后在⼀次性转移

2.ConcurrentHashMap

2.1 JDK7中的ConcurrentHashMap是怎么保证并发安全的?

主要利⽤Unsafe操作+ReentrantLock+分段思想。主要使⽤了Unsafe操作中的:

  1. compareAndSwapObject:通过cas的⽅式修改对象的属性
  2. putOrderedObject:并发安全的给数组的某个位置赋值
  3. getObjectVolatile:并发安全的获取数组某个位置的元素

分段思想是为了提⾼ConcurrentHashMap的并发量,分段数越⾼则⽀持的最⼤并发量越⾼,程序员可以通过concurrencyLevel参数来指定并发量。 ConcurrentHashMap的内部类Segment就是⽤来表示某⼀个段的。

每个Segment就是⼀个⼩型的HashMap的,当调⽤ConcurrentHashMap的put⽅法是,最终会调⽤到Segment的put⽅法,⽽Segment类继承了ReentrantLock,所以Segment⾃带可重⼊锁,当调⽤到Segment的put⽅法时,会先利⽤可重⼊锁加锁,加锁成功后再将待插⼊的key,value插⼊到⼩型HashMap 中,插⼊完成后解锁。

2.2 JDK7中的ConcurrentHashMap的底层原理

ConcurrentHashMap底层是由两层嵌套数组来实现的:

  1. ConcurrentHashMap对象中有⼀个属性segments,类型为Segment[];
  2. Segment对象中有⼀个属性table,类型为HashEntry[];

当调⽤ConcurrentHashMap的put⽅法时,先根据key计算出对应的Segment[]的数组下标j,确定好当前key,value应该插⼊到哪个Segment对象中,如果segments[j]为空,则利⽤⾃旋锁的⽅式在j位置⽣成⼀个Segment对象。

然后调⽤Segment对象的put⽅法。

Segment对象的put⽅法会先加锁,然后也根据key计算出对应的HashEntry[]的数组下标i,然后将key,value封装为HashEntry对象放⼊该位置,此过程和JDK7的HashMap的put⽅法⼀样,然后解锁。

在加锁的过程中逻辑⽐较复杂,先通过⾃旋加锁,如果超过⼀定次数就会直接阻塞等等加锁。

2.3 JDK8中的ConcurrentHashMap是怎么保证并发安全的?

主要利⽤Unsafe操作+synchronized关键字。
Unsafe操作的使⽤仍然和JDK7中的类似,主要负责并发安全的修改对象的属性或数组某个位置的值。

synchronized主要负责在需要操作某个位置时进⾏加锁(该位置不为空),⽐如向某个位置的链表进⾏插⼊结点,向某个位置的红⿊树插⼊结点。

JDK8中其实仍然有分段锁的思想,只不过JDK7中段数是可以控制的,⽽JDK8中是数组的每⼀个位置都有⼀把锁。

当向ConcurrentHashMap中put⼀个key,value时,

  1. ⾸先根据key计算对应的数组下标i,如果该位置没有元素,则通过⾃旋的⽅法去向该位置赋值。
  2. 如果该位置有元素,则synchronized会加锁
  3. 加锁成功之后,在判断该元素的类型
    a. 如果是链表节点则进⾏添加节点到链表中
    b. 如果是红⿊树则添加节点到红⿊树
  4. 添加成功后,判断是否需要进⾏树化
  5. addCount,这个⽅法的意思是ConcurrentHashMap的元素个数加1,但是这个操作也是需要并发安全的,并且元素个数加1成功后,会继续判断是否要进⾏扩容,如果需要,则会进⾏扩容,所以这个⽅法 很重要。
  6. 同时⼀个线程在put时如果发现当前ConcurrentHashMap正在进⾏扩容则会去帮助扩容。

2.4 JDK7和JDK8中的ConcurrentHashMap的不同点

这两个的不同点太多了…,既包括了HashMap中的不同点,也有其他不同点,⽐如:

  1. JDK8中没有分段锁了,⽽是使⽤synchronized来进⾏控制
  2. JDK8中的扩容性能更⾼,⽀持多线程同时扩容,实际上JDK7中也⽀持多线程扩容,因为JDK7中的扩容是针对每个Segment的,所以也可能多线程扩容,但是性能没有JDK8⾼,因为JDK8中对于任意⼀个线程都可以去帮助扩容
  3. JDK8中的元素个数统计的实现也不⼀样了,JDK8中增加了CounterCell来帮助计数,⽽JDK7中没 有,JDK7中是put的时候每个Segment内部计数,统计的时候是遍历每个Segment对象加锁统计。

3. Other

3.1 JDK8中HashMap有哪些改动?

  1. JDK7中的底层实现是数组+链表,JDK8中使用的是数组+链表+红黑树。
  2. JDK7中扩容时有可能出现死锁,JDK8中通过算法优化不会出现死锁了。
  3. JDK8中对算哈希值的哈希算法进行了简化以提高运算效率。

3.2 JDK8中为什么要使用红黑树?

因为JDK7中是用数组+链表来作为底层的数据结构的,但是如果数据量较多,或者hash算法的散列性不够,可能导致链表上的数据太多,导致链表过长,考虑一种极端情况:如果hash算法很差,所有的元素都在同一个链表上。那么在查询数据的时候的时间复杂度和链表查询的时间复杂度差不多是一样的,我们知道链表的一个优点是插入快,但是查询慢,所以如果HashMap中出现了很长的链表结构会影响整个HashMap的查询效率,我们使用HashMap时插入和查询的效率是都要具备的,而红黑树的插入和查询效率处于完全平衡二叉树和链表之间,所以使用红黑树是比较合适的。

3.3 HashMap扩容机制是怎样的?JDK7和JDK8有什么不同?

首先,我们需要知道HashMap为什么需要扩容,道理很简单,HashMap底层是用数组+链表实现的,而数组是预先就已经分配好内存的,如果需要对数组进行扩容,需要重新开辟一个新的数组再将旧数组上的元素进行转移,如果不进行扩容,那么会导致HashMap的链表过长,查询效率降低,所以需要对数组进行扩容。

在JDK7中,HashMap扩容的条件是 (size >= threshold) && (null !=
table[bucketIndex]) , size 为HashMap当前的容量, threshold 初始化值为
12, table[bucketIndex] 代表所put进来的key所对应的数组上的元素,所以在JDK7中扩容条件是当Put操作传入的 Key值所对应的数组位置上不为空时并且当前容量大于等于了扩容的阈值时才进行扩容,JDK7中的扩容思路是:开辟一个新的数组,数组大小为原数组的两倍,然后再将数组上的链表与元素转移到新数组上,此过程可能会出现死锁。

JDK8中的扩容条件比JDK7中要少,只有当前容量大于等于了扩容的阈值时才进行扩容 ,并且扩容的思路也发生了变化,思路比较复杂。

3.4 为什么重写对象的equals方法时,要重写hashcode方法?跟hashmap有关系吗,为什么?

跟HashMap有关系,或者说因为HashMap中用到了对象的hashcode方法所以会有关系,因为我们如果在设计两个对象相等的逻辑时,如果只重写Equals方法,那么一个类有两个对象A1,A2,他们的A1.equals(A2)为true,
A1.hashcode和A2.hashcode不一样,当将A1和A2都作为HashMap的key时,HashMap会认为它两不相等,因为HashMap在判断key值相不相等时会判断key的hashcode是不是一样,hashcode一样相等,所以在这种场景下会
出现我们认为这两个对象相等,但是hashmap不这么认为,所以会有问题。

3.5 HashMap是线程安全的吗?遇到过ConcurrentModificationException异常吗?为什么会出现?怎么解决?

HashMap不是线程安全的,ConcurrentModificationException这个异常通常会出现在多线程环境中,比如两个线程共享一个hashmap,一个线程在遍历,一个线程在删除,那么就有可能出现ConcurrentModificationException异常,假设如果不出现这个异常,那么则可能出现并发问题,可能遍历的线程发现hashmap存的元素少了,HashMap为了防止这种情况出现,所以直接会抛出ConcurrentModificationException异常,这是Fast-Fail机制,让错误尽快出现,不让用户继续“错下去”。

3.6 在使用HashMap的过程中我们应该注意些什么问题?

  1. HashMap的扩容机制是很影响效率的,所以如果事先能确定有多少个元素需要存储,那么建议在初始化HashMap时对数组的容量也进行初始化,防止扩容。
  2. HashMap中使用了对象的hashcode方法,而且很关键,所以再重写对象的equals时建议一定要重写hashcode方法。
  3. 如果是用对象作为HashMap的key,那么请将对象设置为final,以防止对象被重新赋值,因为一旦重新赋值其实就代表了一个新对象作为了key,因为两个对象的hashcode可能不同。

3.7 HashMap与HashTable的区别

  1. HashMap 是非线程安全的,HashTable 是线程安全的;HashTable 内部的方法基本都经过 synchronized修饰
  2. 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰,不要在代码中使用它
  3. HashMap 中,null 可以作为键,这样的键只有一个,可以有一个或多个键所对应的值为 null。但是在HashTable 中 put 进的键值只要有一个 null,直接抛出 NullPointerException。
  4. JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值