八股文笔记 #2 Java 集合

再好的项目,也敌不过 HashMap 的 resize 过程没讲清楚

概念

1# 数组与集合的区别是?用过哪些?

数组和集合的区别:

  • 数组是固定长度的数据结构,一旦创建长度就无法改变,而集合是动态长度的数据结构,可以根据需要动态增加或减少元素
  • 数组可以包含基本数据类型和对象,而集合只能包含对象
  • 数组可以直接访问元素,而集合需要通过迭代器或其他方法访问元素

我用过的一些 Java 集合类:

  1. List 系列(有序、可重复)

    • ArrayList: 动态数组,随机访问快,适合读多写少场景
    • LinkedList: 双向链表,插入/删除快,适合频繁操作头尾
  2. Set 系列(无序/有序、不可重复)

    • HashSet: 基于 HashMap 实现的 Set 集合,用于存储唯一元素
    • LinkedHashSet:有插入顺序的 HashSet
    • TreeSet:基于红黑树,元素自动排序(按自然顺序或比较器)
  3. Map 系列(键值对)

    • HashMap: 基于哈希表的 Map 实现,存储键值对,通过键快速查找值
    • ConcurrentHashMap:线程安全的高性能并发 Map
    • TreeMap: 基于红黑树实现的有序 Map 集合,可以按照键的顺序进行排序
    • LinkedHashMap: 基于哈希表和双向链表实现的 Map 集合,保持插入顺序或访问顺序
  4. Queue 系列(队列/优先队列)

    • PriorityQueue: 优先队列,可以按照比较器或元素的自然顺序进行排序

    • ArrayDeque:双端队列,可用于栈或队列结构

2# 说说 Java 中的集合?

Java 集合

List 是一个有序、可重复的集合,它允许根据索引精确控制元素的插入位置,也支持根据索引访问、搜索和修改元素

  • ArrayList 基于动态数组实现,元素按插入顺序排序。因为底层是数组,支持快速随机访问(时间复杂度 O(1)),插入和删除操作(特别是在中间位置)较慢,需移动大量元素(时间复杂度 O(n))。当几何扩容时,会创建更大的数组,并把原数组复制到新数组。线程不安全,适用于单线程环境
  • LinkedList 基于双向链表实现,每个节点都保存前后指针。插入和删除操作性能较好,尤其是在首尾位置添加/删除元素时(O(1)),不支持高效的随机访问(访问第 n 个元素需从头或尾遍历,时间复杂度 O(n))。同样是线程不安全
  • Vector:与 ArrayList 类似,也是基于动态数组实现的。Vector线程安全的,其大多数方法都使用了 synchronized 关键字修饰,保证线程同步。属于过时类,在 JDK 的后续发展中不再推荐使用
  • Stack:是 Vector 的子类,继承了 Vector 的所有特性,线程安全。它实现的是**后进先出(LIFO)**的数据结构,提供了栈操作方法。同样存在设计上的历史局限,不推荐在新的项目中使用

Set 是一个不包含重复元素的集合,元素通常是无序的,但具体顺序取决于其实现类

  • HashSet :基于 HashMap 实现,底层以 HashMap 的 key 存储元素,所有 key 对应的 value 都是一个统一的常量 PRESENT。它不保证元素顺序,插入顺序和取出顺序可能不同。由于依赖 HashMap,因此是线程不安全的
  • LinkedHashSet:继承自 HashSet,通过 LinkedHashMap 实现,内部使用双向链表维护元素的插入顺序,因此是有序的(按插入顺序)
  • TreeSet:基于 TreeMap 实现,底层结构为红黑树。元素将根据其自然顺序或提供的比较器(Comparator)进行排序,因此是有序集合

Map 是一种键值对集合,key 不可重复value 可重复。它不是 Collection 的子接口,但同样是 Java 集合框架的核心部分

  • HashMap:最常用的 Map 实现,key 无序、唯一,value 可重复。JDK 1.8 之前底层是数组 + 链表结构,使用拉链法解决哈希冲突;JDK 1.8 之后,当单个桶中的链表长度超过阈值(默认 8),链表将转为红黑树以提高性能。线程不安全,适用于单线程环境
  • LinkedHashMap:是 HashMap 的子类,底层在 HashMap 基础上添加了双向链表,以维护插入顺序或访问顺序。可以通过构造函数设置为按访问顺序排序,常用于实现 LRU 缓存
  • HashTable:较早的实现,底层为数组 + 链表,链表主要是为了解决哈希冲突。所有方法都使用 synchronized 修饰,线程安全但性能较差,已基本被 ConcurrentHashMap 替代
  • TreeMap:是基于红黑树实现的Map,它可以对键进行排序,默认按照自然顺序排序,也可以通过指定的比较器进行排序。TreeMap 是非线程安全的,在多线程环境下,如果多个线程同时对 TreeMap 进行插入、删除等操作,可能会破坏红黑树的结构,导致数据不一致或程序出现异常
  • ConcurrentHashMap:支持高并发访问的线程安全 Map 实现。JDK 1.8 以前使用 Segment 分段锁机制;JDK 1.8 后采用数组 + 链表 + 红黑树结构,并通过 CAS + synchronized 等机制实现更细粒度的并发控制,性能显著提升

3# Java 中线程安全的集合有什么?

java.util 包中的线程安全的类主要 2 个,其他都是非线程安全的:

  • Vector:和 ArrayList 类似,也是基于数组实现。其大多数方法通过 synchronized 保证线程安全,但在单线程场景中同步开销大,性能略低于 ArrayListVector 内部是使用对象数组来保存数据,可以根据需要自动的增加容量,当数组已满时,会创建新的数组,并拷贝原有数组数据
  • Hashtable:线程安全的哈希表,HashTable 的加锁方法是给每个方法加上 synchronized 关键字,这样锁住的是整个 Table 对象,不支持 null 键和值,由于同步导致的性能开销,所以已经很少被推荐使用,如果要保证线程安全的哈希表,可以用 ConcurrentHashMap

java.util.concurrent 包提供的都是线程安全的集合:

并发 Map

  • ConcurrentHashMap:它与 HashTable 的主要区别是二者加锁粒度的不同,在 JDK 1.8 前,ConcurrentHashMap 加的是分段锁,也就是 Segment 锁,每个 Segment 含有整个 table 的一部分,这样不同分段之间的并发操作就互不影响。在 JDK 1.8 后,它取消了 Segment 字段,直接在 table 元素上加锁,实现对每一行进行加锁,进一步减小了并发冲突的概率。对于 put 操作,如果 key 对应的数组元素为 null,则通过 CAS 操作(Compare and Swap)将其设置为当前值。如果 key 对应的数组元素(也即链表表头或者树的根元素)不为 null,则对该元素使用 synchronized 关键字申请锁,然后进行操作。如果该 put 操作使得当前链表长度超过一定阈值,则将该链表转换为红黑树,从而提高寻址效率
  • ConcurrentSkipListMap:实现了一个基于 SkipList(跳表)算法的可排序的并发集合,SkipList 是一种可以在对数预期时间内完成搜索、插入、删除等操作的数据结构,通过维护多个指向其他元素的 “跳跃” 链接来实现高效查找

并发 Set

  • ConcurrentSkipListSet:是线程安全的有序的集合。底层是使用 ConcurrentSkipListMap 实现
  • CopyOnWriteArraySet:是线程安全的 Set 实现,它是线程安全的无序的集合,可以将它理解成线程安全的 HashSet。有意思的是,CopyOnWriteArraySetHashSet 虽然都继承于共同的父类 AbstractSet;但是,HashSet 是通过散列表实现的,而 CopyOnWriteArraySet 则是通过动态数组 CopyOnWriteArrayList 实现的,并不是散列表

并发 List

  • CopyOnWriteArrayList:它是 ArrayList 的线程安全的变体,其中所有写操作(add,set 等)都通过对底层数组进行全新复制来实现,允许存储 null 元素。即当对象进行写操作时,使用了 Lock 锁做同步处理,内部拷贝了原数组,并在新数组上进行添加操作,最后将新数组替换掉旧数组;若进行的读操作,则直接返回结果,操作过程中不需要进行同步

并发 Queue

  • ConcurrentLinkedQueue:是一个适用于高并发场景下的队列,它通过无锁的方式(CAS),实现了高并发状态下的高性能。通常,ConcurrentLinkedQueue 的性能要好于 BlockingQueue
  • BlockingQueue:与 ConcurrentLinkedQueue 的使用场景不同,BlockingQueue 的主要功能并不是在于提升高并发时的队列性能,而在于简化多线程间的数据共享。BlockingQueue 提供一种读写阻塞等待的机制,即如果消费者速度较快,则 BlockingQueue 则可能被清空,此时消费线程再试图从 BlockingQueue 读取数据时就会被阻塞。反之,如果生产线程较快,则 BlockingQueue 可能会被装满,此时,生产线程再试图向 BlockingQueue 队列装入数据时,便会被阻塞等待

并发 Deque

  • LinkedBlockingDeque:是一个线程安全的双端队列实现。它的内部使用链表结构,每一个节点都维护了一个前驱节点和一个后驱节点。LinkedBlockingDeque 没有进行读写锁的分离,因此同一时间只能有一个线程对其进行操作
  • ConcurrentLinkedDequeConcurrentLinkedDeque 是一种基于链接节点的无限并发链表。可以安全地并发执行插入、删除和访问操作。当许多线程同时访问一个公共集合时,ConcurrentLinkedDeque 是一个合适的选择

太长不看版

类型 主要实现 线程安全机制 & 典型特性 适用场景
早期同步类(java.util VectorHashtable 整体 synchronized,简单但锁粒度大,已基本淘汰 兼容遗留代码
并发 Map ConcurrentHashMap``ConcurrentSkipListMap JDK 8:数组 + 链表/红黑树 + CAS / synchronized(行锁)SkipList:基于跳表,有序、读写并发 高并发 KV 存储;有序场景用 SkipList
并发 Set ConcurrentSkipListSet``CopyOnWriteArraySet 前者基于 SkipList(有序)后者基于 Copy‑On‑Write,读多写少 排序去重 / 读多写少
并发 List CopyOnWriteArrayList 写时复制:写操作复制数组,读无需加锁 读多写少、迭代不要求实时一致
并发 Queue / Deque ConcurrentLinkedQueue(无锁链表)ArrayBlockingQueueLinkedBlockingQueueLinkedBlockingDeque(阻塞队列)ConcurrentLinkedDeque(无锁双端) CAS 或 ReentrantLock + 条件队列,实现非阻塞或阻塞语义 生产者/消费者模型、任务队列、并发栈/队列

4# Collection 和 Collections 的区别

  • Collection 是 Java 集合框架的顶层接口,位于 java.util 包中,定义了集合的基本操作,如增删改查、遍历等。它是 ListSetQueue 等集合接口的父接口,用于表示一组元素的抽象结构
  • Collections 是 Java 提供的一个 工具类,同样位于 java.util 包中,专门用于对集合进行操作。它包含大量的静态方法,如排序 sort()、查找 binarySearch()、替换、反转 reverse()、线程安全包装 synchronizedList()等,可用于处理实现了 Collection 接口的集合对象(如 ListSet 等)

5# 遍历集合的方式有哪些?

  1. 普通 for 循环:可以使用带有索引的普通 for 循环来遍历 List
1
2
3
4
for (int i = 0; i < list.size(); i++) {
String element = list.get(i);
System.out.println(element);
}
  1. 增强 for 循环(for-each 循环): 用于循环访问数组或集合中的元素
1
2
3
for (String element : list) {
System.out.println(element);
}
  1. Iterator 迭代器: 可以使用迭代器来遍历集合,特别适用于需要删除元素的情况
1
2
3
4
5
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
String element = iterator.next();
System.out.println(element);
}
  1. ListIterator 列表迭代器ListIterator 是迭代器的子类,可以双向访问列表并在迭代过程中修改元素
1
2
3
4
5
ListIterator<String> listIterator = list.listIterator();
while (listIterator.hasNext()) {
String element = listIterator.next();
System.out.println(element);
}
  1. 使用 forEach() 方法: Java 8 引入了 forEach() 方法,可以对集合进行快速遍历
1
list.forEach(element -> System.out.println(element));
  1. Stream API: Java 8 的 Stream API 提供了丰富的功能,可以对集合进行函数式操作,如过滤、映射等
1
list.stream().forEach(element -> System.out.println(element));

List

1# 说说常见的 list 集合?

List 集合

非线程安全

  • ArrayList :基于动态数组实现,元素按插入顺序排序。因为底层是数组,支持快速随机访问(时间复杂度 O(1)),插入和删除操作(特别是在中间位置)较慢,需移动大量元素(时间复杂度 O(n))。当几何扩容时,会创建更大的数组,并把原数组复制到新数组。线程不安全,适用于单线程环境
  • LinkedList :基于双向链表实现,每个节点都保存前后指针。插入和删除操作性能较好,尤其是在首尾位置添加/删除元素时(O(1)),不支持高效的随机访问(访问第 n 个元素需从头或尾遍历,时间复杂度 O(n))。同样是线程不安全

线程安全

  • Vector:和 ArrayList 类似,也是基于数组实现。其大多数方法通过 synchronized 保证线程安全,但在单线程场景中同步开销大,性能略低于 ArrayListVector 内部是使用对象数组来保存数据,可以根据需要自动的增加容量,当数组已满时,会创建新的数组,并拷贝原有数组数据

  • CopyOnWriteArrayList:一种并发容器类,写操作(如添加、删除)时会复制一份底层数组,并在新数组上修改,写完后再替换旧数组;读操作无需加锁,始终在旧数组上执行,实现了读写分离,大幅提升读性能。适用于读多写少的并发场景,如事件监听器列表、缓存快照等。缺点是写操作开销较大,且不适合大数据量频繁写入的场景

2# 讲一下 List 的几种实现,几种实现有什么不同?

ArrayListVectorLinkedList,概念如上

ArrayListVector 作为动态数组,因为底层是数组,支持快速随机访问(时间复杂度 O(1)),插入和删除操作(特别是在中间位置)较慢,需移动大量元素(时间复杂度 O(n))

LinkedList 插入和删除操作性能较好,尤其是在首尾位置添加/删除元素时(O(1)),不支持高效的随机访问(访问第 n 个元素需从头或尾遍历,时间复杂度 O(n))

3# List 可以一边遍历一边修改元素吗?

  1. 使用普通 for 循环遍历:可以在遍历过程中修改元素,只要修改的索引不超出List的范围即可
1
2
3
for (int i = 0; i < list.size(); i++) {
list.set(i, list.get(i) * 2);
}
  1. 使用 for-earch 循环遍历:一般不建议在 for-earch 循环中直接修改正在遍历的 List 元素,可能会导致意外的结果或 ConcurrentModificationException 异常。因为 for-earch 循环底层是基于迭代器实现的,在遍历过程中修改集合结构,会导致迭代器的预期结构和实际结构不一致
1
2
3
for (Integer num : list) {
list.set(list.indexOf(num), num * 2);
}
  1. 使用迭代器遍历:可以使用迭代器的 remove() 方法来删除元素,但如果要修改元素的值,需要通过迭代器的 set() 方法来进行,而不是直接通过 Listset() 方法,否则也可能会抛出 ConcurrentModificationException 异常
1
2
3
4
5
Iterator<Integer> iterator = list.iterator();
while (iterator.hasNext()) {
Integer num = iterator.next();
iterator.set(num * 2);
}

4# List 如何快速删除指定下标的元素?

  1. ArrayListremove(int index) 方法:该方法在删除元素后,会将后续元素向前移动,以填补被删除元素的位置。如果删除的是列表中间的元素,时间复杂度为 O (n);如果删除的是列表末尾的元素,时间复杂度为 O (1)

  2. LinkedListremove(int index) 方法:它需要先遍历到指定下标位置,然后修改链表的指针来删除元素,时间复杂度为 O (n);如果删除的是链表的头节点或尾节点的元素,可以直接通过修改头指针或尾指针来实现删除,时间复杂度为 O (1)

  3. CopyOnWriteArrayListremove(int index) 方法:由于 CopyOnWriteArrayList 在写操作时会创建一个新的数组,所以删除操作的时间复杂度取决于数组的复制速度,通常为 O (n)。但在并发环境下,它的删除操作不会影响读操作,具有较好的并发性能

5# ArrayList 线程安全吗?把 Arraylist 变成线程安全的方式有哪些?

不是线程安全的。变成线程安全的方式:

  1. 使用 Collections 类的 synchronizedList 方法将 ArrayList 包装成线程安全的 List
1
List<String> synchronizedList = Collections.synchronizedList(arrayList);
  1. 使用 CopyOnWriteArrayList 类代替 ArrayList,它是一个线程安全的 List 实现:
1
CopyOnWriteArrayList<String> copyOnWriteArrayList = new CopyOnWriteArrayList<>(arrayList);
  1. 使用 Vector 类代替 ArrayListVector 是线程安全的 List 实现:
1
Vector<String> vector = new Vector<>(arrayList);

6# 为什么 ArrayList 不是线程安全的?

在高并发添加数据时,ArrayList 会暴露三个问题:

  • 部分值为 null
  • 索引越界异常(IndexOutOfBoundsException
  • size 与 add 的数量不符

ArrayList 添加元素的代码如下:

1
2
3
4
5
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 可能扩容
elementData[size++] = e; // 赋值后再自增 size
return true;
}

ensureCapacityInternal() 方法判断将当前的新元素加到列表后面,列表的 elementData 数组的大小是否满足,如果 size + 1 的这个需求长度大于了 elementData 这个数组的长度,那么就要对这个数组进行扩容

大体可以分为三步:

  1. 判断数组需不需要扩容,如果需要的话,调用 grow() 方法进行扩容

  2. 将数组的 size 位置设置值(因为数组的下标是从 0 开始的)

  3. 将当前集合的大小加 1

那么三个问题都是如何产生的?

  • 部分值为 null:根因是同一索引被重复写,导致后继位置空洞。线程 T1 与 T2 都判断无需扩容(容量 =10,size =9) → T1 把元素写入 elementData[9],尚未 size++ 就被抢占 → T2 也把元素写入同一槽位并随后 size++ → 槽位 10 从未被写入,被视为 null
  • 索引越界异常:根因是第二线程基于过期的容量判断,写到实际不存在的索引。线程 T1 与 T2 都判断无需扩容(容量 =10,size =9)→ T1 写索引  9 并 size++ → size=10 → T2 继续写 索引 10 → 数组下标越界(数组的下标索引从 0 开始)
  • size 与 add 的数量不符:根因是size++非原子递增,发生 “写覆盖”。两线程几乎同时执行 size++:都读取到 size=5 → 都各自加1 → 都写回 6 → 少加了一次

7# ArrayList 和 LinkedList 的应用场景分别是什么?

  • ArrayList:适用于需要频繁访问集合元素的场景。它基于动态数组实现,可以通过索引快速访问元素,因此在按索引查找、遍历和随机访问元素的操作上具有较高的性能
  • LinkedList:适用于频繁进行插入和删除操作的场景。它基于双向链表实现,插入和删除元素的操作只需要调整节点的指针,因此在插入和删除操作上具有较高的性能

8# 说一下 ArrayList 的扩容机制

ArrayList 在添加元素时,如果当前元素数量已达到内部数组的容量上限,就会触发扩容操作。ArrayList 的扩容操作主要包括以下几个步骤:

  1. 计算新的容量:一般情况下,ArrayList 会将容量扩大为原来的 1.5 倍(在 JDK 10 之后,扩容策略做了调整),这是通过移位运算来避免浮点运算,效率较高,例如:
1
int newCapacity = oldCapacity + (oldCapacity >> 1);
  1. 创建新的数组:根据新的容量创建一个更大的数组

  2. 将元素复制:将旧数组中的元素逐个复制到新数组中

  3. 更新引用:将内部数组引用指向新的数组

  4. 完成扩容:之后即可继续向列表中添加新元素

扩容过程中涉及到数组复制和内存重新分配,是一个相对开销较大的操作。因此,在预计会添加大量元素的场景下,建议在构造 ArrayList 时指定初始容量,以减少扩容次数,提高性能

9# CopyonWriteArraylist 是如何实现线程安全的?

CopyOnWriteArrayList 是一种线程安全的列表实现,其核心思想是**:写时复制**(Copy-On-Write)。它的底层通过一个被 volatile 修饰的数组来保存数据,并结合 ReentrantLock 实现写操作的互斥,确保线程安全

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock(); // 加锁,保证写操作互斥
try {
Object[] elements = getArray(); // 获取当前数组
int len = elements.length; // 原数组长度
Object[] newElements = Arrays.copyOf(elements, len + 1); // 创建新数组
newElements[len] = e; // 添加新元素
setArray(newElements); // 替换原数组引用
return true;
} finally {
lock.unlock(); // 释放锁
}
}

在执行写入操作时,首先获取当前的底层数组,然后创建一个长度为原数组长度加一的新数组,并将旧数组中的所有元素复制到新数组中。接着,将待添加的新元素放置到新数组的末尾位置。最后,将内部数组的引用更新为这个新数组。整个写入过程在加锁的保护下进行,确保在并发环境中写操作的互斥性和数据的一致性

1
2
3
public E get(int index) {
return get(getArray(), index);
}

由于新数组是在写操作期间独立构建的,读操作始终访问旧数组,因此不会受到影响,实现了读写分离的线程安全策略。因为数组引用是 volatile 的,所以读取总是能看到最新或一致的有效数据

Map

1# 说说常见的 Map 集合?

Map 集合

非线程安全

  • HashMap:最常用的 Map 实现,key 无序、唯一,value 可重复。JDK 1.8 之前底层是数组 + 链表结构,使用拉链法解决哈希冲突;JDK 1.8 之后,当单个桶中的链表长度超过阈值(默认 8),链表将转为红黑树以提高性能。HashMap 是非线程安全的,在多线程环境下,当多个线程同时对 HashMap 进行操作时,可能会导致数据不一致或出现死循环等问题

  • LinkedHashMap:是 HashMap 的子类,底层在 HashMap 基础上添加了双向链表,以维护插入顺序或访问顺序。可以通过构造函数设置为按访问顺序排序,常用于实现 LRU 缓存。由于它继承自HashMap,在多线程并发访问时,同样会出现与HashMap类似的线程安全问题

  • TreeMap:是基于红黑树实现的Map,它可以对键进行排序,默认按照自然顺序排序,也可以通过指定的比较器进行排序。TreeMap 是非线程安全的,在多线程环境下,如果多个线程同时对 TreeMap 进行插入、删除等操作,可能会破坏红黑树的结构,导致数据不一致或程序出现异常

线程安全

  • HashTable:较早的实现,底层为数组 + 链表,链表主要是为了解决哈希冲突。所有方法都使用 synchronized 修饰,线程安全但性能较差,已基本被 ConcurrentHashMap 替代
  • ConcurrentHashMap:支持高并发访问的线程安全 Map 实现。JDK 1.8 以前使用 Segment 分段锁机制,将数据分成多个段(Segment),每个段都有自己的锁。在进行插入、删除等操作时,只需要获取相应段的锁,而不是整个 Map 的锁,这样可以允许多个线程同时访问不同的段,提高了并发访问的效率;JDK 1.8 后采用数组 + 链表 + 红黑树结构,并通过 CAS + synchronized 等机制实现更细粒度的并发控制,性能显著提升

2# 如何对 Map 进行快速遍历?

  1. 使用 for-each 循环和 entrySet() 方法:这是一种较为常见和简洁的遍历方式,它可以同时获取 Map 中的键和值
1
2
3
for (Map.Entry<String, Integer> entry : map.entrySet()) {
System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
}
  1. 使用 for-each 循环和 keySet() 方法:只需要遍历 Map 中的键,这种方式相对简单,性能也较好
1
2
3
for (String key : map.keySet()) {
System.out.println("Key: " + key + ", Value: " + map.get(key));
}
  1. 使用迭代器:通过获取 MapentrySet()keySet() 的迭代器,也可以实现对 Map 的遍历,这种方式在需要删除元素等操作时比较有用
1
2
3
4
5
Iterator<Entry<String, Integer>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Entry<String, Integer> entry = iterator.next();
System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
}
  1. 使用 Lambda 表达式和 forEach() 方法:这种方式更加简洁和函数式
1
map.forEach((key, value) -> System.out.println("Key: " + key + ", Value: " + value));
  1. 使用 Stream API:可以将 Map 转换为流,然后进行各种操作
1
2
3
4
5
6
7
map.entrySet().stream().forEach(entry -> System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue()));

// 还可以进行其他操作,如过滤、映射等
Map<String, Integer> filteredMap = map.entrySet().stream()
.filter(entry -> entry.getValue() > 1)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
System.out.println(filteredMap);

3# 介绍一下 HashMap实现原理?

在 JDK 1.8 之前,HashMap 数据结构是数组和链表HashMap 通过哈希算法将元素的键映射到数组中的槽位(Bucket)。如果多个键映射到同一个槽位,它们会以链表的形式存储在同一个槽位上,因为链表的查询时间是O(n),所以冲突很严重,一个索引上的链表非常长,效率就很低了

Java7 HashMap 结构

所以在 JDK 1.8 做了优化,当一个链表的长度超过 8 的时候就转换数据结构,不再使用链表存储,而是使用红黑树,查找时使用红黑树,时间复杂度O(log n),可以提高查询性能,但是在数量较少时,即数量小于 6 时,会将红黑树转换回链表

Java8 HashMap 结构

4# 哈希冲突的解决方式有哪些?

  1. 链接法:使用链表或其他数据结构来存储冲突的键值对,将它们链接在同一个哈希桶中
  2. 开放寻址法:在哈希表中找到另一个可用的位置来存储冲突的键值对,而不是存储在链表中。常见的开放寻址方法包括线性探测、二次探测和双重散列
  3. 再哈希法(Rehashing):当发生冲突时,使用另一个哈希函数再次计算键的哈希值,直到找到一个空槽来存储键值对
  4. 哈希桶扩容:当哈希冲突过多时,可以动态地扩大哈希桶的数量,重新分配键值对,以减少冲突的概率

5# HashMap 是线程安全的吗?

HashMap 不是线程安全的,HashMap 在多线程会存在下面的问题:

  • JDK 1.7 的 HashMap 采用数组 + 链表的数据结构,多线程背景下,在数组扩容的时候,有可能导致环形链表的出现,形成死循环
  • JDK 1.8 的 HashMap 采用数组 + 链表 + 红黑树的数据结构,优化了 1.7 中数组扩容的方案,使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。但是多线程同时执行 put() 方法,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失

如果要保证线程安全,可以通过这些方法来保证:

  • 多线程环境可以使用 Collections.synchronizedMap 同步加锁的方式,还可以使用 HashTable,但是同步的方式显然性能不达标,而 ConcurrentHashMap 更适合高并发场景使用
  • ConcurrentHashMap 在 JDK 1.7 和 1.8 的版本改动比较大,1.7 使用 Segment + HashEntry 分段锁的方式实现,1.8 则抛弃了 Segment,改为使用 CAS + synchronized + Node 实现,同样也加入了红黑树,避免链表过长导致性能的问题

6# HashMap 的常见方法有哪些?

HashMap 常用于存储和快速查找 键值对 数据。常见方法包括:

  1. put(K key, V value):添加或更新键值对
  2. get(Object key):根据键获取对应的值
  3. containsKey(Object key):判断某个键是否存在
  4. remove(Object key):移除指定键的键值对
  5. keySet()values()entrySet():遍历键、值或整个映射项

7# 讲一下 HashMap 的 put 过程

HashMap 的 put 过程

当调用 put(key, value)HashMap 添加键值对时,会按以下步骤执行:

  1. 计算索引

    根据键的哈希码,通过扰动函数(hash 方法)计算出其在数组中的位置(桶索引)

  2. 检查该位置是否为空

    • 如果该位置为空(无碰撞),直接创建一个新节点存入该桶
    • 修改计数器 modCount,以支持快速失败机制(fail-fast)
  3. 该位置已有节点(发生哈希冲突)

    • 比较头节点的哈希值和键,若相同则直接替换其值

    • 否则遍历链表或红黑树:

      • 链表结构:遍历节点,比较键的 equals() 方法,若找到则替换;否则将新节点追加到链尾

      • 红黑树结构:使用哈希和键比较规则在树中查找,若找到则替换;否则插入为新节点

  4. 检查是否需要树化
    如果该桶中链表长度 ≥ 8 且数组容量 ≥ 64,则将链表转换为红黑树,以提升查询效率。

  5. 检查是否需要扩容
    若当前键值对总数超过阈值(容量 × 负载因子,默认 0.75),则触发扩容。

  6. 扩容操作

    • 创建新数组,容量为原数组的 2 倍。

    • 重新计算所有节点的索引位置,并迁移到新数组。

    • 更新阈值与数组引用。

  7. 插入完成
    最终完成键值对的添加,否则返回 null

8# HashMap 的 put(key, val) 和 get(key) 过程

插入数据(put(key, value))时HashMap 首先通过 hashCode() 方法计算哈希值,再根据哈希值定位到对应的桶位置。如果该位置为空,直接插入;如果已存在元素(哈希冲突),则通过链表或红黑树的方式将新节点插入其中。若元素数量超过负载因子(默认 0.75)与容量的乘积,HashMap 会触发扩容,通常为原容量的 2 倍

查询数据(get(key))时,同样根据 hashCode() 方法定位到桶,再遍历桶中节点(通过 equals() 判断键是否相等)以查找目标键值对。若该桶中的节点数超过阈值(默认 8),且总容量大于等于 64,链表将转化为红黑树,以提高查找效率

9# HashMap 调用 get() 方法一定安全吗?

并不,调用 get() 方法有几点需要注意的地方:

  • 空指针异常:如果你尝试用 null 作为键调用 get() 方法,而 HashMap 没有被初始化(即为 null),那么会抛出空指针异常。不过,如果 HashMap 已经初始化,使用 null 作为键是允许的,因为 HashMap 支持 null
  • 线程安全HashMap 本身不是线程安全的。如果在多线程环境中,没有适当的同步措施,同时对 HashMap 进行读写操作可能会导致不可预测的行为。例如,在一个线程中调用 get() 方法读取数据,而另一个线程同时修改了结构(如增加或删除元素),可能会导致读取操作得到错误的结果或抛出 ConcurrentModificationException。如果需要在多线程环境中使用类似 HashMap 的数据结构,可以考虑使用 ConcurrentHashMap

10# 为什么 String 适合作为HashMap 的 key?

因为 String 对象是不可变的,一旦创建就不能被修改,这确保了 key 的稳定性。如果 key 是可变的,可能会导致 hashCodeequals() 方法的不一致,进而影响 HashMap 的正确性

11# 为什么 HashMap 要用红黑树而不是平衡二叉树?

平衡二叉树追求的是一种 “完全平衡” 状态:任何结点的左右子树的高度差不会超过 1,优势是树的结点是很平均分配的。这个要求实在是太严了,导致每次进行插入/删除节点的时候,几乎都会破坏平衡树的第二个规则,进而我们都需要通过左旋右旋来进行调整,使之再次成为一颗符合要求的平衡树 1

红黑树不追求这种完全平衡状态,而是追求一种 “弱平衡” 状态:整个树最长路径不会超过最短路径的 2 倍。优势是虽然牺牲了一部分查找的性能效率,但是能够换取一部分维持树平衡状态的成本。与平衡树不同的是,红黑树在插入、删除等操作,不会像平衡树那样,频繁着破坏红黑树的规则,所以不需要频繁着调整,这也是我们为什么大多数情况下使用红黑树的原因

12# HashMap 的 key 可以为 null 吗?

可以

HashMap 中使用 hash() 方法来计算 key 的哈希值,当 key 为空时,哈希值为 0,不走 key.hashCode() 方法

1
2
3
4
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

HashMap 虽然支持 key 和 value 为 null,但是 null 作为 key 只能有一个, null 作为 value 可以有多个

13# 重写 HashMap 的 equals() 和 hashCode() 方法需要注意什么?

在使用 HashMap 时,键对象的 hashCode()equals() 方法起着决定性作用:

  • hashCode() 用于确定键值对在哈希表中的存储位置(即桶的位置)
  • equals() 用于在哈希冲突时,判断两个键是否 “真正相等”

当我们向 HashMap 中存入或读取数据时,会先调用键的 hashCode() 方法定位桶,再通过 equals() 判断桶中是否已存在相同的键。如果这两个方法没有被正确重写:

  • 可能导致不同的键被误认为相同,从而覆盖原有的数据(没有正确重写 equals() 方法)
  • 或者导致相同的键被误认为不同,存储在不同位置,无法正确获取(没有正确重写 hashCode() 方法)

此外,所有基于哈希结构、不允许键重复的集合类(如 HashSet)也依赖这两个方法判断元素的唯一性

重写时必须遵循 Java 规范中规定的以下契约:

  • 如果 o1.equals(o2)true,那么 o1.hashCode() == o2.hashCode() 必须也为 true
  • 如果 o1.hashCode() == o2.hashCode(),并不要求 o1.equals(o2) 一定为 true

14# 介绍一下 HashMap 的扩容机制

HashMap 的默认负载因子是 0.75,也就是说,当哈希表中元素数量超过总容量的 75% 时,就会触发扩容操作。扩容主要包括两个步骤:

  1. 扩展数组容量为原来的 2 倍
  2. 将旧数组中的元素重新映射到新数组中(即重新分配位置)

因为 HashMap 使用的是 2 次幂的扩展,所以,元素在新数组中的位置,要么与原位置相同,要么是在原位置再移动 2 次幂

例如,从 16 扩展为 32 时,具体的变化如下所示:

img

在计算桶索引时,HashMap 使用 (n - 1) & hash 来定位元素。扩容时,数组长度 n 变为原来的两倍,n - 1 的二进制表示会在高位多出一个 1,因此新的索引就会发生这样的变化:

img

因此我们只需观察原哈希值中的这个新 bit:如果该 bit 是 0,元素在新数组中的位置不变;如果该 bit 是 1,元素的新位置 = 原位置 + oldCap

img

这意味着不需要重新计算 hash 值,只需简单地判断一个 bit 位即可完成重新分配,极大提升了扩容效率。同时,由于新增的 bit 是 0 还是 1 可以认为是随机的,原先冲突的元素更均匀地分布到了新的桶中,从而提升了查找性能

15# 为什么 HashMap 的容量是 2 的幂次方?

在 JDK 1.7 中,HashMap 扩容时,会遍历旧数组中的每个元素,并根据它们的 hash 值重新计算在新数组中的位置。这些元素通常以链表形式存储。当链表中的元素被转移到新数组时,会根据新数组容量重新计算索引位置。由于遍历是从链表头节点开始,重新插入时顺序发生变化,原本在尾部的元素可能被移到新链表的头部

到了 JDK 1.8,HashMap 的扩容过程做了优化,避免了重新计算完整的 hash 值。扩容时数组长度总是原来的两倍(即左移一位),因此可以通过 hash 值中新增的那一位是否为 1,来判断元素的新位置:

  • 如果该位为 0,则元素在新数组中的索引与原索引相同
  • 如果该位为 1,则元素的新索引为 原索引 + oldCap

这依赖于 HashMap 中定位桶的位置是通过 index = (n - 1) & hash 计算的。当 n 是 2 的幂次方时,这个与运算可以高效地代替取模操作,同时保证索引分布的均匀性

16# 往 HashMap 存入 20 个元素,会扩容几次?

总共会触发 一次扩容操作。在默认情况下,HashMap 的初始容量为 16,负载因子为 0.75。也就是说,当元素个数超过 16 × 0.75 = 12 时,就会触发扩容操作:

  • 初始容量:16
    • 插入第 1 到第 12 个元素时,不会触发扩容;
    • 插入第 13 个元素时,超过负载因子阈值(12),触发扩容,容量翻倍为 32。
  • 扩容后容量:32
    • 插入第 14 到第 20 个元素时,元素总数仍未超过 32 × 0.75 = 24,因此不会再次扩容。

17# 说说 HashMap 的负载因子

HashMap 的负载因子(loadFactor)默认值为 0.75,表示当元素个数超过当前容量的 75% 时,就会触发扩容操作

这个默认值是在 时间效率和空间利用率之间取得的权衡

  • 如果负载因子设置过低,会导致哈希表中存在大量空桶,浪费内存空间;
  • 如果设置过高,会增加哈希冲突的概率,导致查找效率下降。

0.75 是在实际使用中得出的一个经验值,能够在 减少冲突次数节省空间成本 之间实现较好的平衡

18# ConcurrentHashMap 怎么实现的?

ConcurrentHashMap 的两代实现思路

JDK 版本 主要结构 加锁策略 冲突链 典型特征
1.7 Segment 数组 → Segment 内部 HashEntry 数组 + 链表 每个 Segment 一把 ReentrantLock(分段锁) 单向链表 锁粒度 = Segment,最大并发度 = Segment 数
1.8 Node 数组 → 单个桶为 链表 / 红黑树 ① 首次初始化和扩容:CAS + synchronized② 桶内插入/替换:给桶头节点加 synchronized 链表 → 长度 ≥ 8 时且容量 ≥ 64 转红黑树 锁粒度 = 单个桶头节点,并发更高;查询 O(1)/O(log n)

一、JDK 1.7:Segment‑Lock 方案

JDK 1.7 ConcurrentHashMap

  1. 数据分段:整个表被切分为若干 Segment,每段内部维护一个 HashEntry[](桶数组)
  2. 分段加锁:线程操作键值对时,只需竞争目标 Segment 的 ReentrantLock,其它 Segment 可并行访问
  3. 缺点:Segment 数固定,极端高并发下仍存在锁争用。桶内冲突仅靠链表,数据量大时查找性能 O(n)

二、JDK 1.8:CAS + Node + 红黑树

JDK 1.8 ConcurrentHashMap

  1. 取消 Segment:直接对头结点加锁来保证线程安全的,锁的粒度相比 Segment 来说更小了,发生冲突和加锁的频率降低了,并发操作的性能就提高了
  2. 初始化 & 扩容:采用 volatile + CAS 竞争表头,少量线程失败后退让,再由 “领头线程” 使用 synchronized 完成初始化/迁移
  3. 插入流程
    • 计算索引 idx,判断桶是否为空
    • 桶为空:CAS 直接放节点
    • 桶非空:对桶头节点加 synchronized,遍历链表 / 红黑树,并替换或追加节点,最后再判断是否需要转为红黑树(链表长度 ≥ 8 且容量 ≥ 64 → 树化)
  4. 优点
    • 锁粒度更小:仅锁冲突桶的头节点
    • 查询性能提升:链表转为红黑树后,桶内查找由 O(n) → O(log n)
    • 更高并发度:大量场景下只用 CAS,无显式锁

19# 分段锁是如何加锁的?

JDK 1.7 的 ConcurrentHashMap 中,采用了一种叫作 分段锁(Segment Lock) 的机制来实现线程安全

整个 ConcurrentHashMap 被划分为多个 Segment,每个 Segment 本质上就是一个小型的 HashMap,并配有一把独立的锁。这样一来,多个线程只要访问的是不同的 Segment,就可以并发地进行读写操作,无需互相等待,从而大大提升了并发性能

当执行插入、更新或删除操作时,ConcurrentHashMap 首先会根据 key 的 hash 定位到对应的 Segment,然后只对这个 Segment 加锁,而不是对整个 Map 加锁。这种粒度更细的加锁方式,相比传统的全表锁(如 Hashtable 中的 synchronized 方法),性能提升非常明显

20# 分段锁是可重入的吗?

JDK 1.7 的 ConcurrentHashMap 中,分段锁是通过 ReentrantLock(可重入锁) 实现的。也就是说,每个 Segment 内部维护了一把 ReentrantLock,使得同一个线程在未释放锁的情况下,可以多次获取同一把锁而不会发生死锁。这种设计既保证了线程安全,又提高了灵活性

21# 已经用了 synchronized,为什么还要用 CAS 呢?

ConcurrentHashMap 中,CAS 和 synchronized 是互补使用的,目的是在不同的并发场景下权衡性能与安全性

例如,在 putVal() 方法中,如果计算出的桶(槽)位置为空,也就是说没有发生哈希冲突,那么就会尝试使用 CAS(无锁操作) 来插入新节点。由于哈希值经过扰动处理后冲突概率较低,这种情况下通过少量自旋即可完成插入操作,避免了加锁的开销,性能更高

而当发生哈希冲突、桶中已有节点时,说明当前桶出现竞争或者数据较多,此时再使用 CAS 会导致频繁失败和自旋,反而浪费资源。因此在这种场景下,ConcurrentHashMap 会退而使用 synchronized 来保证线程安全,处理冲突的链表或红黑树操作

总结来说:

  • 无冲突时优先用 CAS,提高性能;
  • 发生冲突时使用 synchronized,降低竞争消耗

22# ConcurrentHashMap用了悲观锁还是乐观锁?

ConcurrentHashMap 同时使用了乐观锁和悲观锁,依据场景选择不同的并发策略,以实现性能与线程安全之间的平衡

在添加元素(put 操作)时,其执行流程如下:

  1. 初始化阶段

如果表为空,使用 volatile 配合 CAS(乐观锁) 来初始化桶数组

  1. 定位桶位时
    如果目标位置(槽位)为空,尝试使用 CAS(乐观锁) 直接将节点插入该位置,无需加锁
  2. 发生哈希冲突时
    如果目标位置已有其他节点(发生哈希碰撞),则使用 synchronized(悲观锁) 加锁处理。在锁定的情况下,会遍历链表或红黑树,更新或新增节点,并在必要时判断是否需要将链表转为红黑树,以提升性能

23# HashTable 底层实现原理是什么?

HashTable 原理
  • Hashtable 底层主要由数组 + 链表组成,数组负责存储数据,而链表用来解决哈希冲突
  • 为了保证线程安全,Hashtable 中所有的公共方法都使用了 synchronized 关键字进行同步。当一个线程访问同步方法时,其他线程如果也想访问这些方法,就会被阻塞,直到锁被释放

24# HashTable 线程安全是怎么实现的?

Hashtable 之所以线程安全,是因为其所有涉及数据访问的方法(如 putget 等)都使用了 synchronized 关键字进行同步控制。这意味着,在任何时刻,只能有一个线程对整个 Hashtable 实例进行操作,从而保证了线程安全性,但也带来了性能瓶颈

源码示例(以 put()get() 方法为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public synchronized V put(K key, V value) {
if (value == null) {
throw new NullPointerException();
}
Entry<?,?>[] tab = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>) tab[index];
for (; entry != null; entry = entry.next) {
if (entry.hash == hash && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
addEntry(hash, key, value, index);
return null;
}

public synchronized V get(Object key) {
Entry<?,?>[] tab = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
for (Entry<?,?> e = tab[index]; e != null; e = e.next) {
if (e.hash == hash && e.key.equals(key)) {
return (V) e.value;
}
}
return null;
}

如上所示,Hashtable 通过给整个方法加上 synchronized,确保了多线程环境下的可见性与互斥访问

同步机制说明

在 Java 中,synchronized 是一种内置锁机制。它可以修饰方法或代码块,当一个线程进入 synchronized 方法或代码块时,会独占该对象的锁,其他线程只能等待该锁被释放后才能继续执行

这种做法虽然简单直接,但由于锁粒度较大(作用于整个对象),并发性能相对较低。因此在高并发场景下,Hashtable 逐渐被 ConcurrentHashMap 等更高效的并发容器取代

25# HashTable 和 ConcurrentHashMap 有什么区别?

底层数据结构

  • ConcurrentHashMap
    • JDK 1.7,采用 分段锁机制(Segment)+ 数组 + 链表 的结构。整个 Map 被划分为多个 Segment,每个 Segment 内部是一个小的 HashMap,具备独立锁
    • JDK 1.8 及以后,移除了 Segment,使用 数组 + 链表 + 红黑树 实现,结构与 HashMap 类似。链表过长(超过 8 个元素)时转为红黑树以提升查询效率
  • HashTable
    • 始终采用 数组 + 链表 的结构,数组是主体,链表用于处理哈希冲突

实现线程安全的方式

  • ConcurrentHashMap
    • JDK 1.7:通过 分段锁(Segment + ReentrantLock) 实现部分同步,锁粒度小,多个线程可并发访问不同的 Segment,提升并发性能
    • JDK 1.8:采用 CAS(乐观锁) + synchronized(悲观锁) 结合的方式实现更细粒度的同步控制:
      • 桶位为空时,用 CAS 插入节点
      • 桶位非空(发生哈希冲突)时,用 synchronized 加锁处理
      • 结构中引入红黑树,优化了链表查询性能
  • HashTable
    • 所有方法都使用了 synchronized 关键字,属于粗粒度锁,整个对象在同一时刻只能被一个线程访问,线程安全但效率低下

26# HashMap、HashTable 和 ConcurrentHashMap 的区别

HashMap

  • 线程不安全,但性能较高,适合单线程或线程安全有保障的场景
  • 允许一个 null key 和多个 null value
  • 默认初始容量为 16,扩容时按 2 的倍数增长
  • 如果用户指定初始容量,内部会将其调整为 最接近且大于该值的 2 的幂次方
  • 底层结构:数组 + 链表(JDK 1.8 以后引入红黑树),当链表长度超过阈值(默认为 8),并且数组长度大于 64 时,会将链表转换为 红黑树,以提升查询效率;若数组长度不够,则优先选择扩容

HashTable

  • 线程安全,但效率低,所有公共方法都使用 synchronized 修饰,属于粗粒度锁
  • 不允许 null 的 key 和 value
  • 默认初始容量为 11,扩容时容量变为 2n + 1
  • 创建时如果指定容量,就直接使用该容量(不会调整为 2 的幂次方)
  • 底层结构:数组 + 链表,用于解决哈希冲突
  • 已逐渐被淘汰,如需线程安全更推荐使用 ConcurrentHashMap

ConcurrentHashMap

  • 线程安全,性能优于 HashTable,适合高并发场景
  • 不允许 null 的 key 或 value(防止空值导致歧义)
  • JDK 1.7 实现:基于 分段锁(Segment),将哈希表分为若干段,每段维护自己的锁,多个线程可同时访问不同段,提高并发性
  • JDK 1.8 实现:移除了 Segment,改为使用 CAS(乐观锁)+ synchronized(悲观锁) 的组合方式进行细粒度同步
    • 使用 CAS 插入空桶节点,避免加锁
    • 若发生哈希冲突,再使用 synchronized 锁定桶进行处理
    • 链表过长时转为 红黑树,提升查询效率
  • 读操作在多数情况下是无锁的,性能更优

Set

1# Set 集合有什么特点?如何实现 key 无重复的?

Set 的特点

Set 集合用于存储不重复的元素不保证元素的顺序(具体顺序取决于其具体实现,例如 HashSetTreeSetLinkedHashSet

如何保证元素唯一性

Set 是通过内部使用的 数据结构 来保证元素不重复的:

  • 对于 HashSet:底层基于 HashMap 实现,插入元素时会计算其 hashCode(),再通过 equals() 方法判断是否已经存在相同元素,若存在,则不会重复插入
  • 对于 TreeSet:底层基于 红黑树(TreeMap) 实现,依赖元素的自然顺序或指定的 Comparator 来比较是否相等
  • 对于 LinkedHashSet:底层是 HashMap + 双向链表,既保证了插入顺序,又保证了元素唯一性

因此,Set 保证元素不重复,依赖的是 hashCode()equals() 方法(哈希结构)或 compareTo() / Comparator(排序结构)

2# 有序的 Set 是什么?记录插入顺序的集合是哪种?

有序的 Set

  • TreeSet :它基于红黑树实现,能够按照元素的自然顺序(或自定义的 Comparator)自动排序
  • LinkedHashSet :它基于 哈希表 + 双向链表 实现,保持的是元素的插入顺序,而非排序顺序

记录插入顺序的 Set

  • LinkedHashSet 是专门用来记录插入顺序的 Set。它在保证元素不重复的同时,也能按照插入的顺序进行迭代访问