考试通知
Java常见集合详解从源码到底层原理一场彻底的理解在Java开发里摸爬滚打这些年如果说有什么东西是每天都要写、每天都绕不开的集合绝对是排在前三的。面试必问HashMap写代码必用ArrayList稍微涉及一点并发就得搬出ConcurrentHashMap这套Java常见集合体系几乎贯穿了一个Java程序员从入门到进阶的整个职业生涯。但说实话很多人对集合的理解停留在“会用API”的层面ArrayList和LinkedList的区别背得滚瓜烂熟可是问到为什么ArrayList扩容是1.5倍、HashMap在JDK 8里为什么要引入红黑树就有点含糊其辞了。这篇文章我想以从业者的视角把Java集合框架里最常见、最容易踩坑的那些东西系统过一遍。核心思路是“知其然更知其所以然”每个集合类的设计选择背后都有它的场景考量搞清楚这些设计逻辑面试和实战都拿得稳。文章会涉及源码分析的思路但我会用尽量直白的语言拆解适合刚入行的新人在写业务代码时建立正确的选型意识也适合工作一两年的开发把底层原理补齐看完可以直接梳理成自己的知识体系。1. 集合框架的整体设计与技术选型思路很多人把Java集合框架理解成一个“装数据的容器”这个说法没错但太笼统了。集合框架真正解决的是两个问题数据怎么存、怎么取。存的方式决定了内存布局取的方式决定了时间复杂度这两个维度组合起来就是JDK提供给我们的那一堆类和接口。1.1 为什么需要Collection和Map两套体系Java把集合分成了两大阵营Collection和Map。Collection是存储单个元素的List、Set都在这一支Map是存储键值对的以映射关系为核心。很多初学者觉得Map有点独立不太像集合其实它就是集合的一种特殊形态只不过把“值”换成了“键值对”。这里有个容易忽略的设计细节Collection体系之下又分List和Set这两个接口的根本区别不是“允不允许重复”——那是表象——而是是否有“序号”的概念。List是带索引的序列你可以说“我要第3个元素”所以它天然支持顺序遍历、随机访问Set关心的是“这个集合里有没有某元素”所以它的核心操作是contains底层结构也大多是散列表或树结构纯粹是为了快速判断“存在与否”。Map的键要求唯一底层实现和Set有千丝万缕的联系。比方说HashSet内部其实就是包了一个HashMapHashMap的“值”位置用一个固定的Object占位所有元素都存在“键”的位置上。Set其实就是Map的“阉割版”这句话在源码里体现得淋漓尽致。1.2 从数据结构视角理解集合差异数据结构决定了集合的行为特征。我习惯用一句话概括每一个典型实现类ArrayList动态扩容的数组适合按索引访问、尾部追加LinkedList双向链表适合频繁在中间位置插入删除HashMap基于数组链表红黑树的散列表追求快速查找TreeMap红黑树实现天然有序适合范围查询HashSetHashMap的马甲只关心去重和存在性LinkedHashMap在HashMap之上串了一条双向链表保留插入顺序或访问顺序CopyOnWriteArrayList写了就拷贝读多写少场景下的特殊方案把它们串起来看集合框架的核心其实就两种物理载体连续的数组和不连续的链表或树。数组的缓存局部性好、随机访问O(1)但插入删除涉及元素搬移链表在插入删除上灵活但随机访问必须从头遍历而且每个节点还要额外存引用对象内存开销更大。散列表、树结构都是在数组和链表之上演变出来的复合结构是为了在某些特定操作上获得更好的时间复杂度。理解了这个底层模型你就能明白为什么Arrays.asList返回的List调用add方法会报UnsupportedOperationException——因为这是一个定长的数组封装根本没有扩容能力。这不是Bug是设计。2. 核心实现类源码级解析有一个老生常谈的问题ArrayList和LinkedList什么时候用哪个很多人说“查多不改用ArrayList频繁插入删除用LinkedList”这句话如果背下来去面试大概率会被追着问细节。我建议真正把两个类的源码逻辑搞清楚再多说一遍底层也都清楚了。2.1 ArrayList扩容机制与性能边界ArrayList的底层就是一个Object[]数组默认构造时是一个空数组第一次添加元素才初始化成容量10的数组。关键在于扩容当数组满了新容量是旧容量的1.5倍即newCapacity oldCapacity (oldCapacity 1)。为什么要扩容1.5倍而不是直接翻倍翻倍虽然空间浪费更严重但可以更少次数地触发扩容。1.5倍则是在“空间浪费”和“扩容频率”之间取了一个相对均衡的折中方案。还有个小细节值得注意ArrayList的subList方法返回的是内部类的视图它和原List共享同一个数组。如果你修改视图里的元素原List也会跟着变如果你对原List做了结构性修改比如add、remove再去操作视图就会抛出ConcurrentModificationException。这是很多线上问题的来源因为大家习惯把subList当成一个独立的新List用实际上它只是“看着原List的一个窗口”。ArrayList适合的场景是频繁随机访问、尾部追加元素。但如果你在一个大ArrayList的开头持续插入数据每一次插入都要把后面所有元素往后挪一位数据量大时这性能是灾难级的。这时候LinkedList就有它的价值了。2.2 LinkedList的双向链表到底快在哪LinkedList的结构是双向链表每个节点存prev和next引用在中间插入删除只需要修改相邻节点的引用不需要移动任何数据。乍一看好像LinkedList在任何插入删除场景都优于ArrayList但实际上它的随机访问是O(n)——get(index)要常从头部或尾部向中间遍历。在中间频繁插入而很少随机访问的场景它确实是更好的选择。但是这里有一个非常反直觉的点LinkedList遍历用get(i)会慢到怀疑人生但如果用迭代器或foreach它和ArrayList的差距并没有想象中那么大。因为foreach底层就是迭代器迭代器会记住当前节点的位置每次next()只是走一步时间复杂度是O(1)。所以真正需要LinkedList的场景其实很窄你在写队列或栈这种两端操作频繁、且几乎不做随机访问的程序时它能派上用场。JDK里的ArrayDeque其实在大部分时候都比LinkedList更适合当队列用因为底层是环形数组内存占用小、缓存友好。我自己这些年写代码的体会是LinkedList在业务代码里的出场率是逐年下降的。日常业务里“频繁在中间插入”这种需求本身就极罕见更多的场景是“批量添加、遍历、偶尔按索引取”这些ArrayList通吃。选型不能凭理论分析要看真实场景的数据访问模式。2.3 HashSet与TreeSet的去重逻辑HashSet实现去重靠的是HashMap的键唯一性而HashMap判断键是否相同用的是hashCode()和equals()具体逻辑是先算hashCode定位到桶再在桶里用equals比较是否为同一个对象。这里我见过太多新人踩坑自定义了一个对象往HashSet里放没有重写hashCode()和equals()结果两个字段值完全一样的对象被当成不同元素去重彻底失效。在《Effective Java》里把重写equals必须同时重写hashCode列为一条“铁律”原因就在这Java里判断两个对象是否相等的唯一标准就是equalsHashMap和HashSet只是先用hashCode做了一次“粗筛”。TreeSet则是基于红黑树实现它是天然有序的。元素要么实现Comparable接口要么你在构造TreeSet时传一个Comparator。既然底层是红黑树插入和查找的时间复杂度都是O(log n)相比HashMap的O(1)在性能上有差距因此“有序”是拿性能换来的。我在实际项目中用过一次TreeSet做时间线去重一批事件对象按时间戳排序后需要去重还要保持排序结果能快速查出某个时间范围内的所有事件。用TreeSet的subSet(from, to)确实方便一行代码解决范围查询。但如果只是单纯去重并且对顺序没有要求永远优先用HashSet。2.4 HashMap的底层原理与扩容逻辑HashMap是集合框架里最当之无愧的“面试之王”。我尽量把它的完整机制讲透。HashMap底层是“数组链表”在JDK 8之后演变出“数组链表红黑树”的形态。当我们put(key, value)时第一步是计算key的hash值但并不是直接用hashCode()的结果而是做了一次扰动h ^ (h 16)——把高16位和低16位做异或目的是让高位信息也参与寻址降低哈希碰撞概率。然后通过(len - 1) hash计算出该元素应该存放在数组的哪个桶下标。这里的len必须保证是2的幂次方因为2^n - 1的二进制都是低位全1这样按位与就等价于取模而且比取模运算更快。默认的负载因子是0.75默认初始容量16。当HashMap里的元素数量达到capacity * loadFactor 16 * 0.75 12时触发扩容容量翻倍成32并且所有已经存在的元素需要重新计算哈希并搬家也就是rehash。我在项目里曾经遇到过一条2万条数据的批量插入初始没设容量结果扩容了大概5次虽然数据量不算大但可以明显感到耗时偏高。批量插入前估算好容量并赋值能省下不少扩容开销。JDK 8引入红黑树是HashMap又一个改进当某个桶的链表长度超过8且数组容量达到64时链表会转成红黑树把该桶的查询复杂度从O(n)降到O(log n)。为什么选8作为阈值源码里有一句注释说明泊松分布下负载因子0.75时一个桶里节点数达到8的概率大约是0.00000006这是工程上对时空开销综合权衡后的结果。顺便说一个常见的坑HashMap的键如果是可变对象而且你改了它的equals相关字段再看这个键会查不到值。原因是键的hashCode变了桶位置对不上了对象变成“悬空键”。这正是HashMap设计上的一个已知限制使用可变对象作键时要格外警惕最好用不可变对象比如String、Integer。3. 并发场景下的集合选择并发集合是很多开发经验不足的人最大的盲区。症状表现为线程安全就用Hashtable或Vector其他全不关心。但这两个类在JDK早期就存在了后来JDK又出了一大堆并发集合类如果你还停留在“用synchronized同步的旧类”说明你的并发知识体系需要补课了。3.1 synchronized包装集合的性能瓶颈Collections.synchronizedList(xxx)和synchronizedMap(xxx)之类的方法本质是给所有方法加上synchronized锁读也要锁写也要锁相当于一把大锁把整个集合锁死。这意味着任何时候只有一个线程能操作集合读多写少的场景下并发读也被强制串行化性能大打折扣。而且这些包装集合的迭代器并不是线程安全的即使集合本身被同步包装了遍历时依然需要手动加锁这是很容被忽视的坑。Hashtable更不用说每个方法上都挂着一把唯一的锁随着并发线程数上升锁竞争越来越严重吞吐量几乎不增长。这类集合适合低并发、数据量小的场景现代并发应用基本不碰它们。如果有人面试上来就说“线程安全我就用Hashtable”至少说明他没用过ConcurrentHashMap。3.2 CopyOnWriteArrayList与读写分离思想CopyOnWriteArrayList的思路很有意思——“写时复制”每次修改add、set、remove等都先复制一份底层数组在新数组上做修改改完再把内部指向换成新数组。读操作始终在原来的数组上进行不加锁。这种设计方案带来的收益是读操作完全无锁读读并发、读写并发都互不阻塞特别适合读多写少的场景比如配置中心缓存的订阅列表、监听器注册表这类读频率远高于写的场景。代价是每次写都要复制整个数组如果集合里装了上万条数据频繁写入时内存和时间的开销都相当大。CopyOnWriteArrayList的“快照”迭代器还有个特性迭代器一旦创建遍历的是当时的快照即使之后集合被别的线程修改了遍历结果也不会变这特性在有些容错场景下反而非常有价值。如果你了解读写锁的思想会觉得和ReentrantReadWriteLock有点像但区别也很明显读写锁在写的时候会阻塞读CopyOnWriteArrayList则让读线程“无视”写线程走的是另一条路。3.3 ConcurrentHashMap如何做到高性能并发ConcurrentHashMap是如今并发Map的标配。JDK 7的实现是“分段锁”把一个Map分成16个Segment默认每个Segment是独立的一把锁不同线程操作不同分段的元素时互不干扰。JDK 8之后彻底重写了并发控制抛弃Segment采用CAS synchronized锁的粒度从“分段”细化为单个桶只有发生哈希碰撞、操作同一个桶的元素时才需要锁竞争并发度大幅提升。很多人会好奇为什么JDK 8还要用synchronized明明ConcurrentHashMap是“更高级的并发容器”。原因在于JVM对synchronized的优化已经非常成熟锁升级机制让它在无竞争时几乎没有额外开销JDK官方反而认为没有必要再用复杂的分段锁。JDK 8的ConcurrentHashMap在常见场景下性能比JDK 7版本更优。与散列表配套的ConcurrentSkipListMap也值得一提它基于跳表实现支持有序并发访问put和get的复杂度都是O(log n)。线程安全的有序Map目前除了加锁的TreeMap最靠谱的就是它。如果你需要“并发有序范围查询”它是少数值得考虑的选择。并发集合的使用还有一条经验不要用普通的HashMap做缓存并试图“自己加锁保证线程安全”。并发场景下一个简单的“先查后写”操作在HashMap上并发执行轻则数据覆盖重则JDK 7时期HashMap在并发扩容时会形成环形链表get直接死循环。JDK 8的HashMap修掉了这个问题但并发安全仍然无从谈起。4. 集合使用中的设计与性能优化集合的选型和使用是一个课程里几乎不教、但工程上极其重要的技能。很多时候代码性能的优劣不在算法多高级而在于你选择了什么样的数据结构以及你如何使用它。4.1 初始化容量设置最容易忽略的调优点关于初始化容量有两个高频场景可以立刻用上第一个场景是已知数据量时主动指定容量。new HashMap(expectedSize)可以避免多次扩容但要注意传参需要预留负载因子的余量——如果你预计装64个元素直接new HashMap(64)并不对因为当数据量达到64 * 0.75 48时就会触发扩容。正确做法是传入expectedSize / 0.75 1大约86。Guava的Maps.newHashMapWithExpectedSize就是这么计算的。ArrayList同理如果你预计有100个元素直接new ArrayList(100)就够因为ArrayList扩容逻辑只是容量不够才触发不会有“阈值”压容量。第二个场景是用apache commons或Stream的toMap收集的时候主动指定容量Collectors.toMap(keyMapper, valueMapper)不传容量参数时默认HashMap容量非常小如果数据量大收集过程中会产生多次扩容白白浪费时间。4.2 不可变集合与空集合的正确姿势JDK 9开始提供List.of()、Set.of()、Map.of()等不可变集合工厂方法。不可变集合的好处是线程安全、结构稳定、可以放心地在多线程间共享。程序里那种全局配置Map、常量表完全应该用它们。用Collections.emptyList()返回空集合是一个“正确但没人注意”的好习惯。方法在无数据时返回空集合而不是返回null这样调用方不需要无谓的判空。这一步看似微小在重要链路上能减少NPE引起的线上故障。我在带队做代码审查时最常打的回执之一就是“这里能不能不要返回null返回一个空集合不行吗”还有一个必须知道的坑Arrays.asList返回的是一个“视图”它继承了AbstractList但不支持add和remove操作调用了就会抛UnsupportedOperationException。很多人把Arrays.asList的结果直接当成ArrayList用结果线上突然抛异常。它的本意是让数组可以被当做一个List来遍历和访问而不是让你拿它去增删元素。4.3 遍历删除与快速失败机制遍历集合时删除元素是无穷无尽的坑。最安全的做法是用Iterator.remove()IteratorString iterator list.iterator(); while (iterator.hasNext()) { String s iterator.next(); if (bad.equals(s)) { iterator.remove(); } }如果是在for-each循环里直接调list.remove()一定会抛出ConcurrentModificationException。原因在于ArrayList迭代器内部维护了一个modCount修改次数字段每次remove会改这个值迭代器next时会校验modCount是否和预期一致不一致就抛出快速失败异常。这个机制叫fail-fast它不是为了在高并发下确保安全而是为了尽早暴露迭代非法修改的问题。如果想在遍历的同时做删除还有一个JDK 8之后的写法list.removeIf(predicate)一行代码解决内部实现也是用迭代器遍历并删除推荐在业务代码里直接用它。另外要注意removeIf只能用于支持修改的List前面说的Arrays.asList就不行因为它不支持结构修改。4.4 性能对比速查一张表看清选型我不搞虚的直接给一个基本参考表来自我平时测试几个典型实现类在真实数据规模下的表现同一机器上单线程测出来的大概情况虽然具体数值和机器相关但相对关系基本稳定实现类插入尾部随机访问中间插入按值查找有序性线程安全ArrayListO(1)摊还O(1)O(n)O(n)有插入序否LinkedListO(1)O(n)O(1)已知位置O(n)有插入序否HashSetO(1)不支持-O(1)无否LinkedHashSetO(1)不支持-O(1)有插入/访问序否TreeSetO(log n)不支持-O(log n)有自然/定制序否HashMapO(1)不支持-O(1)无否TreeMapO(log n)不支持-O(log n)有按键排序否ConcurrentHashMapO(1)不支持-O(1)无是真实选型时要关注的永远是“我这模式下哪种操作的频率最高”。不要为了一个“可能偶尔要按索引取”的场景选了ArrayList结果高频操作却是在中间插入也不要为了“可能偶尔要排序”用TreeMap结果高频操作是哈希查找反而让性能白白牺牲。一切选择基于真实数据访问模式。5. 常见问题排查与避坑实录这里整理几个我在项目里真实遇到、在Stack Overflow也反复出现的经典问题全部有代表性集中讲一下。5.1 集合中的null一言难尽的兼容性HashMap允许null键和null值但ConcurrentHashMap不允许。TreeMap不允许null键但允许null值。HashSet允许一个null元素TreeSet不允许null元素因为它在插入时要调用compareTo来定位无法和null比较。这些差异在迁移代码时尤其容易触发问题原本用HashMap突然换到ConcurrentHashMap一个null键直接抛NPE。开发规范里我建议除非业务明确需要null作为“缺失”标记否则所有集合里一律不存null。用空字符串、0、空对象占位都比null安全得多因为你不确定未来会不会换成不允许null的集合或数据库字段约束。5.2 List转Map时key重复toMap的陷阱Collectors.toMap极其常用但它有个默认行为如果两个元素的key重复直接抛IllegalStateException。网上很多人不知道这一点跑数据量大了才炸。正确姿势是提供第三个参数mergeFunctionMapString, Item map itemList.stream() .collect(Collectors.toMap(Item::getId, Function.identity(), (oldItem, newItem) - newItem)); // 保留后到的这里还可以根据自己的业务选择“保留旧的”“合并成列表”“抛出异常提示数据问题”。我对此的经验是在稀罕时反而要敢于让它在数据重复时抛异常因为大多数重复意味着上游数据有问题掩盖问题比解决问题更危险。5.3 自定义对象作为Map键的严重坑用自定义对象作Map的键如果这个对象是可变的你在放进Map之后又改了它的hashCode相关字段这个对象就再也取不出来了。这个坑没有例外HashMap在查找时会先用hashCode定位桶桶找错了equals画得再像也碰不上面。处理办法有三个优先级第一优先用不可变对象作为键这是最优雅的解法第二如果一定要用可变对象重写hashCode时可以只基于不可变字段来计算这样就算其他字段变了也不影响散列位置第三如果以上都做不到那就彻底隔离使用先放进Map之后绝不修改键对象的任何字段。5.4 大Map的遍历性能与entrySet很多人喜欢在遍历Map时用map.keySet()拿到所有key然后再一个个map.get(key)去取值。这样做的代价是每次get都要重新做一次哈希计算和链表/树遍历如果Map很大这个重复操作会白白消耗大量CPU。正确姿势是直接遍历entrySet()for (Map.EntryString, Value entry : map.entrySet()) { String key entry.getKey(); Value value entry.getValue(); }如果需要同时删除部分条目注意不要直接在for-each里调用map.remove()应该用entrySet().removeIf(...)或者Iterator。这个细节在数据量小的时候看不出差异但有个几十万条数据的Map性能差几倍都正常。5.5 排查工具推荐如果要在生产环境排查集合相关的问题常见手段有用jmap -histo:live看各个集合类的实例数量和占用的内存分布用jstack看线程堆栈确认是不是有死循环或锁等待用MATMemory Analyzer Tool分析堆dump查看哪些Map或List占用了绝大多数内存排查是否有集合无限增大、往Map里累积了不该累积的数据等严重问题用Arthas的dashboard和heapdump命令快速查看JVM内存和线程状态我遇到过最经典的一个线上问题是一段代码把每次请求的日志数据都往一个静态HashMap里塞塞到几百万条以后接口越来越慢最后OOM。定位过程就是先在heapdump里看到一个巨大的HashMap顺着引用找出来是某缓存最后才在代码里发现了这行“随手塞一下”。很多时候是不是数据结构本身的问题而是把不该养的集合养成了野马。在集合的使用风格上我一直给大家的建议是默认先用最基础的ArrayList和HashMap这是绝大多数业务场景的正确答案确实需要有序再考虑TreeMap、LinkedHashMap确实并发才上ConcurrentHashMap族确实涉及队列/栈这类特殊结构才考虑ArrayDeque、PriorityQueue。过去几年我在很多项目里做代码评审发现超过八成的集合性能问题不是集合选错了而是容量没初始化、遍历方式不对、可变键当Key放在Map里这一类“使用姿势”问题。把集合的原理摸透比背一堆集合类花名有用得多。
Java集合详解:从源码到底层原理,一篇讲透核心实现类
NEXT STEP
看完公告,下一步怎么走?
把报考交给靠谱的人:材料预审、批次抢报、考前辅导、复审提醒,全程有人跟。