day10 集合框架
前两天还在折腾流程控制和面向对象,到了第十天,终于碰上了Java里真正每天都在用的东西——集合框架。很多人学到这儿会觉得“不过就是几个容器,能放东西就行”,但真到了写代码和面试的时候才发现,ArrayList和LinkedList选错,HashMap初始化容量没给,遍历的时候顺手删了个元素,程序直接给你抛ConcurrentModificationException。这篇内容不只是帮你把Collection、Map这些概念捋清楚,更会讲明白每种实现背后的数据结构、适用场景,以及我在实际项目里踩过的那些坑。不同基础的读者都能从中拿到自己能用的东西:初学者可以把集合框架的整体脉络建立起来,写过一段时间代码的人则可以对照着检查自己平时的用法有没有问题。
集合框架要理解到位,关键不在于背下多少个类,而在于想清楚一个问题:为什么Java在数组之外还要设计这一整套容器体系?把这层逻辑打通了,后面所有的API、性能差异、选型原则都是顺理成章的事。
1. 集合框架存在的理由:从数组的“不灵活”谈起
1.1 数组的边界在哪里
很多初学者有个疑问:数组不是也能存对象吗,为什么还要搞出List、Set、Map这么一大堆东西?这个问题问到点子上了。数组确实可以存对象,但它有几个先天性的短板。
第一,数组的长度一旦创建就固定了。new String[10]就是10个位置,想塞第11个元素,只能重新new一个更长的数组,手动拷贝数据。这在业务开发里几乎不可接受——你永远不知道用户会传多少条数据过来。第二,数组的增删操作非常麻烦。在数组中间插一个元素,后面的所有元素都得往后挪;删一个元素,所有后续元素又得往前补。这些操作你得自己写循环。第三,数组只能通过下标访问,如果你想判断某个元素是否存在,或者想根据某个属性找到对应的对象,数组不够直观。
集合框架解决的就是这三个问题:长度动态变化、增删元素由容器自身管理、提供丰富的查找和遍历能力。你可以把集合理解成“加强版数组”,但它做的事情远不止存储,还包括了数据结构层面的优化。
1.2 集合框架的三层结构
集合框架整体上可以拆成接口、实现、算法三个层面来理解。接口定义“能做什么”,实现类决定“怎么做”,Collections工具类则提供了一堆通用的算法,比如排序、查找、反转、不可变包装等。
接口这一层最核心的就是两个家族:一个是Collection,代表一组元素的集合;另一个是Map,代表键值对的映射关系。Collection下面又派生出了List、Set、Queue三个子接口,分别对应有序可重复列表、无序不可重复集合、队列。
实现类这一层就是各种具体的数据结构了。ArrayList底层是数组,LinkedList底层是双向链表,HashSet底层其实是HashMap,TreeSet底层是红黑树。搞清楚每个实现类的底层结构,比记住它的方法列表重要得多。
算法这一层则有Collections和Arrays两个工具类,提供了排序sort、二分查找binarySearch、转换singletonList这一类静态方法。日常开发中,这些工具方法能帮你省掉大量重复代码。
2. 先看清家族图谱:Collection和Map两条主线的定位差异
2.1 Collection接口的能力边界
Collection是Java集合框架里最上层的容器接口之一,它定义了一组通用的操作:add往容器里加元素,remove删元素,contains判断元素是否存在,size获取元素数量,iterator获取迭代器。这些方法在所有Collection的实现类里都存在,所以你写代码的时候,可以面向接口操作,而不必关心底层具体是数组还是链表。
但Collection本身没有定义元素的“顺序规则”,也没有规定“能不能重复”——这些规则是在子接口里细化的。List允许重复元素且元素有明确顺序,Set不允许重复元素,Queue则通常按先进先出的规则管理元素。
理解这一点很重要。你在写代码的时候如果只需要“装一组对象”,那就可以用Collection来接收,但一旦你需要按顺序取元素,或者需要去重,就必须用更具体的接口类型。
2.2 Map接口的键值对模型
Map和Collection最本质的区别是,Map不继承Collection,它存储的不是单个元素,而是一组键值对。每个键只能映射到一个值,键不能重复,但值可以重复。Map的核心操作也相应变成了put(key, value)和get(key),按“键”定位,而不是按下标定位。
这种模型在现实开发里太常见了。配置项解析、用户会话管理、对象属性缓存,全部能用Map来表达。我自己在项目里最常做的事情之一,就是从数据库查出结果集之后,把某列作为key、整行作为value扔进一个Map,方便后面快速查找。
Map家族里同样有多个实现,分别对应不同的需求场景:需要快速存取就用HashMap,需要按插入顺序遍历就用LinkedHashMap,需要按键排序就用TreeMap,需要线程安全就用ConcurrentHashMap。
2.3 两大分支的横向对比
为了方便对照,我把常用集合类的几个关键特征整理成了一张表。
| 接口/实现 | 底层结构 | 顺序特征 | 是否允许重复 | 是否允许null | 线程安全 | 典型场景 |
|---|---|---|---|---|---|---|
| ArrayList | 动态数组 | 按插入顺序 | 允许 | 允许 | 否 | 频繁随机访问、尾部追加 |
| LinkedList | 双向链表 | 按插入顺序 | 允许 | 允许 | 否 | 频繁头尾增删、实现队列/栈 |
| HashSet | HashMap(哈希桶) | 无保证 | 不允许 | 允许一个null | 否 | 快速去重、判断存在性 |
| TreeSet | 红黑树 | 按排序规则 | 不允许 | 不允许 | 否 | 需要有序不重复集合 |
| LinkedHashSet | 哈希桶+双向链表 | 按插入顺序 | 不允许 | 允许 | 否 | 保序去重 |
| HashMap | 数组+链表+红黑树 | 无保证 | 键不重复 | 允许一个null键和多个null值 | 否 | 缓存、快速键值查找 |
| LinkedHashMap | 哈希桶+双向链表 | 按插入顺序或访问顺序 | 键不重复 | 允许 | 否 | LRU缓存、保序Map |
| TreeMap | 红黑树 | 按键排序 | 键不重复 | 不允许 | 否 | 范围查询、有序键映射 |
| ConcurrentHashMap | 数组+链表+红黑树(分段锁/CAS) | 无保证 | 键不重复 | 不允许 | 是 | 高并发缓存、计数器 |
这张表建议收藏。选型的时候先想清楚三个问题:元素是否允许重复?遍历时是否需要保持某种顺序?是否有并发访问需求?答案基本能帮你定位到正确的实现类。
3. 常用实现类的真实面貌:数据结构与选择逻辑
3.1 ArrayList vs LinkedList:时间复杂度不是唯一标准
很多教程喜欢列一张时间复杂度对照表,然后告诉你“随机访问用ArrayList,频繁插入删除用LinkedList”。这个结论大方向没错,但我在实际开发中发现,盲目选LinkedList的情况远比想象中多。
ArrayList底层是Object数组,默认容量10,当元素数量超过容量时,会扩容为原来的1.5倍,并把旧数组拷贝到新数组。这个扩容操作是有代价的,但均摊下来每次add的时间复杂度仍然是O(1)。随机访问直接按下标偏移,是O(1)操作。中间插入或删除则需要移动后续元素,是O(n)操作。
LinkedList底层是双向链表,每个节点持有前驱和后继的引用。头尾插入删除是O(1),但中间插入需要先遍历到目标位置,是O(n)。随机访问更是需要从头或尾逐个遍历,也是O(n)。
看起来各有利弊,但问题在于:JVM的内存模型决定了引用类型对象本身就带有额外开销。链表每个节点都是一个独立对象,不仅内存占用更大,还会因为对象在堆上分散存储导致CPU缓存命中率下降。所以很多实际场景下,即使你做的是“在中间插入元素”的操作,数据量不大时ArrayList反而更快。
我的建议是:默认首选ArrayList,除非你能明确证明瓶颈出在头尾增删,否则不要轻易上LinkedList。真需要频繁头尾操作的场景,更现代的方案是用ArrayDeque,它实现了Deque接口,既支持头尾增删,又有数组的缓存友好性。
3.2 HashMap的哈希原理与扩容机制
HashMap是整个集合框架里最重要的类,没有之一。它底层是一个Node数组,每个Node包含key、value、hash和next指针。插入时先计算key的hashCode,再做一次扰动函数处理,然后定位到数组下标。如果这个位置已经有元素,就说明发生了哈希冲突,此时根据链表长度决定是追加到链表还是升级成红黑树。
Java 8开始,当链表长度超过8且数组长度大于等于64时,链表会转换成红黑树,把最坏情况下的查找时间复杂度从O(n)降为O(log n)。这个优化解决的是极端情况下哈希函数分布不均的问题,正常情况下链表长度很少会超过1到2个。
关于扩容,HashMap默认初始容量是16,负载因子是0.75。意思是当元素个数超过容量 * 0.75时,就会扩容为原来的两倍,并重新计算所有元素的位置。这里有个常见的性能优化点:如果预先知道数据量,就应该在构造时指定初始容量,避免频繁扩容。
举个例子,如果你知道大约要存1000个键值对,直接new HashMap<>(1024)或者new HashMap<>(2048)都会比默认容量好很多,因为用默认容量存1000个元素,中间至少会触发三次扩容,每次扩容都要重新哈希所有元素。不过也要注意容量别设置得太夸张,否则遍历时会做大量无效检查。
还有一个细节我要单独强调:HashMap允许null键和null值,put一个null键时会把它放在数组的0号位置。但ConcurrentHashMap是禁止null键和null值的,因为并发场景下无法区分“值为null”和“键不存在”。你如果哪天从HashMap迁移到ConcurrentHashMap,这个差异可能会导致空指针。
3.3 LinkedHashMap与TreeMap:两种“有序”的代价
解决“Map是否需要顺序”这个问题,你需要先搞清楚要的是哪种顺序。
LinkedHashMap维护了一个双向链表,链表里记录的是元素的插入顺序。遍历时按这个链表的顺序输出,就和插入顺序一致。它还有一个更高级的用法,就是那个accessOrder参数。当这个参数设为true时,每次get都会把访问的元素移动到链表末尾,这样一来,链表的头部就是最久没有被访问的元素,尾部就是最近访问的。这正好可以用来实现一个LRU缓存——当缓存满了,就淘汰掉链表头部的元素。
TreeMap则不同,它不是按插入顺序排序,而是按键的自然顺序或者构造时传入的Comparator排序。底层是红黑树,核心优势是支持范围查询,比如subMap(fromKey, toKey)取一段区间、firstKey()和lastKey()取最小最大键、floorKey和ceilingKey查找最近匹配的键。如果你需要“按分数排序的学生成绩表”“按时间排序的日志表”这类数据,TreeMap非常合适。
代价也要说清楚:LinkedHashMap比HashMap多了维护链表的开销,TreeMap的每次操作都是O(log n),和HashMap的O(1)相比性能差距明显。所以凡是能用HashMap解决问题的场景,不要因为“想保持顺序”就盲目换TreeMap,先想想这个顺序是不是真的强需求。
3.4 Set的三个实现:什么时候用哪个
HashSet、LinkedHashSet、TreeSet三者最容易让人搞混。记住一点:Set都是基于Map实现的,HashSet底层是HashMap,LinkedHashSet底层是LinkedHashMap,TreeSet底层是TreeMap,值都放在key的位置,value统一用同一个Object占位。
HashSet的核心能力就是快速判断“是否存在”,时间复杂度O(1)。这也意味着它需要用到元素的hashCode和equals方法,存储的元素必须正确重写这两个方法。如果一个对象用两个字段判断是否相同,只重写equals却不重写hashCode,两个对象会落到不同的哈希桶里,HashSet就会把它们当成两个元素,去重失败。
LinkedHashSet在HashSet的去重能力上额外保持了插入顺序,适合“需要记录用户访问轨迹但又要去重”的场景。
TreeSet则适合“既要排序又要去重”的场景,比如排行榜。但要注意,TreeSet判断重复不是靠equals,而是靠compareTo方法的返回值。compareTo返回0就认为是同一个元素,所以如果compareTo只比较了其中一个字段,另外字段不同的对象也会被当成重复元素剔除。
4. 遍历与操作时那些容易踩的坑:并发修改与迭代策略
4.1 四种遍历方式的性能差异
集合的遍历方式现在至少有四种:传统的for循环加下标、增强for循环、显式Iterator迭代器、Java 8的forEach。
用下标遍历只适用于List,因为只有List支持随机访问。对ArrayList来说这种遍历方式很快,但换成LinkedList就灾难了,每次get(i)都要从头遍历链表,整体时间复杂度是O(n²)。数据量稍微大一点就能明显感觉到卡顿。
增强for循环和Iterator本质上是同一回事,编译器会把增强for转换成Iterator调用。这是遍历Collection最推荐的方式,因为不管是List还是Set都能正确工作。
Java 8的forEach接收一个Consumer接口,写法简洁,但它和普通的Iterator有一个区别:forEach里不能方便地获取当前元素的索引,而且某些情况下无法中止循环。还要注意,forEach只是内部迭代的语法糖,底层仍然是基于Iterator实现的。
如果你追求极致性能,在JDK中遍历LinkedList时,用Iterator会比for循环加get快很多。这个建议我现在依然坚持:除非在collect的stream场景里要求简洁,否则普通遍历用增强for循环就够了。
4.2 边遍历边删除的ConcurrentModificationException
写代码时间长了的人,基本都遇到过一个异常:ConcurrentModificationException。我用一个很简单的例子说明问题——你有一个ArrayList,想要删除里面所有“A”元素,最常见的方式是for循环加if判断,然后remove。运行的时候很可能就直接抛异常了。
原因是集合内部维护了一个modCount(修改次数计数)字段,每次结构性修改(add、remove)都会加1。迭代器创建时会记录当前的modCount,之后每次调用next方法都会检查modCount有没有变化,如果发现不一致,就会立即抛出ConcurrentModificationException。这个设计是为了在迭代过程中及时发现并发修改,避免出现不可预期的行为。
那正确的删除方式是什么呢?推荐几种:
第一种是用Iterator的remove方法,而不是Collection的remove。Iterator.remove会在删除元素之后同步更新迭代器内部的expectedModCount,所以不会触发异常。
Iterator<String> iterator = list.iterator(); while (iterator.hasNext()) { String item = iterator.next(); if (condition(item)) { iterator.remove(); } }第二种是用Java 8的removeIf方法,一行代码搞定:
list.removeIf(item -> condition(item));removeIf内部也是通过迭代器实现的,但把条件和删除动作封装好了,代码更简洁,推荐优先使用。
第三种是在一个临时列表里收集要删除的元素,遍历结束后统一删除。这种方式适合逻辑比较复杂的情况,比如需要根据多个集合的交集来决定删除哪些元素。
4.3 遍历过程中其他容易被忽略的操作
除了删除,遍历里做修改也要小心。比如用增强for循环遍历一个Map,然后往map里put一个新的键值对,同样会触发ConcurrentModificationException,因为Map也有modCount机制。
遍历Map的时候如果你需要在过程中给部分键更新值,用entrySet()遍历,再通过entry.setValue()更新是允许的——这不是结构性修改,modCount不会变。但如果你要新增键值对,就应该先把新元素收集到一个新的Map里,遍历结束之后统一putAll。
再比如遍历时修改元素本身的属性,如果这个属性没有参与hashCode和equals的计算,那没问题;如果改了hashCode相关的字段,问题就大了。元素在HashSet里的位置已经按旧hashCode计算过了,现在hashCode变了,集合却不知道,后续的contains、remove都可能找不到它。这种情况属于“未定义行为”,不建议在生产代码里做,如果要改对象的关键字段,先移出集合再改。
5. 代码之外的选型智慧:接口声明、不可变集合与线程安全
5.1 用接口类型声明引用:看似小事,影响很大
很多新手写代码习惯直接ArrayList<String> list = new ArrayList<>(),这本身没错,但更推荐的做法是List<String> list = new ArrayList<>()。
用接口类型声明引用有实实在在的好处。第一,它能约束你的代码在编译期就只使用List接口定义的方法,不会不小心用到ArrayList特有的方法,比如ensureCapacity、trimToSize。第二,它保证了你后续可以轻松替换实现类,比如想换成LinkedList,只需要改new的那一行,其余代码不用动。第三,从可读性上讲,List表达的是“我需要的是一组按顺序排列的元素”这个语义,比直接暴露具体的实现类更合理。
这个原则对Map同样适用。大部分场景声明为Map<String, Object>就够了,除非你明确需要用到TreeMap.firstKey()这类特有方法,才需要把类型声明为具体的TreeMap。
5.2 不可变集合:防御式编程的利器
业务代码里很常见的一个问题是:你给调用方返回了一个List,调用方顺手往里面add了一个元素,结果你的内部状态被污染了。要避免这种情况,方式是把集合变成不可变的。
Java 9开始,接口上直接有List.of、Set.of、Map.of这些静态工厂方法,创建出来的集合不能再添加、删除、修改元素。它们和传统的Collections.unmodifiableList有区别:Collections.unmodifiableList只是给原集合包了一层只读视图,原集合仍然可以修改,修改后视图也跟着变;而List.of创建的是真正不可变的集合,底层就是固定数据,任何修改尝试都会抛UnsupportedOperationException。
List<String> emptyList = List.of(); List<String> fixedList = List.of("a", "b", "c"); Map<String, Integer> map = Map.of("key1", 1, "key2", 2);顺便提一个防御式拷贝的实践:当你从外部接收一个List时,如果这个List可能在后续被修改,而你内部需要保存它的快照,那就应该new ArrayList<>(外部List)来拷贝一份。这个方法看着简单,却能在多级调用链路里避免大量隐藏的bug。
5.3 线程安全:不要为了“安全”付出过大的性能代价
集合框架里大部分类都是线程不安全的。如果多个线程同时读写同一个ArrayList或HashMap,可能出现元素丢失、结构损坏、无限循环等问题。要解决并发问题,有三种思路。
第一种是加锁,最简单粗暴。用synchronized块或者ReentrantLock包住所有对集合的操作。这种方案的缺点是并发度很低,即使不同的线程在做读操作也会互相阻塞。
第二种是Collections.synchronizedList这类包装方法。它把所有方法用synchronized锁住,代价一样是并发度受限。
第三种是使用并发包下的专用实现:CopyOnWriteArrayList、ConcurrentHashMap、LinkedBlockingQueue这些。ConcurrentHashMap是这里面最值得好好研究的。它把整个Map划分成多个桶,读操作完全不加锁,写操作只锁住单个桶,并发度远高于全局锁方案。
需要注意一个细节:即使使用了ConcurrentHashMap,它的“线程安全”也只是针对单个操作而言的。像“如果key不存在就放入”这种复合操作,仍然需要手动加锁或者使用putIfAbsent这类原子方法。
Map<String, AtomicInteger> counters = new ConcurrentHashMap<>(); counters.computeIfAbsent("click", k -> new AtomicInteger()).incrementAndGet();这段代码中,computeIfAbsent是原子的,不会出现并发场景下同名key被多次创建的问题。用对工具方法,比到处加锁优雅得多。
6. day10之后的自测方案与常见误区
6.1 上机练习建议:不要只背API
学集合框架最忌讳的就是只看不练。我给你整理一套上机练习清单,难度从低到高,一天之内就能完成,覆盖了大部分核心知识点。
第一题:统计一篇文章里每个单词出现的次数。要求使用HashMap,同时考虑输入里大小写不同的问题。这个练习能帮你熟悉HashMap插入、合并、遍历的完整流程。
第二题:实现一个LRU缓存,容量为3,超过容量自动淘汰最久未使用的键。建议用LinkedHashMap的removeEldestEntry方法实现,再手动写一版用HashMap加双向链表的,对比一下代码复杂度。
第三题:给一个Student类,包含name和score两个字段。用TreeSet按score降序排列,然后取前三名。这个练习需要正确实现Comparable或者提供Comparator。
第四题:把一个List去重,保持原有顺序。先用LinkedHashSet实现,再想一下如果用HashSet直接去重会发生什么顺序变化。
第五题:初始化一个ArrayList和LinkedList,各放100万个元素,分别测试“随机访问第50万个元素”和“在中间插入1万次”的时间差距。这个练习会让你对数据结构的时间复杂度有真实的体感。
6.2 常见误区清单
最后整理一下我在review代码时经常看到的错误,每条都是血泪经验:
不要用==判断两个字符串是否相等,集合里的查找同样依赖equals方法。如果你存的是自定义对象,一定要重写equals和hashCode,否则用contains找不到元素。
不要在foreach循环里删除元素。这个问题上面讲过了,每次看到有人这么写,我都建议他换成removeIf。
不要存了可变对象之后又去修改它。尤其当这个对象是HashSet或HashMap的key时,修改会影响hashCode,导致整个集合行为异常。
不要随手给HashMap设置过大的初始容量。容量是2的幂,设置太大浪费内存,遍历时也慢,一般预估元素数量后凑一个2的幂就好。
不要把所有Map都声明成HashMap。如果你需要有序遍历,LinkedHashMap的代价几乎和HashMap一样小;如果你需要范围查询,TreeMap才是不二之选。选错类型后面再改,牵扯的代码会非常多。
6.3 一个真实项目的集合使用复盘
说一个我经历过的实际情况。之前做一个订单导出功能,从数据库查出十万条订单记录,每条记录需要关联查一次用户信息。最开始用循环,每条记录查一次数据库,导出一份报表花了快五分钟,被运营吐槽了好几次。
后来优化思路其实很简单:把用户信息先全部查出来放一个Map里,key是userId,value是用户对象。循环订单时,直接从Map里get,一步拿到用户数据。改造之后,整个导出过程从五分钟降到了三秒以内。这就是HashMap在实际项目中带来的巨大收益——时间复杂度从O(n²)降到了O(n)。
类似的场景还有很多:批量翻译、字典映射、统计计数、缓存去重。集合框架不只是一个知识点,它是你写出高性能代码的底层基本功。第十天能把这块吃透,后面学Stream、多线程、框架源码都会轻松很多。