HashMap 了解吗?平时在什么地方使用过它呢?
HashMap 也就是哈希表,底层利用数组支持下标随机访问数据的特性,快速的对键值对进行增删改查操作。
HashMap 底层数据结构说一下?
在最新的 JDK 1.8 中,HashMap 的底层数据结构为 “哈希表 + 链表 + 红黑树”。当哈希表中出现哈希冲突时,HashMap 采用 “链地址法” 来解决,也就是哈希表中的每个槽位,都会对应一个链表,所有哈希值相同的元素都会被放到同一个槽位对应的链表中。但随着链表长度的增加,元素的读取效率会下降,直到达到某个阈值时(目前JDK是8),并且哈希表的容量大于或等于64,HashMap 会将链表转化为红黑树,进一步提升性能。
如果链表大于8,但是哈希表容量小于64,此时会转化为红黑树吗
在 JDK 1.8 中,如果一个桶中链表的长度大于 8 但是 HashMap 的总容量小于 64,此时不会将链表转换为红黑树。相反,HashMap 会先尝试扩容整个表以减少单个桶中的元素数量,从而降低哈希冲突的概率。
这样的设计是出于性能考虑。当 HashMap 的容量较小时,扩容对于减少哈希冲突更有效,且成本较低。而当 HashMap 的容量较大时,单个桶中元素数量增多的概率提升,此时使用红黑树来优化搜索效率更为合适。因此,JDK 设计者选择了这种折衷的方式来平衡性能和资源使用。
为什么用红黑树呢?用平衡二叉树不可以吗?或者你讲一讲他们各自的优缺点吗?
红黑树是弱平衡二叉树,整棵树可以有局部的不平衡。AVL 树是强平衡二叉树,它严格要求整棵树的平衡性。也就是说,虽然两者的插入,删除复杂度都为 O(logn),实际中 AVL 树需要执行更多的旋转操作来保证强平衡性,效率要低于红黑树。但红黑树也有缺点,它需要额外的字段来记录每个节点的颜色,因此会占用更多的存储空间。
为什么选择 8 之后转为红黑树呢?另外链表转为红黑树之后,还会继续转为链表吗?
这个在源码的注释中有解释,大致意思为:如果元素的哈希值足够随机,理想情况下链表的长度对应的概率符合泊松分布,达到 8 的概率小于千万分之一。也就是说,一般情况下并不会发生链表到红黑树的转化,更多是一种防止自己选取的哈希算法不好的保底策略,在极端情况下仍会有较好的效率。
但是,当红黑树的节点小于 6 时,红黑树又会转回链表,原因是数据量很小的情况下,空间和时间上链表都要比红黑树优秀。至于为什么要把这个阈值定为 6,而不同样定为 8,主要是而为了防止元素数量在 8 附近导致两种数据结构的频繁转换。
简单描述下 put 的流程?可以说一下JDK位了效率更快,在 put 的时候,做了哪些优化不?
首先 put( ) 会计算出要插入 key 的哈希值,通过哈希值计算出其在数组中的索引位置,如果该位置上没有元素则直接插入,有元素则需要遍历这个位置上的所有元素。如果能找到与当前键相等的键值对,则将其更新为当前值并返回旧值,如果找不到与当前键相等的键值对,则需要执行真正的插入操作,将其插入到链表或者红黑树中,最后判断插入后是否需要扩容。
put( ) 的优化我印象深的是计算 key 哈希值的 hash( ),主要有两个优化的点:使用位运算代替取模运算和对 hashCode 进行搅动计算。具体来说,可以用x这个公式将取模转变为位运算来提升性能,但是同时也需要底层数组的长度是 2 的倍数,这个在 HashMap 的初始化和扩容方法中做了保证。
除此之外,为了进一步降低哈希冲突的概率,hash( ) 又通过多个与运算将哈希值的高位和低位进行搅动,尽可能的做到在不同 key 中哪怕有一个位的不同,都会对最终产生的哈希值造成影响。
多线程情况下,put 是线程安全的吗?可以简单举个例子,说一下哪里不安全吗?
不是,在 JDK 1.7 中多线程同时进行 put( ) 会出现数据覆盖问题,在需要扩容时也可能会出现链死循环问题。JDK 1.8 修复了链死循环,但数据覆盖问题依然存在。
JDK 1.7 的 HashMap 底层为数组 + 链表,扩容的 transfer( ) 会遍历原链表中的每个节点,采用头插法将其转移到新哈希表槽位的链表中,这个过程在多线程下会导致新链表中出现环路,并造成某些元素丢失。
JDK 1.8 采用的是尾插法,保证了元素在扩容前后的顺序一致,避免了死循环问题,但还会造成数据覆盖。比如两个线程同时执行 put( ),且两个线程都同时判断槽位为空,则后插入的数据会覆盖先插入的数据。
如果我想要让 hashmap 变成线程安全的,你觉得可以怎么做?
想要解决 HashMap 的线程不安全问题,首先我们不能修改源码,那就要么使用一些 “辅助” 操作,让它变得安全,要么就寻找替代品。首先说的 “辅助” 操作是指,使用 Collections 类的 synchronizedMap 方法包装一下,它返回由指定映射支持的同步映射,是线程安全的。换替代品的话,可以考虑 HashTable,HashTable 通过将整个表上锁来实现线程安全,某些情况下效率很低。还可以使用 ConcurrentHashMap,它使用分段锁或者 CAS 操作来保证线程安全。
头插法会导致死循环,那你觉得在以前的版本中,为啥会使用头插法呢?
采用头插法的话,最新插入的数据就会在链表的最前边,根据程序的局部性原理,最近被访问的数据很可能不久之后会再次访问,那么此时可以在 O(1) 时间返回。
那我们再说一说 HashMap 的扩容吧,什么时候会扩容呢?你觉得为啥负载因子为啥选择 0.75 呢?
HashMap 需要扩容时,可以分为几种情况来考虑。
首先是在无参的构造函数中。在第一次进行 put 操作之前,HashMap 内部数组为 null,第一次 put 后才会开始第一次初始化扩容,默认为 16 。
其次是指定了初始容量的构造参数,也是在第一次 put 操作之后才开始初始化扩容,但此时的容量是第一个不小于指定容量的 2 的幂数,阈值为计算后容量乘负载因子。
其它情况就是,非首次 put,导致容量大于阈值(16*0.75=12),需要扩容。容量和阈值都变为原来的 2 倍,负载因子不变。
负载因子为 0,75 的原因,简单来说是 “哈希冲突” 和 “空间利用率“ 矛盾的一个折中。原因是,扩容因子是用来计算阈值的,阈值为底层 table 长度乘负载因子,当 HashMap 容量大于阈值时会触发扩容。所以如果负载因子过小,table 中还没填几个元素就要扩容,虽然哈希冲突概率很小,但空间浪费太多。相反,如果负载因子过大,空间利用率是高,但哈希冲突的概率也大大增加。那就取个折中吧,为 0.75。
频繁扩容会导致效率比较低下,那你觉得在平时,在实际的开发场景中,可以怎么优化来避免频繁扩容呢?
容易想到的就是,提前预估业务的存储量,设置一个较大的初始容量。这时不用考虑它是否是 2 的次幂,HashMap 自己会计算出第一个大于等于给定容量的 2 次幂来作为初始容量。除此之外,可以自定义负载因子的大小,对哈希函数优化等等。
一个场景题:只存60个键值对,需要设置初始化容量吗?设置的话设置多少初始化容量比较好呢?
HashMap 默认的初始容量大小为 16。如果不设置初始容量的话,根据规则 size > threshold 时会触发扩容,且 threshold = loadFactor *capacitry,最终 capacity 会经历 16 – 32 – 64 – 128 三次扩容操作。考虑到HashMap 自己会计算出第一个大于等于给定容量的 2 次幂来作为初始容量,所以随机选一个 65 – 128 之间的数作为初始容量即可。
HashTable和HashMap
HashTable
线程安全:HashTable 是线程安全的,所有的方法都是同步的,这意味着它在多线程环境下是安全的,但这也导致了性能上的开销。
键和值:HashTable 不允许键(Key)或值(Value)为 null。
HashMap
非线程安全:HashMap 默认情况下不是线程安全的。如果需要在多线程环境中使用 HashMap,则必须通过外部同步机制来保证线程安全,例如使用 Collections.synchronizedMap() 包装 HashMap。
键和值:HashMap 允许一个 null 键和多个 null 值。
性能:由于非同步,HashMap 通常比 HashTable 有更好的性能。
内部结构:从 Java 1.8 开始,HashMap 在处理冲突时使用链表和红黑树,以提高性能。
请你说一说 ArrayList 和 LinkedList 区别?
ArrayList 底层是用数组实现的,根据索引访问元素,使得查询的复杂度仅为 O(1)。但在插入和删除时有数组的复制和移动,复杂度为 O(n);
LinkedList 底层使用双向链表实现的,由于每个节点都含有前驱和后继节点的引用,所以它插入删除时只需修改这些引用,效率要比 ArrayList 高 。但在查询元素时需要从头节点开始依次遍历整个链表,时间复杂度为 O(n)
如果我要删除第 k 个元素,也就是会执行 remove(k),那么这个 remove 的操作,它们的时间复杂度各自是多少?
都是 O(n) 。
ArrayList 首先会以 O(1) 时间定位到第 k 个元素,然后将被这个元素分割的两部分复制拼接到一个新数组上,总体为 O(n) 。
LinkedList 首先以 O(n) 时间定位到第 k 个元素,然后 O(1) 时间处理这个元素前后节点的引用,总体也为 O(n) 。
可以说一说它们的使用场景吗?或者说一说你平时在处理什么事情的时候,用过它们?
ArrayList
ArrayList 是基于动态数组的实现,它在随机访问元素方面非常高效。因此,当你需要频繁读取列表中的元素时,ArrayList 是一个很好的选择。但是,由于它是数组基础上的实现,插入和删除操作(尤其是在列表的中间)可能比较慢,因为这可能涉及到移动元素以保持数组的连续性。
使用场景:
频繁地按索引读取元素。
列表大小经常变化,但大部分时间列表不会进行大量的插入和删除操作。
需要存储大量元素,而且元素的大小在创建时相对明确。
LinkedList
LinkedList 基于双向链表实现。它在插入和删除操作时更加高效,特别是对于列表的开头或结尾的操作,因为这些操作不需要像数组那样移动其他元素。但是,LinkedList 在随机访问方面性能较低,因为需要从头开始或从尾开始遍历链表来访问特定索引的元素。
使用场景:
需要频繁地在列表的头部或尾部添加或删除元素。
在列表中间插入和删除元素的操作比较频繁。
不需要频繁地随机访问元素。
ArrayList 的具体使用例子
数据分析应用:在一个数据分析应用中,我需要存储从数据库中检索出的数千条记录,并且频繁地根据索引查询特定记录。在这种情况下,我选择 ArrayList,因为它提供了快速的随机访问能力。
电子商务网站的商品展示:在开发一个电子商务网站时,我需要展示商品列表。这些商品数据一旦加载,很少进行修改,但经常需要根据用户的滚动或过滤操作来访问特定商品。在这种场景下,ArrayList 是更好的选择。
LinkedList 的具体使用例子
消息队列系统:在开发一个消息处理系统时,我需要一个队列来处理传入的消息。由于消息队列需要频繁地添加和删除消息(特别是在队列的头部和尾部),LinkedList 是更合适的选择。
动态操作的游戏应用:在一个游戏应用中,我有一个角色列表,这个列表经常根据游戏事件动态添加或移除角色。在这种场景下,由于需要频繁地在列表中间插入或删除元素,我选择 LinkedList
ArrayList 底层实现是数组,数组就会有容量限制,可以简单说一下 ArrayList 的扩容机制吗?
ArrayList 的默认容量为 10,当需要扩容时,会先申请一个容量为旧容量 1.5 倍的新数组,然后把旧数组复制到新数组中。值得注意的是,JDK 1.8 中 ArrayList 底层数组的最大容量为 Integer.MAX_VALUE – 8 ,目的是防止某些虚拟机会在数组中存一些额外的信息导致内存溢出。
counrrenthashmap 是如何实现线程安全的?可以简单说一下为了效率更快,比起 HashTable,counrrenthashmap 作了哪些优化吗?
JDK 1.7 中 ConcurrentHashMap 使用了分段锁来实现线程安全。它的底层是一个 Segment 数组,每个 Segment 通过继承 ReetrantLock 来控制自己这部分的加锁。其中每个 Segment 就类似一个 HashTable,这样只要保证每个 Segment 是线程安全的,就能确保整个哈希表也是安全的了。
JDK 1.8 为了摆脱哈希表中 Segment 个数对并发度的限制,底层采用和 HashMap 类似的实现:数组 + 链表 + 红黑树,加锁用 CAS 和 synchronized 实现。具体来说,在进行 put 操作时,如果槽位为空,则使用 CAS 插入新节点。如果槽位不为空,则需要进一步判断其它线程是否在对其扩容,是则协助扩容,不是则使用 synchronized 锁住当前槽位,再进行插入节点操作。
ConcurrentHashMap 线程安全的实现
分段锁(Segmentation):在 Java 8 之前,ConcurrentHashMap 使用分段锁技术。它将数据分成一段段,每段都有自己的锁。当一个线程访问一部分数据时,只需锁定相应的段,而不是整个映射。这减少了线程间的竞争,提高了并发效率。
CAS 操作和锁粒度降低:从 Java 8 开始,ConcurrentHashMap 改进了其内部结构,使用了一种基于节点的锁定策略,通过使用比较并交换(CAS)操作和同步代码块来减少锁的粒度,这大大提高了并发访问时的效率。
无锁读取:ConcurrentHashMap 允许多个读操作无锁并行执行,这意味着读操作通常不会被写操作所阻塞,这提高了在高读取比例的应用中的性能。
相较于 HashTable 的优化
锁的粒度:HashTable 对整个数据结构使用单一的全局锁,无论是读取还是写入操作,都需要锁定整个映射,这在多线程环境中导致严重的性能瓶颈。相比之下,ConcurrentHashMap 的分段锁或细粒度锁大大减少了锁竞争,提高了性能。
读取效率:由于 HashTable 对所有操作都加锁,这意味着即使是并发读取也会被阻塞。而 ConcurrentHashMap 允许多线程同时读取,无需等待锁,提高了读取效率。
扩容效率:在扩容时,ConcurrentHashMap 也采用了优化的策略,它允许部分线程继续读写操作,而不是像 HashTable 那样在扩容期间阻塞所有操作。
平时我们会经常使用 HashMap,但是 counrrenthashmap 很少使用到,你可以简单说一下什么样的场景下使用 counrrenthashmap 吗?
场景描述
假设你正在开发一个电子商务平台,该平台允许数千名用户同时在线浏览商品、添加商品到购物车、进行结账等操作。在这个平台中,有一个关键的组件是用户的购物车管理系统,它需要实时跟踪每个用户的购物车内容。
问题
对于每个在线用户,系统需要跟踪其购物车中的商品。考虑到高用户并发量,需要一种有效的方式来存储和访问这些购物车数据,同时保证数据的一致性和线程安全。
为什么选择 ConcurrentHashMap
高并发访问:由于电商网站可能同时有成千上万的用户进行操作,因此购物车系统将面临高并发的读写请求。
线程安全:购物车内容的更新(添加商品、删除商品)必须是线程安全的,以防止数据冲突或损坏。
性能要求:购物车的读取和更新操作需要快速响应,以提供良好的用户体验。
动态内容:用户可能随时添加或删除购物车中的商品,因此数据结构需要高效地支持动态更改。
在这个例子中,ConcurrentHashMap 被用于存储每个用户ID及其对应的 ShoppingCart 对象。由于 ConcurrentHashMap 提供了线程安全的读写访问,所以即使在高并发的情况下,也能有效地管理每个用户的购物车数据。此外,通过利用 ConcurrentHashMap 的 computeIfAbsent 方法,可以高效地处理新用户的购物车创建和现有用户的购物车更新。
简单说一下 list 和 set 的区别?以及使用场景?
两者都继承自 Collection,都是用来存储数据的集合。其中 List 接口会维护元素的插入顺序,并且允许根据索引进行数据查询和操作。而 Set 接口只强调元素的不可重复性,不保证元素的特定顺序,也不支持索引查询。
List 接口常见的实现类有 ArrayList,LinkedList 等,前者底层为数组,适合随机访问多的场景。后者底层为双向链表,适合插入删除操作多的场景。
Set 接口常见的实现类有 HashSet,TreeSet 等,前者底层为哈希表,提供快速的插入,删除和查找性能,但不保证元素的顺序。后者底层为红黑树,元素间可以使用自定义比较器进行排序。