写这篇的时候我刚从一场代码走查里出来。某同事把一个方法的入参设计成了String[]然后在方法体里反复Arrays.asList、toArray来回倒腾就为了在一个固定长度的数组上做增删操作。我看完没说话因为我知道这不是他一个人的习惯——很多写过一两年代码的人数组和 List 的边界在哪里、泛型到底在保护什么其实一直没有真正想清楚。List 集合和泛型是 Java 里使用频率最高的基础能力之一但会用和用明白之间隔着一道坎为什么ArrayList扩容不总是 1.5 倍为什么边用增强 for 边remove会抛并发修改异常为什么List? extends Number不能往里add一个Integer这些问题的答案不在 API 文档里而在源码和 JVM 运行时规则里。这篇不是 API 手册式的罗列我会从源码行为、运行期机制、常见坑点、选型判断四条线往下拆每一节都带能直接落进项目的经验判断。适合已经写过 Java 基础、但想从调 API走向懂设计的同学。1. 有了数组为什么还要啃下 List从一次索引遍历事故说起数组是 Java 里最底层的容器连续内存、索引直达、性能极好。但它的短处也很致命长度不可变。我在项目里见过一个很典型的处理方式——为了让一个数组可变先创建一个更大的新数组再用System.arraycopy把旧数据搬过去一个简单的添加操作要写六七行样板代码。这不是优雅不优雅的问题是这种代码多了以后索引计算失误的概率会成倍上升。有一次某项目在分批处理一批订单时需要动态记录待重试的订单编号。用数组实现的话每次判断是否重试都要先算出目标下标、确认有没有越界、再考虑扩容。三个条件叠加后出现了一次数组下标越界因为一个off-by-one错误重试列表漏掉了最后一条订单。排查花费了将近半天最后发现根本不是业务逻辑问题就是手动维护数组长度这件事本身太脆弱。List 接口的出现本质上就是把动态扩容、维护尺寸、插入删除这些高频操作收拢成标准 API。ArrayList负责动态数组的实现细节LinkedList则实现成双向链表。对使用者而言add、get、remove一句话就能完成不用再关心底层到底怎么挪数据。到了进阶这个阶段真正要关注的不是 List 怎么用而是它内部做了什么、在什么场景下做什么会疼。以ArrayList为例它底层就是一个Object[]只是把满了就扩容的过程封装到了add里。无参构造的ArrayList初始是一个空数组第一次add时才扩容到默认容量 10之后每次容量不足就按旧容量的 1.5 倍扩展。这个过程用生活里的事打比方就跟你去自助餐厅取餐——小盘子装不下了换一个大一点的盘子再把原来的菜一个个挪过去。扩容本身不产生业务价值它只是不得不做的搬家成本。理解这一点你就能解释为什么提前指定初始容量在数据量可预估时是一个很实在的优化。List 的另一个价值在于算法支撑。Collections.sort、binarySearch、subList、replaceAll这些操作是数组时代需要手写的活现在全部变成标准能力。进阶的关键不是说背下这些方法名而是能判断这个操作底层会扫描几次、会不会产生临时对象、在数据量变大时是否扛得住。2. ArrayList 和 LinkedList扩容、寻址与删除的真实成本很多教材把ArrayList和LinkedList的对比简化成两句话数组查询快、增删慢链表增删快、查询慢。这句话方向没错但漏掉了最关键的前提——LinkedList 的增删快只在你知道节点位置时才成立。如果你按索引add(2, item)它仍然要先从头或尾部开始遍历定位到第 3 个节点再执行插入。也就是说按索引操作时它依然是 O(n)并没能躲掉寻址的成本。我做过一次小数据量的性能观察实验用两种容器分别往头部插入 10 万条记录。ArrayList每次在头部插入都需要把后面所有元素整体后移10 万次操作肉眼可见地卡顿而LinkedList只需要改四个指针的引用。同样这个场景里如果改成在尾部追加ArrayList反而比LinkedList更快因为尾部追加走的是直接写数组下标而LinkedList还要维护 last 节点指针和建立新 Node 对象单次操作多出来的对象分配成本在这种量级下就会显现。为了说清楚什么时候选谁我把两个实现的核心行为拆成下表。操作ArrayList 实际行为LinkedList 实际行为get(index)数组下标直接定位O(1)按 index 判断在前半还是后半再遍历O(n)add(value)尾部追加数组扩容后直接写入均摊 O(1)新建 Node 再挂链存在对象创建开销add(index, value)元素先移动再插入O(n)先遍历定位再改指针O(n)remove(index)元素整体前移O(n)遍历定位后摘链O(n)内存占用连续数组有冗余空槽每个元素自带 Node 对象头开销更大不连续这张表最大的启发是LinkedList没有绝对优势它唯一的纯赢场景是已知节点引用的情况下做中间插入/删除这个场景在实践里其实很少用。日常代码里绝大多数 List 需求用ArrayList都更合适。至于网络上流传的大量头部插入用 LinkedList在数据量没有达到百万级时两者的绝对耗时差异几乎可忽略实在没必要为了这种理论优势增加复杂度。再补一个ArrayList扩容的底层细节扩容计算方式是int newCapacity oldCapacity (oldCapacity 1)也就是 1.5 倍。如果构造时给了初始容量则第一次扩容是在这个容量基础上进行的。如果你能预估数据规模比如从数据库查出的结果集可能上万条直接new ArrayList(预估大小)能少触发好几次数组搬移。但也要注意预估容量过大会造成内存浪费ArrayList不会自动缩容我见过一个内存敏感型服务里有人给一个最多装几十条的列表初始化为new ArrayList(10000)纯粹是浪费。还有个很经典的坑Arrays.asList()返回的并不是java.util.ArrayList而是一个 Arrays 内部类ArrayList它虽然继承了AbstractList但add和remove方法直接抛UnsupportedOperationException。很多人把Arrays.asList(a, b)当普通 List 传进方法结果一调用add就炸了。要得到真正的可变列表请套一层new ArrayList(Arrays.asList(a, b))。3. 泛型的真面目类型擦除、通配符与 PECS 边界泛型在初学者眼里是一种让集合变得安全的语法这个理解没毛病但要在进阶阶段继续深入必须理解安全是怎么换来的。Java 的泛型属于编译期机制编译器用泛型做类型检查通过检查后具体类型参数会被擦除。到了运行期ListString和ListInteger对 JVM 来说是同一种东西都是裸的List。这就是所谓的类型擦除。这带来一个非常重要的推论泛型信息只存在于编译产物中的 Signature 属性里通过反射拿不到运行时的类型参数。所以你不能直接写出new T()这样的代码也不能if (obj instanceof T)——因为 JVM 在运行期根本没有 T 这个概念。实际写代码时如果你想在泛型方法里创建实例通常是传一个ClassT类型参数进来再用反射创建类似T instance clazz.getDeclaredConstructor().newInstance()。我经常用证件照来比喻类型擦除。身份证上的照片会标明你这张脸属于谁但真正识别身份时看的还是人脸本身。泛型也是一样编译期你在代码里写ListString它像在 API 签名上贴了张标签告诉调用方这里只能放 String编译完成后 JVM 只认 List 这个人不关心标签写什么。明白这条你就不会写出用泛型去做重载的奇怪代码——void f(ListString list)和void f(ListInteger list)在类型擦除后签名完全相同根本编译不过。接着说说通配符?。List?表示某种未知类型的 List它有个很有意思的约束你可以从里面读读出来是 Object但不能往里写任何东西元素类型不确定写什么都会破坏类型安全。这个约束在分层设计里特别重要。比如你有一个方法接收List? extends Animal它内部可以遍历所有动物并调用其共有方法但你无法往里面塞一只 Cat因为编译器不能确认这个集合具体存储的是 Cat、Dog 还是别的Animal子类。这里延伸出进阶必须掌握的 PECS 原则——Producer Extends, Consumer Super。一句话如果你只需要从容器里取出数据来用生产者用extends声明上界如果你只需要往容器里放数据进去消费者用super声明下界。一个既能读又能写、且类型安全的泛型容器往往会产生类型上的矛盾所以设计 API 时要想清楚它主要扮演哪个角色。// 上界通配符只能读不能写null 除外 public void showAnimals(List? extends Animal animals) { for (Animal a : animals) { System.out.println(a.name()); } // animals.add(new Cat()); // 编译错误 } // 下界通配符可以写但读出来只能当 Object 用 public void addCats(List? super Cat list) { list.add(new Cat()); // Cat c list.get(0); // 编译错误不能安全读取 }泛型还有几个进阶细节值得注意。一个是泛型不能用在基本类型上Listint是不合法的必须用ListInteger因为泛型参数最终要对应到真实类对象而int不是类。另一个是泛型类型与继承的关系ListCat不是ListAnimal的子类型即使Cat是Animal的子类两者之间也没有父子关系。这跟数组不一样——数组是协变的Cat[]可以被当Animal[]用代价是运行期ArrayStoreException而泛型是不变的编译器就不让你过。我对团队里新人的一条建议是写泛型方法时把类型参数的命名落实到含义上。T用于任意类型E用于集合元素K, V用于键值对。这些命名约定虽然不是语法强制的但能让读代码的人第一眼就明白类型变量在扮演什么角色。类名后面跟T方法返回值前面的T表示这是一个泛型方法两者别混用——class FooT里T在类级别可用而public T T getById(Long id)里的T只在方法范围内有效。4. 遍历删除、排序去重与 fail-fast实战里的高频坑与对应解法这一节要讲的内容几乎每家公司的 Java 代码评审里都会出现边遍历边删除、排序时忽略Comparator的规则、对象去重后结果对不上。先看遍历删除。下面这段代码看起来哪里都没问题但运行起来会抛ConcurrentModificationExceptionListString list new ArrayList(List.of(a, b, c)); for (String s : list) { if (b.equals(s)) { list.remove(s); // 抛异常 } }问题出在哪增强 for 循环的语法糖背后是IteratorIterator在被创建时记录了容器当前的modCount结构性修改次数。每次循环Iterator检查当前modCount是否和记录的expectedModCount一致发现list.remove(s)把modCount改了校验失败立刻抛出并发修改异常。这个机制叫 fail-fast是 Java 集合在并发修改问题上的快速防御。要正确处理有三种可选方案。第一种是直接用Iterator.remove()因为迭代器自己的删除方法会同步expectedModCount不会触发校验失败第二种是用普通 for 循环倒着遍历从尾部向头部删除这样删除当前元素后未处理的元素索引不受影响第三种是 Java 8 以后的removeIf一行完成内部同样由迭代器驱动list.removeIf(s - b.equals(s)); list.removeIf(item - item.age() 18);再说排序。Collections.sort(list)要求元素实现Comparable如果你不想修改元素类本身或者你要按多个条件排就传Comparator。一个常见误区是仅按主字段排序导致相同主字段的元素顺序不稳定。正确做法是用链式比较器users.sort( Comparator.comparing(User::getDeptId) .thenComparing(User::getAge, Comparator.reverseOrder()) .thenComparing(User::getName) );这里还有一个细节Comparator.comparing后面接的thenComparing是按顺序叠加的越靠前的条件优先级越高。返回值直接从Comparator里构造可读性很好不需要手写匿名内部类。去重也是一个容易踩坑的点。ListString去重很老实——重复的字符串必然相等。但ListUser用stream().distinct()去重时一去就可能出问题若User没有重写equals和hashCodedistinct 比较的是对象的引用地址两个内容完全相同的User实例会被当成不同对象去重等于没去。另一个隐患是若User的equals只比较了 id 字段那么两条 id 相同但姓名不同的记录会被合并成一条是否符合业务预期就要看场景。我给团队的默认建议是需要去重的业务对象一定要先明确业务上什么字段相同算重复然后手写equals/hashCode并且在注释里写清楚比较字段的边界。还有一个速度细节值得提要保留数据顺序的去重用LinkedHashSet最稳不会打乱原始顺序如果数据量极大且顺序不重要可以考虑HashSet。stream().distinct()对于 f顺序敏感型数据也是最合适的——distinct 保证按遇到顺序去重内部基于LinkedHashSet实现。5. List 的同步、不可变与初始化容易被低估的选型细节多线程环境下给 ArrayList 加锁这件事在很多课程里只是被一句话带过用Collections.synchronizedList。但我实际排查过几次线上问题之后发现用了 synchronizedList 还是出问题的情况并不少见。Collections.synchronizedList返回的是一个同步包装集合它把每个方法都加上了synchronized锁。问题在于它没有同步复合操作。比如if (!list.contains(x)) { list.add(x); }这行代码两次方法调用之间锁已经被释放了另一个线程可能已经插队执行了 add最终出现重复元素。正确做法是调用方自己在外层加锁或者用CopyOnWriteArrayList这类并发容器。CopyOnWriteArrayList的 add 操作是复制整个底层数组再写入读操作完全不用锁所以在读多写少、且数据变更不频繁的场景下体验很好但如果写次数很频繁反复复制数组带来的内存和 CPU 开销不可忽视。不可变列表也是 Java 9 之后一个用着顺手但要注意边界的能力。List.of(a, b, c)创建的是一个不可变列表任何修改操作都会抛异常同时它不允许任何元素为 null。我自己发现过一个坑从List.of创建的列表直接传进一个老代码方法方法里调用remove时没有捕获异常结果某条业务链路直接失败。这种问题定位起来有点绕因为异常信息只报UnsupportedOperationException不看调用链很难想到源头是初始化方式选错了。现在团队的约定是对外要暴露的集合优先声明为不可变视图防止被下游方法偷偷修改内部需要频繁操作的地方才明确使用可变实现。初始化的写法上也值得统一。我在项目里见过多种写法共存new ArrayList(Arrays.asList(...))、Collections.addAll、List.of、Stream.of(...).collect(Collectors.toList())。这几种方式在效果和依赖上不完全等价的。List.of要求 jdk9 及以上且结果不可变Arrays.asList返回固定长度列表add/remove 不支持Stream.collect会多一次收集适合后续还要链式操作数据的场景。为了避免代码风格混乱我会在团队规范里写明固定少量元素且不允许改动用List.of需要一个可变的空列表用new ArrayList()需要快速把数组变成可变列表用new ArrayList(Arrays.asList(arr))。规则定了评审时就不用每次都解释一遍。泛型配合 List 在方法设计上的好处也必须提。比如查询用户信息的接口返回值若不是ListUser而是裸List调用方拿到的每个元素都得强转代码里到处是(User) o。一旦类型转换出错问题要等运行期才暴露。用了泛型错误在编译期就能被拦住本质上把运行期炸弹变成了编译期提示。这也是我反复强调泛型的价值在编译期的原因——它保护的不是 JVM 的安全而是开发者的心智安全。最后说个同步容器选型的经验。如果列表的数据量不大且并发读多写少CopyOnWriteArrayList几乎是最好用的如果写多读少老老实实用ArrayList加锁或者直接用Collections.synchronizedList并细化锁粒度别盲目套高性能容器如果追求的是某个方法内的局部列表那它天然线程隔离根本不用考虑同步。写在最后的实际操作体会说句实话List 和泛型这两个东西单独拎出来讲语法可能一堂课就讲完了但把它们放到真实项目里演变出的问题我在代码走查中至少碰到过五六种不同的变体——Arrays.asList被当成可变列表、ConcurrentModificationException反复出现、泛型通配符用得不对导致编译失败、CopyOnWriteArrayList在写频繁场景下性能反而拉垮。与其等线上出问题再排错不如在写每一行代码时多问一句这个列表是不可变吗这个泛型边界够不够安全这个操作是读多还是写多如果你现在还在用数组手工扩容强转的姿势处理业务数据我建议你从下一个需求开始把这几件事做一次彻底的重构所有可变长度集合换成List所有从公共方法传出去的集合显式声明泛型所有需要实例化的容器指定预估容量。做完这三个调整再去读一遍ArrayList和HashMap的源码很多进阶疑问会自己解开。