Java集合面试总结
数组与集合区别
- 数组长度一旦创建不可改变,而集合长度试试可变的。
- 数组可以包含基本数据类型和引用数据类型,而集合只能包含引用数据类型。
- 数组可以通过索引直接访问元素;集合中的
List
类型也支持通过索引访问,而其他集合(如Set
、Map
)则需要通过迭代器、增强for
循环、forEach
或流来访问元素。
一些常见的集合类???索引规则
- ArrayList:基于动态数组实现。内部维护一个可自动扩容的数组,当元素数量超过容量时,会创建一个更大的新数组并复制原有元素。支持随机访问(通过索引直接访问元素),时间复杂度为 O(1)。插入和删除操作可能需要移动大量元素,尤其是在数组中间或开头操作时,时间复杂度为 O(n)。
- LinkedList:基于双向链表实现。每个元素(节点)包含数据、前驱节点和后继节点的引用。插入和删除操作效率高(只需修改指针),时间复杂度为 O(1)(前提是已知节点位置)。不支持随机访问,访问元素需要从头或尾遍历链表,时间复杂度为 O(n)。
- HashSet:使用
HashMap
存储元素,元素作为键(key),值(value)统一为PRESENT
(静态常量)。通过哈希码(hashCode()
)和equals()
方法保证元素唯一性,不保证元素的插入顺序,遍历时顺序可能随机变化。插入、删除、查找操作的时间复杂度均为 O(1)(平均情况)。 - LinkedHashSet:继承自
HashSet
,但使用 哈希表 + 双向链表 维护元素的插入顺序(或访问顺序)。链表记录元素的插入顺序,遍历时按链表顺序返回。插入、删除、查找操作的时间复杂度仍为 O(1),但略慢于HashSet
(因维护链表开销) - TreeSet:使用
TreeMap
存储元素,基于 红黑树(自平衡二叉搜索树) 实现。元素必须实现Comparable
接口,或在构造时传入Comparator
,以确定元素的排序规则。元素按自然顺序(如String
、Integer
的字典序)或自定义比较器排序。插入、删除、查找操作的时间复杂度为 O(log n),但支持有序遍历。 - Hashmap:数组 + 链表 + 红黑树(当链表长度超过 8 且数组长度 ≥ 64 时,链表转换为红黑树)。当元素数量超过
容量 × 负载因子(0.75)
时,数组扩容为原来的 2 倍(避免重新计算哈希,只需判断高位)。 - LinkedHashmap:LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。
- ConcurrentHashMap:底层同样由数组和链表或红黑树组成,线程安全的,jdk7以前用分段锁,jdk8之后用CAS。
- TreeMap:底层由红黑树实现,可以自定义比较器排序。
- HashTable:数组+链表组成,数组是体,链表则是为了解决哈希冲突。
什么是CAS
CAS(Compare-and-Swap)是一种无锁的原子操作技术,用于在多线程环境下实现变量的线程安全更新,广泛应用于高性能并发编程。其核心机制是通过三个参数(内存地址 V、预期值 A、新值 B)实现原子性的 “比较 - 交换” 操作:首先读取内存地址 V 的当前值,将其与预期值 A 比较,若相等则原子性地将 V 的值更新为 B,否则操作失败(通常需重试)。会出现ABA问题并且在高并发场景下会频繁自旋消耗CPU。
常见的线程安全的List集合
- Vector:
Vector
是同步的(Synchronized),所有公共方法(如add()
、get()
)都由synchronized
关键字修饰,确保在多线程环境下同一时间只有一个线程能操作列表,由于同步带来的开销,Vector
在单线程环境下性能较差。每次方法调用都需要获取锁,即使没有线程竞争。 - CopyOnWriteArrayList:当执行写操作(如
add()
、remove()
)时,CopyOnWriteArrayList 会先复制原数组,在新数组上进行修改操作,而非原数组。此时,其他线程的读操作仍访问原数组,不受影响。当修改完成后,CopyOnWriteArrayList 会通过原子操作将新数组替换原数组,后续的读操作将访问到最新数据。由于读操作无需加锁,且写操作在副本上进行,因此 CopyOnWriteArrayList 能保证线程安全,同时在读多写少的场景中提供出色性能
把ArrayLis变为线程安全的方法有哪些?
- 使用Collections类的synchronizedList方法将ArrayList包装成线程安全的Lis。
- 使用CopyOnWriteArrayList类代替ArrayList,它是一个线程安全的List实现。
- 使用Vector类代替ArrayList,Vector是线程安全的ist实现。
- 通过显示操作保护关键代码块
- 使用并发框架。例如ConcurrentLinkedQueue。
为什么ArrayLis是线程不安全的?会发生什么
-
部分值为null:
初始场景(以 add 操作为例):
- 初始状态:容量 10,
size=9
(索引 0~8 有值,索引 9 为null
)。 - 线程 1 执行
add(value1)
:- 检查
size=9 < 10
,无需扩容,向索引 9 写入value1
。 - 未执行
size++
时被挂起。
- 检查
- 线程 2 执行
add(value2)
:- 检查
size=9 < 10
,向索引 9 写入value2
(覆盖value1
)。 - 执行
size++
,size=10
。
- 检查
- 线程 1 恢复,执行
size++
,size=11
。 - 此时容量已扩容为 15(因
size=10
时触发扩容),数组索引 0~14 有效,索引 9 的值为value2
,索引 10~14 为null
。
- 初始状态:容量 10,
-
索引越界异常:
初始状态:
capacity=10
,size=9
,线程 1 和线程 2 同时执行add(element)
- 线程 1 执行流程:
- 读取
size=9
,判断9 < 10
,准备放入elementData[9]
- CPU 调度切换,未执行
elementData[9] = element
和size++
- 读取
- 线程 2 执行流程:
- 读取
size=9
,判断9 < 10
,放入elementData[9]
- 执行
size++
,此时size=10
- 读取
- 线程 1 恢复执行:
- 此时
size
已被线程 2 改为 10,但线程 1 仍按原逻辑执行elementData[size]
- 即尝试访问
elementData[10]
(数组最大索引为 9),直接触发IndexOutOfBoundsException
- 注意:此时还未执行
size++
,越界发生在size=10
时,而非size=11
- 此时
- 线程 1 执行流程:
-
size与我们add的数量不符合:
size++
非原子:实际是size = size + 1
,包含三个字节码指令(读取、加 1、写入)。无同步保护:多线程并发执行
add()
时,可能交叉执行上述步骤,导致size
更新丢失。线程A 线程B GETFIELD size(0) GETFIELD size(0) ICONST_1 IADD ICONST_1 IADD PUTFIELD size(1) PUTFIELD size(1) // 两次自增后size=1(本应=2)
ArrayList的扩容机制
- 计算新的容量:一般情况下,新的容量会扩大为原容量的1.5倍(在DK10之后,扩容策略做了调整),
- 然后检查是否超过了最大容量限制。
- 创建新的数组:根据计算得到的新容量,创建一个新的更大的数组。
- 将元素复制:将原来数组中的元素逐个复制到新数组中。
- 更新引用:将ArrayListl内部指向原数组的引用指向新数组。
- 完成扩容:扩容完成后,可以继续添加新元素。
哈希冲突的解决方法有哪些?
-
开放寻址法:
- 线性探测法:当冲突发生时,从冲突位置开始,依次向后探测,直至找到空闲位置。容易产生聚集现象,即连续多个元素占据相邻位置,查找效率低。
- 二次探测法:冲突时,探测位置按
hash(key) + i²
计算(i=1,2,3…),首次冲突后尝试hash+1
,再次冲突尝试hash+4
,第三次尝试hash+9
等,减少了聚集现象,但是因为无法覆盖所有位置,会导致空间浪费。
-
链地址法:哈希表的每个位置维护一个链表,当冲突发生时,将元素插入到对应位置的链表中,若多个键映射到同一位置,则该位置的链表依次存储这些元素,链表过长时,查找效率下降(最坏情况 O (n)),需额外空间存储链表节点。当链表长度超过阈值时,转换为红黑树等平衡树结构(如 Java 的 HashMap),将查找复杂度降为 O (log n)
-
再哈希法:准备多个哈希函数,当第一个函数产生冲突时,依次尝试后续函数计算新的哈希值。探测序列更随机,减少聚集。
-
建立公共溢出区:将哈希表分为基本表和溢出表。当基本表发生冲突时,将元素存入溢出表。
-
动态扩容:当哈希表的负载因子超过阈值(如 0.75)时,创建更大的哈希表,并将所有元素重新哈希到新表中,降低负载因子,减少冲突概率,保持平均查找效率为 O (1)。扩容操作耗时(需重新哈希所有元素),属于 “以空间换时间”。
JDK1.7HashMap链表死循环问题
JDK 1.7 的 HashMap
在多线程扩容时可能因链表操作的线程不安全导致死循环。例如,原链表为 A→B→C
,当两个线程同时扩容时:线程1先读取头节点 A
并记录 next=B
,此时被暂停;线程2完成扩容后链表变为 C→B→A
;线程1恢复后继续迁移,将 A
、B
、C
依次头插到新链表中,形成 C→B→A
,但此时 A
的 next
仍指向 B
(线程2修改的结果未被线程1感知),导致循环 C→B→A→B→A→...
。JDK 1.8 通过尾插法避免了链表反转,从根本上解决了这一问题。
注意点:
为什么是cbaba…一直循环下去?因为线程1在暂停时,保存了a.next=b,会一直保存下去,除非他主动结束或者修改,并且此时线程2保存了b.next=a,导致bababa一直循环下去。
HashMap的Put过程
- 根据要添加的键的哈希码计算在数组中的位置(索引)。
- 检查该位置是否为空(即没有键值对存在),如果为空,则直接在该位置创建一个新的Entry对象来存储键值对。
- 如果该位置已经存在其他键值对,检查该位置的第一个键值对的哈希码和键的内容以及键的引用是否与要添加的键值对相同?相同则覆盖旧值。
- 如果不相同,则需要遍历链表或红黑树来查找是否有相同的键,然后覆盖旧值,没有找到则添加在尾端。
- 检查链表长度是否达到阈值(默认为8),如果达到阈值,并且HashMap数组长度大于等64,则将链表变成红黑树。
- 检查负载因子是否超过阈值(默认为0.75),如果键值对的数量(size)与数组的长度的比值大于阈值,则需要进行扩容操作。
- 扩容操作:创建一个新的两倍大小的数组。 将旧数组中的键值对重新计算哈希码并分配到新数组中的位置。 更新HashMap的数组引用和阈值参数。
- 完成添加操作。
HashMap调用get会发生的异常
- 空指针异常:如果使用null作为键,并且hashmap没有被初始化,则会发生空指针异常,反之则是允许的。
- 线程安全:一个线程调用get读取数据,而另一个线程同时进行修改操作,则会导致错误的结果或者抛出ConcurrentModificationException。
HashMap的Get过程
HashMap的get方法执行时,首先计算键的哈希值(通过将键的hashCode与高16位异或),再根据哈希值与数组长度取模确定桶位置;若桶为空则直接返回null,否则检查首节点,若匹配则直接返回其值,否则根据节点类型处理:若是红黑树则调用树的查找方法(通过哈希值和键的比较快速定位),若是链表则遍历比较每个节点的哈希值和键,找到则返回对应值,否则返回null。
为什么Strin适合做key
String适合作为HashMap的键,因其具有不可变性(保证哈希值固定,避免插入和查找时的不一致)、正确重写的hashCode和equals方法(基于内容比较且哈希分布均匀,减少冲突)、线程安全(多线程环境下无需担心值被修改),以及常量池优化(相同字面量共享对象,节省内存并加速查找,可加速引用比较),这些特性与HashMap的设计机制高度契合,能确保键的唯一性和查找效率。
为什么HashMap要用红黑树而不是平衡二叉树?
平衡二叉树任何节点的左右子树的高度差不超过1,导致每次插入或删除节点使都会破坏平衡树的第二个规则,此时需要左旋和右旋来进行调整,是一种强平衡。红黑树是一种弱平衡状态,整个树的最长路径不会超过最短路径的2倍,在插入删除操作时,不会频繁的破坏红黑树规则。
红黑树的核心规则
-
每个节点要么是红色,要么是黑色
-
根节点必须是黑色
-
所有叶子节点(NIL节点,空节点)是黑色
-
如果一个节点是红色的,则它的子节点必须是黑色的
-
对每个节点,该节点到其所有后代叶子节点的简单路径上,均包含相同数目的黑色节点(黑高,包括根节点)。
HashMap的Key可以为null吗?
- 可以,当key为空时,hashMap中的hash()会直接另该key的哈希值为0,然后返回。
- key为null只有一个,而value为null可以有多个。因为key在相同的情况下value会被覆盖为新值。
仅重写HashMap的equals方法但是不重写hashcode方法会出现什么问题?
会导致假唯一键问题,假如有两个对象p1和p2,p1.equals(p2)为true,按约定要求,p1.hashcode(p2)也为true,但是因为并未重写hashcode,导致为false,并且hashmap中键的桶位置是根据hashcode来确定的,所以此时p1和p2的桶位置是不相同的,导致hashmap的size为2,但是从逻辑预期上看,因为p1.equals(p2)为true,所以p1.hashcode(p2)也应该为true,此时的size为1,但是因为实际上hashcode并不相同,其实equals方法并未进入,所以这只是逻辑上的预期,而实际物理上hashmap的size为2,逻辑与物理的冲突导致了假唯一键。
最终结论:
重写 equals 而不重写 hashCode,会导致‘逻辑相等的对象在哈希结构中被物理存储为不同键’,这既是对 Java 规范的违反,也是‘假唯一键’问题的根源。只有两者协同重写,才能保证逻辑预期与物理存储一致。
HashMap的扩容机制
HashMap的扩容机制是其高效处理元素存储的核心特性。当HashMap中的元素数量(size)超过扩容阈值(threshold = 容量 × 负载因子,默认0.75)时,会触发扩容操作,将数组容量扩大为原来的2倍(如从16扩容至32),并重新分配所有元素的存储位置。扩容的核心步骤如下:首先计算新的容量和阈值,然后创建新的空数组,接着遍历原数组中的每个桶,对每个桶内的元素(链表或红黑树)重新计算在新数组中的位置。为优化性能,JDK 8利用位运算优化索引计算:由于容量始终是2的幂,新容量比原容量多一个高位(如原容量16是10000,新容量32是100000),通过判断元素哈希值与原容量的按位与结果,若为0则保留原索引,若非0则索引为原索引加原容量,从而将元素高效拆分到新数组的对应位置,避免重新计算全部哈希值,确保扩容过程在O(n)时间复杂度内完成,同时维持哈希表的性能平衡。(建议查阅相关资料和视频,这里索引优化和为什么是2的n次幂有点难懂)
HashMap的负载因子
是一个浮点数(默认值为 0.75),表示 HashMap 中元素数量与桶数量的比值。过小的负载因子,会让 HashMap 提前扩容,桶的利用率降低,减少哈希冲突,但增加了空间开销。过大的负载因子,会让 HashMap 更晚扩容,桶的利用率提高,但哈希冲突增多,导致查询、插入效率下降。
如何选择?
内存充足且对时间敏感选择较小的负载因子,减少哈希冲突,内存有限但对时间不敏感,选择较大的负载因子,提高空间利用率。
ConcurrentHashMap是如何实现的?
JDK7以前
JDK 7 及以前的 ConcurrentHashMap
采用 分段锁(Segment) 机制实现高并发,核心设计是将整个哈希表拆分为多个独立的 Segment
(默认 16 个),每个 Segment
继承自 ReentrantLock
并维护一个独立的哈希表(HashEntry
数组 + 链表)。不同 Segment
之间的操作可并发执行,锁粒度从整个哈希表缩小到单个 Segment
,显著提高并发度。读操作(如 get()
)通过 volatile
变量直接访问数据,无需加锁;写操作(如 put()
)需锁定目标 Segment
,同一 Segment
内的写操作互斥,但不同 Segment
的写操作可并行。初始化时 Segment
数组懒加载,每个 Segment
单独扩容。统计方法(如 size()
)先尝试无锁统计,若失败则锁定所有 Segment
。该设计在读多写少场景下表现优异,但锁粒度固定(无法动态调整)、统计操作开销大,且内存占用较高
初始化时 Segment
数组懒加载?
首次执行put()操作时,会根据key的hash值定位到指定的Segment,如果该Segment为null,则通过cas操作创建并初始化这个Segment,并确保只初始化一次,后续进行put操作时,定位到指定数组后,通过UNSAFE.getObjectVolatile(segments, 3)
读取已初始化的Segment,无需再进行初始化。
统计方法(如 size()
)先尝试无锁统计,若失败则锁定所有 Segment
?
无锁统计:遍历所有Segment,累加每个Segement的count
字段和modCount
字段,重复统计两次,然后比较两次统计的modcount
的总和是否相同,若相同则返回元素总数,反之则锁定所有Segment再统计。
锁定所有锁统计:对每个Segment调用lock()方法,此时所有写操作被阻塞,统计结果绝对正确,统计完毕释放所有锁,线程恢复运行。
JDK8之后
通过 “数组 + 链表 + 红黑树” 结构和 CAS + synchronized 机制实现高效并发。当首次插入元素时,通过 CAS 原子操作初始化数组(懒加载),避免提前分配内存。插入元素时,首先计算桶位置,若桶为空则通过 CAS 无锁插入新节点;若桶已存在节点,则对链表头节点或红黑树根节点加 synchronized
锁,保证操作的原子性。当链表长度超过 8 且数组长度≥64 时,链表自动转换为红黑树,提升查询效率。扩容时,首个线程创建新数组(大小为原数组的 2 倍),并将原数组按段拆分(默认每段 16 个桶),通过 transferIndex
原子变量分配迁移任务。其他线程在访问时若遇到 ForwardingNode
(标记已迁移的桶),会被引导至新数组查找数据,并可能协助扩容,实现多线程协作迁移。
疑难点
ConcurrentHashMap在扩容时会创建一个新的数组,将扩容前原数组的元素以及扩容期间新添加到原数组的元素一并迁移到新数组中,在线程扩容期间,若线程访问的是已被迁移的桶,则会 通过ForwardingNode
的 nextTable
直接去新数组查找数据(如 get
操作,并不会协助扩容),或者执行put操作,put操作时,会在新数组上先协助扩容然后再进行put操作。若遇到尚未迁移的桶,则在原数组上进行操作。
ConcurrentHashMap用了悲观锁还是乐观锁?
乐观锁
- 初始化数组:通过CAS 操作将sizectl改为-1,确保只有一个线程初始化数组。
- 插入新节点:当桶为空时,通过CAS 插入新节点。
- 计数更新:通过 CAS 更新
baseCount
或CounterCell
,减少锁竞争。
悲观锁
- 链表操作:当桶不为空时,通过synchronized锁定头节点后对链表进行替换插入删除。
- 红黑树操作:当桶不为空时,通过synchronized锁定Treebin节点,而非直接锁定红黑树的根节点。这一设计确保了在树结构调整(如旋转、变色)过程中的线程安全,然后再对红黑树进行操作。
Set集合有什么特点?无重复是怎么实现的?
元素唯一性,元素无序性。
HashSet/LinkedHashSet 的去重实现
- 计算新元素的哈希值,确定其在哈希表中的桶位置(通过
hashCode() % 数组长度
)。 - 遍历桶内元素(链表或红黑树),逐个比较元素的hashCode:
- 若哈希值不同,则元素不同,直接插入。
- 若哈希值相同,再调用equals()方法比较内容:
- 若
equals()
返回false
,说明元素不同(哈希冲突),插入到桶中。 - 若
equals()
返回true
,说明元素重复,拒绝插入
- 若
TreeSet的去重实现
当插入新元素时,TreeSet会调用比较器或者自身的compareTo方法与树中元素进行比较,若结果为0,重复,拒绝插入,若结果>0,插入右子树,<0插入左子树。
Set的有序性
LinkedHashSet的插入顺序有序,排序依据是元素的插入的先后顺序;TreeSet的排序顺序有序,排序依据是Comparable 或 Comparator。