摘要:Hashmap面试内容总结
前言:Hashmap面试内容总结,为面试做准备。
1、为什么用HashMap?
- HashMap 是一个散列桶(数组和链表),它存储的内容是键值对 key-value 映射
- HashMap 采用了数组和链表的数据结构,查询和修改方便,继承了数组的线性查找和链表的寻址修改
- HashMap 是非 synchronized,所以 HashMap 很快
- HashMap 可以接受 null 键和值,而 Hashtable 则不能(原因就是 equlas() 方法需要对象,因为 HashMap 是后出的 API 经过处理才可以)
- HashMap 存储着Entry(hash, key, value, next)对象
2、HashMap 的工作原理是什么?
HashMap 通过put和get存储和获取对象。存储对象时,我们将K/V传给put方法时,它调用hashCode计算hash从而得到bucket位置,进一步存储,HashMap会根据当前bucket的占用情况自动调整容量(超过Load Facotr则resize为原来的2倍)。获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键值对。如果发生碰撞的时候,Hashmap通过链表将产生碰撞冲突的元素组织起来,在Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度。
1 | 以下是 HashMap 初始化 |
以下是具体的 put 过程(JDK1.8)
- 对 Key 求 Hash 值,然后再计算下标
- 如果没有碰撞,直接放入桶中(碰撞的意思是计算得到的 Hash 值相同,需要放到同一个 bucket 中)
- 如果碰撞了,以链表的方式链接到后面
- 如果链表长度超过阀值(TREEIFY THRESHOLD==8),就把链表转成红黑树,链表长度低于6,就把红黑树转回链表
- 如果节点已经存在就替换旧值
- 如果桶满了(容量16*加载因子0.75),就需要 resize(扩容2倍后重排)
以下是具体 get 过程
考虑特殊情况:如果两个键的 hashcode 相同,你如何获取值对象?
当我们调用 get() 方法,HashMap 会使用键对象的 hashcode 找到 bucket 位置,找到 bucket 位置之后,会调用 keys.equals() 方法去找到链表中正确的节点,最终找到要找的值对象。
3、有什么方法可以减少碰撞?
碰撞:所谓“碰撞”就上面所述是多个元素计算得出相同的hashCode,在put时出现冲突。
处理方法:
Java中HashMap是利用“拉链法”处理HashCode的碰撞问题。在调用HashMap的put方法或get方法时,都会首先调用hashcode方法,去查找相关的key,当有冲突时,再调用equals方法。
hashMap 通过put和get方法存取对象。当我们将键值对传递给put方法时,他调用键对象的hashCode()方法来计算hashCode,然后找到bucket(哈希桶)位置来存储对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。HashMap使用链表来解决碰撞问题,当碰撞发生了,对象将会存储在链表的下一个节点中。hashMap在每个链表节点存储键值对对象。当两个不同的键却有相同的hashCode时,他们会存储在同一个bucket位置的链表中。键对象的equals()来找到键值对。
HashMap基本结构概念图:
到目前为止,我们了解了两件事:
- 1、HashMap通过键的hashCode来快速的存取元素。
- 2、当不同的对象发生碰撞时,HashMap通过单链表来解决,将新元素加入链表表头,通过next指向原有的元素。单链表在Java中的实现就是对象的引用(复合)。
4、为什么 String、Integer 这样的 wrapper 类适合作为键?
因为 String 是 final,而且已经重写了 equals() 和 hashCode() 方法了。不可变性是必要的,因为为了要计算 hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的 hashcode 的话,那么就不能从 HashMap 中找到你想要的对象。
5、HashMap 中 hash 函数怎么是实现的?
我们可以看到,在 hashmap 中要找到某个元素,需要根据 key 的 hash 值来求得对应数组中的位置。如何计算这个位置就是 hash 算法。
前面说过,hashmap 的数据结构是数组和链表的结合,所以我们当然希望这个 hashmap 里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个。那么当我们用 hash 算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表。 所以,我们首先想到的就是把 hashcode 对数组长度取模运算。这样一来,元素的分布相对来说是比较均匀的。
但是“模”运算的消耗还是比较大的,能不能找一种更快速、消耗更小的方式?我们来看看 JDK1.8 源码是怎么做的.
1 | static final int hash(Object key) { |
简单来说就是:
- 1、高16 bit 不变,低16 bit 和高16 bit 做了一个异或(得到的 hashcode 转化为32位二进制,前16位和后16位低16 bit和6 bit做了一个异或)
- 2、(n·1) & hash = -> 得到下标
6、拉链法导致的链表过深,为什么不用二叉查找树代替而选择红黑树?为什么不一直使用红黑树?
之所以选择红黑树是为了解决二叉查找树的缺陷:
二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成层次很深的问题),遍历查找会非常慢。而红黑树在插入新数据后可能需要通过左旋、右旋、变色这些操作来保持平衡。引入红黑树就是为了查找数据快,解决链表查询深度的问题。我们知道红黑树属于平衡二叉树,为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少。所以当长度大于8的时候,会使用红黑树;如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢。
7、说说你对红黑树的见解?
- 每个节点非红即黑
- 根节点总是黑色的
- 如果节点是红色的,则它的子节点必须是黑色的(反之不一定)
- 每个叶子节点都是黑色的空节点(NIL节点)
- 从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)
8、解决 hash 碰撞还有那些办法?
1.开放地址法
开放地执法有一个公式:Hi=(H(key)+di) MOD m i=1,2,…,k(k<=m-1)其中,m为哈希表的表长。di 是产生冲突的时候的增量序列。如果di值可能为1,2,3,…m-1,称线性探测再散列。
如果di取1,则每次冲突之后,向后移动1个位置.如果di取值可能为1,-1,2,-2,4,-4,9,-9,16,-16,…kk,-kk(k<=m/2),称二次探测再散列。
如果di取值可能为伪随机数列。称伪随机探测再散列。
2.再哈希法
当发生冲突时,使用第二个、第三个、哈希函数计算地址,直到无冲突时。缺点:计算时间增加。比如上面第一次按照姓首字母进行哈希,如果产生冲突可以按照姓字母首字母第二位进行哈希,再冲突,第三位,直到不冲突为止。
3.链地址法(拉链法)
将所有关键字为同义词的记录存储在同一线性链表中。如下:
因此这种方法,可以近似的认为是筒子里面套筒子。
优点:
- 拉链法处理冲突简单,且无堆积现象,即非同义词决不会发生冲突,因此平均查找长度较短;
- 由于拉链法中各链表上的结点空间是动态申请的,故它更适合于造表前无法确定表长的情况;
- 开放定址法为减少冲突,要求装填因子α较小,故当结点规模较大时会浪费很多空间。而拉链法中可取α≥1,且结点较大时,拉链法中增加的指针域可忽略不计,因此节省空间;
- 在用拉链法构造的散列表中,删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。而对开放地址法构造的散列表,删除结点不能简单地将被删结 点的空间置为空,否则将截断在它之后填人散列表的同义词结点的查找路径。这是因为各种开放地址法中,空地址单元(即开放地址)都是查找失败的条件。因此在 用开放地址法处理冲突的散列表上执行删除操作,只能在被删结点上做删除标记,而不能真正删除结点。
缺点:
- 指针需要额外的空间,故当结点规模较小时,开放定址法较为节省空间,而若将节省的指针空间用来扩大散列表的规模,可使装填因子变小,这又减少了开放定址法中的冲突,从而提高平均查找速度。
9、如果 HashMap 的大小超过了负载因子(load factor)定义的容量怎么办?
当put时,如果发现目前的bucket占用程度已经超过了Load Factor所希望的比例,那么就会发生resize。在resize的过程,简单的说就是把bucket扩充为2倍,之后重新计算index,把节点再放到新的bucket中。resize的注释是这样描述的:
1 | Initializes or doubles table size. If null, allocates in accord with initial capacity target held in field threshold. Otherwise, because we are using power-of-two expansion, the elements from each bin must either stay at same index, or move with a power of two offset in the new table. |
怎么理解呢?例如我们从16扩展为32时,具体的变化如下所示:
因此元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
因此,我们在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。可以看看下图为16扩充为32的resize示意图:
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。
10、重新调整 HashMap 大小存在什么问题吗?
重新调整 HashMap 大小的时候,确实存在条件竞争。
因为如果两个线程都发现 HashMap 需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来。因为移动到新的 bucket 位置的时候,HashMap 并不会将元素放在链表的尾部,而是放在头部。这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。多线程的环境下不使用 HashMap。
为什么多线程会导致死循环,它是怎么发生的?
HashMap 的容量是有限的。当经过多次元素插入,使得 HashMap 达到一定饱和度时,Key 映射位置发生冲突的几率会逐渐提高。这时候, HashMap 需要扩展它的长度,也就是进行Resize。
- 扩容:创建一个新的 Entry 空数组,长度是原数组的2倍
- rehash:遍历原 Entry 数组,把所有的 Entry 重新 Hash 到新数组
详细文章参考: HashMap扩容全过程
11、HashTable
- 数组 + 链表方式存储
- 默认容量:11(质数为宜)
- put操作:首先进行索引计算 (key.hashCode() & 0x7FFFFFFF)% table.length;若在链表中找到了,则替换旧值,若未找到则继续;当总元素个数超过 容量 * 加载因子 时,扩容为原来 2 倍并重新散列;将新元素加到链表头部
- 对修改 Hashtable 内部共享数据的方法添加了 synchronized,保证线程安全
12、HashMap 与 HashTable 区别
- 默认容量不同,扩容不同
- 线程安全性:HashTable 安全
- 效率不同:HashTable 要慢,因为加锁
13、可以使用 CocurrentHashMap 来代替 Hashtable 吗?
- 我们知道 Hashtable 是 synchronized 的,但是 ConcurrentHashMap 同步性能更好,因为它仅仅根据同步级别对 map 的一部分进行上锁
- ConcurrentHashMap 当然可以代替 HashTable,但是 HashTable 提供更强的线程安全性
- 它们都可以用于多线程的环境,但是当 Hashtable 的大小增加到一定的时候,性能会急剧下降,因为迭代时需要被锁定很长的时间。由于 ConcurrentHashMap 引入了分割(segmentation),不论它变得多么大,仅仅需要锁定 Map 的某个部分,其它的线程不需要等到迭代完成才能访问 Map。简而言之,在迭代的过程中,ConcurrentHashMap 仅仅锁定 Map 的某个部分,而 Hashtable 则会锁定整个 Map
14、CocurrentHashMap(JDK 1.7)
- CocurrentHashMap 是由 Segment 数组和 HashEntry 数组和链表组成
- Segment 是基于重入锁(ReentrantLock):一个数据段竞争锁。每个 HashEntry 一个链表结构的元素,利用 Hash 算法得到索引确定归属的数据段,也就是对应到在修改时需要竞争获取的锁。ConcurrentHashMap 支持 CurrencyLevel(Segment 数组数量)的线程并发。每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment
- 核心数据如 value,以及链表都是 volatile 修饰的,保证了获取时的可见性
- 首先是通过 key 定位到 Segment,之后在对应的 Segment 中进行具体的 put 操作如下:
- 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry。
- 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value
- 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容
- 最后会解除在 1 中所获取当前 Segment 的锁。
- 虽然 HashEntry 中的 value 是用 volatile 关键词修饰的,但是并不能保证并发的原子性,所以 put 操作时仍然需要加锁处理
- 首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁。
- 尝试自旋获取锁,如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功。最后解除当前 Segment 的锁
15、CocurrentHashMap(JDK 1.8)
CocurrentHashMap 抛弃了原有的 Segment 分段锁,采用了 CAS + synchronized 来保证并发安全性。其中的 val next 都用了 volatile 修饰,保证了可见性。
最大特点是引入了 CAS
借助 Unsafe 来实现 native code。CAS有3个操作数,内存值 V、旧的预期值 A、要修改的新值 B。当且仅当预期值 A 和内存值 V 相同时,将内存值V修改为 B,否则什么都不做。Unsafe 借助 CPU 指令 cmpxchg 来实现。
CAS 使用实例
对 sizeCtl 的控制都是用 CAS 来实现的:
- -1 代表 table 正在初始化
- N 表示有 -N-1 个线程正在进行扩容操作
- 如果 table 未初始化,表示table需要初始化的大小
- 如果 table 初始化完成,表示table的容量,默认是table大小的0.75倍,用这个公式算 0.75(n – (n >>> 2))
CAS 会出现的问题:ABA
解决:对变量增加一个版本号,每次修改,版本号加 1,比较的时候比较版本号。
put 过程
- 根据 key 计算出 hashcode
- 判断是否需要进行初始化
- 通过 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功
- 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容
- 如果都不满足,则利用 synchronized 锁写入数据
- 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树
get 过程
- 根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值
- 如果是红黑树那就按照树的方式获取值
- 就不满足那就按照链表的方式遍历获取值
16、ConcurrentHashMap 在 Java 8 中存在一个 bug 会进入死循环
原因是递归创建 ConcurrentHashMap 对象,但是在 JDK 1.9 已经修复了。场景重现如下:
1 | public class ConcurrentHashMapDemo{ |
- 本文作者: th3ee9ine
- 本文链接: https://www.blog.ajie39.top/2021/05/16/Java面试之Hashmap/
- 版权声明: 本博客所有文章除特别声明外,均采用 LICENSE 下的许可协议。转载请注明出处!