1、说说有哪些常见的集合框架?

集合框架可以分为两条大的支线:
- Map 接口:表示键值对的集合,一个键映射到一个值。键不能重复,每个键只能对应一个值。Map 接口的实现类包括 HashMap、LinkedHashMap、TreeMap 等。
- Collection 接口:最基本的集合框架表示方式,提供了添加、删除、清空等基本操作,它主要有三个子接口:
- List 代表有序、可重复的集合,典型代表就是封装了动态数组的 ArrayList 和封装了链表的 LinkedList。
- Set 代表无序、不可重复的集合,典型代表就是 HashSet 和 TreeSet。
- Queue 代表队列,典型代表就是双端队列 ArrayDeque,以及优先级队列 PriorityQueue。
1.1 集合框架有哪几个常用工具类?
集合框架位于 java.util 包下,提供了两个常用的工具类:
- Collections:提供了一些对集合进行排序、二分查找、同步的静态方法。
- Arrays:提供了一些对数组进行排序、打印、和 List 进行转换的静态方法。
1.2 简单介绍一下队列?
Java 中的队列主要通过 Queue 接口和并发包下的 BlockingQueue 两个接口来实现。
优先级队列 PriorityQueue 实现了 Queue 接口,是一个无界队列,它的元素按照自然顺序排序或者 Comparator 比较器进行排序。双端队列 ArrayDeque 也实现了 Queue 接口,是一个基于数组的,可以在两端插入和删除元素的队列。
1.3 用过哪些集合类,它们的优劣?
我常用的集合类有 ArrayList、LinkedList、HashMap、LinkedHashMap。
ArrayList 可以看作是一个动态数组,可以在需要时动态扩容数组的容量,只不过需要复制元素到新的数组。优点是访问速度快,可以通过索引直接查找到元素。缺点是插入和删除元素可能需要移动或者复制元素。
LinkedList 是一个双向链表,适合频繁的插入和删除操作。优点是插入和删除元素的时候只需要改变节点的前后指针,缺点是访问元素时需要遍历链表。
HashMap 是一个基于哈希表的键值对集合。优点是可以根据键的哈希值快速查找到值,但有可能会发生哈希冲突,并且不保留键值对的插入顺序。
LinkedHashMap 在 HashMap 的基础上增加了一个双向链表来保持键值对的插入顺序。
1.4 队列和栈的区别了解吗?
队列是一种先进先出的数据结构,第一个加入队列的元素会成为第一个被移除的元素。栈是一种后进先出的数据结构,最后一个加入栈的元素会成为第一个被移除的元素。
1.5 哪些是线程安全的容器?
Vector、Hashtable、ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue、ArrayBlockingQueue、LinkedBlockingQueue 都是线程安全的。
1.6 Collection 继承了哪些接口?
Collection 继承了 Iterable 接口,这意味着所有实现 Collection 接口的类都必须实现 iterator() 方法,之后就可以使用增强型 for 循环遍历集合中的元素了。
2、ArrayList 和 LinkedList 有什么区别?
ArrayList 是基于数组实现的,LinkedList 是基于链表实现的。
2.1 ArrayList 和 LinkedList 的用途有什么不同?
多数情况下,ArrayList 更利于改查,LinkedList 更利于增删。
2.2 ArrayList 和 LinkedList 是否支持随机访问?
- ArrayList 是基于数组的,也实现了 RandomAccess 接口,所以它支持随机访问,可以通过下标直接获取元素。
- LinkedList 是基于链表的,所以它没法根据下标直接获取元素,不支持随机访问。
2.3 ArrayList 和 LinkedList 内存占用有何不同?
- ArrayList 是基于数组的,是一块连续的内存空间,所以它的内存占用是比较紧凑的;但如果涉及到扩容,就会重新分配内存,空间是原来的 1.5 倍。
- LinkedList 是基于链表的,每个节点都有一个指向下一个节点和上一个节点的引用,于是每个节点占用的内存空间比 ArrayList 稍微大一点。
2.4 ArrayList 和 LinkedList 的使用场景有什么不同?
ArrayList 适用的场景:
- 随机访问频繁:需要频繁通过索引访问元素的场景。
- 读取操作远多于写入操作:如存储不经常改变的列表。
- 末尾添加元素:需要频繁在列表末尾添加元素的场景。
LinkedList 适用的场景:
- 不需要快速随机访问:顺序访问多于随机访问的场景。
- 频繁插入和删除:在列表中间频繁插入和删除元素的场景。
- 队列和栈:由于其双向链表的特性,LinkedList 可以实现队列(FIFO)和栈(LIFO)。
2.5 链表和数组有什么区别?
- 数组在内存中占用的是一块连续的存储空间,因此我们可以通过数组下标快速访问任意元素。数组在创建时必须指定大小,一旦分配内存,数组的大小就固定了。
- 链表的元素存储在于内存中的任意位置,每个节点通过指针指向下一个节点。
3、ArrayList 的扩容机制了解吗?
当往 ArrayList 中添加元素时,会先检查是否需要扩容,如果当前容量 + 1 超过数组长度,就会进行扩容。扩容后的新数组长度是原来的 1.5 倍,然后再把原数组的值拷贝到新数组中。
4、ArrayList 怎么序列化的知道吗?
ArrayList 自定义了序列化逻辑从而只序列化有效数据,因为数组的容量一般大于实际的元素数量。
4.1 为什么 ArrayList 不直接序列化元素数组呢?
出于效率的考虑,数组可能长度 100,但实际只用了 50,剩下的 50 没用到,也就不需要序列化。
5、快速失败 fail-fast 了解吗?
在用迭代器遍历集合对象时,如果线程 A 遍历过程中,线程 B 对集合对象的内容进行了修改,就会抛出 Concurrent Modification Exception。迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用 hashNext()/next()遍历下一个元素之前,都会检测 modCount 变量是否为 expectedmodCount 值,是的话就返回遍历;否则抛出异常,终止遍历。
java.util 包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改),比如 ArrayList 类。
5.1 什么是安全失败?
采用安全失败机制的集合容器,在遍历时并不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。
场景:java.util.concurrent 包下的容器都是安全失败,可以在多线程下并发使用,并发修改,比如 CopyOnWriteArrayList 类
6、有哪几种实现 ArrayList 线程安全的方法?
- 可以使用 Collections.synchronizedList() 方法,它可以返回一个线程安全的 List。内部是通过 synchronized 关键字加锁来实现的。
- 也可以直接使用 CopyOnWriteArrayList,它是线程安全的 ArrayList,遵循写时复制的原则,每当对列表进行修改时,都会创建一个新副本,这个新副本会替换旧的列表,而对旧列表的所有读取操作仍然在原有的列表上进行。
6.1 ArrayList 和 Vector 的区别?
Vector 属于 JDK 1.0 时期的遗留类,不推荐使用,仍然保留着是因为 Java 希望向后兼容。
ArrayList 是在 JDK 1.2 时引入的,用于替代 Vector 作为主要的非同步动态数组实现。因为 Vector 所有的方法都使用了 synchronized 关键字进行同步,所以单线程环境下效率较低。
7、CopyOnWriteArrayList 了解多少?
CopyOnWriteArrayList 就是线程安全版本的 ArrayList。采用了一种读写分离的并发策略。CopyOnWriteArrayList 容器允许并发读,读操作是无锁的。至于写操作,比如说向容器中添加一个元素,首先将当前容器复制一份,然后在新副本上执行写操作,结束之后再将原容器的引用指向新容器。
8、能说一下 HashMap 的底层数据结构吗?
JDK 8 中 HashMap 的数据结构是数组 + 链表 + 红黑树:
数组用来存储键值对,每个键值对可以通过索引直接拿到,索引是通过对键的哈希值进行进一步的 hash() 处理得到的。当多个键经过哈希处理后得到相同的索引时,需要通过链表来解决哈希冲突:将具有相同索引的键值对通过链表存储起来。不过,链表过长时,查询效率会比较低,于是当链表的长度超过 8 时(且数组的长度大于 64),链表就会转换为红黑树。红黑树的查询效率是 O(logn),比链表的 O(n) 要快。
hash() 方法的目标是尽量减少哈希冲突,保证元素能够均匀地分布在数组的每个位置上。如果键的哈希值已经在数组中存在,其对应的值将被新值覆盖。
HashMap 的初始容量是 16,随着元素的不断添加,HashMap 就需要进行扩容,阈值是capacity * loadFactor,capacity 为容量,loadFactor 为负载因子,默认为 0.75。扩容后的数组大小是原来的 2 倍,然后把原来的元素重新计算哈希值,放到新的数组中。
9、你对红黑树了解多少?
红黑树是一种自平衡的二叉查找树:
- 左根右;
- 根叶黑;
- 不红红;
- 黑路同;
9.1 为什么不用二叉树?
二叉树是最基本的树结构,每个节点最多有两个子节点,但是二叉树容易出现极端情况,比如插入的数据是有序的,那么二叉树就会退化成链表,查询效率就会变成 O(n)。
9.2 为什么不用平衡二叉树?
平衡二叉树比红黑树的要求更高,每个节点的左右子树的高度最多相差 1,这种高度的平衡保证了极佳的查找效率,但在进行插入和删除操作时,可能需要频繁地进行旋转来维持树的平衡,维护成本更高。
9.3 为什么用红黑树?
链表的查找时间复杂度是 O(n),当链表长度较长时,查找性能会下降。红黑树是一种折中的方案,查找、插入、删除的时间复杂度都是 O(log n)。
9.4 红黑树插入删除规则?
默认新节点为红色:
插入逻辑:若插入后破坏红黑性质:
情况1:父节点为黑色 → 无需调整。
情况2:父节点为红色,递归调整。
删除逻辑:更复杂,需处理黑节点删除后的黑高减少问题。
10、红黑树怎么保持平衡的?
旋转和染色:
- 旋转:通过左旋和右旋来调整树的结构,避免某一侧过深。
- 染⾊:修复红黑规则,从而保证树的高度不会失衡。
11、HashMap 的 put 流程知道吗?

- 首先计算键的哈希值,通过 hashCode() 高位运算和扰动函数,减少哈希冲突。
- 进行第一次的数组扩容,确定桶的位置,根据哈希值按位计算数组索引。
- 处理键值对,若桶为空,直接插入新节点。若桶不为空,判断当前位置第一个节点是否与新节点的 key 相同,如果相同则更新 value,如果不同说明发生哈希冲突,吐过是链表,将新节点添加到链表的尾部;如果链表长度大于等于 8,则将链表转换为红黑树。
- 检查容量并扩容,插入后若 size > threshold (capacity * loadFactor),则扩容为原容量的两倍。并且重新计算每个节点的索引,数据将会重新分布。
11.1 只重写元素的 equals 方法没重写 hashCode,put 的时候会发生什么?
如果只重写 equals 方法,没有重写 hashCode 方法,那么会导致 equals 相等的两个对象,hashCode 不相等,这样的话,两个对象会被 put 到数组中不同的位置,size + 1,导致大量哈希冲突,退化成链表,查询效率降为 O(n)。