目录
1.说说 Java 中 HashMap 的原理?
2.Java 中的 List 接口有哪些实现类?
3.Java 中 ConcurrentHashMap 1.7 和 1.8 之间有哪些区别?
4.为什么 JDK 1.8 对 HashMap 进行了红黑树的改动?
5.JDK 1.8 对 HashMap 除了红黑树还进行了哪些改动?
6.Java 中有哪些集合类?请简单介绍。
7.为什么 Java 中 HashMap 的默认负载因子是 0.75?
8.Java 中 HashMap 的扩容机制是怎样的?
9.为什么 HashMap 在 Java 中扩容时采用 2 的 n 次方倍?
10.数组和链表在 Java 中的区别是什么?
1.Java 中有哪些集合类?请简单介绍。(中)
(一般用于暖场,简单讲讲即可)
Java 集合框架(Java Collections Framework)是 Java 核心库中用于存储和操作数据集合的标准架构,主要分为Collection(单元素集合)和Map(键值对集合)两大接口体系。
Collection:
Map:
图片来源
1)Collection 接口(单元素集合)
List 接口(有序、可重复)
| 类名 | 数据结构 | 线程安全 | 特点 |
|---|---|---|---|
ArrayList | 动态数组 | ❌ | 随机访问快,增删中间元素慢 |
LinkedList | 双向链表 | ❌ | 增删快,随机访问慢;实现了Deque接口 |
Vector | 动态数组 | ✅ | 线程安全但性能差(已过时) |
Stack | 栈(继承Vector) | ✅ | 后进先出(LIFO) |
CopyOnWriteArrayList | 动态数组 + 写时复制 | ✅ | 读操作无锁,写操作复制整个数组(适合读多写少) |
Set 接口(无序、唯一)
| 类名 | 数据结构 | 线程安全 | 特点 |
|---|---|---|---|
HashSet | 哈希表 | ❌ | 基于HashMap实现,查询最快 |
LinkedHashSet | 哈希表 + 双向链表 | ❌ | 保持插入顺序 |
TreeSet | 红黑树 | ❌ | 元素自动排序(需实现Comparable) |
CopyOnWriteArraySet | 数组 + 写时复制 | ✅ | 基于CopyOnWriteArrayList |
ConcurrentSkipListSet | 跳表 | ✅ | 有序且线程安全(高并发场景) |
Queue 接口(队列)
| 类名 | 数据结构 | 线程安全 | 特点 |
|---|---|---|---|
LinkedList | 双向链表 | ❌ | 作为队列使用,支持FIFO操作 |
PriorityQueue | 堆(小顶堆) | ❌ | 元素按优先级排序(自然序或自定义比较器) |
ArrayDeque | 循环数组 | ❌ | 双端队列,性能优于LinkedList |
ConcurrentLinkedQueue | 链表 | ✅ | 无锁并发队列(CAS 实现) |
2)Map 接口(键值对集合)
| 类名 | 数据结构 | 线程安全 | 特点 |
|---|---|---|---|
HashMap | 数组 + 链表/红黑树 | ❌ | 最常用,允许null键/值 |
LinkedHashMap | 哈希表 + 双向链表 | ❌ | 保持插入顺序或访问顺序(LRU 缓存基础) |
TreeMap | 红黑树 | ❌ | 键自动排序(基于红黑树) |
Hashtable | 哈希表 | ✅ | 过时,被ConcurrentHashMap取代 |
ConcurrentHashMap | 数组 + 链表/红黑树 + CAS | ✅ | 高并发推荐,分段锁/桶锁优化 |
3)如何选择
需要键值对→
HashMap(非并发)、ConcurrentHashMap(并发)需要有序→
LinkedHashMap(插入序)、TreeMap(排序)去重存储→
HashSet队列/栈→
ArrayDeque(双端队列)、LinkedList(栈)高并发场景→
ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue排序需求→
TreeSet、TreeMap、PriorityQueue
4)常用声明
| 声明(左) | 实现(右) | 场景 |
|---|---|---|
List<T> | new ArrayList<>() | 一般列表 |
List<T> | new LinkedList<>() | 频繁头插/删除 |
Set<T> | new HashSet<>() | 去重 |
Map<K,V> | new HashMap<>() | 键值映射 |
Queue<T> | new ArrayDeque<>() | 队列 |
Deque<T> | new ArrayDeque<>() | 栈 / 双端队列 |
Deque<T> | new LinkedList<>() | 需要存null时 |
规律:左边永远是接口(或抽象父类),右边是具体实现。
2.Java 中的 List 接口有哪些实现类?(易)
Java 中的 List 接口是 Collection(单元素容器) 的子接口,表示有序、可重复的集合。常见的实现类包括:ArrayList, LinkedList, Vector, Stack, CopyOnWriteArrayList几个实现类。
| 实现类 | 底层结构 | 线程安全 | 适用场景 |
|---|---|---|---|
ArrayList | 动态数组 | ❌ | 随机访问多,增删少 |
LinkedList | 双向链表 | ❌ | 频繁增删,少量随机访问 |
Vector | 动态数组 | ✔️(synchronized) | 遗留代码,不推荐新项目 |
Stack | 继承Vector | ✔️ | 栈结构(推荐Deque替代) |
CopyOnWriteArrayList | 动态数组(COW) | ✔️ | 高并发读,低并发写 |
3.说说 Java 中 HashMap 的原理?(中)
- HashMap基于数组+链表/红黑树实现。
- 存储键值对时,先计算Key的hashCode,再扰动处理,然后(n-1)&hash确定桶位置。
- 如果桶为空直接放;如果桶不为空,则遍历链表/树用equals比较Key:存在则覆盖Value,不存在则添加新节点(尾插法)。
- 当链表长度>=8且数组长度>=64时链表转红黑树;树节点数<=6时树退化为链表。
- 元素总数超过容量*负载因子(默认0.75)时会扩容(通常2倍)并重新哈希。
- 查询时类似定位桶,再遍历链表/树用equals查找。
- 它允许null键值、无序、非线程安全,理想情况下操作时间复杂度是O(1)。
(参考AI)
核心原理:
基于哈希表的键值对存储:
使用数组(称为
桶或bucket)作为主干来存储数据。每个数组元素通常是一个链表的头节点(Java 8后可能变为红黑树)。
put操作(存储键值对):计算哈希值:调用键(Key)对象的
hashCode()方法计算其哈希值。计算桶下标:对哈希值进行特定的扰动计算(Java 8使用
(h = key.hashCode()) ^ (h >>> 16)来减少碰撞),然后通过(数组长度 - 1) & hash(等价于hash % 数组长度,但效率更高)确定键值对应该存储在哪个桶(数组索引)。处理碰撞(哈希冲突):
如果目标桶为空:直接创建一个新节点(包含Key, Value, hash)放入该桶。
如果目标桶不为空(发生碰撞):
链表:遍历桶中的链表(或树),用
equals()方法比较新Key和链表中每个节点的Key:如果找到相等的Key:用新Value覆盖旧Value。
如果没找到相等的Key:将新节点添加到链表末尾(Java 7是头插法,Java 8改为尾插法)。
树化:当链表长度超过阈值(默认=8)且数组总长度达到一定大小(默认>=64)时,该链表会转换为红黑树(
TreeNode),以提高长链表下的查询效率(O(n) -> O(log n))。
扩容:如果添加元素后,整个HashMap中元素的数量(
size)超过了数组长度 * 负载因子(默认负载因子loadFactor=0.75),则触发扩容(resize):创建一个新的、更大的数组(通常是原长度的2倍)。
重新哈希:遍历所有旧的桶和链表/树,根据新的数组长度重新计算每个节点的桶下标,并将节点迁移到新数组中。
树退化:在迁移过程中,如果树中元素数量减少到阈值以下(默认<=6),红黑树会退化为链表。
get操作(根据键取值):计算哈希值 & 桶下标:与
put操作相同的方式计算Key的哈希值和桶下标。遍历链表/树:
如果目标桶为空:返回
null。如果目标桶不为空:
如果桶中第一个节点(链表头或树根)的Key匹配(
equals):直接返回其Value。否则,遍历该桶上的链表或红黑树,用
equals()方法比较查找的Key和节点的Key:找到匹配的Key:返回对应Value。
遍历完未找到:返回
null。
关键特性:
无序:迭代顺序不保证与插入顺序一致,也不保证顺序不变。
允许
null键和null值。非线程安全:多线程环境下并发修改可能导致死循环(Java 7头插法导致)、数据错乱或
ConcurrentModificationException。需要外部同步(如Collections.synchronizedMap)或使用ConcurrentHashMap。性能:在理想情况下(无碰撞或碰撞少),
get和put操作的时间复杂度接近O(1)。最坏情况(所有键都碰撞到同一个桶,退化为链表)是O(n),树化后提升为O(log n)。
4.Java 中 ConcurrentHashMap 1.7 和 1.8 之间有哪些区别?(中)
Java 7 的 ConcurrentHashMap 采用分段锁(Segment)实现,默认16个段,每个段独立加锁,允许并发写入不同段,但扩容和哈希冲突仍受段限制。Java 8 则抛弃分段锁,改用CAS + synchronized对单个桶(Node)加锁,并引入红黑树优化哈希冲突,扩容时支持多线程协同迁移,并发度更高且内存开销更小。此外,Java 8 新增函数式 API(如 forEach、compute),并优化了统计方法(如size()使用 CounterCell 分散计数竞争)。1.8 的实现更简洁高效,锁粒度更细,适应更高并发场景。
| 特性 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 数据结构 | Segment + HashEntry (数组+链表) | Node 数组 + 链表/红黑树 |
| 锁粒度 | Segment 级别(粗粒度) | 桶级别(细粒度) |
| 锁实现 | ReentrantLock | CAS + synchronized |
| 哈希冲突处理 | 链表(O(n)) | 链表转红黑树(O(log n)) |
| 扩容 | 各 Segment 独立扩容 | 多线程协同迁移数据 |
| 适用场景 | 写少读多 | 高并发写入、大数据量 |
5.为什么 JDK 1.8 对 HashMap 进行了红黑树的改动? (中)
JDK 1.8 在 HashMap 中引入红黑树,主要是为了解决哈希冲突严重时长链表导致的查询性能退化(O(n))问题。当单个桶的链表长度>=8且数组长度>=64时,链表会转换为红黑树,将最坏情况下的操作时间复杂度从O(n)优化到O(log n),显著提升了高冲突场景下的性能和容器的抗攻击(防哈希碰撞DoS)能力。当链表长度<=6时,红黑树会重新退化为链表,保证低冲突时链表的效率优势。
| 数据结构 | 查找时间复杂度 | 插入时间复杂度 | 适用场景 |
|---|---|---|---|
| 链表 | O(n) | O(1) | 冲突较少 |
| 红黑树 | O(log n) | O(log n) | 冲突严重 |
为什么阈值是8?
根据泊松分布,哈希冲突达到8的概率小于千万分之一
树节点占用空间是普通节点的两倍,平衡性能与开销
为什么需要最小树化容量64?
避免早期小规模哈希表的不必要树化
优先通过数组扩容分散节点
6.JDK 1.8 对 HashMap 除了红黑树还进行了哪些改动? (中)
1)哈希函数优化
扰动算法升级:计算索引时,新增一步
hash = key.hashCode() ^ (key.hashCode() >>> 16),将高16位与低16位异或混合,显著减少哈希冲突,使元素分布更均匀2410。
2)链表插入方式改变
头插法 → 尾插法:JDK 1.7 使用头插法(易导致多线程扩容死循环),1.8 改为尾插法,避免链表倒置,提升并发安全性(尽管仍非线程安全)710。
3)扩容机制重构
位置重计算优化:扩容时不再全量重新哈希,而是通过
(e.hash & oldCap) == 0判断元素位置:若为
0,索引不变;若为
1,新索引 = 原索引 + 旧容量479。
效率提升:避免了重新计算哈希,仅需一次位操作,扩容性能大幅提高。
4)树化条件精细化
链表转红黑树需同时满足:
链表长度 ≥
TREEIFY_THRESHOLD(默认8);桶数组容量 ≥
MIN_TREEIFY_CAPACITY(默认64)。
否则优先扩容而非树化,避免小表不必要的树结构开销569。
5)并发性能增强
虽仍非线程安全,但内部实现采用CAS 思想(如
size统计通过CounterCell分散竞争),减少锁冲突12。
| 特性 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 数据结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
| 哈希计算 | 直接取模 | 高位扰动后取模 |
| 插入方式 | 头插法 | 尾插法 |
| 扩容开销 | 全量重哈希 | 位运算判断新位置 |
| 树化逻辑 | 无 | 长度 ≥8 且容量 ≥64 才树化 |
7.为什么 Java 中 HashMap 的默认负载因子是 0.75?(中)
Java 中HashMap的默认负载因子(Load Factor)设为0.75是经过严谨权衡的结果,主要目的是在时间效率(查询性能)和空间效率(内存利用率)之间取得最佳平衡。既保障操作效率,又避免内存浪费,适合绝大多数场景。
| 负载因子 | 查询性能 | 内存利用率 | 适用场景 |
|---|---|---|---|
| 0.5 | ✅ 极高(冲突少) | ❌ 低(浪费 50%) | 内存充足、实时系统 |
| 0.75 | ✅平衡(接近 O(1)) | ✅ 较高(闲置 25%) | 默认场景 |
| 1.0 | ❌ 差(冲突频繁) | ✅ 高(无浪费) | 内存紧张、低频访问 |
8.Java 中 HashMap 的扩容机制是怎样的?(中)
HashMap 在元素数超过容量×0.75时触发扩容,新容量为当前容量的两倍。JDK 1.8 后通过高位比特判断节点新位置(免重算哈希),用尾插法拆分链表/树迁移数据,避免死循环并提升效率。
具体步骤:
创建新数组:
新容量 =旧容量的 2 倍(如 16 → 32),保证容量始终为 2 的幂(便于位运算优化)。迁移数据:
遍历旧数组的每个桶(Bucket),重新分配每个节点到新数组:JDK 1.8 优化关键:通过
e.hash & oldCap(原数组长度的二进制数)高位比特判断位置(无需重算哈希,如16为10000,看 e.hash 第五位是否为1):结果为 0→ 节点留在原索引位置(
index不变)。结果为 1→ 节点迁移到新索引 = 原索引 + 旧容量(
index + oldCap)。
链表拆分:若桶中是链表,按高位结果拆分为两个链表(原位置链表 + 新位置链表),保持顺序(尾插法)。
红黑树拆分:若桶中是红黑树,按相同逻辑拆分,若拆分后节点数 ≤6,则退化为链表。
更新引用:
将新数组设置为HashMap的底层存储,旧数组被 GC 回收。重新计算阈值:
新阈值 = 新容量 × 负载因子(如 32 × 0.75 = 24)。
| 操作 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 哈希重计算 | 所有节点重新计算hash | 免重算(用e.hash & oldCap判断) |
| 链表迁移 | 头插法(可能死循环) | 尾插法(避免闭环) |
| 迁移效率 | 单节点遍历迁移 | 按高位结果批量迁移链表/树 |
9.为什么 HashMap 在 Java 中扩容时采用 2 的 n 次方倍?(中)
HashMap 采用 2 的 n 次方容量,核心是通过(n-1) & hash位运算替代取模,极大提升计算桶下标的效率;同时支持扩容时免重算哈希(仅需高位比特判断新位置n & hash),降低迁移开销,并提升哈希分布的均匀性,是性能与设计优雅性的双重优化。
具体分析:
高效计算桶下标(核心优化)
定位桶下标公式:
index = (n - 1) & hash(n为数组长度)。当
n为 2 的幂时:n - 1的二进制全为1(例如16-1=15 → 1111)。位运算替代取模:
(n-1) & hash等价于hash % n,但位运算比取模快 10 倍以上(CPU 指令级优化)。
扩容时免重算哈希(JDK 1.8 优化)
扩容后新下标 =原位置或原位置 + 旧容量(
index或index + oldCap)。判断逻辑:直接通过
e.hash & oldCap的高位比特(0 或 1)决定位置,无需重新计算hash值。例如:旧容量
16(二进制10000),若e.hash & 16 = 0则位置不变,否则新位置 = 原位置 + 16。
减少哈希冲突,分布更均匀
如果
length不是 2 的幂次方,(length - 1)的二进制会有0位(如length = 15→1110),导致某些index永远无法被计算到(如0001),增加哈希冲突概率。2 的幂次方长度能更均匀分布元素(
(n-1) & hash),确保哈希值的所有有效位都参与计算,提高查询效率。
| 特性 | 2^n 容量 | 非 2^n 容量 |
|---|---|---|
| 计算桶位置速度 | 1 CPU 周期 | 10+ CPU 周期 |
| 哈希分布均匀性 | 最优(全低位参与) | 部分桶位不可用 |
| 扩容元素迁移 | 位判断(O(1)) | 全量重哈希(O(n)) |
| 内存利用率 | 100% 有效 | 最高 87.5% |
10.数组和链表在 Java 中的区别是什么?(中)
数组基于连续的内存块,且大小固定,支持O(1) 随机访问但增删成本高;链表基于节点,通过指针动态链接,增删 O(1)但访问需 O(n)。在 Java 中,ArrayList基于数组适合读多写少,LinkedList基于链表适合频繁增删场景。
| 特性 | 数组 | 链表 |
|---|---|---|
| 内存结构 | 连续内存块 | 非连续内存(节点分散存储) |
| 访问效率 | O(1)(通过下标直接寻址) | O(n)(需从头遍历) |
| 增删效率 | O(n)(需移动后续元素) | O(1)(仅修改指针,无需移动) |
| 内存占用 | 固定大小(初始化后不可变) | 动态扩容(按需增删节点) |
| 内存开销 | 仅存储数据 | 额外存储指针(next/prev) |
| 适用场景 | 频繁随机访问、数据量固定 | 频繁增删、数据量动态变化 |
本文到此结束,如果对你有帮助,可以点个赞~
后续会在合集里持续更新 Java 相关的面试题,欢迎关注~
祝各位都能拿到满意的offer~