Java 面向对象 · 交互课程

集合框架与泛型

集合框架与泛型

目标:理解数组为什么装不下「动态增长」的数据;掌握集合框架两大体系——Collection(List/Set/Queue)与 Map;会用 ArrayList / HashSet / HashMap 完成增、删、查、遍历;理解泛型带来的类型安全与类型擦除机制。

一、为什么需要集合:数组的局限

一句话:数组是「定长容器」,集合是「动态容器」。数据量事先不知道、又要频繁增删时,用集合。

两者各有所长——点下面任意维度行,看具体表现与点评:

交互实验:List vs 数组 并排对比(ListVsArrayCompare)

数组和 List 各有所长——点击任意维度行,看两者的具体表现与一句话点评:

← 点击任意维度行查看点评

数组:定长 + 手写操作
int[] scores = new int[3];   // 长度固定为 3
scores[0] = 88;
scores[1] = 76;
scores[2] = 95;
// 想再加第 4 个?
// 只能新建 int[4],把旧值逐个拷过去……
List:动态 + 开箱即用
List<Integer> scores = new ArrayList<>();
scores.add(88);
scores.add(76);
scores.add(95);
scores.add(92);                          // 直接加!自动扩容
System.out.println(scores.contains(76));  // true
六维度汇总:集合占优 2 项(动态长度、内置操作)数组占优 2 项(基本类型、内存性能)平手 2 项(下标访问、类型安全)

选型口诀:日常业务代码默认 ArrayList;纯数值、超高频访问、内存敏感的场景用基本类型数组。

二、集合框架继承树:两大体系

Java 集合分两大体系:

下面的实验树把两大体系的常用接口与实现类都画出来了——点任意节点看它的族、继承链与关键方法,点上方标签可只看一族:

交互实验:Java 集合框架继承树(CollectionHierarchyTree)

Java 集合分两大体系:Collection(单元素集合:List 有序可重复 / Set 无序不重复 / Queue 先进先出)与 Map(key→value 映射,不属于 Collection 体系)。点击任意接口/类,看它的族、继承链与关键方法。

Collection 体系(单元素)Map 体系(key→value,不属于 Collection)CollectionListArrayListLinkedListVectorSetHashSetLinkedHashSetTreeSetQueuePriorityQueueArrayDequeMapHashMapLinkedHashMapTreeMapHashtable

实线框 = 具体可用类,虚线框 = 接口(不能直接 new)。点上方族标签可只看对应一族;两个体系根节点过滤时保持常亮。

← 点击左侧任意接口/类查看它的族、继承链与关键方法

三、常用操作:增、删、查、遍历

三大最常用的集合各写一遍核心操作(ArrayList / HashSet / HashMap):

// List —— ArrayList:按下标存取
List<String> names = new ArrayList<>();
names.add("Tom");              // 尾部添加
names.add("Jerry");
names.remove("Tom");           // 按值删除
System.out.println(names.get(0));   // 按下标取 → "Jerry"

// Set —— HashSet:自动去重,无序
Set<Integer> nums = new HashSet<>();
nums.add(1);
nums.add(2);
nums.add(2);                  // 重复元素被忽略(add 返回 false)
System.out.println(nums.size());    // 2

// Map —— HashMap:key→value
Map<String, Integer> scores = new HashMap<>();
scores.put("Tom", 88);
scores.put("Jerry", 76);
System.out.println(scores.get("Tom"));   // 按 key 取 value → 88

遍历对所有集合都统一——增强 for 循环(底层是 iterator()):

for (String n : names) {
    System.out.println(n);
}
// Map 遍历用 entrySet(),一次拿到 key 和 value
for (Map.Entry<String, Integer> e : scores.entrySet()) {
    System.out.println(e.getKey() + " -> " + e.getValue());
}

HashMap 的底层:桶、链表与红黑树。 put(key, value) 并不是「随便放」——它先算 key.hashCode(),再对桶数取模 hashCode() % n 决定落进哪个桶;同一个桶里再来一个 key(哈希冲突)就挂成链表,当一条链长到 ≥8 时转成红黑树,把最坏查找从 O(n) 拉回 O(log n)。下面插入一组 key,看它们如何按 hashCode() % n 落桶、冲突时链表如何生长、以及「链长 ≥8 转红黑树」的阈值:

交互实验:HashMap 桶冲突可视化(HashMapBuckets)

put(key, value) 先算 key.hashCode(),再对桶数取模 hashCode() % n 落桶;同桶冲突挂成链表,链长 ≥ 8 转红黑树。 换「灌满桶」看链长达到阈值如何触发转树。

桶数 n:键集:
占用桶 4/8最长链 3冲突桶 2转红黑树 0

桶 #0(3 个 key)链表 O(n)

"Tom"→"Jerry"→null
  • 1. "Tom":hashCode = 8,8 % 8 = 0 → 桶 #0
  • 2. "Jerry":hashCode = 16,16 % 8 = 0 → 桶 #0
  • 3. null:hashCode = 0,0 % 8 = 0 → 桶 #0

结论:桶数 n 越小、同哈希 key 越集中,链越长,查找退化为 O(n);JDK 用「链长 ≥ 8 转红黑树」把最坏情况拉回 O(log n)。

四、泛型:编译期类型安全

不用泛型(原始类型)——List 里什么都能放,取出来是 Object,必须强转;转错类型运行时才炸:

List list = new ArrayList();
list.add("字符串");
list.add(new Integer(10));
Date d = (Date) list.get(0);   // 编译通过!运行到这一行抛 ClassCastException

用泛型——List<String> 在声明时就把元素类型定死:只能放 String,取出来直接是 String,不用强转:

List<String> list = new ArrayList<String>();
list.add("Tom");
// list.add(123);             // 编译错误:int 不能放进 List<String>
String s = list.get(0);       // 无需强转,类型安全

类型擦除(type erasure):泛型只存在于编译期——javac 编译后,字节码里的 List<String> 会被「擦除」成裸的 List,元素按 Object 处理;编译器在取出处自动插入强转。所以:

下面逐行对照「源码 vs 擦除后字节码」,并走查原始类型(raw type)为何危险:

交互实验:泛型声明 vs 擦除后字节码(GenericEraseDemo)

泛型只存在于编译期:javac 把 List<String> 擦成裸的 List,元素按 Object 处理;编译器在「取出」处自动插入强转。 点击任意一行,看编译器对它的处理:

源码(带泛型)
编译后字节码(擦除,教学等价写法)

← 点击任意一行,看编译器做了什么(去掉类型参数 / 插入强转 / 不变)

运行时验证(擦除后 JVM 里能观察到什么)
表达式结果说明
names.getClass().getName()"java.util.ArrayList"类名里没有 <String>——类型参数在运行时根本不存在。
names instanceof ArrayListtrue运行时只能判断具体类(ArrayList),无法判断「装的是什么类型」。
names.getClass() == nums.getClass()truenames 是 List<String>、nums 是 List<Integer>——擦除后两者在 JVM 里是同一个类。
为什么擦除是危险的:原始类型走查(第 1/4 步)
List<String> strings = new ArrayList<>();
strings.add("Tom");
编译通过

正常泛型用法:编译期类型安全,一切如常。

结论:泛型检查只发生在编译期——原始类型(raw type)绕过检查后,错误元素能混进集合,直到「取出」那行才以 ClassCastException 的形式爆炸。

五、选型建议

场景推荐集合理由
按下标访问为主、尾部增删多ArrayList动态数组,get(i) O(1)
中间频繁插入/删除、要当队列/栈LinkedList双向链表,头尾操作 O(1)
无序、去重、快速判断「有没有」HashSet哈希表,增删查 O(1)
需要有序且去重TreeSet红黑树,自动排序
key→value 查找、无序HashMap按 key O(1)
key→value 且要有序TreeMapkey 自然顺序/比较器排序

经验法则:默认用 ArrayList + HashMap;有「去重」需求换 HashSet,有「排序」需求换 TreeSet / TreeMap。

六、总结

思考题

  1. Map 为什么不继承 Collection?两者在数据组织上有什么本质区别?
  2. 什么场景下选 LinkedList 而不是 ArrayList?两者的 get(i) 代价各是多少?
  3. 类型擦除后,运行时把 Integer 放进 List<String> 会发生什么?(提示:泛型检查只发生在编译期)

学习小测验

学习小测验

Q1

Java 集合框架中,保证元素唯一、不允许重复的接口是( )

Q2

HashMap 实现的是哪个接口?

Q3

List 与 Set 的主要区别是( )

Q4

已知 Map<String,Integer> scores = new HashMap<>(); scores.put("Tom",88);,则 scores.get("Jerry") 的返回值是( )

Q5

用 List<String> list = new ArrayList<String>(); 声明集合后,list.get(0) 不需要强转的原因是( )

Q6

Java 的类型擦除意味着:编译后 List<String> 在字节码中变成( )

Q7

已知 List<String> strings = new ArrayList<>(); strings.add("Tom");,接着写 List raw = strings; raw.add(123);(能编译通过),则执行 String s = strings.get(1); 时会( )

Q8

与 ArrayList 相比,数组(如 int[])的优势是( )

Q9

HashMap 中,当某个桶里的链表长度达到多少时,JDK 会把它转成红黑树?

Q10

HashMap 里 put(key, value) 后,key 落在哪个桶主要由什么决定?