HashMap的面试常见问题

HashMap和ConcurrentHashMap的面试常见问题

1.说一下HashMap的底层数据结构?

jdk1.7中hashmap底层数据结构是:数组+链表

jdk1.8中hashmap的底层数据结构是:数组+链表+红黑树

2.为什么要这样设计,jdk8中为什么要进行升级呢?

(1)因为Map这种集合,主要的应用就是想通过key,最快的找到value,事实上这个时间复杂度接近O(1),要想实现这么快的速度,于是就引入了数组,数组可以理解为内存中一小块连续的空间,有自己的索引,通过索引能直接找到对应空间的值

(2)在把key映射成索引值时,可能会存在两个不同的key映射成了同一个索引值,这就是哈希碰撞,哈希碰撞会存在两种情况,第一种是key相同,那么就会将原有的值覆盖掉,并返回原来的值,还有一种是key不相同,那我们就需要把两对key,value放到同一个数组索引内存中,于是就形成了链表

(3)但是如果hash碰撞经常出现的话,数组链表就会越来越长,那如果我们想要跟据key查找其对应的value值的话,就需要遍历链表,在最坏的情况下,我们直到遍历到链表最后,才能找到对应的value值,如果链表长度为n的话,时间复杂度就是o(n),这种查询速度太慢了,所以jdk8作出了优化,如果链表的长度大于8,并且数组长度达到64的话,就可以转化为红黑树,因为红黑树的数据结构是二叉搜索树,二叉搜索树查找的时间复杂度是o(lgn),是一种折半查找,一次排除一半的数据查找的速度很快,而红黑树在有二叉搜索树的查询效率的前提下,又保证了树的平衡,所以链表在上述情况下才会进化为红黑树

3.为什么使用红黑树而不是AVL树呢?

因为AVL树它是平衡二叉搜索树,相比于红黑树保持更加严格的平衡,所以他的查找速度更快,但是在进行插入和删除操作时,就要进行更多的旋转,所以速度也就更慢,hashmap它不仅要保证查找速度,插入和删除的速度也要保证,所以采用红黑树。

4.那为什么不直接使用红黑树呢?

因为树节点所占的空间是普通节点的两倍,所以只有当节点足够多的时候,才会使用树节点,也就是说,当节点少的时候,尽管时间复杂度上,红黑树比链表要好一些,但是红黑树所占的空间比较大,综合考虑,认为只能在节点太多的时候,红黑树占空间大这一劣势不太明显的时候,才会舍弃链表使用红黑树

5.什么情况下会转化为红黑树?为什么?

如果链表的长度大于8,并且数组长度大于等于64的话,就会进化为红黑树,因为跟据统计,链表中节点数是8的概率已经接近千分之一了,此时链表的性能已经很差了,但是又不能直接转为红黑树,因为链表转为红黑树也是比较消耗性能的,所以综合考虑之下,如果数组长度还没有达到64,就进行扩容处理,因为通过扩容也可能减小部分链表的长度,一旦链表的长度8,并且数组长度大于等于64的话,说明链表性能非常差了,只能采用红黑树提高性能,这是一种应对策略。

6.说一下hashmap中的key是怎样转为数组索引的呢?

在object中有一个属性是hashcode(),它可以将对象转化为32位的整数,然后这个整数经过一些移位和异或^操作,这里jdk1.7和jdk1.8是有区别的,得到最终的hash值,hash值再与(数组长度-1)相与,得到数组索引。

7.为什么是相与而不是取余呢?

在计算机中位运算的效率是最高的,它是直接操作二进制数的

8.那是不是数组长度为多少都可以呢?

数组长度必须是2的幂次方,如果传入一个自定义的数组长度,那么会转换成离它最近的是2的幂次方的数作为数组长度,因为我们会发现如果数组长度是2的幂次方,那它减一之后的二进制数就是低位全是1,高位全为0,相与呢,是有0则为0,全1才为1,所以数组长度减一再和获得的hash值相与的话,得到的值就完全由hash值的低位决定,并且还可以获得在数组范围内的索引值

9.hash具体是怎么得到的呢?为什么这样计算呢?为什么不直接用hashcode()呢?

在jdk1.7和jdk1.8中是有区别的,在jdk1.7中,进行了4次异或操作,而且都是向低位做异或操作,因为刚才有提到说索引是通过hash和数组长度-1相与获得的,相与是有0则为0,全1才为1,因为数组的长度一般不会特别大,所以索引是由hash值的低位决定的,高位一般用不到,我们就没有必要直接用hashcode()因为它有32位,会增加hash碰撞的几率,像低位做异或操作是为了更加分散,将高位与低位的特性融合,使元素更容易分散到不同的hash桶中,避免hash碰撞的发生,这就叫做扰动函数

在jdk1.8中只进行了一次异或操作(和高16位),是因为在jdk1.8中我们引入了红黑树,查询的效率已经有一定的提升了,没有必要异或太多次也足够分散了

10.怎么将传入的数组长度转化为2的幂次方的呢?

先将传入的cap减1,然后因为int是32位的,所以最多经过5次或运算就能将最高位1的后面全部变成1,最后结果再加1,就能得到最小的大于等于cap的2的幂次方的数

11.开始的时候为什么要减1呢?

因为如果传入的数是32的话也就是刚好是2的幂次方,经过或运算再加1后得到的值是大于cap值的2的幂次方,减一就是为了保证当传入的数组长度是2的幂次方时,就不在改变了

12.hashmap为什么会造成线上cpu100%?(hashmap线程是安全的么?为什么呢?)

是使用了jdk1.7,因为在jdk1.7中hashmap是线程不安全的,hashmap进行扩容时,会调用resize方法,在这个方法中又会调用transfer方法,因为jdk1.7使用了头插法,在transfer方法中,进行节点转移时,可能会将链表进行一次倒序处理,当有两个线程同时进行扩容的时候,如果原来存在链表a->b,线程1处理的比较慢,线程2已经完成倒序处理了,变成了b->a,此时线程1再接着处理就会变成b->a->b,循环引用,使cpu的使用率飙升从而达到了100%

13.那么jdk1.7中是怎么进行节点转移的呢?

先遍历整个数组,然后遍历数组中的链表的每个节点,重新进行hash,获取在新数组中的索引位置,在采用头插法,将节点插入到新数组中

14.jdk1.8中还需要重新进行hash么?

jdk1.8中避免了扩容之后重新进行rehash,因为在通过hash和数组长度-1相与获得索引值的时候有一个规律的,如果数组长度变为了原来的2倍,假如原来是16,变成了32,那么31会比15的二进制高位多一个1,又因为相与是有0则为0,全1才为1,所以length=16和32的索引运算结果是否相同,取决于hash值的第五位,如果第五位是0,那么索引不变,如果是1,那么索引比原来多了一个16,也就是扩容前的容量,按照这个规律,只需要判断hash值的高一位是0,还是1就可以了。

15.jdk1.8中hashmap的线程安全么?为什么?

jdk1.8中只是解决了jdk1.7中循环引用和数据丢失的问题,但是还会出现数据覆盖的问题,因为

1.如果有两个线程同时进行put操作,并且通过他们的key获得的hash值和(n-1)相与得到的数组下标是相同的,当第一个线程执行到if ((p = tab[i = (n - 1) & hash]) == null)判断该位置上没有节点,可以直接添加新节点时,第二个线程抢占了cpu,并且也通过了上述判断成功添加了节点在此数组下标的位置上,这时又切换回第一个线程,由于之前已经通过了if判断,所以也会直接添加新节点在此数组下标的位置,这样一来就覆盖了线程二已经添加的节点。

2.在put方法中还存在size++操作(i++操作本身就是线程不安全的,i++会被分解成三个指令①getfield拿到原始值②执行iadd进行加一操作③putfield把累加后的值返回),如果两个线程同时执行size++操作,线程一先拿到了size的初始值12,然后线程二抢占了cpu,拿到了size的值为12,并进行了+1操作,变成了13,将修改写回size,线程一又继续在开始时拿到的值上执行+1操作,得到结果13,写回size,这就又造成了数据覆盖。

16.数组什么时候会扩容?

在hashmap中有一个值叫负载因子,默认是0.75,这个值是为了在时间和空间上取得平衡,表示hashmap的拥挤程度,影响哈希操作到同一个数组位置的概率,如果数组中节点的数量,算上新加的节点>数组容量*负载因子,数组就会扩容为原来的2倍

17.说一下hashmap的get方法的执行流程?

1.首先获取传入key的hash值,这个hash值是经过异或运算计算出的hash值

2.然后用hash值和数组长度减一相与计算该key对应的数组索引,如果数组索引所在位置没有节点返回null

3.有节点就判断该节点的hash值和key的hash值是否相等,因为可能存在hash冲突,还要通过==或equals判断key值是否相等,条件都满足,就返回该节点对象

4.否则继续便遍历该节点下的链表或红黑树,按同样的方法判断,找到就返回节点,找不到返回null

18.hashmap的put方法的执行流程?

1.计算关于key的hashcode值(与Key.hashCode的高16位做异或运算)

2.如果数组为空时,调用resize()初始化数组

3.然后计算key对应的索引,如果没有发生碰撞,也就是该数组下标处没有节点,直接添加元素到数组中去

4.如果发生了碰撞(索引相同),进行三种判断

4.1:若当前节点和key的hash值相同并且key地址相同或者equals后内容相同,则替换旧值

4.2:如果不同就判断是链表结构还是树节点

4.2.1:如果是红黑树结构,就调用树的插入方法

4.2.2:链表结构,循环遍历直到链表中某个节点为空,在这个过程中,如果遍历到有节点与插入元素的哈希值和内容相同,进行覆盖;遍历到链表尾部,还没有相同的节点,采用尾插法进行插入(jdk1.8),插入之后判断链表个数是否到达变成红黑树的阙值8,如果到达了并且数组的长度大于等于64了,就进行树化,否则扩容。

5.如果数组中节点总个数大于阀值(数组长度*负载因子),则resize进行扩容

19. 平时在使用HashMap时一般使用什么类型的元素作为Key?

1.一般采用string,integer这样的类,因为他们的内部已经很规范的重写了hashcode()和equals()方法,减少hash碰撞的产生,减少比较上出现的错误

2.因为是被final修饰的,具有不可变性,作为不可变类是线程安全的

3.可以很好的优化比如缓存hash值,避免重复计算`等等

20.hashmap的底层原理是什么?

基于hashing的原理,jdk8后采用数组+链表+红黑树的数据结构。我们通过put和get存储和获取对象。当我们给put()方法传递键和值时,先对键做一个hash()的计算,然后通过hash值和数组长度-1相与,来得到它在数组中的位置来存储Entry对象。当获取对象时,会计算出key所对应的数组索引,再通过键对象的equals()或==方法找到正确的键值对,然后在返回值对象。

21.谈一谈hashmap的特性?

1.HashMap存储键值对实现快速存取,允许为null。key值不可重复,若key值重复则覆盖。

2.非同步,线程不安全。

3.底层是hash表,不保证有序(比如插入的顺序)

22.如果两个键的hashcode相同,你如何获取值对象?

HashCode相同,通过equals或==比较内容获取值对象

23.HashMap和HashTable的区别

相同点:都是存储key-value键值对的

不同点:

1.HashMap允许Key-value为null,hashTable不允许;

2.hashMap没有考虑同步,是线程不安全的。hashTable是线程安全的,给api套上了一层synchronized修饰;

3.HashMap继承于AbstractMap类,hashTable继承与Dictionary类。

4.迭代器(Iterator)。HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException。

5.容量的初始值和增加方式都不一样:HashMap默认的容量大小是16;增加容量时,每次将容量变为"原始容量x2"。Hashtable默认的容量大小是11;增加容量时,每次将容量变为"原始容量x2 + 1";

6.添加key-value时的hash值算法不同:HashMap添加元素时,是使用自定义的哈希算法。Hashtable没有自定义哈希算法,而直接采用的key的hashCode()。

24.HashMap中的key若是object类型,需要实现哪些方法?

1.实现hashcode()方法,通过它计算hash值,通过hash值计算数据的存储位置

2.实现equals()方法,比较相同存储位置上的节点的key是否相等,从而保证key的唯一性

25.jdk1.7和jdk1.8中有哪些区别?

1.jdk1.7中hashmap的底层结构是数组+链表,jdk1.8中采用的数组+链表+红黑树来存储数据

2.jdk1.7中向链表中添加数据采用的是头插法,jdk1.8采用的是尾插法

3.在jdk1.7中是先扩容再插入数据的,jdk1.8中先插入数据再扩容

4.jdk1.7中的hash方法,经历了4次异或运算,jdk1.8中只与高16位进行了一次,jdk1.7中的hash算法更复杂

5.jdk1.7中扩容后转移节点需要重新计算索引值,jdk1.8中不需要,利用了jdk1.7中的计算规律,只需要判断,hash新增参与运算的位是0还是1,是0原来的索引值,是1就是原始位置+扩容大小值

6.jdk1.7中在扩容时可能会对key重新进行hash,与hash种子有关,jdk18不会

7.jdk1.7和jdk1.8中元素转移的逻辑不一样,jdk1.7一次转移一个元素采用头插法,jdk1.8????????????

26.jdk1.7中为什么采用头插法而不是尾插法呢?

因为虽然头插法和尾插法的效率基本相同,但是,使用头插法能够保证我们最快的获取到刚刚插入的值

27.jdk1.7中为什么重新rehash

来提高hash值的散列性,减小hash碰撞的产生,扩容时,缩短了链表的长度。

28.怎样解决hashmap线程安全问题?

使用concurrentHashMap…

29.jdk1.7中的concurrenthashmap是怎么保证并发安全的?

主要利用了Unsafe类+ReentrantLock+分段+自旋的思想:

1.利用了Unsafe操作的一些方法:

compareAndSwapObject:通过cas的方式来修改对象属性

putOrderedObject:并发安全的给数组的某个位置赋值

getObjectVolatile:并发安全的获取数组某个位置的元素

2.当我们计算出元素要放在segments数组中的哪个位置时,可能需要创建segment对象,此时采用自旋+Unsafe的方式,来保证多线程的情况下,同一数组位置,segment对象只会被创建一次

3.因为采用了分段的思想,每个段内都会存放一个HashEntry数组,在向数组中添加HashEntry对象的时候,也就是调用segment的put操作的时候,就会对数组所在的segment对象上锁,segment对象又继承了ReentrantLock,自带可重入锁,直接用可重入锁上锁,保证同一时刻只能有一个线程来操作HashEntry[]

4.采用分段的思想是为了提高concurrenthashmap的并发量,分的段数越多,并发量越高,程序猿可以通过修改concurrencyLevel参数来指定并发量

30.jdk1.7中的ConcurrentHashMap的底层实现原理是什么?

在jdk1.7中,ConcurrentHashMap底层是由两层嵌套数组实现的:

ConcurrentHashMap中有一个segments属性,类型是segment[]

Segement对象中有一个属性table,类型为HashEntry[]

当调用ConcurrentHashMap的put方法时,会现根据key计算出对应的segement[]的数组下标j,确定好当前key,value应该插入到哪个segment对象中,如果segemnt[j]为空,则采用自旋锁的方式在j位置生成一个segment对象

然后调用segment对象的put方法,

segment对象的put方法会先加锁,利用trylock通过自旋的方式试着加锁,然后跟据key计算出对应的HashEntry[]的数组下标i,然后将key,value封装成HashEntry对象放入该位置,此过程根jdk7的hashmap的put方法一样,然后解锁

31.jdk8中concurrentHashmap是怎样保证并发安全的?

主要利用了CAS+synchronized关键字

Unsafe操作和jdk7中类似,主要负责并发安全的修改对象的属性值,或者数组某个位置的值,

synchronized主要负责在需要操作某个位置,对该位置进行加锁,而不是整个数组,比如向链表中的某个位置添加元素,就会对该链表的根节点进行加锁等

当向ConcurrentHashMap中put一个key,value时

1.首先会判断key或者value是否为null,如果为null抛出 NullPointerException异常

2.接着会根据key计算hash值,循环判断节点数组是否存在,如果不存在就会采用自旋锁+Unsafe类去初始化节点数组

3.如果存在就会计算数组索引,如果该位置没有元素就调用Unsafe类的CompareAndSwapObject方法添加封装好的节点对象到该位置

4.如果这个位置节点的hash值为-1,也就是moved,会发现此刻有线程正在扩容,就会去协助扩容,直到扩容完成返回新的节点数组

5.如果此位置有节点,并且不是fwdNode,则对当前节点对象加synchronized锁,执行插入操作,如果是链表节点就添加节点到链表中,如果是红黑树就调用红黑树的插入算法

6.然后判断当前链表中节点数量是否>=8,并且数组长度>=64,如果是的话就进行树化,否则扩容

7.最后还会调用addCount方法,将节点总数量加一,加完之后,会计算节点总数,利用节点总数与sizeCtlb(扩容阈值)比较去判断是否需要扩容,如果需要就进行扩容

32.jdk1.8中是怎样计算节点数量的?

在ConcurrentHash中有了两个属性

一个是baseCount,记录节点的基本数量

一个是CounterCell[],每个CounterCell对象中都有一个属性value,当通过cas的方式向baseCount中添加失败后,就会试着向CounterCell[]中的CounterCell对象上添加(具体比较复杂)

通过baseCount+CounterCell[]数组中每个CounterCell对象的value值

33.jdk1.7和jdk1.8中ConcurrentHashmap的区别?

1.jdk7中使用了分段锁数据结构,jdk8中取消了分段锁的数据结构,使用数组+链表+红黑树的结构

2.jdk1.7中使用segment分段锁机制+cas保证线程安全,segment继承了ReentrantLock,jdk1.8使用cas+synchronized保证线程安全,

3.jdk1.7中对segment对象加锁,jdk1.8中提供了更细粒度的锁,对链表的根节点或者树进行加锁

4.jdk8中的扩容性能更高,支持多线程同时扩容,实际上jdk7也支持多线程扩容,可能会有多线程对不同的segment对象中的hashmap[]进行扩容

5.jdk7中统计节点的个数是遍历每个segment对象的个数,而jdk8中增加了CountCell对象,来帮助计数

34.jdk1.8中是如何进行扩容的?

因为jdk1.8中支持多线成同时扩容的,会给每个线程分配需要扩容的区间,所以:

1.首先,会跟据cpu的核数来确定每个线程处理的步长,最小的步长是16

2.如果没创建新的节点数组,会去创建一个容量为原来2倍的,新的节点数组

3.会创建ForwardingNode节点,该节点继承了Node节点,有一个属性,存放新创建的节点数组,每当一个位置完成扩容,都会将fwdNode放在此位置上

4.接着给线程分配需要去处理的区间,用i和bound标记,i表示1正在扩容的位置,bound表示需要扩容到的位置,还用transferIndex标记从该索引位置开始后面已经被分配给其他线程了,使得其他线程帮助扩容的时候,被分配新的扩容区间,而不会造成重复分配

5.先判断需要扩容的位置元素是否为null,如果为null,直接在该位置添加forword节点,

6.如果发现该位置所对应节点的hash值为-1,表明此位置已经完成了扩容,继续向前寻找扩容节点

7.如果该位置有节点并且还没有完成扩容,就对该节点进行加锁,与jdk7中的转移节点方式相同了,转移完成后,继续进行下一个节点的扩容,

8.直到线程到了头节点仍然没有需要自己去扩容的了,就将sizeCtl-1,因为在扩容时每多一个线程进行扩容,都会将sizeCtl+1,表示自己的任务已经完成,

之后又进入判断是否全部线程都结束了扩容

9.如果没有就结束该线程的扩容操作

10.如果是就标记结束,留下这最后一个线程将扩容好的节点数组赋值给concurrenthashmap的table属性,替换原节点数组,并将sizeCtl设置为新数组的扩容阈值

11.如果在此期间一个线程完成了协助扩容,但扩容操作并没有完成,还有其他线程在扩容,那个该协助扩容的数组会一直循环等待获取到新的table,完成插入操作

(很复杂,欢迎改正补充…)

如有问题欢迎指正

参考视频:https://www.bilibili.com/video/BV1x741117jq?p=8&spm_id_from=333.1007.top_right_bar_window_history.content.click

参考文章:https://yanghang.blog.csdn.net/article/details/120994790?spm=1001.2014.3001.5502

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值