Java 集合面试题精讲


底层原理类

Q1: HashMap 的底层数据结构是什么?

🎯 面试直答版

JDK8 的 HashMap 底层是数组 + 链表 + 红黑树。数组是主体,链表解决哈希冲突,当链表长度 ≥ 8 且数组长度 ≥ 64 时链表转为红黑树,将查找从 O(n) 优化为 O(log n)。

📖 深度解析版

  1. 数组(Node[] table):HashMap 的骨架,每个位置叫一个”桶”(bucket),默认初始长度 16,必须是 2 的幂
  2. 链表:当多个 key 的 hash 值映射到同一个桶时,用链表串起来(拉链法)。JDK8 采用尾插法(JDK7 是头插法)
  3. 红黑树:当链表过长时查找退化为 O(n),转为红黑树后为 O(log n)。树化需要两个条件同时满足:
    • 链表长度 ≥ 8(TREEIFY_THRESHOLD)
    • 数组长度 ≥ 64(MIN_TREEIFY_CAPACITY),否则优先扩容

💡 加分项

  • 为什么 JDK8 从头插法改为尾插法?因为头插法在多线程 resize 时会导致链表形成环,造成死循环(CPU 100%)。尾插法不会改变链表顺序,避免了这个问题
  • 红黑树退化为链表的阈值是 6(不是 8),中间留了缓冲区防止频繁转换抖动

Q2: HashMap 的 put 过程?

🎯 面试直答版

  1. 计算 key 的 hash(高低 16 位异或扰动)
  2. (n-1) & hash 定位桶
  3. 桶为空直接放入;不为空则遍历链表/红黑树,找到相同 key 就覆盖 value,没找到就插入末尾
  4. 插入后判断是否需要树化(链表≥8)或扩容(size > threshold)

📖 深度解析版

① 对 key 做 hash 运算:hash = (h = key.hashCode()) ^ (h >>> 16)
   高 16 位和低 16 位异或,让所有位都参与运算,减少碰撞

② 如果 table 为空或长度为 0,调用 resize() 初始化

③ 计算桶索引 i = (n - 1) & hash
   - 桶为空 → 直接创建节点放入
   - 桶不为空:
     a. 首节点 key 相同 → 覆盖
     b. 首节点是 TreeNode → 红黑树插入
     c. 遍历链表 → 尾部插入,插入后判断链表长度是否 ≥ 8

④ 如果 key 已存在(覆盖),返回旧 value

⑤ modCount++,size++,判断 size > threshold 则 resize()

💡 加分项

  • null key 固定放在 table[0],因为 hash(null) = 0
  • 扰动函数将碰撞概率从 hashCode 的质量中”解救”出来,即使 hashCode 实现不好,扰动后分布也相对均匀

Q3: HashMap 的扩容机制?

🎯 面试直答版

当元素数量超过 容量 × 负载因子(默认 16×0.75=12)时触发扩容。新数组容量翻倍,然后将旧元素重新分配到新数组中。JDK8 优化了重新分配逻辑:元素要么在原位置,要么在”原位置 + 旧容量”位置。

📖 深度解析版

  1. 触发条件size > capacity * loadFactor
  2. 扩容大小:新容量 = 旧容量 × 2(左移一位)
  3. 元素迁移(JDK8 优化):
    • 不需要重新计算 hash
    • 只需判断 hash & oldCap 的结果:
      • 等于 0 → 留在原位置
      • 不等于 0 → 移到原位置 + oldCap
    • 链表拆分为高位链表和低位链表,分别放入新数组
旧容量 = 16 (10000),新容量 = 32 (100000)

元素 hash = ...0_1010 → hash & 16 = 0 → 留在 index=10
元素 hash = ...1_1010 → hash & 16 ≠ 0 → 移到 index=10+16=26

💡 加分项

  • 负载因子 0.75 是空间和时间的折中:太小浪费空间,太大碰撞增多
  • JDK7 在多线程扩容时,头插法会导致链表成环 → 死循环。JDK8 用尾插法解决了这个问题,但 HashMap 仍然不是线程安全的(并发 put 可能数据丢失)

Q4: 为什么链表长度到 8 才转红黑树?

🎯 面试直答版

根据泊松分布计算,在 hash 均匀分布的情况下,链表长度达到 8 的概率只有千万分之六。所以 8 是一个极端情况下的兜底策略,平时几乎不会触发。

📖 深度解析版

HashMap 源码注释中给出了泊松分布下桶中元素个数的概率:

0:    0.60653066
1:    0.30326533
2:    0.07581633
3:    0.01263606
4:    0.00157952
5:    0.00015795
6:    0.00001316
7:    0.00000094
8:    0.00000006   ← 六千万分之一

红黑树的节点大小约为链表节点的 2 倍(TreeNode 需要维护 parent、left、right、color 等字段),如果太早树化会浪费空间。8 这个阈值保证了只有在 hash 分布极不均匀的异常场景下才会树化。

💡 加分项

  • 退化阈值设为 6(不是 7 或 8)是为了避免抖动:如果树化和退化用同一个阈值,某个桶反复在阈值附近增删就会频繁转换
  • 树化还有第二个条件:数组长度 ≥ 64。因为数组太小时,碰撞多是正常现象,应该优先扩容

Q5: ConcurrentHashMap 怎么保证线程安全?

🎯 面试直答版

JDK7 用分段锁(Segment,16 个 ReentrantLock),不同段可以并发操作。JDK8 改用 CAS + synchronized,锁粒度从段级别细化到桶级别——空桶用 CAS 无锁写入,非空桶用 synchronized 锁住桶的头节点。

📖 深度解析版

JDK7 分段锁

  • 将数据分为 16 个 Segment,每个 Segment 是一个独立的小 HashMap + ReentrantLock
  • 最大并发度 = Segment 数量 = 16
  • 缺点:锁粒度大,16 个线程以上就有锁竞争

JDK8 CAS + synchronized

  • 取消 Segment,结构和 HashMap 一样(数组 + 链表 + 红黑树)
  • put 流程:
    1. 计算 hash,定位桶
    2. 桶为空 → CAS 写入(无锁,最快路径)
    3. 正在扩容 → 帮助扩容(多线程协作)
    4. 桶不为空 → synchronized(头节点) → 链表/红黑树操作
  • 最大并发度 = 数组长度(远大于 16)

💡 加分项

  • size() 的实现类似 LongAdder:baseCount + CounterCell[],分散 CAS 竞争
  • JDK8 中 synchronized 经过了大量优化(偏向锁→轻量级锁→重量级锁),性能不比 ReentrantLock 差
  • ConcurrentHashMap 不允许 null key 和 null value,因为 get(key)==null 时无法区分”key 不存在”还是”value 就是 null”(在并发场景下这种歧义更危险)

Q6: HashMap 的 hash 函数为什么要用高低位异或?

🎯 面试直答版

桶定位公式是 (n-1) & hash,当数组长度 n 较小时(如 16),只有低 4 位参与运算,高位完全被忽略。高低 16 位异或让高位信息也能影响桶定位,降低碰撞概率。

📖 深度解析版

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

假设 n = 16,则 (n-1) & hash 取 hash 的低 4 位。如果两个 key 的 hashCode 低 4 位相同但高位不同,不扰动的话它们会 100% 碰撞。异或后,高位的差异被混入低位,碰撞概率大幅下降。

💡 加分项

  • 用异或而不是与/或,是因为异或的信息保留最好:AND 倾向产生 0,OR 倾向产生 1,XOR 能均匀保留两边的信息
  • JDK7 做了 4 次扰动(4次位移+异或),JDK8 简化为 1 次,因为实验表明收益递减,1 次就够用了

对比类

Q7: ArrayList 和 LinkedList 的区别?

🎯 面试直答版

ArrayList 基于动态数组,支持 O(1) 随机访问,尾部增删 O(1),中间增删 O(n)。LinkedList 基于双向链表,不支持随机访问,头尾增删 O(1)。实际开发中 99% 的场景用 ArrayList,因为 CPU 缓存对连续内存更友好。

📖 深度解析版

维度ArrayListLinkedList
底层结构动态数组双向链表
随机访问O(1),直接计算偏移O(n),从头/尾遍历
尾部插入均摊 O(1)O(1)
头部插入O(n),要移动所有元素O(1)
中间插入O(n),移动后续元素O(n),先遍历定位再 O(1) 插入
内存占用紧凑(可能有尾部空闲)每个节点额外两个指针(16字节/节点)
CPU 缓存友好(连续内存)不友好(节点散落堆中)

💡 加分项

  • LinkedList 实现了 Deque 接口,可以当栈和队列用,但 ArrayDeque 在这两个场景都更快
  • ArrayList 的 elementDatatransient 修饰,序列化时只写入实际有数据的部分,避免空槽浪费

Q8: HashMap、Hashtable 和 ConcurrentHashMap 的区别?

🎯 面试直答版

  • HashMap:非线程安全,允许 null key/value,性能最好
  • Hashtable:线程安全(所有方法加 synchronized),不允许 null,性能差,已淘汰
  • ConcurrentHashMap:线程安全,不允许 null,细粒度锁(JDK8 锁桶头节点),高并发首选

📖 深度解析版

特性HashMapHashtableConcurrentHashMap
线程安全✅(方法级 synchronized)✅(CAS + synchronized 桶头节点)
null key✅ 允许 1 个
null value✅ 允许
初始容量161116
扩容2 倍2倍+12 倍
数据结构数组+链表+红黑树数组+链表数组+链表+红黑树
迭代器fail-fastfail-fastfail-safe
继承关系AbstractMapDictionary(已废弃)AbstractMap
并发性能极差(全表锁)高(锁粒度=桶)

💡 加分项

  • ConcurrentHashMap 不允许 null 的原因:get(key) 返回 null 时,无法区分”key 不存在”还是”value 是 null”。单线程下可以用 containsKey() 再判断一次,但并发场景下两次调用之间状态可能已变
  • Hashtable 是 JDK1.0 的遗留类,和 Vector 一样不推荐使用

Q9: HashMap 和 TreeMap 怎么选?

🎯 面试直答版

需要按 key 排序或范围查询用 TreeMap(O(log n)),否则用 HashMap(O(1))。绝大多数场景用 HashMap。

📖 深度解析版

维度HashMapTreeMap
底层结构数组+链表+红黑树红黑树
查找/插入O(1)O(log n)
是否有序无序按 key 排序
null key允许不允许(无法比较)
适用场景通用键值映射需要排序/范围查询
// TreeMap 独有能力
TreeMap<Integer, String> map = new TreeMap<>();
map.firstKey();           // 最小 key
map.lastKey();            // 最大 key
map.headMap(50);          // 所有 < 50 的
map.tailMap(50);          // 所有 ≥ 50 的
map.subMap(20, 50);       // 20 ≤ key < 50
map.floorKey(45);         // ≤ 45 的最大 key
map.ceilingKey(45);       // ≥ 45 的最小 key

💡 加分项

  • 如果只需要插入顺序,用 LinkedHashMap 而不是 TreeMap,因为它底层是 HashMap + 链表,O(1) 性能
  • TreeMap 的 key 必须实现 Comparable 或构造时传入 Comparator

Q10: HashSet 如何保证元素不重复?

🎯 面试直答版

HashSet 底层就是 HashMap,元素作为 HashMap 的 key 存储。HashMap 通过 hashCode() 定位桶 + equals() 判断相等 来保证 key 不重复,所以 HashSet 的去重也依赖这两个方法。

📖 深度解析版

// add 的本质
public boolean add(E e) {
    return map.put(e, PRESENT) == null;
    // put 返回 null 说明是新 key → add 成功
    // put 返回旧 value 说明 key 已存在 → add 失败(返回 false)
}

去重流程:

  1. 计算元素的 hashCode(),通过扰动函数得到 hash
  2. hash & (n-1) 定位桶
  3. 遍历桶中元素,用 equals() 逐一比较
  4. 找到相等的 → 元素已存在,不插入
  5. 没找到 → 插入新元素

💡 加分项

  • 所以自定义对象放入 HashSet 时,必须同时重写 hashCode() 和 equals()。只重写 equals 不重写 hashCode,两个”相等”的对象可能落在不同桶中,导致去重失败
  • hashCode 的契约:a.equals(b) == truea.hashCode() == b.hashCode() 必须成立。反之不要求(不同对象可以有相同 hashCode)

Q11: Collection 和 Collections 的区别?

🎯 面试直答版

Collection 是集合框架的根接口(List、Set、Queue 都继承它)。Collections 是工具类,提供排序、查找、同步包装等静态方法。

📖 深度解析版

// Collection:接口,定义集合的基本行为
public interface Collection<E> extends Iterable<E> {
    boolean add(E e);
    boolean remove(Object o);
    int size();
    // ...
}

// Collections:工具类,操作集合的静态方法集
public class Collections {
    public static <T> void sort(List<T> list) { ... }
    public static <T> List<T> unmodifiableList(List<T> list) { ... }
    public static <T> List<T> synchronizedList(List<T> list) { ... }
    public static <T> List<T> emptyList() { ... }
    // ...
}

💡 加分项

  • 类似的设计还有 Array(反射类)和 Arrays(数组工具类),Executor(接口)和 Executors(工厂类)

Q12: Comparable 和 Comparator 的区别?

🎯 面试直答版

Comparable 是类自己实现的排序规则(“内比较器”),只能定义一种排序方式。Comparator 是外部传入的排序规则(“外比较器”),可以定义多种排序方式。

📖 深度解析版

维度ComparableComparator
java.langjava.util
方法compareTo(T o)compare(T o1, T o2)
使用方式类实现接口外部传入 Lambda/匿名类
排序规则数一种(自然排序)无限种
侵入性需要修改类无需修改类
// Comparable:类自身定义排序
public class User implements Comparable<User> {
    @Override
    public int compareTo(User other) {
        return this.age - other.age; // 按年龄升序
    }
}

// Comparator:外部定义排序(更灵活)
users.sort(Comparator.comparing(User::getName));                    // 按姓名
users.sort(Comparator.comparing(User::getAge).reversed());          // 按年龄降序
users.sort(Comparator.comparing(User::getAge)
                      .thenComparing(User::getName));               // 先年龄后姓名

💡 加分项

  • TreeMap/TreeSet 优先使用构造时传入的 Comparator;没传才用 key 的 Comparable
  • JDK8 后 Comparator 提供了大量静态方法和默认方法(comparingthenComparingreversednullsFirst),实际开发中几乎不需要手写比较逻辑

场景设计类

Q13: 如何设计一个线程安全的 LRU 缓存?

🎯 面试直答版

LinkedHashMap(accessOrder=true)重写 removeEldestEntry() 控制容量上限,外层用 Collections.synchronizedMap()ReentrantReadWriteLock 保证线程安全。

📖 深度解析版

方案一:LinkedHashMap + 同步包装(简单场景)

public class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int maxSize;

    public LRUCache(int maxSize) {
        super(maxSize * 4 / 3 + 1, 0.75f, true); // accessOrder=true
        this.maxSize = maxSize;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > maxSize;
    }

    // 线程安全版本
    public static <K, V> Map<K, V> createThreadSafe(int maxSize) {
        return Collections.synchronizedMap(new LRUCache<>(maxSize));
    }
}

方案二:生产环境直接用 Caffeine

Cache<String, Object> cache = Caffeine.newBuilder()
        .maximumSize(10000)
        .expireAfterAccess(Duration.ofMinutes(30))
        .recordStats()
        .build();

💡 加分项

  • 面试时先说 LinkedHashMap 方案展示你懂原理,然后补充说生产环境用 Caffeine/Guava Cache
  • Caffeine 底层用了 W-TinyLFU 算法,命中率优于纯 LRU

Q14: 大数据量去重应该用什么?

🎯 面试直答版

数据量不大用 HashSet。数据量大(百万以上)且允许少量误判用布隆过滤器(BloomFilter)。海量数据精确去重可以用位图(BitSet)或外部排序 + 归并去重

📖 深度解析版

方案适用数据量内存占用精确性说明
HashSet百万以内高(对象开销大)精确最简单直接
BitSet亿级整数极低(1bit/数)精确只能用于整数范围已知的场景
BloomFilter亿级任意类型有误判(假阳性)Guava/Redisson 都有实现
外部排序磁盘级不受内存限制精确MapReduce 思想
Redis Set/HyperLogLog分布式场景取决于方案Set精确/HLL估算跨服务去重
// BloomFilter 示例(Guava)
BloomFilter<String> filter = BloomFilter.create(
    Funnels.stringFunnel(Charset.defaultCharset()),
    10_000_000,  // 预期元素数量
    0.001        // 误判率
);

filter.put("user:1001");
filter.mightContain("user:1001"); // true
filter.mightContain("user:9999"); // false(大概率),可能 true(0.1% 概率误判)

💡 加分项

  • 10 亿个整数去重,用 BitSet 只需要 125MB 内存(10^9 / 8 bytes)
  • BloomFilter 不支持删除,需要删除功能可用 Counting Bloom Filter 或 Cuckoo Filter

Q15: 如何选择合适的 Map 实现?

🎯 面试直答版

  • 通用场景 → HashMap
  • 需要线程安全 → ConcurrentHashMap
  • 需要按 key 排序 → TreeMap
  • 需要保持插入顺序 → LinkedHashMap
  • 枚举作为 key → EnumMap
  • 需要弱引用 key → WeakHashMap

📖 深度解析版

                需要 Map

          需要线程安全?
         ┌── 是 ──┤── 否 ──┐
         │                  │
  ConcurrentHashMap    需要有序?
                    ┌── 是 ──┤── 否 ──┐
                    │                  │
              按什么排序?           HashMap
           ┌─── key ───┤─── 插入顺序 ──┐
           │                            │
        TreeMap                   LinkedHashMap

💡 加分项

  • IdentityHashMap:用 == 而非 equals() 判断 key 相等,适用于序列化/拓扑遍历等需要”引用相等”的场景
  • WeakHashMap:key 是弱引用,GC 时自动回收无外部引用的 entry,常用于缓存(如 ThreadLocalMap 的设计灵感来源)

Q16: 遍历 Map 有几种方式?哪种最优?

🎯 面试直答版

4 种方式。推荐用 entrySet()(或 JDK8 的 forEach),因为一次拿到 key 和 value,避免二次查找。

📖 深度解析版

Map<String, Integer> map = new HashMap<>();

// 方式一:entrySet()(推荐)
for (Map.Entry<String, Integer> entry : map.entrySet()) {
    String key = entry.getKey();
    Integer value = entry.getValue();
}

// 方式二:forEach(JDK8+,最简洁)
map.forEach((key, value) -> {
    System.out.println(key + "=" + value);
});

// 方式三:keySet()(需要二次 get,性能稍差)
for (String key : map.keySet()) {
    Integer value = map.get(key); // 多一次 hash 查找
}

// 方式四:values()(只需要 value 时)
for (Integer value : map.values()) {
    // ...
}

💡 加分项

  • keySet() 方式在 HashMap 中多一次 O(1) 查找,影响不大。但在 TreeMap 中多一次 O(log n) 查找,差距明显

Q17: ArrayList 和 Vector 的区别?

🎯 面试直答版

Vector 是线程安全的(方法加 synchronized),ArrayList 不是。Vector 扩容翻倍,ArrayList 扩 1.5 倍。Vector 是 JDK1.0 的遗留类,已被淘汰,线程安全场景用 CopyOnWriteArrayList

📖 深度解析版

维度ArrayListVector
线程安全是(所有方法 synchronized)
扩容1.5 倍2 倍(或指定增量)
性能低(锁开销)
迭代器Iterator(fail-fast)Iterator + Enumeration
JDK 版本1.21.0
状态活跃使用已淘汰

💡 加分项

  • Vector 的 elements() 方法返回 Enumeration,这也是一个遗留接口
  • 需要线程安全 List 的三种方案对比:
    • Vector:全方法加锁,粒度太粗 ❌
    • Collections.synchronizedList():包装器,遍历时需手动加锁 ⚠️
    • CopyOnWriteArrayList:读无锁,写时复制,读多写少场景最优 ✅

Q18: CopyOnWriteArrayList 的原理?

🎯 面试直答版

写操作时复制一份新数组,在新数组上修改,再用新数组替换旧数组。读操作直接读旧数组,无需加锁。适合读多写少的场景。

📖 深度解析版

// 写操作(add/set/remove)
public boolean add(E e) {
    synchronized (lock) {              // 写加锁
        Object[] es = getArray();
        int len = es.length;
        es = Arrays.copyOf(es, len + 1);  // 复制新数组
        es[len] = e;
        setArray(es);                     // 替换引用(volatile)
        return true;
    }
}

// 读操作(get)
public E get(int index) {
    return elementAt(getArray(), index);  // 直接读,无锁
}
写时复制过程:

线程A 读取(读旧数组)     线程B 写入
     │                        │
     ↓                        ↓
  [a, b, c] (旧数组)      复制 → [a, b, c, d] (新数组)
     │                        │
     ↓                        ↓
  看到 [a, b, c]          引用指向新数组


                    后续读取看到 [a, b, c, d]

缺点

  1. 写操作开销大(每次都要复制数组)
  2. 数据弱一致(读可能读到旧数据)
  3. 不适合大数组频繁写

💡 加分项

  • CopyOnWriteArrayList 的迭代器是 fail-safe 的:创建迭代器时拿到的是当时的数组快照,后续修改不影响迭代
  • 适用场景:事件监听器列表、配置信息缓存、黑白名单等读远多于写的场景

Q19: 什么是 fail-fast 和 fail-safe?

🎯 面试直答版

fail-fast:遍历时如果集合被修改,立即抛出 ConcurrentModificationException。ArrayList、HashMap 都是 fail-fast 的。

fail-safe:遍历时操作的是副本或快照,不会抛异常但可能读到旧数据。CopyOnWriteArrayList、ConcurrentHashMap 是 fail-safe 的。

📖 深度解析版

fail-fast 原理

// 集合维护一个 modCount,每次结构性修改 modCount++
// 创建 Iterator 时记录 expectedModCount = modCount
// 每次 next() 检查 modCount != expectedModCount → 抛异常

fail-safe 原理

  • CopyOnWriteArrayList:Iterator 遍历的是创建时的数组快照
  • ConcurrentHashMap:Iterator 弱一致性,能看到创建后的部分修改(不保证全部)

💡 加分项

  • fail-fast 是一种”尽早暴露错误”的设计策略,不是线程安全保证。即使在单线程中,用 for-each 遍历时调用 list.remove() 也会触发
  • iterator.remove() 不会触发 fail-fast,因为它会同步更新 expectedModCount

Q20: HashMap 中 key 可以是可变对象吗?

🎯 面试直答版

技术上可以,但强烈不建议。如果 key 放入后被修改导致 hashCode 变了,就再也找不到这个 entry 了——它待在旧 hash 对应的桶里,但你用新 hash 去找,会去另一个桶。

📖 深度解析版

List<String> key = new ArrayList<>();
key.add("hello");

Map<List<String>, String> map = new HashMap<>();
map.put(key, "world");

System.out.println(map.get(key)); // "world" ✅

key.add("changed"); // 修改了 key → hashCode 变了

System.out.println(map.get(key)); // null 💥
// key 对应的 entry 还在 map 里,但你找不到了
// 因为 get 用新 hashCode 定位到了另一个桶

System.out.println(map.size()); // 1 —— entry 确实还在

最佳实践:HashMap 的 key 应该用不可变对象(String、Integer、Long 等)。如果必须用自定义对象,保证参与 hashCode 计算的字段不可变。

💡 加分项

  • 这也是为什么 String 是最常用的 HashMap key:String 是 final 类、不可变、hashCode 会缓存
  • String 的 hashCode 缓存:第一次计算后存在 hash 字段中,后续直接返回

Q21: HashMap 在 JDK7 和 JDK8 中的区别?

🎯 面试直答版

四个关键区别:①数据结构从”数组+链表”变为”数组+链表+红黑树”;②插入方式从头插法改为尾插法;③扩容时不再重新计算 hash,用位运算判断新位置;④hash 扰动从 4 次简化为 1 次。

📖 深度解析版

维度JDK7JDK8
数据结构数组 + 链表数组 + 链表 + 红黑树
插入方式头插法尾插法
扩容重新计算 hash & (newCap-1)hash & oldCap 判断高低位
hash 扰动4 次位移 + 异或1 次(高低 16 位异或)
链表遍历无树化优化链表 ≥ 8 且数组 ≥ 64 → 红黑树
初始化构造时分配数组懒初始化(首次 put 时分配)

💡 加分项

  • JDK7 的头插法为什么会造成死循环?两个线程同时 resize,线程A 把链表反转了一半被挂起,线程B 完成了反转。线程A 恢复后继续反转,导致节点互相指向形成环。后续 get() 遍历到这个桶就会死循环
  • JDK8 的尾插法保持链表原始顺序,不会形成环

Q22: 为什么 HashMap 的容量必须是 2 的幂?

🎯 面试直答版

为了用位运算hash & (n-1))代替取模(hash % n),位运算比取模快得多。同时保证 hash 值能均匀分布到各个桶。

📖 深度解析版

当 n = 2^k 时:
n - 1 的二进制是全 1(如 n=16, n-1=15=0b01111)
hash & (n-1) 等价于取 hash 的低 k 位,保证结果在 [0, n-1] 范围内

如果 n 不是 2 的幂:
n-1 的二进制中有 0,某些桶永远不会被映射到 → 分布不均匀
例如 n=10, n-1=9=0b1001
hash & 0b1001 只可能得到 0b0000, 0b0001, 0b1000, 0b1001 → 只有 4 个可能的桶

HashMap 如何保证容量是 2 的幂?

// tableSizeFor:找到 ≥ cap 的最小 2 的幂
static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
// 原理:把最高位的 1 往右"涂满",再 +1 就是 2 的幂
// 例如:cap=10 → n=9(0b1001) → 涂满→0b1111(15) → +1→16

💡 加分项

  • 即使你传入一个非 2 的幂的初始容量(如 new HashMap<>(10)),HashMap 内部也会自动向上取到最近的 2 的幂(16)

Q23: List.of() 和 Arrays.asList() 的区别?

🎯 面试直答版

Arrays.asList() 返回固定大小的 List(不能 add/remove,但能 set)。List.of()(JDK9+)返回完全不可变的 List(add/remove/set 都不行,且不允许 null 元素)。

📖 深度解析版

特性Arrays.asList()List.of() (JDK9+)
可变性固定大小(不能增删,能改)完全不可变
null 元素允许不允许(抛 NPE)
与原数组关系共享底层数组(互相影响)独立副本
序列化
String[] arr = {"a", "b", "c"};

List<String> asList = Arrays.asList(arr);
arr[0] = "x";
System.out.println(asList.get(0)); // "x" ← 被原数组影响了!

List<String> listOf = List.of("a", "b", "c");
// listOf.add("d");    // 💥 UnsupportedOperationException
// listOf.set(0, "x"); // 💥 UnsupportedOperationException

💡 加分项

  • 需要可变 List 时,统一用 new ArrayList<>(List.of(...))new ArrayList<>(Arrays.asList(...))
  • List.of() 内部实现针对元素数量做了优化:0个用空单例、1-2个用特殊字段、3+个用数组

Q24: 如何实现一个线程安全的 List?

🎯 面试直答版

三种方式:

  1. Collections.synchronizedList(new ArrayList<>()) — 包装器,遍历需手动加锁
  2. CopyOnWriteArrayList — 读多写少场景最优
  3. 使用并发队列如 ConcurrentLinkedDeque — 不需要随机访问时

📖 深度解析版

// 方案一:synchronizedList
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
// ⚠️ 遍历时必须手动加锁,否则仍可能 ConcurrentModificationException
synchronized (syncList) {
    for (String s : syncList) { /* ... */ }
}

// 方案二:CopyOnWriteArrayList(推荐:读多写少)
List<String> cowList = new CopyOnWriteArrayList<>();
cowList.add("a");   // 写加锁 + 复制数组
cowList.get(0);     // 读无锁
// 遍历无需加锁,遍历的是快照

// 方案三:并发队列(不需要随机访问时)
Deque<String> deque = new ConcurrentLinkedDeque<>();

💡 加分项

  • synchronizedList 的锁粒度是整个 list 对象,并发性能不如 CopyOnWriteArrayList 的读无锁方案
  • 如果写频繁且需要线程安全 List,考虑用 ConcurrentLinkedDeque + 有限的 API,或者用分段设计自己实现

Q25: HashMap 的 key 用 String 最合适的原因?

🎯 面试直答版

String 是不可变类(final 修饰,char[] 不可修改),保证 hashCode 不变,且 String 会缓存 hashCode 值,作为 HashMap 的 key 安全且高效。

📖 深度解析版

String 作为 HashMap key 的优势:

  1. 不可变性:放入 Map 后不会被意外修改,hashCode 不会变
  2. hashCode 缓存:String 内部有 private int hash 字段,第一次计算后缓存,后续 O(1)
  3. equals 高效:先比 hashCode(int 比较),再比长度,最后逐字符比
  4. 广泛使用:JSON key、配置 key、数据库字段名等天然是 String
// String 源码
public final class String { // final:不可继承,不可修改
    private final char[] value; // JDK9 后改为 byte[]
    private int hash; // 缓存 hashCode

    public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            for (char c : value) {
                h = 31 * h + c;
            }
            hash = h; // 缓存
        }
        return h;
    }
}

💡 加分项

  • 为什么乘数是 31?因为 31 是奇素数,且 31 * i 可以被 JVM 优化为 (i << 5) - i(位运算更快)
  • 如果必须用自定义对象作 key,建议让参与 hashCode 的字段都是 final 的

Q26: 说说你对红黑树的理解?

🎯 面试直答版

红黑树是一种自平衡二叉搜索树,通过”着色 + 旋转”保证最长路径不超过最短路径的 2 倍,所有操作 O(log n)。HashMap 在链表过长时用它来优化查找性能。

📖 深度解析版

五条性质

  1. 每个节点非红即黑
  2. 根节点是黑色
  3. 叶子节点(NIL)是黑色
  4. 红色节点的两个子节点必须是黑色(不能有两个连续红节点)
  5. 从任一节点到其所有叶子的路径上,黑色节点数相同(黑高相同)

为什么这五条性质能保证平衡?

  • 性质 4 + 5 共同保证:最长路径(红黑交替)≤ 2 × 最短路径(全黑)
  • 所以树高最多 2log(n+1),操作都是 O(log n)

与 AVL 树对比

红黑树AVL 树
平衡程度弱平衡(最长 ≤ 2×最短)严格平衡(左右高度差 ≤ 1)
查找稍慢更快
插入/删除旋转少(最多 3 次)旋转多
适用场景增删频繁(HashMap、TreeMap)查找密集(数据库索引)

💡 加分项

  • HashMap 选择红黑树而非 AVL 树,是因为 HashMap 的增删操作频繁,红黑树的插入/删除平均旋转次数少于 AVL
  • Java 中的 TreeMap、TreeSet、HashMap(JDK8链表树化)都用红黑树

Q27: HashMap 和 LinkedHashMap 有什么区别?

🎯 面试直答版

LinkedHashMap 继承自 HashMap,额外维护了一条双向链表来记录插入顺序(或访问顺序)。遍历 LinkedHashMap 时是按链表顺序而非 hash 桶顺序。

📖 深度解析版

// HashMap:遍历顺序不确定
Map<String, Integer> hashMap = new HashMap<>();
hashMap.put("c", 3);
hashMap.put("a", 1);
hashMap.put("b", 2);
// 遍历顺序可能是 a,b,c 或其他任意顺序

// LinkedHashMap(默认 accessOrder=false):按插入顺序
Map<String, Integer> linkedMap = new LinkedHashMap<>();
linkedMap.put("c", 3);
linkedMap.put("a", 1);
linkedMap.put("b", 2);
// 遍历顺序一定是 c, a, b

// LinkedHashMap(accessOrder=true):按访问顺序(LRU)
Map<String, Integer> lruMap = new LinkedHashMap<>(16, 0.75f, true);
lruMap.put("a", 1);
lruMap.put("b", 2);
lruMap.put("c", 3);
lruMap.get("a");
// 遍历顺序:b, c, a(a 最近被访问,移到末尾)

💡 加分项

  • LinkedHashMap 的节点继承自 HashMap.Node,额外增加了 before 和 after 两个指针
  • 内存开销比 HashMap 稍大(每个节点多两个指针),但遍历性能更稳定(只遍历实际元素,不像 HashMap 要跳过空桶)

Q28: ArrayDeque 和 LinkedList 作为队列/栈有什么区别?

🎯 面试直答版

ArrayDeque 基于循环数组,LinkedList 基于双向链表。ArrayDeque 在队列和栈两种场景下性能都优于 LinkedList(连续内存 + 无节点分配开销),且不允许 null 元素。

📖 深度解析版

维度ArrayDequeLinkedList
底层循环数组双向链表
内存紧凑(可能有空位)每个节点额外 24 字节
扩容翻倍无需扩容
null不允许允许
随机访问不支持O(n)
作为栈比 Stack 和 LinkedList 快可用但不推荐
作为队列比 LinkedList 快可用但不推荐
// ✅ 推荐:ArrayDeque 作为栈
Deque<Integer> stack = new ArrayDeque<>();
stack.push(1);  // 头部入
stack.pop();    // 头部出

// ✅ 推荐:ArrayDeque 作为队列
Deque<Integer> queue = new ArrayDeque<>();
queue.offer(1); // 尾部入
queue.poll();   // 头部出

💡 加分项

  • JDK 的官方文档推荐:作为栈用 ArrayDeque 替代 Stack,作为队列也用 ArrayDeque 替代 LinkedList
  • ArrayDeque 不是线程安全的,多线程场景用 ConcurrentLinkedDeque

Q29: 怎么保证 HashMap 线程安全?

🎯 面试直答版

三种方式:

  1. ConcurrentHashMap(首选,分段锁/CAS,并发性能最好)
  2. Collections.synchronizedMap(new HashMap<>())(全表锁,性能差)
  3. Hashtable(已淘汰,不推荐)

📖 深度解析版

// 方案一:ConcurrentHashMap(推荐)
Map<String, Object> map = new ConcurrentHashMap<>();
// JDK8: CAS + synchronized(桶头节点)
// 并发度 = 数组长度
// 不允许 null key/value

// 方案二:Collections.synchronizedMap
Map<String, Object> map = Collections.synchronizedMap(new HashMap<>());
// 所有方法用同一把互斥锁
// 遍历时需要手动加锁:synchronized(map) { for(...) }
// 允许 null(因为底层还是 HashMap)

// 方案三:Hashtable(不要用)
Map<String, Object> map = new Hashtable<>();
// 所有方法加 synchronized,性能差
// 不允许 null key/value

💡 加分项

  • ConcurrentHashMap 的 size() 不是精确值(并发下),如果需要精确 size,可以用 mappingCount() 返回 long 类型
  • ConcurrentHashMap 的复合操作(check-then-act)仍然需要注意原子性,使用 computeIfAbsentcomputemerge 等原子方法

Q30: Iterator 和 ListIterator 的区别?

🎯 面试直答版

Iterator 只能单向向前遍历所有 Collection。ListIterator 是 Iterator 的子接口,专用于 List,支持双向遍历、获取索引、添加和修改元素。

📖 深度解析版

能力IteratorListIterator
适用范围所有 Collection仅 List
遍历方向单向(forward)双向(forward + backward)
hasNext/next
hasPrevious/previous
remove
add
set
nextIndex/previousIndex
List<String> list = new ArrayList<>(List.of("a", "b", "c"));
ListIterator<String> it = list.listIterator();

while (it.hasNext()) {
    String s = it.next();
    if ("b".equals(s)) {
        it.set("B");  // 替换当前元素
        it.add("b2"); // 在当前位置后插入
    }
}
// list: [a, B, b2, c]

💡 加分项

  • listIterator(index) 可以从任意位置开始遍历
  • 实际开发中很少直接用 ListIterator,大多数操作用 Stream API 更简洁