
1. 为什么JAVA集合是绕不过去的一道坎不管你是刚接触Java的新人还是已经在写业务代码的初级工程师集合框架迟早会找上你。我第一次面试的时候被问了一个到现在都记得很清楚的问题ArrayList和LinkedList到底该用哪个你说清楚底层我再让你过。当时我只会背数组和链表的区别结果对方追了一句那ArrayList扩容是几倍为什么我直接卡住了。集合在你日常开发里出现的频率高到你可能意识不到从Controller接收一批参数到数据库查询返回一堆记录再到内存里做分组、去重、排序、缓存几乎每一个环节都在跟集合打交道。数组不是不能用但数组一旦定义长度就固定了增删元素要自己写迁移逻辑存取对象还得自己维护下标真的很麻烦。集合框架干的事情就是帮你把这些脏活累活封装好你只需要关心用哪个容器、怎么操作数据。这篇文章不打算搞那种又臭又长的文档翻译而是按照我自己的学习路径和面试复盘从底层原理到实际踩坑把List、Map、Set一条线讲透。你如果能把底层结构、扩容逻辑、哈希冲突、线程安全这些关键点串起来吃透以后不管是写代码还是应付面试都会硬气很多。2. 集合框架整体脉络先搞清楚家族体系JAVA集合不是几十个类随机堆在一起它有一套非常清晰的设计逻辑。你要做的第一步不是背类名而是建立一张家族图谱。2.1 两大顶级接口Collection 和 Map集合框架最顶层的两个接口一个是Collection一个是Map。它们俩的定位完全不同Collection存放单列元素也就是一个一个独立的数据。它下面又分三个主要分支List、Set、Queue。Map存放键值对每个元素都是key - value的组合像字典、索引一样通过key去查value。这套设计最有价值的地方在于它把接口和实现彻底分离。你写代码的时候往往只需要面向接口比如声明一个ListString list new ArrayList()以后想换成LinkedList只改后面的构造器就行不用动其他逻辑这就是面向接口编程的核心价值。2.2 Collection分支List、Set、QueueList有序、可重复像一个排队列表每个人有固定的下标索引你可以通过get(0)、get(1)快速访问某个位置的数据。Set无序大部分实现、不可重复像一个装球的袋子往里丢重复的球会被弹出来取出来的时候顺序通常和放进去时不一致。Queue先进先出或按优先级排队比如LinkedList实现了Deque接口既可以当队列也可以当双端队列。2.3 Map分支键值对王国Map自己是一套独立的家族HashMap、LinkedHashMap、TreeMap、Hashtable、ConcurrentHashMap各有各的场景。你在开发里接触最多的基本就是HashMap但它背后牵扯的哈希算法、数组扩容、红黑树是面试里最密集的火力区。后面我用一整节来拆。2.4 为什么Set和Map常常纠缠在一起这里必须提前点破一个非常关键的底层事实JAVA的HashSet底层其实就是一个HashMap只不过Set只用了Map的key那一侧value统一用一个固定的Object占位。也就是说理解HashSet之前你先把HashMap搞明白后面会省一大半力气。同理TreeSet底层是TreeMapLinkedHashSet底层是LinkedHashMap。Set和Map从代码实现上是亲戚关系不是两个毫不相干的独立世界。3. List详解从ArrayList到LinkedList的底层真相List接口的官方定义是有序集合也称为序列你可以精确定位每个元素。实际代码里90%以上用的都是ArrayList但问面试题时LinkedList也躲不过去。这两个类代表的是两种完全不同的底层物理结构。3.1 ArrayList动态数组的扩容机制ArrayList底层是一块连续的内存空间本质就是Object[]数组。它为什么能做到动态扩容因为它会在容量不够的时候自动申请一块更大的新数组把旧数组里的元素全部拷贝过去然后释放旧数组。关键点来了Java 8及以后ArrayList的默认扩容倍率是1.5倍。具体逻辑在grow(int minCapacity)方法里int newCapacity oldCapacity (oldCapacity 1);这里的oldCapacity 1就是右移一位相当于除以2所以新容量是旧容量的1.5倍。如果老数组长度是10扩容后变15如果长度是15扩容后变22注意int位数截断后可能不是精确的1.5倍。这里有一个很值得注意的设计动机为什么是1.5倍而不是2倍如果扩容太猛比如倍数过大会浪费大量内存如果每次都只增加1个会频繁触发拷贝性能极差。1.5倍是一个折中方案既避免了过多次扩容又不会让内存浪费太夸张。我第一次读到这个细节的时候有种豁然开朗的感觉因为很多人只会背1.5倍却从不去想背后的权衡。初始容量默认是10不过只有当第一次添加元素时才会实际创建这个数组不是new的时候立刻分配10个空间这也算一个容易记错的点。3.2 LinkedList双向链表没有扩容一说的原因LinkedList底层是双向链表每个节点Node里存着三样东西当前元素的值、指向前一个节点的引用prev、指向后一个节点的引用next。所以你往中间插入或删除一个节点理论上只需要修改前后两个节点的引用指向不需要移动其他元素这是它最大的优势。但它的代价也很明显不支持随机访问。你想拿get(100)它没法直接算地址只能从头或从尾遍历过去。你可以说LinkedList的get(int index)是O(n)而ArrayList的get(int index)是O(1)。所以所谓LinkedList增删快严格讲是在已知节点位置的情况下增删操作本身很快但如果通过下标去定位反而会先付出一次遍历的成本。其实在日常开发中LinkedList用得并不多。ArrayList在随机访问、遍历、内存紧凑性上都更适合大多数场景。LinkedList更多出现在队列、双端队列、栈这类需要频繁头尾操作的场景里因为它实现了Deque接口。3.3 实际开发里的性能测试经验我自己做过一次小测试往100万条数据的ArrayList和LinkedList中间反复插入一条记录结果LinkedList的理论优势只有在索引恰好接近头部或尾部时才比较明显如果按size()/2逐次插入因为定位麻烦整体性能经常反而不如ArrayList。这里必须说明这只是一个基于常见实现的直观体验不同JDK版本和插入模式差异很大但对绝大多数业务代码来说ArrayList是默认选择。3.4 ArrayList的序列化和transient关键字有一个容易被忽略的细节ArrayList内部存储元素的Object[] elementData被transient修饰了。也就是说默认的序列化机制不会直接把这个数组整个写出去。为什么因为数组可能有预留的容量空间比如实际只有5个元素但数组长度是10里面5个位置是空的null如果整个数组序列化会白白多写5个空位置。所以ArrayList自己实现了writeObject和readObject只序列化实际存在的元素个数反序列化时再按实际元素个数重建数组。这个设计在序列化大集合时能省不少存储和网络开销面试里问到ArrayList序列化为什么用transient时就是考这个点。3.5 ArrayList和LinkedList怎么选我给你的实用建议是绝大多数场景直接选ArrayList内存更紧凑、遍历更快、随机访问极快。需要频繁在头部插入/删除或者确定要当作先进先出队列使用时再考虑LinkedList或ArrayDeque。如果数据量巨大又需要频繁按下标访问ArrayList几乎是最优解。不需要一上来就追求性能最优先写出正确、可读的代码再结合真实数据量做优化。4. Map深度拆解HashMap的哈希原理与顺序问题Map家族里HashMap是绝对的主角。你不仅要会用还必须理解它到底是怎么把key映射到value的。4.1 HashMap的存储结构数组链表红黑树Java 8及以后的HashMap底层是一个NodeK,V[] table数组每个下标位置叫一个桶bucket。当你往map里放一个键值对时流程大概是先用key的hashCode()计算出哈希值再经过一次扰动处理。用哈希值和数组长度计算出一个下标决定这个节点落在哪个桶。如果这个桶是空的直接放入。如果这个桶已经有元素了就会发生哈希冲突也就是两个不同key算出的桶下标相同。此时节点会以链表形式挂在桶后面。当链表长度达到8且数组长度达到64时链表会转成红黑树把查找时间从O(n)降到O(log n)。如果链表长度小于6且红黑树节点太少红黑树会退化成链表。这里8和64是两个经常考的数字背后其实有统计学和工程权衡。链表转红黑树并不是为了快而快而是为了在极端哈希冲突下不至于性能崩溃。4.2 扰动函数为什么hashCode要再处理一次HashMap并不是直接用key.hashCode()去计算下标而是static final int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); }就是把高16位和低16位做异或。目的很简单因为数组长度比较短的时候高位根本参与不进下标计算容易哈希分布不均匀。扰动一下让高位信息也混入低位冲突的概率就降低了。这个设计在你写自己的类作为Map的key时特别有启发——你的hashCode如果写得差HashMap怎么扰动都救不回来。4.3 扩容机制为什么默认负载因子是0.75HashMap默认的初始容量是16负载因子DEFAULT_LOAD_FACTOR 0.75f。当元素个数超过容量 * 负载因子也就是16 * 0.75 12时就会触发扩容容量翻倍变为32。为什么是0.75这是一个空间和时间的权衡负载因子太大比如1.0数组快满了才扩容空间利用高但哈希冲突会明显增多链表变长查找和插入效率下降。负载因子太小比如0.5冲突变少但数组经常还有一半空着就扩容内存浪费严重。0.75是官方在随机哈希下大量测试后得出的一个接近泊松分布最优的值。作为使用方除非你有非常明确的需求一般不要改这个默认值。扩容时有个重要细节数组长度变了每个元素的下标是重新计算的而不是简单地把旧数组元素平移到新数组。Java 8里对链表进行了优化根据(e.hash oldCap)是否为0把链表拆成低位链表和高位链表再整体放到新数组的对应位置避免在并发下形成环Java 7的问题但要注意就算这样HashMap也不是线程安全的。4.4 重写equals为什么必须同时重写hashCode这是我见过最多人踩的坑。在HashMap和HashSet里判断两个key是否相同顺序是先比较hashCode()是否相等如果不等直接认为两个key不同。如果相等再调用equals()判断内容是否一致。如果你只重写了equals()而没重写hashCode()就可能出现两个业务上完全相同的对象equals返回true但hashCode不一样结果它们在HashMap里被当成两个不同的key存进去之后你用其中一个查另一个永远查不到。反过来如果两个不同对象的hashCode巧合相同它们会落在同一个桶通过equals进一步区分所以equals仍然必要。这个规则不是我编的它是Java Object规范里的约定两个对象equals相等则hashCode必须相等。所以自己定义实体类要作为Map的key或放进Set时请一定用IDE自动生成hashCode和equals别偷懒手写。4.5 HashMap的key到底有序吗这个问题在面试里被问烂了HashMap的key有序吗答案很明确HashMap不保证任何顺序。因为哈希散列本身是随机的元素的遍历顺序和插入顺序可能完全不同而且扩容后顺序还会变。如果你需要保持插入顺序用LinkedHashMap它内部通过额外的双向链表维护了插入顺序所以遍历顺序和插入顺序一致。如果你需要按key排序用TreeMap底层是红黑树默认按key的自然顺序比如String的字典序、Integer的大小排序也可以传入自定义的Comparator。同样地热搜里还出现了list转分组后这种问题其实就是Java 8的Collectors.groupingBy由分组后的Map默认是HashMap顺序不固定但如果你用groupingBy(Function, LinkedHashMap::new, Collectors.toList())这种三参重载就能控制返回有序的Map。4.6 Map线程安全Hashtable、ConcurrentHashMap怎么选HashMap不是线程安全的多线程同时put可能造成数据覆盖、扩容死循环Java 7容易出问题Java 8修复了环链但依然有覆盖问题。所以并发场景下不能直接用HashMap。可选方案有三个Hashtable老古董了所有方法都加了synchronized简单粗暴但并发效率极低相当于对整个哈希表串行访问基本不推荐。Collections.synchronizedMap包装一层同步锁也是锁整个Map性能提升有限。ConcurrentHashMap正确选择。Java 8之后它采用了CAS synchronized锁桶的方式锁的粒度从整表细化到单个桶并发度大幅提升。读操作几乎不加锁写操作只锁住当前桶不同桶之间的读写可以并行。4.7 Map转字符串和JSON开发中经常要把Map转成JSON比如接口返回。常见做法有用Fastjson、Jackson、Gson。搜“map转json字符串工具”本质是用JSON序列化库把Map转成字符串。需要注意的一点是如果Map的key是自定义对象序列化时容易出问题建议key直接用String、Integer这种基础类型。还有HashMap的字段顺序在JSON里可能不固定接口返回的字段顺序不稳定如果给前端展示有顺序要求用LinkedHashMap。5. Set详解无序不重复背后的真相Set家族看起来简单就三个字不重复但真正问起来还是有很多门道。5.1 HashSet底层就是HashMap前面已经点破HashSet底层是HashMap。我们看源码HashSet里有一个private transient HashMapE,Object map;添加元素时public boolean add(E e) { return map.put(e, PRESENT) null; }那个PRESENT是一个被所有元素共享的new Object()占位值。判断重复的逻辑完全依赖HashMap的key去重机制先比hashCode再比equals。所以向HashSet里放自定义对象时也要遵守equals和hashCode的约定否则去重会失效。HashSet不保证顺序遍历结果跟插入顺序无关因为底层哈希表的桶位置由hashCode决定。5.2 LinkedHashSet能记住插入顺序的SetLinkedHashSet继承自HashSet底层是LinkedHashMap。它在保证不重复的基础上额外用双向链表维护了插入顺序。所以遍历LinkedHashSet时输出顺序和添加顺序一致。适合的场景需要去重、又希望保留原始顺序比如把一些列表去重后按原顺序输出用LinkedHashSet就很合适。5.3 TreeSet自动排序去重TreeSet底层是TreeMapTreeMap本身是红黑树添加的元素会按照某种规则排序。默认按自然顺序比如Integer从小到大String按字典序也可以传Comparator自定义排序规则。注意TreeSet判断元素是否重复不是靠hashCode和equals而是靠Comparator.compare(a, b) 0或者Comparable.compareTo()返回0。所以如果你自定义排序规则时排序相等和业务相等可能不一致这一点要特别小心见过有人在这里翻了车。5.4 Set的几种去重方案对比实现类底层结构顺序是否允许null去重依据HashSetHashMap无序允许一个nullhashCode equalsLinkedHashSetLinkedHashMap保持插入顺序允许一个nullhashCode equalsTreeSetTreeMap红黑树按比较器排序取决于比较器compare / compareTo5.5 实际开发中的Set使用建议单纯去重直接HashSet最快最简单。去重并保持顺序LinkedHashSet。需要有序去重如排行榜、字典排序TreeSet。大集合去重时优先用HashSet不要去搞复杂的双重for循环O(n^2)在几万条数据下就能卡死你。6. 集合综合实战遍历、排序、分组与常见坑6.1 遍历时删除元素最容易踩的坑很多人写代码时喜欢这样删除集合中的元素for (String s : list) { if (s.equals(xxx)) { list.remove(s); } }这样写十有八九会抛出ConcurrentModificationException并发修改异常。原因很简单foreach循环实际上是基于迭代器iterator来遍历的循环里每次next()前都会检查expectedModCount和集合的modCount是否一致。你直接调list.remove()modCount变了迭代器立刻发现版本不一致直接抛异常。正确做法有三种// 方式一使用 Iterator 的 remove会让迭代器同步 modCount IteratorString it list.iterator(); while (it.hasNext()) { String s it.next(); if (s.equals(xxx)) { it.remove(); } } // 方式二使用 removeIfJava 8 推荐 list.removeIf(s - s.equals(xxx)); // 方式三转化成新集合过滤 ListString result list.stream().filter(s - !s.equals(xxx)).collect(Collectors.toList());removeIf是日常里最推荐的方式一行搞定也不用关心迭代器同步问题。我自己就在生产代码里见过有人因为忘了这个坑遍历时删除元素导致线上偶发崩溃排查半天还怪框架不稳定。6.2 List转数组、数组转List的正确姿势数组转List常有人直接用Arrays.asList()String[] arr {a, b, c}; ListString list Arrays.asList(arr);但很多人不知道这个方法返回的List其实是Arrays内部的私有内部类ArrayList它定长不支持add、remove操作你往里面添元素会直接抛UnsupportedOperationException。正确的可变列表用法是ListString list new ArrayList(Arrays.asList(arr));List转数组比较简单String[] arr list.toArray(new String[0]);Java 8以后传一个长度为0的数组就行源码里最终会按list大小生成新数组这比提前按list.size()创建数组要更安全避免并发下大小变化。6.3 Stream流式操作让集合处理像流水线Java 8的Stream API极大改变了集合操作方式。几个高频场景排序list.stream() .sorted(Comparator.comparing(User::getAge).reversed()) .collect(Collectors.toList());分组MapString, ListUser byCity users.stream() .collect(Collectors.groupingBy(User::getCity));去重ListInteger distinctList list.stream().distinct().collect(Collectors.toList());转MapMapInteger, String idToName users.stream() .collect(Collectors.toMap(User::getId, User::getName, (oldValue, newValue) - newValue));第三个参数是为了解决key冲突如果不传当出现相同key时会抛IllegalStateException: Duplicate key。这个坑也很常见分组后的数据转Map时经常因为没有处理重复key直接报错。6.4 Collections工具类的几种实用操作Collections.sort(list)对List排序底层调的是List自己的sort方法。Collections.reverse(list)倒序。Collections.shuffle(list)随机打乱洗牌。Collections.unmodifiableList(list)返回一个只读视图一旦有人尝试修改就会抛异常。把内部集合暴露给外部调用方时用这个保护数据安全。Collections.emptyList()返回一个不可变的空List避免分配内存也避免了返回null的坏习惯。这里要特别强调一个编程习惯接口返回集合时尽量不要返回null返回空集合更好。空集合能让调用方直接放心地for循环遍历而不用写一堆null判空。6.5 不可变集合从Java 9开始更优雅的写法Java 9开始List.of()、Set.of()、Map.of()可以快速创建不可变集合。它们的特点是元素不可增删改。不接受null元素。非常适合定义常量列表或作为方法默认返回值。ListString list List.of(a, b, c); SetInteger set Set.of(1, 2, 3); MapString, Integer map Map.of(k1, 1, k2, 2);注意Map.of()最多支持10个键值对。超过10个要用Map.ofEntries()。这在面试里偶尔也会问到记住就好。6.6 线程安全的 ListCopyOnWriteArrayList 与 Collections.synchronizedList如果需要在多线程环境下使用List最简单的方案是ListString list Collections.synchronizedList(new ArrayList());它对每个方法都加了同步锁简单但并发性能一般。另一个选择是java.util.concurrent.CopyOnWriteArrayList它的核心思想是写操作增删改时复制一份新的底层数组在新数组上修改后替换原数组读操作不加锁直接读老数组。所以它特别适合读多写少的场景比如缓存配置、白名单列表。但如果写频繁每次写都复制数组代价会非常大用之前要斟酌一下。7. 面试高频提问链路与易错点集中盘点集合是Java面试八股文的重灾区但也是最好拿分的地方因为它有标准答案。我把最容易考、最容易说错的问题集中列一遍。7.1 高频面试问题速查ArrayList和LinkedList的区别是什么ArrayList扩容机制是什么为什么是1.5倍初始容量多少HashMap的底层数据结构是什么样的链表什么时候转红黑树为什么HashMap负载因子是0.75为什么重写equals必须重写hashCodeHashMap的key有序吗什么时候用LinkedHashMap什么时候用TreeMapHashMap和Hashtable、ConcurrentHashMap的区别HashMap是线程安全的吗JDK 7和JDK 8中并发下有什么不同HashSet怎么保证元素不重复TreeSet和HashSet的区别集合遍历删除元素为什么会抛ConcurrentModificationException如何解决Arrays.asList()得到的List能增删吗List和Set的区别我先给一套面试思路具体细节你按上面看过的内容自己组织。核心是任何实现类的原理题都要从底层数据结构、时间复杂度和为什么要这样设计三个维度答。比如HashMap的题先说是数组加链表加红黑树再说哈希冲突的解决思路最后提扰动函数、负载因子、扩容对面就知道你真的理解。7.2 我对几道烧脑题的理解第一道HashMap的key如果是自定义对象需要注意什么明确答案必须重写hashCode和equals。重写hashCode时要有足够的散列性避免大量对象落到同一个桶。重写equals时要保证和业务上一一对应。如果这个对象将来可能被修改最好用不可变对象作为key。String和Integer之所以完美适合做key就是因为它们不可变。第二道List和Set相互转换需要注意什么List转Set能去重但会丢失原本的顺序除非用LinkedHashSet。Set转List会丢失无序特性而且如果Set里是TreeSet转出来的List已经排好序了。实际操作中我经常用new ArrayList(new LinkedHashSet(list))来对列表去重且保持顺序。第三道怎么遍历Map最高效Map的遍历方式有很多但要注意别在for循环里重复map.get(key)。推荐用entrySet()for (Map.EntryString, Integer entry : map.entrySet()) { String k entry.getKey(); Integer v entry.getValue(); }如果只想要key或value分别用keySet()和values()。Java 8的forEach也可以map.forEach((k, v) - System.out.println(k v));7.3 易错点集中盘点null值处理HashMap允许一个null key和多个null valueHashtable和ConcurrentHashMap不允许null存在。TreeMap是否允许null key取决于Comparator。Arrays.asList()返回的不是java.util.ArrayList而是Arrays内部类不能增删。自定义对象放入HashSet不做去重先自查有无重写hashCode和equals。用比较Integer超过127时可能为false因为Integer缓存范围是-128到127。集合里的Integer对象比较一定要用equals。使用Collections.unmodifiableList包装后的集合修改的是原引用不是抛异常注意视图概念。subList()返回的是原List的一个视图不是副本对subList做修改会影响原List这也算一个经典坑。7.4 学习路径零基础怎么啃下集合如果你真的是零基础我建议不要一上来就背源码按这个顺序走先会用把ArrayList、HashMap、HashSet的基本操作写熟包括增删改查和遍历。再看源码打开IDEA对着ArrayList、HashMap的源码逐行读不懂的方法就查文档不用记每一行记住关键方法和核心逻辑。再做实验写个main方法打日志观察扩容时机、链表长度变化、TreeSet排序效果。最后刷题背题在会谈的基础上把面试题用自己的话复述出来不要背标准答案要背为什么。我个人见过很多小伙伴上来就啃HashMap红黑树源码结果一周后全忘光原因就是没有在会用阶段积累基础。先把代码跑起来再深入理解远比你硬啃源码有效得多。我在实际调优项目里见过一个非常典型的反面案例某服务的接口每次请求都把上万个对象塞进一个HashSet去重结果响应时间越来越慢。排查后才发现那个对象只重写了equals没重写hashCode导致去重完全失效甚至因为哈希冲突严重某些桶拉出了巨长的链表查询退化成近乎遍历。最后把equals和hashCode重写性能立刻恢复正常。这种问题面试题里写得再清楚都不如自己在线上遇到一次来得深刻。集合这个东西学的时候觉得零散但等你在实战里踩过几次坑、翻过几次源码你会发现它们全部围绕两个核心问题数据存在哪里查找快不快。搞明白了底层存储结构和哈希设计思路剩下的都是围绕这两个问题展开的工程权衡。你现在把这些点吃透以后再遇到什么CopyOnWriteArrayList、BlockingQueue、ConcurrentSkipListMap只要顺着这个思路去看都能很快上手。