Java集合常见面试题总结(下)
背诵重点
下篇重点放在 Map,尤其是 HashMap 和 ConcurrentHashMap。面试回答时优先说清楚“数据结构、扩容、哈希冲突、线程安全、null 支持、JDK 版本差异”。
建议按这条线背:
HashMap和其他 Map 的区别。HashMap底层结构、扩容、树化、2 的幂。HashMap为什么线程不安全。ConcurrentHashMap的线程安全实现。ConcurrentHashMap复合操作、null 限制和 JDK 版本差异。
HashMap 和 Hashtable 有什么区别?
核心区别:
HashMap线程不安全,Hashtable线程安全。HashMap允许一个nullkey 和多个nullvalue,Hashtable不允许 key 或 value 为null。HashMap性能通常更好,Hashtable方法基本用synchronized修饰,锁粒度大,性能较差。HashMap默认容量是 16,扩容为原来的 2 倍;Hashtable默认容量是 11,扩容通常是2n + 1。HashMap在 JDK 8 后是数组 + 链表 + 红黑树;Hashtable仍主要是数组 + 链表。
实际开发中基本不用 Hashtable。需要线程安全 Map,优先使用 ConcurrentHashMap。
HashMap 和 HashSet 有什么区别?
HashMap 实现 Map 接口,存储 key-value 键值对。
HashSet 实现 Set 接口,只存储元素,不存储 value。
HashSet 底层基于 HashMap 实现,添加元素时元素作为 HashMap 的 key,value 使用一个固定的占位对象。
一句话:HashSet 本质上是只用 key 的 HashMap。
HashMap 和 TreeMap 有什么区别?
HashMap 底层是哈希表,查询、插入、删除平均时间复杂度 O(1),不保证顺序。
TreeMap 底层是红黑树,查询、插入、删除时间复杂度 O(log n),key 按自然顺序或比较器排序。
选择:
- 只需要快速查找:
HashMap。 - 需要按 key 排序,或需要范围查询:
TreeMap。
HashSet 如何检查重复?
HashSet 底层用 HashMap 保存元素。
添加元素时,先计算元素的 hashCode() 定位桶;如果桶里已有元素,再用 equals() 判断是否真的相等。
判断重复的规则:
hashCode()不同:一定不是重复元素。hashCode()相同:继续用equals()判断。equals()为true:认为重复,不再添加。
所以,放入 HashSet 的对象如果重写了 equals(),也应该重写 hashCode()。
HashMap 的底层实现是什么?
JDK 8 之后,HashMap 底层是数组 + 链表 + 红黑树。
存储流程:
- 根据 key 的
hashCode()计算 hash。 - 通过
(n - 1) & hash定位数组下标。 - 如果桶为空,直接放入。
- 如果桶不为空,先比较 key,key 相同则覆盖 value。
- key 不同则放到链表或红黑树中。
- 链表过长并且数组容量足够时,会树化成红黑树。
JDK 8 之前主要是数组 + 链表,JDK 8 后链表过长会转红黑树,降低极端哈希冲突下的查询成本。
HashMap 什么时候会树化?
JDK 8 中,链表长度达到树化阈值 8 时,会尝试树化。
但不是一达到 8 就一定树化。如果数组容量小于 64,优先扩容,而不是树化。
树化条件可以简单记:
- 链表长度大于等于 8。
- 数组容量大于等于 64。
如果红黑树节点变少,也可能退化回链表。
HashMap 为什么长度是 2 的幂?
主要是为了高效定位桶下标,并减少哈希冲突。
HashMap 通过 (n - 1) & hash 计算数组下标。只有当 n 是 2 的幂时,n - 1 的二进制低位才全是 1,按位与才能更均匀地保留 hash 的低位信息。
好处:
- 位运算比取模更快。
- 哈希分布更均匀。
- 扩容时元素迁移更简单,要么留在原位置,要么移动到
oldIndex + oldCap。
HashMap 的扩容机制是什么?
HashMap 默认容量是 16,默认负载因子是 0.75。
当元素数量超过 容量 * 负载因子 时,会触发扩容。每次扩容通常变成原来的 2 倍。
扩容时会创建新数组,并把旧数组中的元素重新分布到新数组中。扩容成本较高,所以如果能预估容量,建议初始化时指定合适大小。
HashMap 为什么线程不安全?
HashMap 没有同步控制,多线程同时读写会有问题。
常见风险:
- 多线程同时 put,可能导致数据覆盖。
- 扩容时并发修改,可能导致结构异常。
- 一个线程修改后,其他线程不一定立刻可见。
- 复合操作比如
containsKey()后再put()不是原子的。
并发环境下不要使用普通 HashMap 共享读写,应该用 ConcurrentHashMap 或外部加锁。
HashMap 多线程操作为什么可能出问题?
JDK 7 中,HashMap 并发扩容时可能因为链表头插法造成链表成环,导致查询时死循环。
JDK 8 改成尾插法后,死循环问题大幅缓解,但它仍然不是线程安全的,仍可能出现数据覆盖、读到旧值、结构不一致等问题。
所以结论不变:多线程共享写 Map,用 ConcurrentHashMap。
HashMap 有哪些遍历方式?
常见方式:
- 遍历
entrySet():推荐方式,可以同时拿到 key 和 value。 - 遍历
keySet():只需要 key,或再通过 key 获取 value。 - 遍历
values():只需要 value。 - 使用
forEach():代码更简洁。
如果需要同时使用 key 和 value,优先遍历 entrySet(),避免通过 key 再查一次 value。
HashMap 和 ConcurrentHashMap 有什么区别?
HashMap 是非线程安全 Map,适合单线程或局部变量场景。
ConcurrentHashMap 是线程安全 Map,适合多线程共享读写。
主要区别:
HashMap允许nullkey 和nullvalue;ConcurrentHashMap不允许。HashMap没有并发控制;ConcurrentHashMap使用 CAS、synchronized等机制保证并发安全。HashMap复合操作不安全;ConcurrentHashMap提供putIfAbsent()、compute()、merge()等原子复合操作。- 单线程下
HashMap开销更低;并发场景ConcurrentHashMap更合适。
ConcurrentHashMap 和 Hashtable 有什么区别?
两者都是线程安全 Map,但实现方式不同。
Hashtable 使用 synchronized 修饰方法,相当于锁住整张表,锁粒度大,并发性能差。
ConcurrentHashMap 锁粒度更细。JDK 7 使用分段锁;JDK 8 使用 CAS + synchronized 锁桶头节点,读操作大多无锁。
实际开发中需要线程安全 Map,优先使用 ConcurrentHashMap。
ConcurrentHashMap 底层怎么保证线程安全?
JDK 7 中,ConcurrentHashMap 使用 Segment 分段锁。不同段可以并发写,提高并发度。
JDK 8 中,取消了分段锁,底层结构类似 HashMap,是数组 + 链表 + 红黑树。并发控制主要依赖:
- CAS:用于初始化数组、更新计数、插入空桶等场景。
synchronized:桶不为空时,锁住桶头节点进行写操作。volatile:保证关键字段的可见性。
一句话:JDK 8 的 ConcurrentHashMap 用更细粒度的桶级锁和 CAS 保证线程安全。
JDK 7 和 JDK 8 的 ConcurrentHashMap 有什么不同?
JDK 7:
- 数据结构是
Segment数组 +HashEntry数组 + 链表。 - 通过
Segment分段锁保证线程安全。 - 并发度受
Segment数量影响。
JDK 8:
- 数据结构是数组 + 链表 + 红黑树。
- 取消
Segment分段锁。 - 使用 CAS +
synchronized保证并发安全。 - 锁粒度更细,通常并发性能更好。
ConcurrentHashMap 为什么 key 和 value 不能为 null?
主要是避免并发场景下的二义性。
如果 map.get(key) 返回 null,无法判断是 key 不存在,还是 key 存在但 value 就是 null。
普通 HashMap 可以再调用 containsKey() 判断,但并发场景下两次调用之间 Map 可能已经被其他线程修改,因此这个判断不可靠。
所以 ConcurrentHashMap 直接禁止 key 和 value 为 null。
ConcurrentHashMap 能保证复合操作的原子性吗?
单个方法调用是线程安全的,但普通组合逻辑不一定原子。
例如下面逻辑不是原子的:
if (!map.containsKey(key)) {
map.put(key, value);
}因为判断和写入之间可能被其他线程插入。
应该使用 ConcurrentHashMap 提供的原子复合方法:
putIfAbsent()computeIfAbsent()compute()merge()
ConcurrentHashMap 读操作要加锁吗?
大多数读操作不加锁。
ConcurrentHashMap 通过 volatile 保证节点和数组引用的可见性,读线程通常可以直接读取。
写操作才需要 CAS 或锁控制。这样读多写少场景下性能很好。
ConcurrentHashMap 统计 size 准确吗?
并发修改时,size() 返回的是一个瞬时统计值,不适合作为强一致判断依据。
如果 Map 在被其他线程持续修改,size() 可能刚返回就过期。
实际业务中,不要用 size() 做并发流程控制。需要精确控制时,应使用额外的同步、计数器或业务约束。
Collections 工具类常用方法有哪些?
Collections 是集合工具类,常见方法:
- 排序:
sort()。 - 查找:
binarySearch()、max()、min()。 - 替换/填充:
replaceAll()、fill()。 - 反转/打乱:
reverse()、shuffle()。 - 同步包装:
synchronizedList()、synchronizedMap()。 - 不可变包装:
unmodifiableList()、unmodifiableMap()。
注意:Collections.synchronizedMap() 是整体加锁,性能通常不如 ConcurrentHashMap。
Collections 和 Collection 有什么区别?
Collection 是集合顶层接口之一,List、Set、Queue 都继承它。
Collections 是工具类,提供排序、查找、同步包装、不可变包装等静态方法。
一句话:Collection 是接口,Collections 是工具类。
集合判空推荐怎么写?
普通 Java 可以写:
list != null && !list.isEmpty()如果项目引入了工具类,也可以用 CollectionUtils.isEmpty(list)。
注意区分:
null:集合对象不存在。- empty:集合存在,但没有元素。
业务含义不同,不要混用。
