CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
集合框架入门 List、Set、Map、泛型擦除直觉。
集合框架入门
这一课你将能做到
独立使用 List/Set/Map 三大容器完成增删改查和遍历,并按场景正确选型
解释泛型的作用与类型擦除的直觉,避免 Arrays.asList、ConcurrentModificationException 等经典坑
用代码演示 ArrayList 扩容机制和 HashMap 判重原理——两道集合区必考面试题
为什么要学
本站后端每一层都在和集合打交道:Mapper 从 MySQL 查回 List<User>,Service 用 Map<Long, User> 组装索引,Redis 客户端返回 Set<String>,接口传参常见 List<Long> productIds。可以说,不会集合 = 不会写后端 。
数组是定长的原始容器,集合是「可伸缩 + API 丰富 + 类型安全(泛型)」的现代容器。上一课的 HashMap 你已经见过一次,这一课把它讲透。
先建立图像
集合框架三大家族:
Collection(单列)
├── List 有序、可重复 → ArrayList(本站最常用)
├── Set 不重复 → HashSet(去重)
└── Queue 队列 → 少用,算法阶段再见
Map(双列,键值对,不属于 Collection)
└── HashMap:key 唯一,value 可重复(本站做缓存/索引最常用)
泛型 <>:给容器贴「只装什么类型」的标签
List<String> = 只装 String 的 List
选型口诀:要顺序可重复 → List;要去重 → Set;要按 key 找 value → Map。
跟着做
步骤 1:List 基本操作
新建 ListDemo.java:
import java.util.ArrayList;
import java.util.List;
public class ListDemo {
public static void main(String[] args) {
// 推荐写法:左边声明接口类型 List,右边给实现 ArrayList
List<String> techs = new ArrayList<>();
techs.add("java"); // 尾部追加
techs.add("mysql");
techs.add("redis");
techs.add("mysql"); // List 允许重复
System.out.println(techs); // [java, mysql, redis, mysql](List 重写了 toString)
System.out.println(techs.size()); // 4
System.out.println(techs.get(0)); // java(按下标取,O(1))
System.out.println(techs.indexOf("redis")); // 2
techs.set(1, "postgresql"); // 替换指定位置
techs.remove("mysql"); // 删除第一个匹配项
System.out.println(techs.contains("redis")); // true
System.out.println(techs); // [java, postgresql, redis, mysql]
// 截取子列表:含头不含尾
System.out.println(techs.subList(1, 3)); // [postgresql, redis]
}
}
步骤 2:遍历与安全删除
import java.util.ArrayList;
import java.util.List;
public class ListLoopDemo {
public static void main(String[] args) {
List<String> logs = new ArrayList<>(List.of("INFO-1", "ERROR-2", "INFO-3", "ERROR-4"));
// 方式 1:增强 for(只读场景首选)
for (String log : logs) {
System.out.print(log + " ");
}
System.out.println();
// 方式 2:普通 for(需要下标时)
for (int i = 0; i < logs.size(); i++) {
if (logs.get(i).startsWith("INFO")) {
System.out.println("跳过 " + logs.get(i));
}
}
// 方式 3:forEach + lambda(JDK 8+)
logs.forEach(l -> System.out.print(l + " "));
System.out.println();
// 经典坑:增强 for 遍历中直接 remove → ConcurrentModificationException
// for (String log : logs) { if (log.startsWith("ERROR")) logs.remove(log); } // 别这么干!
// 正确姿势 1:removeIf(最推荐)
logs.removeIf(l -> l.startsWith("ERROR"));
System.out.println(logs); // [INFO-1, INFO-3]
// 正确姿势 2:倒序普通 for 删除
List<String> list2 = new ArrayList<>(List.of("A", "B", "C", "B"));
for (int i = list2.size() - 1; i >= 0; i--) {
if (list2.get(i).equals("B")) list2.remove(i);
}
System.out.println(list2); // [A, C]
}
}
步骤 3:Set 去重 import java.util.HashSet;
import java.util.Set;
public class SetDemo {
public static void main(String[] args) {
Set<String> visitedIps = new HashSet<>();
System.out.println(visitedIps.add("10.0.0.1")); // true:新增成功
System.out.println(visitedIps.add("10.0.0.2")); // true
System.out.println(visitedIps.add("10.0.0.1")); // false:已存在,添加失败(去重)
System.out.println(visitedIps.size()); // 2
System.out.println(visitedIps.contains("10.0.0.2")); // true,O(1) 判存在
// 经典应用:List 快速去重
java.util.List<String> tags = java.util.List.of("java", "redis", "java", "mysql", "redis");
Set<String> distinct = new HashSet<>(tags);
System.out.println(distinct); // [java, redis, mysql](顺序不保证!)
// 需要保持插入顺序:LinkedHashSet
Set<String> ordered = new java.util.LinkedHashSet<>(tags);
System.out.println(ordered); // [java, redis, mysql, mysql 消失后的有序版]
}
}
步骤 4:Map 键值对 import java.util.HashMap;
import java.util.Map;
public class MapDemo {
public static void main(String[] args) {
Map<Long, String> userNames = new HashMap<>(); // key=用户id, value=用户名
userNames.put(1001L, "tom");
userNames.put(1002L, "jerry");
userNames.put(1001L, "tomcat"); // 同 key 再 put = 覆盖旧值
System.out.println(userNames); // {1001=tomcat, 1002=jerry}
System.out.println(userNames.get(1001L)); // tomcat
System.out.println(userNames.get(9999L)); // null(key 不存在返回 null,不报错)
System.out.println(userNames.getOrDefault(9999L, "游客")); // 游客
System.out.println(userNames.containsKey(1002L)); // true
// 经典用法:分组/计数
String[] words = {"mysql", "redis", "mysql", "kafka", "redis", "mysql"};
Map<String, Integer> counter = new HashMap<>();
for (String w : words) {
counter.put(w, counter.getOrDefault(w, 0) + 1);
}
System.out.println(counter); // {mysql=3, redis=2, kafka=1}
// 三种遍历方式
for (Map.Entry<Long, String> e : userNames.entrySet()) {
System.out.println("key=" + e.getKey() + ", value=" + e.getValue());
}
userNames.keySet().forEach(k -> System.out.print(k + " ")); // 只遍历 key
userNames.values().forEach(v -> System.out.print(v + " ")); // 只遍历 value
System.out.println();
userNames.remove(1002L); // 删除
System.out.println(userNames.size()); // 1
}
}
步骤 5:泛型与类型安全 import java.util.ArrayList;
import java.util.List;
public class GenericDemo {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
names.add("tom");
// names.add(123); // 编译错:泛型在编译期拦截类型错误
String first = names.get(0); // 取出来不用强转,自动就是 String
// 不用泛型的老写法(JDK 5 之前):什么都装,取出来全是 Object
List raw = new ArrayList();
raw.add("tom");
raw.add(123); // 居然能装进去
Object o = raw.get(1);
// String s = (String) o; // 运行时才炸:ClassCastException
System.out.println(first);
}
}
泛型把「运行时类型炸弹」提前到「编译期报错」。<> 里只能写引用类型(List<Integer>,不是 List<int>——基本类型走包装类,呼应第 3 课)。
步骤 6:ArrayList 扩容实证 import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
public class CapacityDemo {
public static void main(String[] args) throws Exception {
List<Integer> list = new ArrayList<>(); // 底层数组初始为空,首次 add 时容量 10
list.add(1);
System.out.println(capacity(list)); // 10
for (int i = 1; i <= 10; i++) list.add(i);
System.out.println(capacity(list)); // 10(刚好装满)
list.add(11);
System.out.println(capacity(list)); // 15 → 扩容为原来的 1.5 倍
}
// 反射偷看内部数组容量(学习用,生产别这么写)
static int capacity(List<?> l) throws Exception {
Field f = ArrayList.class.getDeclaredField("elementData");
f.setAccessible(true);
return ((Object[]) f.get(l)).length;
}
}
扩容动作 = 新建 1.5 倍数组 + System.arraycopy 整体搬移,O(n)。预知规模时传初始容量:new ArrayList<>(10000) 免去多次搬移。
讲清楚要点
声明用接口、实现选实现类 :List<String> l = new ArrayList<>()。以后换 LinkedList 或线程安全容器,调用方一行不改。
ArrayList 查快改慢 :按下标随机访问 O(1);头/中间插入删除 O(n)。频繁头部插入就换 ArrayDeque 或 LinkedList。
HashSet 的去重依赖 hashCode + equals :自定义类当元素必须重写这两兄弟(或用 record 自动生成),否则「相同」的对象各进各的桶。
HashMap 的 key 同理 :且 key 语义上不可变(用 String、Long 这类不可变类型最安全);key 一般不允许为 null(HashMap 允许一个,其他实现如 Hashtable 不允许)。
遍历中增删必须走 removeIf / 迭代器 / 倒序下标 :直接 remove 会触发并发修改检查(fail-fast)。
不可变集合 :List.of、Map.of、Set.of(JDK 9+)创建后不能增删改,适合做常量表;往可变集合拷贝:new ArrayList<>(List.of(...))。
你可能会懵的地方
ArrayList 和 LinkedList 到底怎么选?
99% 选 ArrayList。LinkedList 的「插入快」只在「已持有中间节点引用」时成立;按下标访问它是 O(n) 遍历,实际反而更慢。它真正的舞台是当 Deque(双端队列)用。
类型擦除是什么?
泛型信息只存在于编译期,编译后 List<String> 和 List<Integer> 都是同一个 List 字节码。推论:运行时拿不到 T 的 Class(new T()、T[] 都不行);list instanceof List<String> 不合法。
为什么 int[] 不能传给 List<Integer>?
泛型不支持基本类型,且 int[] 本身是一个对象(装 int 数组的 List 而不是装 Integer 的 List)。转换要靠循环或流:Arrays.stream(arr).boxed().toList()。
HashMap 是有序的吗?
无序(按 hash 桶分布)。要插入顺序用 LinkedHashMap(维护双向链表);要按 key 排序用 TreeMap。本站做「最近访问」缓存常用 LinkedHashMap 开启 accessOrder 实现 LRU。
HashMap 的底层原理(面试必问)?
数组 + 链表 + 红黑树(JDK 8+):key 经 hash 定位数组下标;冲突挂链表;链表长度超 8 且数组容量 ≥ 64 转红黑树,查询 O(log n)。默认容量 16,负载因子 0.75,超阈值扩容翻倍并 rehash。
遍历时想删除为什么报 ConcurrentModificationException?
集合内部有个 modCount 修改计数,迭代器发现计数对不上就快速失败(fail-fast),防止「边遍历边改」导致漏元素/错位。这是故意的保护机制。
错法对照 错法 问题 正解 List<int> ids泛型不接受基本类型 List<Integer>增强 for 里 list.remove(x) ConcurrentModificationException list.removeIf(...) 或倒序 forArrays.asList(...) 后调 add定长视图,抛 UnsupportedOperationException new ArrayList<>(Arrays.asList(...))自定义类不重写 hashCode 就进 HashSet 去重失效 重写 equals + hashCode,或改 record map.get(key) 直接 .method()key 不存在返回 null → NPE getOrDefault 或先 containsKeynew HashMap<>() 后判断 map.get(k) == null 区分「没存」和「存了 null」二义性 用 containsKey 遍历 Map 用 for (String k : map) Map 不能直接 for 先 keySet() / entrySet() / values()
卡点急救
UnsupportedOperationException :十有八九你在改 List.of / Arrays.asList 的结果。包一层 new ArrayList<>(...) 变可变。
ConcurrentModificationException :见步骤 2,removeIf 一招解决。
HashSet 装自定义对象去重失败 :重写 equals/hashCode(IDEA: Alt+Insert),或类改 record。
从 Map 取值后 NPE :养成 getOrDefault / Optional.ofNullable(map.get(k)) 习惯;后端代码 map 取值判空是肌肉记忆。
泛型报「不兼容的类型」 :检查尖括号类型与 add 内容是否一致;包装类与基本类型混用是最常见原因。
想要有序 Map/Set :HashMap/HashSet 无序;LinkedHash* 保插入序;Tree* 按比较器排序。
面试常问
ArrayList 与 LinkedList 区别?
底层:动态数组 vs 双向链表;随机访问 O(1) vs O(n);尾部追加均摊 O(1) vs O(1);中间插入理论 O(n) vs O(1)(但含查找仍是 O(n))。内存:ArrayList 连续紧凑、LinkedList 每节点多两个引用。结论:默认 ArrayList。
HashMap 的 put 流程?(高频)
计算 key 的 hash → 定位桶下标 → 桶空直接放 → 冲突则比较首节点(hash+equals 相同覆盖,否则挂链尾/入树)→ 超过阈值(12=16×0.75)扩容翻倍。
HashSet 如何保证不重复?
add 时先算 hashCode 定桶,再 equals 比内容;两者共同决定「相同」。所以元素必须正确实现这两个方法。
HashMap 和 Hashtable 区别?
线程安全(否 vs 全表锁)、null key(1 个 vs 不允许)、性能、初始容量(16 vs 11)。多线程场景现代写法用 ConcurrentHashMap。
fail-fast 是什么?
迭代期间检测到结构性修改(modCount 不一致)立即抛 ConcurrentModificationException 的保护机制。单线程用 removeIf 规避,多线程用 ConcurrentHashMap / CopyOnWriteArrayList。
自测三问
三种集合(List/Set/Map)各自的特性和典型场景?本站后端各举一例。
为什么增强 for 里 remove 会炸,而 removeIf 不会?
把 List.of("a","b") 返回的 List 调用 add 会发生什么?怎么改?
小结一句话 List 管顺序、Set 管唯一、Map 管索引;声明用接口、泛型拦截类型错误、遍历增删用 removeIf;ArrayList 1.5 倍扩容、HashMap 数组+链表+红黑树——这四组知识撑起后端日常 90% 的数据操作。
延伸