ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java集合框架详解:Collection、Map与Collections的区别及工程实践

Java集合框架详解:Collection、Map与Collections的区别及工程实践 先从一段面试场景说起。当面试官问出Collection和Collections有什么区别时我见过很多候选人当场卡壳——倒不是不知道答案而是这三个名词实在太像了Collection、Map、Collections最长的一个11个字母最短的Map只有3个字母还都跟集合沾边。更要命的是日常交流里集合这个词有时候指Collection接口有时候指整个集合框架有时候单指某个集合对象语境一乱概念就跟着乱。这篇文章不是来讲八股文的而是把我这些年看源码、写业务、做面试官积累下来的理解一次性讲透。我会把三者的定位差异、Collection和Map两大体系的底层实现、Collections工具类的核心用法以及实际开发中最容易踩的坑串起来讲。适合正在学Java集合的初学者也适合想系统梳理集合框架的进阶开发者。看完你会发现搞清楚这几个概念之间的关系后面读源码、调性能、做选型都会顺很多。1. 三个名字两个体系一类工具先分清楚再谈记忆1.1 一句话掌握三者的定位差异Collection、Map、Collections这三个词看起来像一家人实际上分属三个完全不同的身份名称身份核心定位存储形态Collection接口单列集合的根接口一排元素如[1, 2, 3]Map接口双列集合的根接口键值对映射如{key: value}Collections工具类操作集合的静态方法库不能创建对象全是静态方法如果你只想带走一句话那就是Collection是接口Map也是接口而Collections是一个类一个不能被实例化的工具类。接口定义能干什么工具类提供怎么操作两者在本质上就不在同一个维度上。1.2 为什么这个命名这么容易混淆从Java 1.2开始Collection就是集合框架的基石接口而Collections工具类也几乎是同一时间登场的。给工具类命名时设计者顺手在Collection后面加了个s表示跟集合相关的一堆工具方法于是这一个字母的差别成了无数人初学Java时的第一道坎。至于Map为什么叫Map而不是叫DoubleCollection或者PairCollection是因为它本质上描述的是一种键到值的映射关系。英文里map这个单词本身就有一一对应的含义Java设计者也刻意让Map独立于Collection体系——虽然它也被归入整个Java集合框架但从接口继承关系上看Map并不继承Collection它是另一棵树的根。1.3 从内存结构看单列与双列的本质差异从数据结构角度看Collection是一排数据元素之间是并列关系Map是一张对照表考的是通过键找值的能力。用生活场景类比最直观Collection就像教室里的学生名单每个学生是一个独立元素你可以点名、可以按学号排序Map则像一本课程表周一上午对应数学课周三下午对应体育课核心操作是查对应关系。你拿着课程表问周一上午上什么课这是Map的get(key)你拿着学生名单问第三个是谁这是List的get(index)。这个本质差异也决定了它们在代码中的使用方式完全不同。Collection的每个元素都是平等的个体而Map天然带有索引和被索引对象的角色分工。理解了这一层后面看源码时很多设计决策就都能说得通了。2. Collection单列集合List与Set的底层真相与选型逻辑2.1 Collection接口定义了哪些规矩Collection接口是整个单列集合的顶层契约任何实现它的类都必须遵守这套行为规范public interface CollectionE extends IterableE { int size(); boolean isEmpty(); boolean contains(Object o); boolean add(E e); boolean remove(Object o); void clear(); IteratorE iterator(); // ... }注意Collection继承了Iterable这意味着所有Collection的实现类都能用增强for循环遍历。为什么这很重要因为增强for循环本质上是语法糖编译后其实就是在用Iterator迭代。这条继承关系把可遍历这个能力统一进了整个单列集合家族。这套契约的实际意义在于底层存储方式千差万别但只要是Collection的实现类判空、遍历、转数组、求大小这些共性操作就能统一对待。这也是为什么面向接口编程在集合框架里体现得淋漓尽致。2.2 List三兄弟ArrayList、LinkedList、VectorList是有序、可重复的集合继承自Collection接口。核心实现类有三个各自代表了一种典型的底层数据结构。ArrayList底层是Object数组查询快随机访问的时间复杂度是O(1)插入和删除慢因为涉及元素搬移。默认初始容量是10每次扩容到原来的1.5倍。为什么是1.5倍扩容太小会频繁触发数组复制浪费性能扩容太大占用多余内存空间1.5倍是在时间与空间之间取的折中。LinkedList底层是双向链表插入和删除快因为只需要调整前后节点的引用但随机访问慢时间复杂度是O(n)。它额外实现了Deque接口所以同时还能当队列和栈用。需要注意的是虽然理论上LinkedList随机访问比ArrayList慢很多但实际开发中在数据量不大时差距并不明显选型时还是要结合场景。Vector是ArrayList的老版本线程安全实现方法都加了synchronized但正因为每条命令都串行化性能很差现在基本被淘汰了。它还有个私生子Stack类Stack继承Vector来实现栈功能这是历史遗留设计现在官方推荐的栈实现是ArrayDeque。这里有个很常见的疑问既然LinkedList支持队列和栈操作为什么还要单独用ArrayDeque因为ArrayDeque底层是循环数组缓存命中率高内存开销小综合性能比LinkedList当队列用更好。如果你只是需要一个栈或队列优先考虑ArrayDeque。2.3 Set三兄弟HashSet、LinkedHashSet、TreeSetSet是无序、不可重复的集合。这里无序指的是不保证元素的插入顺序而不是随机排列。不可重复通过equals和hashCode方法保证。HashSet底层其实就是一个HashMap只是只用了key那一列。当你调用add(e)时底层执行的是map.put(e, PRESENT)其中PRESENT是个固定的Object常量value没有任何实际含义纯粹为了占位。private static final Object PRESENT new Object(); public boolean add(E e) { return map.put(e, PRESENT) null; }这段代码是理解Set与Map关系的关键。很多人学到这里才恍然大悟原来Set并没有独立实现存储逻辑而是复用了Map的存储能力。这也是为什么Map的知识储备对理解Set如此重要。LinkedHashSet在HashSet的基础上维护了一条双向链表记录元素的插入顺序所以它的遍历顺序和插入顺序一致。代价是每个元素多了一点内存开销换来的是可预测的遍历顺序。TreeSet底层是TreeMap元素按照自然顺序或者自定义Comparator排序而不是插入顺序。需要注意TreeSet判断元素是否重复不是靠equals和hashCode而是靠compareTo或者Comparator的返回值——如果返回0就认为两个元素是同一个。这一点很容易踩坑当你有一个自定义对象加入TreeSet时如果比较器只比较了部分字段可能会出现明明两个对象的其他字段不同但TreeSet认为它们是同一个的情况。2.4 一张表看懂Collection选型需求推荐实现底层结构核心特点频繁随机访问ArrayListObject数组O(1)查询扩容1.5倍频繁头尾插入删除LinkedList双向链表O(1)首尾操作可实现队列/栈去重且不关心顺序HashSetHashMap存储基于哈希查重O(1)去重且需要插入顺序LinkedHashSetHashMap双向链表保序但内存略高去重且需要排序TreeSetTreeMap(红黑树)有序操作O(log n)这张表不是在说哪个更好而是帮你按需匹配。实际项目里ArrayList和HashMap是出场率最高的两个集合类但如果你的场景是需要频繁按序访问偶尔删除或者需要去重保持插入顺序选对实现类往往能让代码简洁不少也能避免后期性能返工。3. Map双列集合从HashMap到并发容器的关键机制3.1 HashMap的存储原理数组链表红黑树HashMap是Map体系里最核心的实现类也是面试和实战中的常客。它的底层结构在JDK 8之后是数组链表红黑树。先看基础逻辑HashMap内部有一个Node数组也被称为table每个数组元素是一个桶。当put一个键值对时先计算key的哈希值再通过哈希值定位到具体的桶下标然后把Node节点放入这个桶中。如果多个key的哈希值落在同一个桶里就形成了链表这就是处理哈希冲突的链地址法。JDK 8的优化在于当链表长度超过阈值8TREEIFY_THRESHOLD且数组长度大于等于64时链表会转成红黑树。为什么是8因为设计者基于泊松分布计算过在哈希函数足够随机的情况下一个桶里链表长度达到8的概率大约是千万分之一属于极端情况。用8作为临界值既避免了树化频繁发生带来的额外开销又能在极端场景下把最坏时间复杂度从O(n)降到O(log n)。这里也说明一个事不要试图用一个糟糕的hashCode来考验HashMap。如果一个类的hashCode实现得非常差大量key堆在同一个桶里红黑树也救不了整体性能。3.2 容量、加载因子与2的幂次方的秘密HashMap有两个关键参数默认容量是16加载因子是0.75。加载因子表示当前容量被占用到多少比例时触发扩容0.75是时间与空间的权衡——太小则频繁扩容浪费内存太大则哈希冲突加剧影响性能。为什么容量必须是2的幂次方因为定位桶下标用的是hash (n - 1)而不是hash % n。只有n为2的幂时hash (n - 1)才等价于hash % n而位运算比取模快得多尤其是在高并发的循环put场景下这个性能差距会被放大。JDK 8还对哈希值做了一次扰动。key的原生hashCode是32位的int如果数组容量很小参与计算的主要是低位高位信息就浪费了。所以HashMap先执行h ^ (h 16)把高位和低位混合起来让高位特征也能影响下标计算从而降低冲突概率static final int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); }3.3 LinkedHashMap、TreeMap、ConcurrentHashMap的使用场景LinkedHashMap在HashMap的基础上增加了一条双向链表维护了插入顺序。当你需要按插入顺序遍历Map时它是首选。更进阶的用法是通过构造参数accessOrdertrue开启访问顺序模式让最近访问过的元素排到尾部这是实现LRU最近最少使用缓存的经典方案。TreeMap底层是红黑树key按照自然顺序或自定义Comparator排序。它的操作时间复杂度是O(log n)支持范围查询、取最小值最大值等操作。要注意TreeMap的key不能为null因为排序需要比较器null没有可比较性。ConcurrentHashMap线程安全的Map实现。JDK 7使用了分段锁JDK 8后改为CASsynchronized锁粒度细到了单个桶。它把整个Map分成了很多独立的桶不同线程操作不同桶时互不干扰所以并发性能远好于Hashtable那样锁住整个对象的方式。3.4 为什么HashMap是线程不安全的这个问题基本上是Java面试必问。原因有两点。第一在多线程并发put时两个线程可能同时计算出同一个桶下标导致后写入的数据覆盖前面写入的数据造成数据丢失第二JDK 7的扩容采用头插法转移元素多线程并发扩容时可能形成环形链表之后任何查询都会陷入死循环直接把CPU打满。JDK 8改用尾插法解决了死循环问题但数据覆盖问题依然存在。所以在多线程场景下千万不要直接用HashMap该用ConcurrentHashMap就用ConcurrentHashMap。很多人觉得我项目并发量不大应该没事但多线程下的数据覆盖是概率性问题一旦出现就是线上事故排查起来非常痛苦。4. Collections工具类不创建对象却把所有集合玩明白4.1 排序与查找Collections工具类里最常用的一类方法就是排序和查找。ListInteger list new ArrayList(Arrays.asList(3, 1, 4, 1, 5)); Collections.sort(list); // 自然顺序排序 Collections.sort(list, Comparator.reverseOrder()); // 自定义排序 int index Collections.binarySearch(list, 4); // 二分查找sort方法底层调用了List的sort方法核心是TimSort算法它结合了归并排序和插入排序对部分有序的数据性能非常好。binarySearch方法的前提是list必须已经有序否则结果不可预期。如果找到了返回元素下标如果没找到返回的是-(插入点) - 1。这个负值设计很巧妙调用方可以通过判断返回值是否小于0来确定元素是否存在同时用-index - 1直接算出应该插入的位置。4.2 不可变集合emptyList、singletonList、unmodifiableList这三种创建不可变集合的方式在实际开发中出场率很高。ListString empty Collections.emptyList(); ListString single Collections.singletonList(A); ListString unmodi Collections.unmodifiableList(originalList);emptyList()返回一个不包含任何元素的空List好处是不分配数组内存占用几乎为零。如果你在写一个返回值为List的方法当结果为空时返回Collections.emptyList()比返回new ArrayList()更省内存也能避免调用方被迫做null判断。singletonList()返回只包含一个元素的List。底层是专门的SingleList内部类只存一个对象引用比创建一个ArrayList再塞一个元素省得多。unmodifiableList()返回的是原集合的只读视图。原集合怎么改它同步跟着变但如果直接对这个视图调用add、remove等方法会抛出UnsupportedOperationException。这种设计相当于提供了一个只读保护层适合把内部集合暴露给外部而不想让调用方修改的场景。4.3 线程安全包装synchronizedList与synchronizedMap如果你不想引入ConcurrentHashMap又想让Map具备基本的线程安全能力可以用Collections.synchronizedMap()MapString, String syncMap Collections.synchronizedMap(new HashMap()); ListString syncList Collections.synchronizedList(new ArrayList());这个包装在底层给每个方法加了synchronized锁。但必须注意一个坑单方法安全不代表复合操作安全。比如遍历这个syncList时其他线程如果同时在修改依然可能抛出ConcurrentModificationException。官方文档明确要求遍历时需要手动加锁synchronized (syncList) { for (String item : syncList) { // ... } }这也解释了为什么在高并发场景下我通常直接推荐ConcurrentHashMap而不是synchronizedMap。前者牺牲了一定的单线程性能换来了更细粒度的并发控制后者本质上是串行化访问并发量上来后想扩展也难。4.4 其他实用方法除了排序、查找、不可变集合和线程安全包装Collections还有一些开发中经常用到的方法Collections.reverse(list)反转List中的元素顺序。Collections.shuffle(list)随机打乱List。做抽奖、随机出题、洗牌算法时很实用底层用的是Fisher-Yates洗牌算法随机性比直接调用Random逐个交换要好。Collections.fill(list, obj)用指定对象替换List中的所有元素。Collections.copy(dest, src)把src中的元素复制到dest中注意dest的size不能小于src。Collections.swap(list, i, j)交换List中两个位置的元素。Collections.addAll(collection, elements)一次性向集合中添加多个元素比循环add简洁得多。Collections.min(collection)/Collections.max(collection)求集合中的最小/最大元素。4.5 从源码角度理解工具类的设计思路如果只把这些方法当API背那就浪费了Collections这个类最大的学习价值。拿Collections.max()的源码来说它内部并没有针对ArrayList或LinkedList做特殊判断而是通过Collection接口的iterator()方法统一遍历迭代过程中维护一个当前最大值public static T T max(Collection? extends T coll, Comparator? super T comp) { Iterator? extends T i coll.iterator(); T candidate i.next(); while (i.hasNext()) { T next i.next(); if (comp.compare(next, candidate) 0) { candidate next; } } return candidate; }这段代码就是面向接口编程的经典示范。只要一个类实现了Collection接口无论它的底层是数组、链表还是红黑树max方法都能一视同仁地处理。写工具类的时候这种依赖抽象而非具体实现的思路非常值得借鉴。5. 实战选型与踩坑记录集合框架的最后一公里5.1 选型之前先问自己三个问题每次写代码要选集合类型时别急着直接new一个HashMap完事。先问自己三个问题第一允不允许重复元素第二元素的顺序有没有要求第三会不会被多个线程同时访问这三个问题的答案基本能锁定大方向。不允许重复去Set家族里选允许重复去List家族里选需要键值对映射去Map家族里选。顺序方面需要插入顺序用LinkedHashSet/LinkedHashMap需要排序用TreeSet/TreeMap。线程安全方面单线程用非同步集合就行多线程优先考虑ConcurrentHashMap以及并发包下的CopyOnWriteArrayList等。5.2 初始化容量一个被忽略的性能细节很多人new HashMap的时候直接写new HashMap()这在数据量大的时候其实存在性能隐患。默认容量16加载因子0.75意味着元素数量超过12就会触发扩容。如果你明确知道要存放1000个元素又写了默认构造器那中间可能会经历多次扩容每次扩容都要重新计算所有元素的哈希并搬移数据非常耗时。正确的做法是根据期望的容量计算初始值。如果准备存1000个元素为了避免扩容初始容量至少应该是1000 / 0.75 1 ≈ 1335向上取一个2的幂直接给2048会更稳。Guava的Maps.newHashMapWithExpectedSize(1000)也是按这个思路封装的。ArrayList同理如果你大致能估计元素数量直接new ArrayList(1000)可以避免后续多次扩容时数组复制的开销。5.3 Arrays.asList与subList的陷阱Arrays.asList()是很多人写代码时喜欢用的快捷方式但它隐藏着两个容易忽略的坑。第一它返回的不是java.util.ArrayList而是Arrays的内部类ArrayList这个内部类没有实现add和remove方法调用会抛UnsupportedOperationException。第二它返回的List和原数组共享同一份数据修改List中的元素会直接改变原数组。subList()也有类似问题。它返回的是原List的一个视图不是新List。修改subList会影响原List而且subList的size不能缓存因为底层视图结构没有独立的modCount。如果截取subList之后原List发生了结构性修改比如添加或删除了元素再操作subList会抛出ConcurrentModificationException。所以我的经验是截取之后尽量不要再动原List如果确实需要独立的一份用new ArrayList(list.subList(a, b))包一层。5.4 遍历时删除元素的正确姿势这是实战中出现频率极高的问题。在for循环中直接调用list.remove(i)删除元素很容易出现两个问题一个是元素删除后下标错位跳过了本该删除的元素另一个是迭代器检测到modCount变化抛出ConcurrentModificationException。正确的做法是用迭代器的remove方法IteratorString iterator list.iterator(); while (iterator.hasNext()) { String item iterator.next(); if (conditionFulfilled(item)) { iterator.remove(); } }迭代器的remove方法会把modCount同步到迭代器的expectedModCount中所以不会触发并发修改异常。JDK 8之后也可以用list.removeIf(condition)一行搞定它内部封装了同样的逻辑更加简洁。5.5 个人使用集合框架的一些体会最后分享一点我的真实经验。很多人觉得集合框架就是背API但其实它在Java整个体系里的地位相当于工具箱里的扳手和螺丝刀——几乎所有业务代码都离不开它而用得好不好、选型对不对直接决定代码的健壮性、可读性和性能。我在带团队做code review时见过太多HashMap一把梭的情况明明需要保持插入顺序却用了HashMap导致遍历顺序不稳定明明只需要去重却用了ArrayList然后contains方法线性扫描数据量大后性能崩盘明明是多线程场景却用了HashMap线上偶发数据丢失。这些问题用错了集合类型后面要花大量时间去排查。建议你在读完这篇文章后把Collection、Map、Collections三者的关系和源码打开对照着看一遍。先看Collection接口定义了什么方法再看ArrayList和HashMap分别是怎么实现这些方法的最后回来看Collections工具类是怎么利用接口抽象统一操作的。这一套走下来你对整个集合框架的理解绝对会上一个台阶。记住真正的掌握不是记住某个类的用法而是理解它为什么这么设计——这也是Java集合框架这个老伙计至今依然充满生命力的原因。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进