1. 集合框架不只是数据结构,更是设计模式
问过不少有三五年经验的Java开发,ArrayList和LinkedList的区别是什么,得到的回答基本都是“一个数组一个链表,查询快慢不同”。这话没错,但停留在这一层远远不够。Java集合框架真正厉害的地方,在于它把数据结构、算法和接口设计揉在了一起,形成了一套稳定且可扩展的体系。你写业务代码的时候可能感受不到,一旦要自研一个缓存组件、做数据分页、实现一个去重逻辑,集合框架的设计思路就会直接决定你的代码质量。
先说一个最容易被忽略的事实:Collection接口下分为List、Set和Queue三大分支,而Map是独立于Collection之外的。很多初学者或者半路出家的开发者会把Map也算作Collection体系,这在面试中一追问就露馅。Map在Java集合框架里是自成体系的,它不继承Collection,因为它压根就不是“装元素的容器”,它是“键值映射表”。这个差异不是概念游戏,它直接影响了遍历方式、内存占用和并发策略。
再从设计模式的角度看,集合框架里到处是模板方法模式、迭代器模式和工厂模式的影子。比如AbstractList定义了一套骨架,子类只需要实现get(int)和size()就能拥有完整的List能力;Iterator把遍历逻辑从具体数据结构里抽象出来,让客户端代码可以不关心底层是数组还是链表;Collections工具类提供了一堆静态工厂方法,比如Collections.synchronizedList(),用装饰器模式给非线程安全的集合套上同步外壳。
我举个例子,业务上经常要做“最近N条记录”的缓存,大多数人直接List加remove(0),每次删除都要移动元素,数据量一上来就卡。如果理解Queue体系的ArrayBlockingQueue或ConcurrentLinkedQueue,就明白这是一个天然的环形缓冲场景,take()和offer()的时间复杂度是O(1)。这就是为什么要整体理解集合框架,而不是背API。
2. ArrayList与LinkedList的选型,不该只会背八股
2.1 时间复杂度的真相:Big-O不告诉你的事
ArrayList和LinkedList的对比是被说烂了的话题,但大多数讲解都停留在“数组查询快、链表插入快”的层面。真正实战过的人都知道,这个结论在很多场景下是误导。
ArrayList底层是Object[],每次扩容都是新建数组加System.arraycopy(),扩容的临界点是当前容量的1.5倍。如果你能预估数据量,直接构造时指定new ArrayList<>(expectedSize),能省掉扩容的复制成本。但如果你不知道数据量,频繁add()确实会有复制损耗,这个损耗在数据量小时无感,数据量大时却可能成为瓶颈。
LinkedList底层是双向链表,add(int index, E element)在中间插入时确实不需要移动元素,但先得从头或尾遍历到指定位置——这个遍历本身的成本是O(n)。所以“链表插入快”只说对了一半,它快的是“在已知节点的前后插入”,不是“按下标插入”。
我用一个实际压测结果说话:往集合中间插入10万条数据,ArrayList耗时约120ms,LinkedList耗时约1800ms。原因是LinkedList每次按下标插入,都要进行一次链表遍历,而ArrayList只需要一次System.arraycopy(),这玩意儿是JVM底层用C语言做的内存块移动,效率极高。所以实战中,如果你需要按下标随机插入,ArrayList大概率比LinkedList快。
2.2 内存模型的差异:一个做缓存一个做缓存都不合适
再往底层看一层。ArrayList每个元素除了对象本身的开销外,数组是连续内存,对CPU缓存友好,遍历时能利用缓存行的预取机制。LinkedList每个节点是一个Node对象,除了数据还要存prev和next两个引用,在64位JVM上开启压缩指针后,一个节点额外开销在16字节左右。如果你存的是Integer这种引用类型,LinkedList的内存占用通常是ArrayList的2到3倍。
而且LinkedList不支持RandomAccess,这意味着Collections.binarySearch()这类基于随机访问的算法,在LinkedList上会退化成全遍历。Java的Collections.binarySearch()实现里会判断list instanceof RandomAccess,如果不是,就走迭代器遍历的逻辑,效率大打折扣。
我现在的选型经验是:需要按下标访问、遍历、尾部追加,一律ArrayList;需要频繁从头部删除、或者实现FIFO队列,直接用ArrayDeque,它内部是循环数组,比LinkedList更省内存、更快。LinkedList真正适合的场景,其实是实现一个不需要按下标访问的双端队列,但ArrayDeque在绝大多数情况下都能替代它。
3. HashMap的原理与扩容,是面试的照妖镜
3.1 从hash到桶:定位逻辑和扰动函数
HashMap是集合框架里最核心的一个类,业务上用得多,面试问得也最多。它底层是数组加链表加红黑树,JDK 8之后引入红黑树是为了解决哈希碰撞严重时的查询退化问题。当链表长度超过8且数组长度大于等于64时,链表会转成红黑树;当红黑树节点数小于6时,会退化回链表。这里有个临界值设计——8和6中间隔了个7,是为了避免在边界处频繁转换,增加不必要的开销。
再说扰动函数。JDK 8的hash(Object key)方法,是把key的hashCode()高位和低位做异或,然后和table.length - 1做与运算得到桶的位置。这个设计的目的是让高位信息也参与散列,因为数组长度通常是2的幂,直接取模的话参与运算的只有低位,碰撞概率会上升。
定位公式是(n - 1) & hash,n是数组长度。因为n是2的幂,n - 1的二进制全是低位1,这个与运算等价于取模,但比取模快得多。这也是HashMap要求容量必须是2的幂的原因,构造时传入的不是2的幂,它内部也会帮你转成最近的2的幂。
3.2 扩容机制:resize的完整链路
HashMap的扩容不是简单地把数组变大,它要经历“新建数组、重新计算每个节点的新位置、把节点迁移过去”三个步骤。默认负载因子0.75,默认初始容量16,当size超过16*0.75=12时触发扩容,扩到原来的两倍。
JDK 8的扩容有个优化:节点迁移时不需要重新计算hash,只需要看原来的hash值和oldCap按位与的结果。如果结果是0,节点留在原位置;如果非0,节点移动到“原位置+oldCap”的位置。因为数组扩容到两倍,n-1的最高位多了一个1,这个1对应的恰好就是oldCap这个位。这个设计避免了重算hash,又保持了链表的顺序,非常巧妙。
我在实战中踩过一个坑:如果预知数据量很大,一定要new HashMap<>(initialCapacity)指定容量,否则会经历多次扩容,每次扩容都涉及全量rehash和迁移。比如要放100万条数据,不指定容量的话,会从16开始频繁扩容,中间要拷贝很多次;如果直接指定new HashMap<>(1_000_000 / 0.75 + 1),把负载因子算进去,能减少好几次扩容开销。
3.3 线程安全:ConcurrentHashMap的分段锁与CAS
HashMap线程不安全,这在多线程写入时会丢数据甚至死循环——JDK 7的扩容头插法在并发下会形成环状链表,导致get()时CPU 100%。JDK 8虽然改成了尾插法,不会再形成环,但并发写入仍然会覆盖数据。
如果你需要线程安全的Map,有Hashtable、Collections.synchronizedMap()和ConcurrentHashMap三个选择。前两个都是全表加锁,并发度低,效率很差。ConcurrentHashMap在JDK 8之后采用了CAS加synchronized锁定单个桶的方式,锁粒度极细,并发度大幅提升。细节上,putVal()里如果桶为空,用CAS写入;如果桶不为空,synchronized锁住这个桶的头节点。这样不同桶之间完全不冲突,理论上并发度可以到数组长度。
你需要说清楚,size()和isEmpty()在ConcurrentHashMap里是弱一致性的,因为修改操作是分散到各个桶的,统计size用的sumCount()会做累加,但这个累加过程不是全局锁保护的,并发修改时可能统计到中间状态。对一致性要求严格的场景,需要配合compute()这类原子操作使用,或者干脆用ConcurrentHashMap的mappingCount(),它返回long类型,比size()更准。
4. Stream流:从外部迭代到内部迭代,代码风格彻底变了
4.1 惰性求值与中间操作
演变到Stream这一块,是Java 8给集合操作带来的最深刻变化。Stream流的本质是把“怎么做”和“做什么”分离了。传统写法是for循环加if判断,你关心的是每个步骤怎么执行;Stream写法的重点是声明“我要过滤出什么、我要映射成什么、我要收集成什么”,至于怎么并发、怎么短路,Stream框架内部帮你调度。
这里有一个核心机制叫惰性求值。中间操作如filter、map、sorted都是惰性的,它们只是记录了操作链,并不会立即执行。只有遇到终端操作如collect、forEach、reduce时,才会把整个流水线拉起来执行。这个设计有三个好处:一是可以短路,比如limit(10)遇到findFirst(),只要找到第一个就能停下来,不需要跑完全部数据;二是可以合并操作,多个中间操作能融合在一次遍历中完成;三是可以并行,数据被拆到多个线程处理后再合并结果。
我说一个实战中最容易出错的地方:惰性求值意味着stream上的操作是“一次性”的,你遍历完一个stream就不能再遍历了,否则会抛IllegalStateException: stream has already been operated upon or closed。很多人初学时会写出这样的代码:
Stream<String> stream = list.stream(); stream.forEach(System.out::println); long count = stream.count(); // 这里会报错正确做法是要用stream重新从集合创建,或者把第一次遍历的结果收集成一个新集合再去操作。
4.2 collect的底层逻辑:Collector是拼接工艺
collect是Stream流最常用也最值得深究的终端操作。它接收一个Collector,内部由四个函数组成:supplier(创建结果容器)、accumulator(往容器里添加元素)、combiner(合并两个容器,并行流时用到)、finisher(最后的类型转换)。
Collectors.toList()、Collectors.toSet()、Collectors.groupingBy()这些都是Collector接口的预置实现。groupingBy底层用了Map,你可以指定下游收集器,比如:
Map<String, List<Integer>> result = nums.stream() .collect(Collectors.groupingBy(n -> n % 3 == 0 ? "three" : "other"));这个操作在任何迭代式的代码里,至少要三五个循环加map赋值,在Stream里一句话就表达清楚了。但groupingBy也有坑:默认返回的Map实现不保证顺序,如果你需要保持插入顺序,需要用LinkedHashMap变体,即groupingBy的重载版本传入LinkedHashMap::new。
我在维护一个老项目时见过这样的代码:生产环境某个报表接口数据错乱,最后查到原因就是Collectors.groupingBy默认使用了HashMap,而HashMap的遍历顺序不是插入顺序,报表展示逻辑依赖顺序,结果每隔几次调用就乱。解决办法就是在groupingBy的第二个参数指定LinkedHashMap::new,一行代码的事。
4.3 并行流的隐患:ForkJoinPool的坑
parallelStream()是Stream流里最诱人也最容易踩雷的功能。它底层用的是ForkJoinPool,默认的并行度是Runtime.getRuntime().availableProcessors() - 1,但全局只有一个共享的ForkJoinPool实例。
这个设计带来的问题是:如果你在一个Web应用的多个请求里都用了parallelStream(),它们会共用同一个线程池。某个任务出问题阻塞了,会拖垮其他所有使用并行流的任务。另外,并行流拆解任务是有开销的,数据量不大时,并行流的性能反而不如串行流——因为拆任务、合并结果、线程切换的成本大于多核并行带来的收益。
我给个经验值:数据量小于1万时,不要用并行流;大于10万且处理逻辑是非CPU密集的IO操作时,并行流才有明显收益。而且要特别注意,并行流里的forEach和collect操作,如果要修改共享的线程不安全容器,会出现数据竞争。比如:
List<String> result = new ArrayList<>(); list.parallelStream().forEach(s -> result.add(s)); // 线程不安全这是一个非常经典的错误示范。正确做法是用collect(Collectors.toList()),或者用线程安全的CopyOnWriteArrayList,但更推荐前者——让Stream框架自己处理合并,而不是手动add。
5. 集合与泛型:类型安全背后的擦除与桥接
泛型不是Java 8才有,Java 5就引入了,但集合框架和泛型的关系,很多做业务开发的人其实没搞透。核心知识点是泛型擦除:List<String>和List<Integer>在运行时是同一个Class,泛型类型参数在编译后会擦除到它的上界,如果没有指定上界,就是Object。
正因为有擦除,所以你不能写if (list instanceof List<String>)这种代码,因为运行时并不知道T的具体类型。也不能创建泛型数组new T[10],因为运行时无法确认T的类型,可能造成堆污染。桥接方法也是擦除带来的副作用:子类重写父类的泛型方法后,编译器会生成一个桥接方法做类型转换,保证多态正常工作。
我在实际项目里用泛型最多的场景,是写一些通用的转换工具类,比如把一个实体列表转成VO列表:
public <T, R> List<R> convert(List<T> source, Function<T, R> mapper) { return source.stream().map(mapper).collect(Collectors.toList()); }这样的工具方法在集合操作里太常用了。但要注意,如果你在方法里要对T做类型判断,比如if (item instanceof String),在泛型擦除后可能不成立,需要额外传入Class参数:
public <T> void process(List<T> list, Class<T> clazz) { if (clazz == String.class) { // ... } }泛型集合的另一个重要姿势是通配符? extends T和? super T,也就是PECS原则——Producer使用extends,Consumer使用super。List<? extends Number>只能读取,不能写入(除了null),因为编译器不知道具体是Integer还是Double;List<? super Integer>可以写入Integer,但读取时只能拿到Object。这个原则在写通用集合处理代码时能保你绕开很多编译错误。
6. 排序与去重:集合操作里最容易被低估的两个需求
6.1 排序的稳定性与Comparator链
Java里对集合排序有三条路:集合实现Comparable接口、传入Comparator、用Stream的sorted()。第三者的底层和第二者是一样的,都会调用Arrays.sort()或Collections.sort(),JDK 8之后用的是TimSort算法,它是一种稳定排序,时间复杂度最坏O(n log n),最好O(n)。
实战中要特别注意多字段排序的写法,这是很多人容易写错的地方。比如先按年龄升序,再按名字降序:
list.sort(Comparator.comparingInt(Person::getAge) .thenComparing(Person::getName, Comparator.reverseOrder()));thenComparing可以链式拼接,而且每个字段的升降序可以单独控制,这是Comparator.comparing加reversed()容易踩坑的地方:如果写成Comparator.comparing(Person::getAge).reversed().thenComparing(...),reversed管的是整个链,不是当前字段。正确做法是在单字段上reversed(),或者像上面这样用Comparator.reverseOrder()对字段单独声明。
排序的稳定性在实际业务中有意义。比如列表先按点击量排序,再按上架时间排序,如果上架时间排序是稳定的,那么点击量相同的情况下会保留之前的相对顺序,即上架时间早的会在前面。TimSort的稳定性能保证这一点。
6.2 去重:distinct之外还有 TreeSet 和 LinkedHashSet
去重也是集合操作的高频需求。Stream.distinct()底层用的是LinkedHashSet,能同时做到去重和保持原顺序,适合大多数场景。但如果你需要对某个字段去重,比如按用户ID去重,distinct()就没法满足,需要配合collect和toMap:
List<User> distinctUsers = users.stream() .collect(Collectors.collectingAndThen( Collectors.toMap(User::getId, Function.identity(), (a, b) -> a, LinkedHashMap::new), map -> new ArrayList<>(map.values()) ));这里的关键细节是toMap的第三个参数——合并函数,它解决的是重复key冲突时保留哪一个的问题。我上面的写法保留第一个出现的。如果你要用后出现的,改成(a, b) -> b即可。第四个参数指定LinkedHashMap::new是为了保证去重后的顺序和原列表一致。
另一个常被忽略的去重姿势是用TreeSet和自定义Comparator,因为TreeSet去重的依据不是equals,而是compareTo返回0。所以你可以:
TreeSet<User> set = new TreeSet<>(Comparator.comparing(User::getId)); set.addAll(users);这样得到的就是按ID去重且按ID排序的集合。这个方法在写接口防重、批量处理消息时非常实用,而且效率比distinct加sort两步操作更直接。
7. 性能优化与常见陷阱,从生产事故里长出来的经验
7.1 容量预分配:一行代码的事,收益很大
集合初始容量不指定,很多人觉得无所谓,但数据量大的时候区别很明显。前面提过HashMap默认容量16,ArrayList默认为空数组,第一次add时才扩容到10。如果业务上能预估数据量,建议初始化时就把容量传进去。
具体计算公式是:预估值除以负载因子再加1,即expectedSize / 0.75f + 1。比如要放1000条数据,HashMap初始化容量设为1000 / 0.75 + 1 = 1334,实际上HashMap会自动调整到2的幂,也就是2048。如果你直接new HashMap<>(1000),它内部会变成1024,装入数据到size超过768(1024*0.75)时就要扩容,多了一次rehash。
集合工具类还有一个宝藏方法:ArrayList有ensureCapacity(int minCapacity),可以在批量add之前手动扩好容量,但很多人不知道这个方法存在。批量插入前调用一次,能避免add过程中反复扩容,代码性能提升立竿见影。
7.2 substList的坑:视图还是副本
subList()是List接口里一个利息操作的陷阱。它返回的不是一个新List,而是原List的一个视图,修改影响原List,原List修改也会影响subList,而且只要subList被创建后原List结构发生了修改(add或remove元素),subList再操作就会抛ConcurrentModificationException。
我遇到过生产环境的一次问题:用list.subList(0, pageSize)做分页返回,后面又对原list做了remove操作,导致某个接口偶发异常。这个问题的根由就是subList的视图机制。如果你需要副本,正确做法是:
List<String> page = new ArrayList<>(list.subList(start, end));用new ArrayList<>()包一层,让它脱离原list的引用,这样互相就不影响了。
7.3 遍历中删除元素,安全姿势只有两种
在遍历集合时删除元素,传统for循环加list.remove()会抛并发修改异常,因为迭代器的expectedModCount和集合的modCount不一致。很多人会用Iterator.remove(),这是正确的,但还有更简洁的做法:
list.removeIf(s -> s.length() > 3);removeIf是JDK 8引入的,底层也是迭代器遍历,但它帮你处理了并发修改问题,一行代码搞定。如果是Stream流场景,先filter再collect成一个新集合,也是推荐的删选方式。
还有一个和多线程相关的坑:ArrayList在并发修改时不仅可能抛异常,还可能返回错误数据,因为它的size()和内部数组的写入不是原子的。CopyOnWriteArrayList是替代方案,读操作不加锁,写操作加锁并复制整个数组,适合读多写少的场景,比如配置项缓存、监听器列表。
7.4 equals与hashCode:集合正确性的基石
用HashSet或HashMap的key时,hashCode()和equals()的一致性直接决定了集合行为是否正确。两个相等的对象必须有相同的hashCode,否则HashMap里get()根据hash找桶,桶都不同,根本找不到同一个对象,会出现“equals比较相等,但contains返回false”的诡异问题。
我见过最典型的案例是:一个实体类只重写了equals()没重写hashCode(),放进HashSet后去重失效,同一份数据出现了多条。这个坑定位起来很费劲,因为它不报错,只是结果不对。如果你要自定义对象作为Map的key,序列化协议里还要注意equals比较的字段不能是可变字段,否则对象放进Map后字段变了,hashCode随之改变,原来的key就找不到了。
Lombok的@EqualsAndHashCode注解能省很多事,但要注意它默认包含所有字段,如果类继承体系里有父类字段,需要callSuper = true。默认不调用super的equals和hashCode,会导致子类对象equals时忽略父类字段。
8. 我现在的集合与流使用习惯,分享给你
说一说我在实际项目里养成的几个固定套路。集合初始化一律预测容量,能防空则空。能用Collections.emptyList()返回空集合,就不要返回null,避免调用方做空指针判断。返回集合的方法,优先返回不可变集合,用Collections.unmodifiableList()或Java 9以后的List.of(),防止调用方误改数据。
Stream流的厂子,我尽量不在中间操作里写太复杂的逻辑。filter的lambda里不要塞超过三行代码,map的lambda尽量提取成单独的方法引用。这不仅仅是为了可读性,更是为了排错方便——lambda里抛异常时,堆栈里的信息非常有限,代码太复杂会很难定位。如果业务逻辑复杂,我一般先map到一个临时DTO,再对DTO做后续操作,而不是在一个lambda里做七八件事。
并行流我用得很克制,只在确定数据量大且处理逻辑无状态、无共享可变对象时使用。涉及IO操作的并行,比如并行调用外部接口,会直接用CompletableFuture配合自定义线程池来控并发度,而不是用parallelStream共享ForkJoinPool。这是我的经验,虽然parallelStream一行就能搞定,但生产环境里线程池失控的代价远比你省的那几行代码要大。
关于collect,我还有一个建议:尽量用Collectors.toList()而不是自己收集到ArrayList。前者在数据量大时会走ArrayList或带预估容量的ArrayList,性能更好,代码也更简洁。如果收集到Map时遇到key冲突,一定显式指定合并函数,不要依赖默认行为抛异常。
最后分享一个排查线上问题的小技巧:如果你怀疑集合操作有问题,先在本地用相同数据量压一遍,对比不同写法的耗时和结果;再小范围上灰度;最后才全量。我吃过太多次“看着代码没问题”的亏,集合和流的坑往往发生在数据量大了以后才能暴露出来。写代码时把集合的容量、顺序、线程安全、不可变性都想在前面,远比出了问题再修要划算得多。