Skip to content

集合框架(Collections)深度解析 ​

本模块面试重点 ​

  • 集合体系结构:熟悉 Collection / Map 两大体系,知道 List、Set、Map 及它们常见实现类的大致特点和使用场景。
  • 常见实现类对比:ArrayList vs LinkedList、HashMap vs Hashtable vs ConcurrentHashMap,增删改查的时间复杂度和线程安全差异。
  • 典型坑点:HashMap 扩容/负载因子、遍历时修改集合导致的 ConcurrentModificationException、Arrays.asList 的不可变列表等。

❓ 面试官:Java 集合框架有哪些顶层接口?你能画一下大概的结构图吗? ​

频率:🔥🔥🔥🔥

💡 一句话总结(先抛结论): Java 集合框架主要分为两大派系:Collection(单列集合) 和 Map(双列键值对集合)。它们之下又派生出了 List、Set 和具体的 HashMap 等实现类。

📝 核心脉络梳理:

  1. Collection(单列存取)
    • List:有序、可重复、有索引。(代表:ArrayList、LinkedList)
    • Set:无序、不可重复、无索引。(代表:HashSet、TreeSet)
  2. Map(键值对存取)
    • 存储 Key-Value,其中 Key 不可重复,Value 可重复。(代表:HashMap、TreeMap、ConcurrentHashMap)

❓ 面试官:ArrayList 和 LinkedList 有什么区别?平时开发中怎么选? ​

频率:🔥🔥🔥🔥🔥

💡 一句话总结(先抛结论):ArrayList 底层是动态数组,适合多读少写(或尾部追加)的场景;LinkedList 底层是双向链表,适合频繁在头部或中间进行插入/删除的场景。实际开发中,99% 的情况都用 ArrayList。

📝 详细原理解析:

  1. 数据结构:
    • ArrayList:一块连续的内存空间。支持通过下标 O(1) 极速随机访问。但如果要在中间插入或删除元素,需要移动后面所有的元素,时间复杂度 O(N)。
    • LinkedList:内存不连续,靠指针连接。不支持随机访问,查第 N 个元素必须从头遍历 O(N)。但在已知节点位置的情况下,插入和删除只需修改指针,非常快。
  2. 空间占用:
    • ArrayList 会预留一定的容量空间(尾部可能有空闲),但总体紧凑。
    • LinkedList 每个节点都要额外存储两个指针(前驱和后继),消耗内存更大。

🌟 面试加分项(实战坑点): 面试官常追问:“你说 LinkedList 插入快,那如果我在末尾追加 1 万个元素,谁快?” 绝杀回答: "依然是 ArrayList 快! 很多人以为 LinkedList 插入一定快,其实是误区。LinkedList 每次 add 都要去堆内存里 new 一个 Node 对象,海量数据下会触发频繁的垃圾回收(GC),非常慢。而 ArrayList 只要没有触发扩容,尾部追加只是简单地给数组赋值,极其迅速。即使触发扩容(系统级的 System.arraycopy),整体性能依然秒杀 LinkedList。"


❓ 面试官:能详细讲讲 HashMap 的底层原理吗?(JDK 8 之后的结构)【中高级】 ​

频率:🔥🔥🔥🔥🔥

💡 一句话总结(先抛结论): JDK 8 中,HashMap 的底层数据结构是 “数组 + 链表 + 红黑树”。这种设计既保证了极快的通过哈希计算找桶的速度,又通过红黑树解决了极端情况下哈希冲突导致的链表过长、查询变慢的问题。

📝 详细原理解析:

  1. put(Key, Value) 核心流程:
    • 第一步:对 Key 调用 hashCode(),并经过一次扰动函数(高低 16 位异或),算出最终的 hash 值。
    • 第二步:根据 (n - 1) & hash 算出在数组中的索引位置。
    • 第三步:如果该位置为空,直接存入。
    • 第四步:如果发生哈希冲突(该位置已经有元素了),则用 equals() 判断 Key 是否一样。一样就覆盖 Value;不一样就在此节点后挂一个新节点(尾插法)。
    • 第五步(树化):如果当前链表长度大于等于 8,且数组总长度大于等于 64,就会把链表转化为红黑树,把 O(N) 的查询时间优化为 O(log N)。
  2. 扩容机制(Resize):
    • 默认初始容量是 16,加载因子是 0.75。
    • 当 size > 16 * 0.75 = 12 时,触发扩容,数组容量翻倍(变成 32)。
    • 扩容时极其巧妙:因为容量是 2 的幂次方,原来在链表上的元素重新计算位置时,只有两种结果:要么留在原位置,要么移动到(原位置 + 旧容量)的位置。

🌟 面试加分项(连环追问):

  • 问:为什么加载因子是 0.75 而不是 1 或 0.5? 答:这是时间和空间的折中。如果是 1,空间利用率高,但哈希冲突极其严重,链表会很长导致查询极慢;如果是 0.5,冲突少了,但内存浪费一半,且频繁触发扩容影响性能。0.75 是经过泊松分布概率计算得出的最优解。
  • 问:为什么链表转红黑树的阈值是 8? 答:根据泊松分布,在加载因子为 0.75 的理想哈希情况下,一个桶里挂 8 个节点的概率不到千万分之一。如果真的到了 8,说明哈希算法极差或者遭遇了恶意的哈希碰撞攻击,这时候才逼不得已转为红黑树保命(因为红黑树每个节点占空间比普通节点大一倍)。

❓ 面试官:HashMap 是线程安全的吗?多线程下会出什么问题?应该用什么代替?【中级】 ​

频率:🔥🔥🔥🔥

💡 一句话总结(先抛结论):HashMap 是绝对线程不安全的。在多线程并发 put 时,会导致数据覆盖丢失;如果是 JDK 7 版本,还会引发死循环(环形链表)导致 CPU 100%。必须用 ConcurrentHashMap 来代替。

📝 ConcurrentHashMap 怎么保证线程安全的?

  • JDK 7:采用了 “分段锁(Segment)” 机制。把整个大数组切分成 16 个小数组(Segment)。每次加锁只锁一个小数组,其他小数组的读写不受影响。
  • JDK 8 终极优化: 摒弃了臃肿的分段锁,直接采用和 HashMap 一样的 “数组+链表+红黑树” 结构。 线程安全靠的是 CAS(乐观锁) + synchronized(悲观锁)。 在 put 时:
    1. 如果发现该桶的位置是空的,直接使用无锁的 CAS 操作尝试把新节点塞进去。
    2. 如果发现桶里已经有元素了发生冲突,只使用 synchronized 锁住这一个头节点(链表首节点或树根)。锁的粒度变得极其细小,并发度达到了理论最大值(有多少个桶,就允许多少个线程同时并发写)。