很多Java开发者写了好几年代码,每天都在new ArrayList、new HashMap,可真要问到集合框架为什么这样设计,ArrayList扩容到底在做什么,HashMap什么时候会触发树化,很多人就开始含糊了。我自己也是在一个项目里因为集合使用不当导致线上问题,才沉下心把集合这一块从底层彻底翻了一遍。这篇总结就是那段时间的复盘笔记,不打算讲太基础的概念,直接从源码逻辑、性能取舍、并发场景这几个角度,把Java集合里最值得搞明白的点重新捋一遍。
这篇内容适合已经写过不少Java代码、但想进一步把集合用对、用稳、用明白的开发者。看的时候建议打开IDE里的源码对照着读,效果比单纯看文章要好得多。我会把每个关键设计背后的"为什么"一起讲清楚,而不是只给结论。
1. 集合框架的整体地图:先看清两大家族的设计意图
集合框架在Java里不是"一堆类的集合"这么简单,它是一套完整的数据结构抽象。Java帮我们把数组、链表、哈希表、树这些底层结构封装成一套统一的接口模型,让开发者在日常编码里不需要每次都从零搭轮子。
1.1 数组到底输在哪里
数组是Java里最基础、也是性能最高的连续内存结构。但它的短板太明显了:长度在创建时就固定了,没法动态扩展;插入和删除需要移动元素,成本高;而且数组本身没有提供查找、排序、去重这类高级操作。最麻烦的是,数组是"类型强约束"的容器,处理业务数据时往往需要更灵活的组织方式。
集合框架出现之后,这些问题基本被逐一解决了:
- 动态扩容:ArrayList和HashMap都能自动扩容,无需手动管理长度
- 多样化的数据结构:链式存储、哈希存储、树形存储,按需选择
- 泛型约束:编译期就能发现类型问题,比数组的运行时强制更安全
- 算法内建:排序、查找、去重、洗牌这些常用方法直接开箱即用
我自己刚工作时有一段特别深的体会:当时写一个批量导入功能,需要缓存几十万条记录。用数组做得维护一个"已用位置"的游标,还要手动扩容,写起来又难读又容易出bug。换成ArrayList之后,代码量直接砍了一半,而且可读性好了非常多。这就是集合框架最大的意义——它把数据结构最底层的复杂度封装好,让开发者专注于业务逻辑。
1.2 两大阵营:Collection和Map
集合框架的顶层就两个概念:Collection和Map。
Collection是"单元素集合"的根接口,底下派生出List、Set、Queue三个子接口。List关心元素的顺序和可重复性;Set关心元素的唯一性;Queue关心元素的进出顺序,比如先进先出、优先级等。
Map则是"键值对集合"的根接口,它不继承Collection,是独立的一套体系。Map的设计思路是"通过键找到值",HashMap、TreeMap、LinkedHashMap都是它的具体实现。
我建议在脑子里把整个框架装成一张这样的地图:
| 接口 | 主要实现类 | 核心特性 | 底层结构 |
|---|---|---|---|
| List | ArrayList / LinkedList / Vector | 有序、可重复、可索引 | 动态数组 / 双向链表 |
| Set | HashSet / LinkedHashSet / TreeSet | 唯一、无序(或有序) | HashMap / LinkedHashMap / TreeMap |
| Queue | LinkedList / PriorityQueue | 先进先出 / 优先级 | 链表 / 堆 |
| Map | HashMap / LinkedHashMap / TreeMap | 键值对、键唯一 | 数组+链表+红黑树 / 链表 / 红黑树 |
这张表看起来简单,但背后每一行的实现细节都藏着一大堆门道。比如HashSet本质上就是一个"只管key不管value"的HashMap,TreeSet底层就是个红黑树结构的TreeMap。把这些底层关系理清了,面试和实战都不容易虚。
2. List拆解:ArrayList与LinkedList的同门殊途
List接口下有三个经常被比较的实现:ArrayList、LinkedList和Vector。Vector基本已经退出实战舞台了,它所有方法都用synchronized修饰,线程安全但性能低下,并发场景我们有更好的替代方案。真正值得深挖的是ArrayList和LinkedList。
2.1 ArrayList的扩容机制:为什么是1.5倍
ArrayList底层就是一个Object数组,加上一个size计数器。初始创建时,如果走的是无参构造,内部数组其实是空数组,只有第一次往里add元素时才真正创建长度为10的数组——这是懒加载的思路。
关键点来了:当数组装满了,ArrayList怎么扩容?源码里是这样处理的:
int newCapacity = oldCapacity + (oldCapacity >> 1);oldCapacity >> 1就是除以2,所以新容量是老容量的1.5倍。比如10扩容到15,15扩容到22(向下取整),依次类推。
为什么选1.5倍而不是2倍?这里面有个空间和时间的平衡问题。扩容倍率越大,需要拷贝的次数越少,性能越好,但每次扩容后留下的空闲空间也越大,内存浪费就多。2倍扩容是比较"激进"的,适合空间换时间;1.5倍相对"温和",在时间和空间上取了一个中间值。
还有一个细节容易被忽略:ArrayList扩容时用的是Arrays.copyOf,它底层调用System.arraycopy,这是一个native方法,效率极高。但无论多高效,拷贝始终是有成本的。所以如果你能预估数据量,创建ArrayList时直接指定初始容量,是性价比非常高的优化手段。比如你明确知道要装1万条数据,直接new ArrayList<>(10000),能省掉好几次数组拷贝。
2.2 LinkedList的节点结构:中间插入真的总是更快吗
LinkedList底层是双向链表,每个节点就是一个Node对象,包含三个字段:item(数据)、prev(前驱引用)、next(后继引用)。
private static class Node<E> { E item; Node<E> next; Node<E> prev; }链表结构的天然优势是中间插入和删除只需要修改指针指向,不需要移位。所以教科书上通常会告诉你:插入删除多就选LinkedList,随机访问多就选ArrayList。
但实战里这个结论需要打折扣。我用几百万条数据实测过:
- 在尾部追加元素,ArrayList反而比LinkedList快。因为ArrayList在尾部append只是数组赋值,不需要移动已有元素,而LinkedList每次addLast都要创建一个Node对象,对象创建本身就有开销
- 在中间插入元素,LinkedList确实有优势,但前提是你已经拿到了目标位置的节点引用。如果只是按索引插入,LinkedList需要先从头或尾遍历找到那个位置,这个遍历的代价直接抵消了它的结构优势
- 随机访问(比如
get(500000)),ArrayList是O(1)直接按下标算地址,LinkedList需要从头部或尾部一步步走过去,差距巨大
所以我的建议是:在绝大多数业务场景里,优先用ArrayList。LinkedList真正适合的场景是"需要频繁从头部和尾部操作元素"——比如实现一个双端队列,而不是"中间插入很多"这种笼统的场景。这个认知和很多人的直觉不一样,但这就是真实数据给出的答案。
3. HashMap深度拆解:哈希、碰撞、树化与扩容
HashMap是集合框架里最复杂、面试最高频、实战最容易出问题的一个类。它的底层结构经历了从JDK 1.7的"数组+链表"到JDK 1.8的"数组+链表+红黑树"的演进。要真正理解HashMap,得把它的每个核心环节都拆开看。
3.1 hash值与index计算:为什么不做直接用hashCode
HashMap确定元素存储位置时,并不是直接用对象的hashCode(),而是先做一次扰动处理:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }这个操作是把hashCode的高16位和低16位做异或。目的是什么?因为计算数组下标时用的是(n - 1) & hash,n是数组长度,通常不会特别大。如果直接用原始hashCode,高位的信息在"按位与"的过程中就全部丢失了,容易加剧碰撞。通过右移16位再异或,相当于让高16位也参与了低位计算,让散列分布更均匀。
有人会问为什么用&而不是取模%。这里有个二进制技巧:当n是2的幂次时,hash % n和hash & (n - 1)的结果完全一致,但位运算比除法快得多。这也解释了为什么HashMap的容量始终是2的幂次。
3.2 put操作的一条完整链路
put(key, value)的执行流程是这样的:
- 计算
hash(key) - 如果底层数组table是空的,先触发resize初始化,默认容量16
- 用
(n - 1) & hash算出桶下标 - 如果这个桶是空的,直接new一个Node放进去
- 如果桶不为空,说明发生碰撞了,此时分三种情况:
- 如果桶里第一个节点的key与传入key完全相等(hash相同且equals为true),直接替换value
- 如果桶里是一个TreeNode,走红黑树的插入逻辑
- 否则就是普通链表,遍历链表找相同key,没找到就尾插新节点,然后判断链表长度是否超过8且数组长度是否达到64,条件满足就转红黑树
整个链路里最关键的判断条件是"key相等"的标准:先判断hash,再用equals。这意味着你塞进HashMap的key对象,必须正确重写hashCode()和equals()方法。我见过太多因为没重写equals导致同一个业务key在Map里存了两份的bug,这个后面会展开说。
3.3 树化阈值8、退化阈值6、负载因子0.75,都是怎么定的
为什么链表长度到8才转红黑树?这里面有个统计学依据。HashMap的作者在源码注释里用泊松分布算过:在负载因子0.75、随机哈希的理想情况下,单个桶内链表长度达到8的概率大约是千万分之六,已经非常罕见了。树化本身就是一种"防极端情况"的兜底手段,因为树节点的内存占用是普通节点的两倍,如果动不动就树化,反而是浪费。
那为什么红黑树节点数降到6时又退化成链表?这是为了给频繁插入删除的场景留一个缓冲区间。如果树化阈值和退化阈值都设为8,那么一个桶在7和8之间反复横跳时,会不停地在链表和树之间转换,产生多余的性能开销。所以8和6中间留了1的余量。
负载因子0.75同样是个经过权衡的经验值。负载因子越大,空间利用率越高,但碰撞概率也增大;负载因子越小,碰撞概率低,但内存浪费多。0.75是在时间和空间上的一个均衡点。
3.4 扩容为什么是2倍
当HashMap的size超过threshold = capacity * loadFactor时触发扩容,新容量是旧容量的两倍。
两倍扩容的核心原因,正是上面提到的"容量保持2的幂次"。扩容后元素重新定位时,不需要重新计算每个元素的hash值,只需要看原hash值新增的那一位是0还是1:
- 如果新增位是0,元素留在原来的桶
- 如果新增位是1,元素移动到原位置+旧容量的位置
这比"全量rehash"快得多,而且新元素的分布也非常均匀。这也是JDK 1.8优化过的方案,1.7还要重新计算index。
我之前排查过一次线上性能问题,就是HashMap不停触发扩容,每次扩容都要迁移大量节点,导致接口RT一路飙升。后来定位到原因是初始容量设置太小,数据量又远超预期。改成按预估数据量计算初始容量后,问题立刻消失了。
4. Set家族的隐藏身份:大多数Set就是披着马甲的Map
Set这个概念单独拿出来看很简单:不允许重复元素。但它底下那几个实现类的真正结构,很多人没仔细想过。其实Set的三大实现类,干的全是Map的活。
4.1 HashSet与HashMap的代码级关系
先看HashSet的源码,它内部维护的字段是这样的:
private transient HashMap<E,Object> map; private static final Object PRESENT = new Object();HashSet的所有操作,全部委托给内部的HashMap。往HashSet里add一个元素,实际上是往HashMap里put一个键值对,key是你要加入的元素,value是一个固定不变的PRESENT对象。
这个设计非常巧妙,因为HashMap的key天然具有唯一性——重复的key会被覆盖而不是新增。HashSet正好借用了这个特性来实现去重。
所以HashSet的迭代顺序是不保证的,因为它底层就是HashMap,存储位置由哈希值决定。如果你想要"按插入顺序迭代"的Set,得用LinkedHashSet。
4.2 LinkedHashSet和TreeSet的顺序到底从哪里来
LinkedHashSet内部维护的是LinkedHashMap,而LinkedHashMap在HashMap的基础上,给每个节点额外增加了一条"双向链表"的引线,用来记录插入顺序(和访问顺序)。所以LinkedHashSet的迭代顺序就是元素的插入顺序。
TreeSet则完全不同,它内部是TreeMap,底层是一棵红黑树。红黑树是一种自平衡的二叉搜索树,插入、删除、查找的时间复杂度都是O(log n)。TreeSet天然支持排序,你可以在构造时传入一个Comparator,也可以依赖元素自身的Comparable接口。
但这里要警惕一个陷阱:TreeSet的元素去重和排序依赖的是compareTo或compare方法的返回值。如果两个元素compareTo返回0,TreeSet会认为它们是同一个元素,即使它们的equals方法返回false。这就可能导致一个元素被"吃"掉。所以使用TreeSet时,equals和compareTo的语义必须保持一致,否则会出现匪夷所思的bug。
5. 遍历集合的三种姿势:for循环、Iterator与Stream的底层差异
遍历集合写起来很简单,但不同遍历方式的底层机制完全不同,性能差异和踩坑概率也完全不同。
5.1 三种遍历方式的性能差异来源
第一种是普通for循环配合get(i),这种方式只适用于List,因为它依赖随机访问能力。ArrayList的get(i)是O(1),十年如一日地稳定高效。但LinkedList如果也用这种方式遍历,就是灾难——每次get都要从头走到第i个位置,整体复杂度直接变成O(n²)。我测过一个20万的LinkedList用普通for循环遍历,耗时是ArrayList的几千倍。
第二种是增强for循环。它底层其实就是Iterator的语法糖,编译阶段会被转换成迭代器调用。Iterator用next()一个接一个地取,不依赖随机访问,所以LinkedList用增强for遍历是没问题的。
第三种是Stream API,list.stream().forEach(...)。Stream在串行流的情况下,性能上和传统for循环差距不大,而且代码更简洁。但要注意,Stream一旦用了parallelStream()并行流,虽然大集合时性能可能更快,但线程安全问题和CPU开销会随之而来。我自己在项目里很少对集合直接开并行流,除非数据量大、无状态操作且内存足够,否则得不偿失。
5.2 遍历时删除元素,为什么总报ConcurrentModificationException
这是集合类最经典的坑:在遍历过程中直接调用list.remove(),会抛出ConcurrentModificationException。
原因在于Iterator内部有一个expectedModCount字段,它在创建迭代器时被初始化为集合当前的modCount。modCount是集合结构被修改的次数记录,每次add或remove都会让它自增。当迭代器进行next操作时,会检查expectedModCount是否等于modCount,如果不相等就说明集合被并发修改了,立即抛异常。
注意,即使是在单线程环境下,用迭代器的remove和直接用集合的remove,也会触发这个检查。因为迭代器内部的expectedModCount已经固定了,集合的modCount被改了,两者就对不上了。
正确的删除姿势有两种:
// 方式一:用迭代器自己的remove Iterator<String> it = list.iterator(); while (it.hasNext()) { if (it.next().equals("delete")) { it.remove(); } } // 方式二:用ListIterator,支持在遍历中修改 ListIterator<String> lit = list.listIterator(); while (lit.hasNext()) { if (lit.next().equals("delete")) { lit.remove(); } } // 方式三:倒序for循环删除,只适用于List for (int i = list.size() - 1; i >= 0; i--) { if (list.get(i).equals("delete")) { list.remove(i); } }从Java 8开始更推荐用removeIf,一个方法搞定,内部已经处理好了这些细节:
list.removeIf(str -> str.equals("delete"));6. 并发场景下的集合生存指南:从fail-fast到ConcurrentHashMap
集合在并发环境下的表现,是进阶和面试都绕不开的大山。很多人在单线程下用集合很顺手,一旦多线程就抓瞎。这块我按"为什么会出问题、怎么解决的、什么时候选谁"的思路来讲。
6.1 Vector和Hashtable为什么被淘汰
旧时代的并发方案非常简单粗暴:把方法加上synchronized。Vector的所有方法都同步,Hashtable也是。这导致每个线程操作集合时都要抢同一把锁,并发效率极低,而且锁的粒度是整个集合,读和写互斥。
所以现在实战中基本没人用这两个类了。它们不是"错",只是"傻"。并发集合需要的是更细粒度的锁控制,甚至无锁方案。
6.2 CopyOnWriteArrayList和ConcurrentHashMap的设计哲学
CopyOnWriteArrayList的思路是:读的时候不加锁,直接读原数组;写的时候先把原数组复制一份,在新的副本上做修改,然后用新数组替换旧数组的引用。这样读操作永远不会阻塞,写操作通过锁保证只有一个线程在改。
它的缺点是:每次写都要复制整个底层数组,内存开销很大,频繁写入的场景性能很差。所以CopyOnWriteArrayList只适合读多写极少的场景,比如缓存白名单、配置项列表这种基本只读、偶尔更新的数据。
ConcurrentHashMap就聪明多了。JDK 1.7时代它用分段锁(Segment),把整个Map分成16段,每段独立加锁,不同线程操作不同段时可以并行。到了JDK 1.8,实现更进一步:放弃分段锁,改用CAS(比较并交换)加synchronized,锁的粒度细化到单个桶(bin)。也就是说,两个线程只要不在同一个桶上操作,就能真正并行。
这种设计的核心思路是"尽量降低锁竞争"。读操作基本无锁,利用volatile保证可见性;写操作只锁对应桶的头节点,互不干扰。
我做过一个简单的压测对比:8个线程并发往一个HashMap、Hashtable和ConcurrentHashMap里写入100万条数据,HashMap抛ConcurrentModificationException直接挂掉,Hashtable耗时是ConcurrentHashMap的3倍以上。这不是理论差异,是实际数据。
不过要提醒一句:ConcurrentHashMap能保证的是单个方法级别的线程安全,不是复合操作的原子性。比如"先判断key是否存在,不存在则写入"这种check-then-act操作,如果你直接写两个语句,中间还是会被别的线程穿插。要保证复合操作,得使用它的computeIfAbsent、merge这类原子方法。
6.3 线程安全集合的选型清单
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 读多写极少 | CopyOnWriteArrayList / CopyOnWriteArraySet | 读不加锁,写复制,适合配置类数据 |
| 高并发读写Map | ConcurrentHashMap | 细粒度锁,性能最好 |
| 高并发排队 | ConcurrentLinkedQueue | 无锁队列,CAS实现 |
| 高并发顺序控制 | ConcurrentSkipListMap | 跳表结构,支持有序并发访问 |
| 需要阻塞特性 | LinkedBlockingQueue / ArrayBlockingQueue | 适合生产者消费者模型 |
这些类都是在项目里反复被验证过的成熟方案。你不需要把源码全背下来,但至少要知道它们各自适合什么场景,能说出为什么。
7. 集合框架的高频雷区与压箱底经验
最后这一部分,把我在实际项目里踩过、见证过的几个经典问题集中讲一下。这些问题和上面的章节有些重叠,但从"踩坑"角度再拎出来看一遍,更有参考价值。
7.1 key对象的hashCode和equals不能重写一半
最常见的翻车现场:自定义了一个对象放进HashMap或HashSet,只重写了equals,没重写hashCode。这会导致什么?两个属性完全一样的对象,hashCode却不一样,被HashMap当作两个不同的key存了进去;或者反过来,只重写了hashCode没重写equals,哈希碰撞时判断key相等失败,同样存了两份。
正确的做法是:要么都不重写(用默认的内存地址比较),要么两个都重写,而且重写的逻辑要保证:两个对象equals相等时,hashCode必须相等。这是Object类的通用契约,违反它集合就会出各种诡异问题。
7.2 集合转换时的坑:Arrays.asList的返回值不是ArrayList
Arrays.asList()返回的是一个内部类java.util.Arrays$ArrayList,它不是我们熟悉的java.util.ArrayList。这个类长度固定,不能add也不能remove,一操作就抛UnsupportedOperationException。
需要可变列表时应该这样写:
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c"));用new ArrayList<>(...)包一层,把它转成真正的ArrayList。
7.3 大量数据拼接字符串,别用String的+号
这个虽不完全是集合的问题,但和集合强相关:当你需要把List里的元素拼接成一个大字符串时,如果循环里用str += item,每一次拼接都会创建新的String对象,几万条数据就能让内存吃紧。正确方式是:
String result = list.stream() .map(String::valueOf) .collect(Collectors.joining(","));Collectors.joining内部使用StringJoiner,效率远高于循环拼接。
7.4 初始化容量是日常最容易被忽略的性能陷阱
我在代码评审里最常挑的就是"new HashMap()什么都没传"。HashMap默认容量只有16,负载因子0.75,意味着只要装到12个元素就会扩容。而扩容是重新分配一个两倍的数组并迁移所有节点,数据量大时成本非常高。
正确的初始化方式:
// 明确知道数据量,留给负载因子一点余量 Map<String, Object> map = new HashMap<>(expectedSize * 4 / 3 + 1); // 或者用Guava的Maps.newHashMapWithExpectedSize这个余量公式我记得很牢:HashMap容量始终是2的幂次,所以最好设置一个比实际数据量除以0.75略大的数。ArrayList同理,能预估就预估,别等它自动扩容。
7.5 用不可变集合给代码上保险
好代码要主动阻止误操作。Java 9开始提供了List.of()、Set.of()、Map.of()几个方法,直接创建不可变集合,任何修改操作都会抛异常。如果你的集合初始化之后不会再变,强烈建议用这几个方法——它们比Collections.unmodifiableList()写起来方便得多,而且能提前在编码阶段暴露"试图修改只读数据"的逻辑错误。
在处理集合这一块,我最大的体会是:数据结构的选型,本质上是在选择一种时间和空间的权衡策略。ArrayList的连续内存换来了随机访问的速度,却要付出扩容拷贝的代价;HashMap用哈希换来了O(1)查找,却要处理碰撞的连锁反应;CopyOnWriteArrayList用内存复制换来了读的高并发,却牺牲了写入吞吐。没有哪个集合是万能的,真正重要的是在合适的场景,选对合适的容器,并且知道这个选择背后的代价是什么。希望这篇总结能帮你少走一些我走过的弯路。