
很多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都是它的具体实现。我建议在脑子里把整个框架装成一张这样的地图接口主要实现类核心特性底层结构ListArrayList / LinkedList / Vector有序、可重复、可索引动态数组 / 双向链表SetHashSet / LinkedHashSet / TreeSet唯一、无序或有序HashMap / LinkedHashMap / TreeMapQueueLinkedList / PriorityQueue先进先出 / 优先级链表 / 堆MapHashMap / LinkedHashMap / TreeMap键值对、键唯一数组链表红黑树 / 链表 / 红黑树这张表看起来简单但背后每一行的实现细节都藏着一大堆门道。比如HashSet本质上就是一个只管key不管value的HashMapTreeSet底层就是个红黑树结构的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扩容到1515扩容到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 NodeE { E item; NodeE next; NodeE 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计算为什么不做直接用hashCodeHashMap确定元素存储位置时并不是直接用对象的hashCode()而是先做一次扰动处理static final int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); }这个操作是把hashCode的高16位和低16位做异或。目的是什么因为计算数组下标时用的是(n - 1) hashn是数组长度通常不会特别大。如果直接用原始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就是披着马甲的MapSet这个概念单独拿出来看很简单不允许重复元素。但它底下那几个实现类的真正结构很多人没仔细想过。其实Set的三大实现类干的全是Map的活。4.1 HashSet与HashMap的代码级关系先看HashSet的源码它内部维护的字段是这样的private transient HashMapE,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返回0TreeSet会认为它们是同一个元素即使它们的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 APIlist.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 IteratorString it list.iterator(); while (it.hasNext()) { if (it.next().equals(delete)) { it.remove(); } } // 方式二用ListIterator支持在遍历中修改 ListIteratorString 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读不加锁写复制适合配置类数据高并发读写MapConcurrentHashMap细粒度锁性能最好高并发排队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的返回值不是ArrayListArrays.asList()返回的是一个内部类java.util.Arrays$ArrayList它不是我们熟悉的java.util.ArrayList。这个类长度固定不能add也不能remove一操作就抛UnsupportedOperationException。需要可变列表时应该这样写ListString 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个元素就会扩容。而扩容是重新分配一个两倍的数组并迁移所有节点数据量大时成本非常高。正确的初始化方式// 明确知道数据量留给负载因子一点余量 MapString, 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用内存复制换来了读的高并发却牺牲了写入吞吐。没有哪个集合是万能的真正重要的是在合适的场景选对合适的容器并且知道这个选择背后的代价是什么。希望这篇总结能帮你少走一些我走过的弯路。