02-Java集合泛型与资源边界

O泡李华 6

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. 容器之外的资源与异常

类型正确不代表生命周期正确;迭代、流和连接仍需要明确关闭与失败边界。

捕获异常后应该做什么?

处理异常通常有三种合理选择:

  1. 当前层确实能恢复,例如使用有明确语义的备用数据。
  2. 转换成当前层的异常类型,并保留原始 cause。
  3. 不处理,让它传播到有决策能力的上层。

例如:

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 为什么不能赋给 List?

如果允许,Number 列表就能写入 Double,破坏原 Integer 列表的承诺。应使用合适的通配符表达允许的操作。

问:extends 集合不能修改吗?

不是。它限制具体元素类型的安全写入,不禁止 clear 或删除。是否允许结构修改由具体集合实现决定。

问:throws 会自动抛异常吗?

不会。它是方法可能传播异常的声明,实际异常来自 throw 或执行过程。

问:try-with-resources 和 finally 关闭资源有什么区别?

前者自动组织关闭顺序,并在多处失败时保留主异常和 suppressed 异常,减少手工代码覆盖原因的风险。

参考资料