【Java】HashMap原理及相关面试题
HashMap与Hashtable两个类都是通过Key-Value对存储的数据结构。
根据官方的说法,二者唯二的区别是HashMap线程不安全而Hashtable线程安全,并且HashMap允许null值而Hashtable不允许。
Hashtable实现线程安全的方式是使用synchronized
修饰方法,所以二者基本一致。由于Hashtable效率较低,所以Java官方不建议使用这个类了;单线程的情况下使用HashMap,多线程的时候使用ConcurrentHashMap。
一、数据结构
1. 结构
HashMap的本质是一系列的Key-Value对的集合,一个Key-Value对称为一个Entry。
HashMap的基本数据结构是数组。数组中的每个非空元素(被称为桶/箱)都存放了一个链表或者一棵红黑树(准确来说是它们的头节点,同时这也是一个Entry)。
一个节点放置在哪个格子里,是这么决定的:首先计算出这个节点key的哈希值h
;h
对数组长度取模,即是这个节点所在链表(或树)在数组对应的序号。
2. 扩容
数组长度初始值为16。当数组中大部分(默认是3/4)的格子中都存放了元素,说明此时哈希碰撞的几率较高了,那么就会进行扩容;每次扩容后的长度是旧长度的2倍,扩容后的长度必然是2的幂。
为什么长度要设计成2的幂?因为在哈希查找的过程中,需要对数组长度进行求模运算;而对于一个2的幂数的模数n,对其的求模运算%n
可以转换为位运算&(n-1)
,这样可以大大提高运算效率。
3. 链表和红黑树的转换
数组中的元素初始以链表的形式保存。
当链表的长度达到阈值(默认为8),这个链表就会转换为红黑树。
当红黑树的size低于阈值(默认为6),则会重新退化为链表。
二、增删改查
1. 增/改
HashMap通过put
方法添加/修改元素。put
最终会调用putVal
方法:
public V put(K key, V value) {return putVal(hash(key), key, value, false, true);}final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {Node<K,V>[] tab; Node<K,V> p; int n, i;if ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length;if ((p = tab[i = (n - 1) & hash]) == null)tab[i] = newNode(hash, key, value, null);else {Node<K,V> e; K k;if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))e = p;else if (p instanceof TreeNode)e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);else {for (int binCount = 0; ; ++binCount) {if ((e = p.next) == null) {p.next = newNode(hash, key, value, null);if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1sttreeifyBin(tab, hash);break;}if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))break;p = e;}}if (e != null) { // existing mapping for keyV oldValue = e.value;if (!onlyIfAbsent || oldValue == null)e.value = value;afterNodeAccess(e);return oldValue;}}++modCount;if (++size > threshold)resize();afterNodeInsertion(evict);return null;}
梳理一下往HashMap中放入K-V对的流程:
- 计算出key的哈希值
hash
并以数组长度为模求余,得到该K-V对应节点在数组中的存放位置; - 如果这个格子里面没有元素,那么放入以该K-V对创建的节点作为链表的头节点;放入之后数组占用率如果达到了扩容的阈值,那么就进行扩容;
- 如果这个格子中已经有元素了(检测到哈希碰撞):遍历这个链表或者红黑树,如果找到了key的哈希值与
hash
相等的节点,那么就直接更新这个节点的value
(此处为修改元素);否则就将这个新节点插入到链表或者红黑树中,其中链表插入到尾部,红黑树插入到适当的位置并进行重平衡。
2. 删
删除的过程比较简单,首先计算出待删除key的哈希值hash
并以数组长度为模求余,找到对应的链表/红黑树,遍历其节点;如果找到哈希值等于hash
的节点,那么就删除这个节点,删除方式与链表/红黑树删除节点相同。
3. 查
计算出待查找key的哈希值,并对数组长度取模,找到对应链表或红黑树;遍历链表或搜索红黑树找到节点并返回。
4. 注意
这里对key求哈希值,并不是直接调用key.hashCode()
,而是通过HashMap的hash(key)
来计算的。
hash
方法如下:
static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);}
这样比起直接调用hashCode()
能增强随机性,减少哈希碰撞的发生。
例如,对于浮点数,如果直接调用hashCode()
会造成,一个浮点数x
与2x
、4x
……2^n*x
的哈希值末22位都相同,这样显然是不愿意看到的。所以HashMap使用了这个机制来保证更高的随机性。
以下代码输出都相同:
System.out.println(new Float(1.2f).hashCode() & 0x3FFFFF);
System.out.println(new Float(2.4f).hashCode() & 0x3FFFFF);
System.out.println(new Float(4.8f).hashCode() & 0x3FFFFF);
System.out.println(new Float(9.6f).hashCode() & 0x3FFFFF);
三、相关面试题
1. HashMap的特点?结构?插入过程是什么?
HashMap的特点是使用Key-Value对存储数据,无序,增删改查很快,平均时间复杂度均为O(1)。
结构是数组+链表/红黑树,数组中的每个元素保存了链表/红黑树的头节点。
插入流程:
- 计算出key的哈希值
hash
并以数组长度为模求余,得到该K-V对应节点在数组中的存放位置; - 如果这个格子里面没有元素,那么放入以该K-V对创建的节点作为链表的头节点;放入之后数组占用率如果达到了扩容的阈值,那么就进行扩容;
- 如果这个格子中已经有元素了(检测到哈希碰撞):遍历这个链表或者红黑树,如果找到了key的哈希值与
hash
相等的节点,那么就直接更新这个节点的value
;否则就将这个新节点插入到链表或者红黑树中,其中链表插入到尾部,红黑树插入到适当的位置并进行重平衡。
2. HashMap与Hashtable的区别是什么?
- HashMap线程不安全,Hashtable线程安全。原因是Hashtable的方法都用synchronized修饰了。
- HashMap允许null值而Hashtable不允许。
- HashMap继承自
AbstractMap
,Hashtable继承自Dictionary
。
3. HashMap的扩容(resize)机制讲一下?
在没有特殊设置的情况下,HashMap内部数组table
的初始长度为16。当table
中超过3/4(即负载因子,默认值为0.75即3/4,可以修改)的元素已经存放了节点,这时表明再添加元素就比较容易产生哈希碰撞了,这个时候table
数组就需要扩容,通过resize()
方法。
新的table
数组为大于原数组长度的最小的2的幂数。由于**table
数组长度必然是2的幂**,所以新数组的长度是原数组长度的2倍。
以上为简单回答,以下为加餐:
扩容之后,节点的位置需要重新排布,放到正确的位置组成新的链表/红黑树,那么这些节点应该放在什么地方呢?
这里使用i
表示这个节点在原数组中的序号,newI
表示节点在新数组中的序号。
从原理上来说,假设旧的数组长度为oldCap
,新的数组长度为newCap
且为旧长度的2倍。那么对于一个Entrye
来说,其在新数组中的坐标newI = e.hash % newCap
。一般情况下,问题本应该就此解决了,但是由于求模运算的效率不高,为了避免取模运算,Java采用了一种精妙的方式……
由求模运算的原理可以知道,i
和newI
二者之间的关系是:newI == newI < oldCap ? i : i + oldCap
;
所以,newI = e.hash % newCap
等价于:
if(e.hash % newCap < oldCap) {newI = i;
} else {newI = i + oldCap;
}
以旧长8,新长16为例。
假设两个节点e1
和e2
,哈希值分别为17和25。因为对8的余数都是1,所以它们被保存在了旧数组坐标为1的元素中。当数组扩容到16时,二者对16的余数变成了1和9,所以二者在新数组中的坐标变成了1和9,相当于e1
留在原地,e2
往右移动了8位。
而由于oldCap
和newCap
是2的幂数,所以e.hash % newCap
又等价于e.hash & (newCap - 1)
。到了这里,就把效率低的求模运算替换为了效率极高的位运算:
if((e.hash & (newCap - 1)) < oldCap) {newI = i;
} else {newI = i + oldCap;
}
又由于newCap
是oldCap
的2倍,也即是oldCap
左移一位,e.hash & (newCap - 1)
表示了e.hash
末尾n位的值,其中n为oldCap
的二进制位数。
又由于oldCap
是二进制长度为n的最小数,如果e.hash
二进制倒数第n位是1的话,那么它必然大于等于oldCap
;故而**(e.hash & (newCap - 1)) < oldCap
等价于:e.hash
二进制倒数第n位为0。**而e.hash
的倒数第n位为0,又等价于e.hash & oldCap == 0
,所以上面的代码等价于:
if((e.hash & oldCap) == 0) {newI = i;
} else {newI = i + oldCap;
}
这就是Java源码中的算法。
总结来说一句话,如果节点哈希值对新数组长度求模小于旧数组长度
的话,那么节点就在原地不动;否则,节点往右移动旧数组长度
位。
杠精面试官:为什么负载因子是0.75而不是0.7、0.8?为什么扩容是2倍而不是1.5、2.5?
答:0.75是随手瞎jb设的,扩容2倍为了方便求模转换为位运算,如果数组长度不是2的幂,e.hash & oldCap
这一步就不成立了。
4. 哈希值相同的两个对象equals一定返回true吗?反之呢?
Object类的 hashCode
是一个 native 方法。其包含三个约定:
- 在一次程序执行期间,如果一个对象
equals
中所比较的信息没有改变,那么hashCode
必须返回相同值;- 如果两个对象
equals
返回 true,那么hashCode
必须相等;- 如果两个对象
equals
返回 false,那么hashCode
不需要不相等;但是为了提高 HashMap 的效率,最好不要让不同对象返回相同哈希值。
对于Object的默认实现来说,哈希值相同的两个对象,equals
不一定返回true,这表明产生了哈希碰撞;equals
返回true的两个对象,哈希值一定相等。
对于自行实现了equals
或hashCode
方法的类,以实现方法为准。比如我非要把equals
方法重写为return o != null && hashCode() == o.hashCode()
,虽然这违反了推荐的规范,但是是可行的。
5. 为什么Java8引入了红黑树?它的优缺点是什么?
哈希碰撞是无可避免的。当最坏情况发生时,大量的节点累计在一个桶——即数组中的同一项——内。这时,HashMap的增删改查效率退化到O(n),n为链表的长度。
在链表的长度达到8时,链表会转换为红黑树。红黑树的优势为查找效率为O(logn),在冲突数据量大时效率优于链表。劣势为在建树和拆分的时候需要额外的时间、操作小步骤多、占用内存空间大,所以只有在链表长度长到足以抵消红黑树的劣势时,才进行转换。
当数组长度小于64时,即使链表长度达到8,HashMap也会优先考虑扩容而不是转红黑树。
事实上,根据概率论,在正常情况下,**如果哈希值分布比较均匀,几乎不可能出现链表转为红黑树的情况。**红黑树只是为了针对由于失误或者故意等情况造成的哈希分布不均,比如类的hashCode()
方法直接返回常数。
6. HashMap线程安全吗?为什么?如何解决?
HashMap线程不安全。
原因和其他线程不安全相同,多线程操作会导致读写数据异常。
使用ConcurrentHashMap代替,或使用Collections.synchronizedMap(hashMap)
包装类,或者操作时加锁,或者使用Hashtable。
7. Java7和Java8中HashMap的不同点?
Java8中加入了红黑树;
Java7链表插入方式为头插法,Java8为尾插法。
扩容时,Java7对每个元素都重新放置,Java8只对e.hash & oldCap
不为0的元素移动位置。
【Java】HashMap原理及相关面试题相关推荐
- JAVA并发与多线程相关面试题总结
JAVA并发与多线程相关面试题总结 1.什么是进程.线程.协程,它们之间的关系是怎样的? 进程: 本质上是一个独立执行的程序,是计算机中的程序关于数据集合上的一次运行活动,进程是操作系统进行资源分配和 ...
- Mybatis执行流程、缓存原理以及相关面试题
一.Mybatis执行流程 具体分析Mybatis是如何操作数据库的! . 1.定义我们的核心配置文件的路径,这个路径是从target/classes下开始找的! String config = &q ...
- Java HashMap 原理
原文:https://tryenough.com/java-hashmap 本文涉及HashMap的: HashMap的简单使用 HashMap的存储结构原理 HashMap的扩容方法原理 HashM ...
- Java HashMap原理及内部存储结构
本文将通过如下简单的代码来分析HashMap的内部数据结构的变化过程. public static void main(String[] args) {Map<String, String> ...
- Java Web 相关面试题总结
为什么80%的码农都做不了架构师?>>> Spring 相关的的面试题 1.什么是 IoC 容器? 2.解释下 Spring Bean 的生命周期 IoC 容器是 Spring ...
- 多线程的实现方式_一文搞懂Java多线程使用方式、实现原理以及常见面试题
本系列文章将整理到我在GitHub上的<Java面试指南>仓库,更多精彩内容请到我的仓库里查看 https://github.com/h2pl/Java-Tutorial 喜欢的话麻烦点下 ...
- Hashmap 原理、源码、面试题(史上最全)
文章很长,建议收藏起来慢慢读!疯狂创客圈总目录 语雀版 | 总目录 码云版| 总目录 博客园版 为您奉上珍贵的学习资源 : 免费赠送 :<尼恩Java面试宝典>持续更新+ 史上最全 + 面 ...
- 使用线程锁(lock)实现线程同步_一文搞懂Java多线程使用方式、实现原理以及常见面试题...
本系列文章将整理到我在GitHub上的<Java面试指南>仓库,更多精彩内容请到我的仓库里查看 https://github.com/h2pl/Java-Tutorial 喜欢的话麻烦点下 ...
- java springcloud面试题_JAVA语言之springboot+springcloud相关面试题
本文主要向大家介绍了JAVA语言之springboot+springcloud相关面试题,通过具体的内容向大家展示,希望对大家学习JAVA语言有所帮助. 什么是springboot 用来简化sprin ...
最新文章
- VSLAM|回环检测之词袋字典效果测试及一点思考
- python所有函数用法_python函数用法总结
- 自研DevOps实践
- Robert算子的运用
- 最全解释P2P、P2C 、O2O 、B2C、B2B、 C2C的定义
- HTML 5入门学习,源码中全部使用HTML 5标签
- 鲲鹏920 centos7 postgresql12 postgis2.5.4编译
- 10以内的分解与组成怎么教_【一年级数学】(上)10以内的分与合技巧及练习题...
- 《目标检测》YOLO、SSD简单学习
- idea 设置jdk_IDEA开发小技巧~jdk问题
- Netty工作笔记0017---Channel和Buffer梳理
- JAVA创建对象有哪几种方式
- PHP实现生成二维码的示例代码
- IT 程序员、软件工程师值得考的证书有哪些?有什么证书可以考?
- 【HAVENT原创】使用 Spring Boot 的 AOP 全局记录执行时间日志
- 巴厘岛最全的美食攻略
- 基于Python的淘宝用户行为分析
- MSM8953_Linux-Android软件手册资料
- 苹果屏蔽更新描述文件_iOS屏蔽更新描述文件以及超级详细安装方法分享
- python图片保存pdf_python将JPG图片转换为PDF
热门文章
- 解决mac电脑打开应用“意外退出”的问题
- HFSS学习笔记——Vivaldi天线仿真(一)
- Android 网络 --framework层面 -two
- Linux之cp命令详解
- 常用的JavaScript保留小数方法
- 2021五款高性价比游戏蓝牙耳机推荐,喜欢吃鸡王者的小姐姐不要错过哦~
- 科沃斯扫地机器人电池哪里买_科沃斯扫地机器人的电池是什么电池
- php将数组变成字符串,php怎样将数组转成字符串
- 计算机桌面常见故障,电脑桌面图标常见问题
- 【RK3399 Ubuntu】基于rk3399的lxde的lubuntu桌面移植适配记录之第三步:Ubuntu根文件系统的桌面化分析和选用