02-Java集合泛型与资源边界
Java 集合与泛型:怎样把错误挡在编译期?
一个 List 可以编译,却在读取时抛出类型转换异常;一个 LinkedList 声称擅长插入,却在按下标循环时越来越慢。集合选择和类型设计不是互不相关的知识点,它们共同决定接口允许什么,以及操作需要付出什么成本。
1. 操作方式决定容器成本
先按访问、定位和修改方式分析,避免只看容器名称选择。
按下标读取与按值查找,不是一回事
对于 ArrayList,get(index) 可以直接访问数组槽位,时间复杂度为 O(1)。
LinkedList 没有一个保存全部节点位置的数组。get(index) 需要从头或尾沿节点移动,具体选择较近的一端;中间位置的访问仍然是 O(n) 量级。
但是 contains(value) 或 indexOf(value) 是按值查找。没有额外索引时,两种列表都可能要逐个比较,最坏为 O(n)。
所以“查询快”必须说清楚查询方式。随机下标访问和按值查找不能混为一谈。
中间插入,要把定位和修改分开计算
假设在下标 2 插入 X:
插入前:A B C D
插入后:A B X C D
ArrayList 需要为 X 腾出位置,把 C、D 对应的引用向后移动。中间插入的最坏成本是 O(n),必要时还会分配更大的数组。
LinkedList 找到位置以后,主要是创建节点和调整相邻连接。但调用 add(index, value) 时,往往需要先遍历找到该位置,因此整个操作的最坏复杂度也是 O(n)。
如果已经通过 ListIterator 到达当前位置,再在这里插入,LinkedList 才能体现不需要移动大段元素的特点。
按下标插入:定位成本 + 插入成本
迭代器当前位置插入:位置已经找到,直接调整连接
不能只比较第二部分,就得出全部增删操作都更快的结论。
常用操作的复杂度
下面比较的是常规单线程操作的算法成本,n 表示元素数量;真实耗时还受分配、缓存、JVM 和负载影响。
| 操作 | ArrayList | LinkedList |
|---|---|---|
| get(index)、set(index, value) | O(1) | 最坏 O(n) |
| contains(value) | 最坏 O(n) | 最坏 O(n) |
| 尾部追加 | 均摊 O(1),扩容的一次可能 O(n) | O(1) |
| 头部插入或删除 | O(n) | O(1) |
| 中间按下标插入或删除 | 最坏 O(n) | 最坏 O(n),包含定位 |
| 迭代器定位后插入或删除 | 通常要移动后续引用 | 调整节点连接,O(1) |
| 完整顺序遍历 | O(n) | O(n) |
“均摊 O(1)”不是每次都只做固定工作。它表示把一系列追加操作的成本合起来计算,平均到每次操作的成本为常数量级。ArrayList 的性能约定
为什么 LinkedList 不适合下标循环?
下面的代码看起来只遍历了一遍:
for (int i = 0; i < list.size(); i++) {
consume(list.get(i));
}
如果 list 是 ArrayList,每次 get 是常数时间,整个循环为 O(n)。
如果 list 是 LinkedList,每次 get 都要重新定位。即使它选择从较近的一端移动,所有位置的移动次数加起来仍是平方量级,整个循环可以达到 O(n²)。
对顺序遍历,使用迭代器或增强 for 循环更合适:
for (Integer value : list) {
consume(value);
}
这两个片段中的 consume 代表业务处理方法。LinkedList 的迭代器保存当前位置,可以沿连接逐步访问,不需要从头为每个下标重新定位。
内存与实际运行成本
ArrayList 需要数组,并可能保留未使用的槽位;LinkedList 则为每个元素维护额外节点和连接字段。
在相同元素数量下,LinkedList 的节点通常带来更多对象分配与额外开销。但准确字节数取决于 JVM、对象对齐、引用压缩等条件,不能不加前提地写一个固定数字。
ArrayList 的连续引用槽位也有利于顺序访问;LinkedList 的节点跳转可能带来较差的局部性。它们会影响实际耗时,而不体现在简单的大 O 表格里。
如果需要性能结论,应针对业务操作比例和数据规模测量。使用 JMH 时,至少要考虑预热、多次测量、独立进程、结果消费,以及是否把数据准备混入计时。一次 nanoTime() 的结果不足以代表稳定性能。JMH 项目
队列场景,不必默认选择 LinkedList
LinkedList 同时实现 Deque,可以操作两端;但如果需求只是一个非线程安全、禁止 null 元素的队列或栈,ArrayDeque 也值得作为候选。
先进先出:offerLast → pollFirst
后进先出:push → pop
ArrayDeque 使用数组管理双端操作,避免了每个元素都需要独立链表节点的结构。它不允许 null,这也让 poll 返回 null 时可以表示队列为空。ArrayDeque API
多线程、阻塞等待、有界容量等需求,应再选择相应并发或阻塞队列,不能直接把这两个普通集合当成线程安全实现。
2. 泛型怎样限制接口使用者
把读写方向说清楚,才能理解泛型不变性与通配符。
为什么需要泛型?
假设一个容器用 Object 保存值:
class ObjectBox {
Object value;
}
它能保存字符串,也能保存整数。但读取方如果以为里面一定是字符串,就可能错误地转换:
ObjectBox box = new ObjectBox();
box.value = 42;
String text = (String) box.value; // 运行时发生 ClassCastException
泛型把容器的元素类型变成约束:
static final class Box<T> {
private final T value;
Box(T value) {
this.value = value;
}
T get() {
return value;
}
}
使用 Box<String> 时,构造和读取都围绕 String:
Box<String> box = new Box<>("hello");
String text = box.get();
不兼容的赋值会在编译时被拒绝。前提是没有绕过检查,例如使用原始类型或不安全转换。Oracle 泛型介绍
Integer 是 Number 的子类,为什么列表不能直接赋值?
下面的代码不能通过编译:
List<Integer> integers = new ArrayList<>();
List<Number> numbers = integers; // 编译错误
假设允许赋值,numbers 就可以添加 Double:
numbers.add(3.14);
这样同一个列表里就出现了 Double,但原来的 integers 仍然承诺提供 Integer,类型安全被破坏。
因此 Java 的这类泛型参数化类型是不变的:List<Integer> 不是 List<Number> 的子类型。两个类型都使用 List,不代表它们之间继承了元素类型的父子关系。泛型与子类型
数组的规则不同:可以把 String[] 赋给 Object[],但向该数组写入 Integer 会触发运行时的 ArrayStoreException。不能把数组的协变规则套用到泛型集合上。JLS:数组存储检查
? extends:读取时知道上界
如果想接收 Integer、Double 等数字列表,可以写:
static double sum(List<? extends Number> values) {
double total = 0;
for (Number value : values) {
total += value.doubleValue();
}
return total;
}
? extends Number 表示元素类型是 Number 或它的某个子类型,但是当前方法不知道具体是哪一种。
读取时,元素可以按 Number 使用。写入则不同:
List<? extends Number> values = new ArrayList<Integer>();
values.add(1); // 编译错误
虽然这里恰好用 Integer 列表初始化,变量类型仍然只表达“某种 Number 类型”。对于这个类型签名,编译器不能认为它一定可以接收 Integer;相同签名也可能指向 Double 列表。
除 null 等情况之外,不能通过这个引用添加具体的数字值。
但不能因此称它为不可变集合。clear()、某些删除操作仍可执行,前提是底层集合支持。它限制的是能按什么类型安全写入,不是冻结整个对象。通配符使用指南
? super:写入时知道下界
如果方法需要把 Integer 放进一个列表,可以接受:
static void addOne(List<? super Integer> target) {
target.add(1);
}
target 可以是 List<Integer>、List<Number> 或 List<Object>,这些列表都能接收 Integer。
但读取时只能保证得到 Object:
Object value = target.get(0);
原因是 target 可能是已经放有字符串的 List<Object>,不能承诺读出的每个元素都是 Integer。
这里限制的是这个通配符引用。一个原本声明为 List<Number> 的列表,在它自己的引用处仍然可以按 Number 读取。下界通配符
用 PECS 设计一个复制接口
PECS 指 Producer Extends、Consumer Super。它帮助判断一个参数在当前方法里的角色:提供 T 类型值的一方用 extends,接收 T 类型值的一方用 super。
static <T> void copy(
List<? extends T> source,
List<? super T> target) {
for (T value : source) {
target.add(value);
}
}
这里 source 提供元素,target 接收元素。一个方法声明 T,把两端联系起来,因此可以将 Integer 列表的元素复制到 Number 或 Object 列表。
List<Integer> ──提供 Integer──→ copy ──写入──→ List<Number>
或 List<Object>
“生产者”和“消费者”是参数在这个方法中的职责,不是类永远不变的属性。同一个集合,在另一个方法里可能扮演不同角色。
这个简单实现假设源与目标不存在会干扰迭代的共享结构,且目标允许 add。不应直接用于 source 与 target 是同一个列表、或它们是共享底层结构的视图的情况。
类型擦除,不等于所有类型信息都消失
Java 通过擦除转换处理泛型。无上界的类型变量通常擦除为 Object;有上界时,擦除为其最左侧的边界。
例如:
class Box<T> { /* ... */ }
class NumericBox<T extends Number> { /* ... */ }
它们的 T 分别以 Object 和 Number 作为擦除后的类型。List<String> 与 List<Integer> 的擦除类型都是 List;示例中两个 ArrayList 实例的运行时 Class 也相同。JLS:类型擦除
但是对象本身没有变成“无类型的数据”。列表中的字符串仍然是 String 对象,整数仍然是 Integer 对象。
类、字段和方法声明中的部分泛型签名也可以保存在元数据中,供反射读取。例如一个明确声明的 List<String> 字段,可以通过反射获取其声明类型。与此不同,不能仅从一个普通 ArrayList 实例的 getClass() 推断出它使用了什么类型实参。
泛型读取需要时,编译器会插入类型转换;出现泛型覆盖关系时,也可能生成桥接方法。运行时类相同,并不意味着编译期约束失去意义。Oracle:类型擦除
3. 容器之外的资源与异常
类型正确不代表生命周期正确;迭代、流和连接仍需要明确关闭与失败边界。
捕获异常后应该做什么?
处理异常通常有三种合理选择:
- 当前层确实能恢复,例如使用有明确语义的备用数据。
- 转换成当前层的异常类型,并保留原始 cause。
- 不处理,让它传播到有决策能力的上层。
例如:
throw new IllegalStateException("读取配置失败", originalException);
如果只保留 originalException.getMessage(),就可能丢失堆栈和异常链。重复在每一层打印同一异常,也会产生重复日志;日志应由明确负责记录的边界统一处理。
重试需要结合错误类型、次数、等待时间和操作幂等性。校验失败通常不应原样重试,具有写入副作用的操作也不能盲目重复执行。
try-with-resources 如何关闭资源?
实现 AutoCloseable 的资源可以放在 try-with-resources 中,由语言机制调用 close。
try (Resource first = openFirst();
Resource second = openSecond()) {
use(first, second);
}
这里是结构示意,openFirst、openSecond 和 use 代表业务方法。资源按与初始化相反的顺序关闭:先 second,再 first。
如果第二个资源初始化失败,第一个已成功初始化的资源仍会被关闭。若 try 主体已经抛出异常,关闭过程中又失败,主体异常被保留,关闭异常作为 suppressed exception 附加。JLS try-with-resources
这与 cause 不同:cause 表达一个异常由另一个异常引起;suppressed 表达处理主要异常时还有其他异常被抑制。
资源关闭在业务代码中的边界
文件流、数据库连接等资源的 close 含义不同:有的释放系统资源,有的归还连接池。资源所有权要明确,谁创建、谁负责关闭,或由哪个框架管理。
普通集合不需要因为“对象不用了”就调用关闭;对象的垃圾回收也不能替代文件描述符等资源的及时释放。
对于 InterruptedException,不能简单吞掉。在不继续向上传播它时,通常应恢复当前线程的中断标记,并让业务退出或交给上层处理;具体策略依调用契约决定。
不要用 catch(Throwable) 把严重错误一律转换成“操作成功”。对于返回给客户端的错误信息,应使用稳定的错误语义;详细堆栈保留在受控日志中。
副作用与共享状态为什么危险?
下面的写法把外部列表作为共享结果容器:
List<String> result = new ArrayList<>();
users.parallelStream().forEach(user -> result.add(user.name()));
ArrayList 不支持这种并发写入。即使一次运行得到了预期结果,也不能证明安全。
对于结果收集,可以直接使用 toList 或合适的 collect,让流水线组织归约,而不是自己维护共享可变状态。
Stream 的行为参数一般应满足非干扰、无状态等要求:不要在处理源时又修改这个源,也不要依赖无法正确协调的跨元素共享状态。
toList 与 collect(toList) 不能想当然地互换
Java 17 的 Stream.toList() 返回不可修改的列表。不可修改只针对列表结构,列表里的元素如果可变,仍可能被修改。
Collectors.toList() 则不保证具体列表类型,也不保证可修改性。不要依赖当前实现碰巧返回 ArrayList。
如果明确需要可修改的 ArrayList,使用:
.collect(Collectors.toCollection(ArrayList::new))
API 契约比偶然观察到的实现更值得依赖。Stream.toList、Collectors.toList
资源型 Stream 要及时关闭
普通集合创建的流通常不持有必须关闭的外部资源;但 Files.lines(path) 等流与文件资源关联,应使用 try-with-resources。
try (Stream<String> lines = Files.lines(path)) {
long count = lines.filter(line -> !line.isBlank()).count();
}
这里 path 是待读取文件的路径,外层方法需要处理 IOException。执行终止操作不应被当成所有流都会自动关闭资源的保证。Files.lines
4. 设计判断与适用边界
设计集合接口时,先判断调用者是提供数据、读取数据还是修改数据。输入集合不应被随意修改;需要快照时复制并明确是否可变。泛型约束能减少非法用法,但不能表达所有业务规则,例如金额范围与对象归属仍由运行时校验。
集合复杂度是理解成本的起点,实际性能还受数据规模、对象分配与访问局部性影响。没有相同条件的测量,不把某种容器写成绝对最快。异常处理同样保持证据:业务失败与关闭失败并存时,不覆盖最初的原因。
5. 问答
问:为什么“LinkedList 增删快”不够准确?
因为它省下的是位置已经确定后移动大量元素的成本。按下标增删需要先定位,整个操作仍可能是 O(n)。
问:ArrayList 尾部追加是 O(1) 吗?
均摊为 O(1)。需要扩容的那次追加可能要复制已有引用,成本是 O(n)。
问:List
如果允许,Number 列表就能写入 Double,破坏原 Integer 列表的承诺。应使用合适的通配符表达允许的操作。
问:extends 集合不能修改吗?
不是。它限制具体元素类型的安全写入,不禁止 clear 或删除。是否允许结构修改由具体集合实现决定。
问:throws 会自动抛异常吗?
不会。它是方法可能传播异常的声明,实际异常来自 throw 或执行过程。
问:try-with-resources 和 finally 关闭资源有什么区别?
前者自动组织关闭顺序,并在多处失败时保留主异常和 suppressed 异常,减少手工代码覆盖原因的风险。