ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java集合框架避坑指南:从List到HashMap源码与实战

Java集合框架避坑指南:从List到HashMap源码与实战 自从环境变量配置折腾了一晚上终于搞定把 “Hello World” 跑出来的那一刻我真的觉得自己离 Java 大神不远了。前三弹里我陆陆续续把运算符、流程控制、数组、方法、面向对象这些基础过了一遍甚至冒泡排序也手动写了好几遍用 IDE 跑通的时候还挺有成就感。直到正式开始学集合框架我才发现自己完全想多了。数组长度固定、循环里删除元素直接抛异常、HashMap 在多线程下数据莫名其妙就没了……这些问题一个接一个冒出来我才反应过来基础语法只是开胃菜集合框架才是新手被“编程现实”毒打的第一站。这一弹我不打算再背 API 了而是想用踩坑驱动学习把 List、Set、Map 这些最常用的集合从使用到源码逻辑彻底捋一遍。如果你也是自学 Java 的新手正好卡在集合这里或者马上要准备 java 面试题、看到一堆八股文就头疼那我这篇记录应该能让你少走不少弯路。1. 为什么第四弹死磕集合框架1.1 前三弹的学习路线回顾先简单回顾一下我自己的学习进度方便你对得上号。第一弹主要是 Java 环境变量配置和第一个 Hello World。看起来简单但我当时连 PATH 和 JAVA_HOME 都分不清跟着网上的教程在命令行敲java -version一直提示找不到命令。后来才搞明白环境变量配置完必须重新打开终端窗口才会有反应。这个小细节很多教程根本没提。第二弹开始写真正的代码变量类型、运算符、if/else、switch、for 和 while 循环。那时候写了个九九乘法表、判断闰年的小练习再配合数组和下标玩了一些简单逻辑。第三弹进入面向对象类、对象、封装、继承、多态还有接口和抽象方法。顺带手写了冒泡排序、选择排序理解了数组是怎么被“搬来搬去”的。到了这里我原本以为已经可以把 Java 当地球语言用了。结果一打开集合框架的教程里面全是“Collection”“List”“Set”“Map”“泛型”“迭代器”“比较器”瞬间觉得自己回到了刚看环境变量配置的那个晚上。说句实话第四弹选择死磕集合不是因为我学得有多快而是我发现自己不会的东西实在太多了绕不过去。1.2 集合框架是新手的第一道分水岭为什么说集合框架是分水岭因为数组能解决的问题太碎了。比如我要存班里所有人的成绩数组长度一开始就得定死。要是半路转来一个新同学数组就得重新 new 一个更大的再把旧数据一个个复制过去。这只是最简单的场景。如果我要在中间插入一条数据数组后面的所有元素都得手动往后挪要删除一条数据又得手动往前挪。用数组写这种代码烦得让人想摔键盘。集合框架把这些通用操作都封装好了List 可以直接在任意位置插入元素Set 会自己去重Map 可以像查字典一样用 key 找到 value。更重要的是集合是后面几乎所有 Java 面试题、框架源码、企业项目里的基础结构。你去看 Spring、MyBatis 的源码到处都是各种集合的操作。如果连集合都理解不透后面学框架基本就是看天书。所以我的策略很明确这一弹不贪多只把集合框架的核心部分啃下来遇到报错就去查源码争取做到“为什么”和“怎么做”都能说清楚。2. 先从需求出发List、Set、Map到底怎么选2.1 数组的局限性和集合的诞生先聊聊最根本的问题为什么有了数组还要搞出集合这套东西数组最大的问题有三个。第一是长度固定声明时必须指定容量后面不能变。第二是访问方式单一只能通过下标来定位元素想在某个位置插入或删除代价都很大。第三是它只能保存同一种类型的数据如果业务上需要“一个班级里有学生姓名、学号、成绩”你用数组写会非常别扭。集合框架就是为了解决这些问题出现的。它把“存储数据”和“怎么操作数据”分开你只管往集合里放数据扩容、自动装箱、迭代这些事情都由集合内部搞定。尤其是泛型登场后集合还能在编译期帮你检查类型提前拦下很多低级错误。2.2 三个接口的语义对比新手最容易犯的错误是一上来就背 ArrayList、HashMap 这些实现类的名字结果不知道它们背后对应的接口是什么。其实只要把三个顶层接口搞明白了后面的选择就顺理成章。List有序、可重复。就像排队打饭谁先来谁排在前面而且可以有两个长得一模一样的人同时排队。Set无序、不可重复。就像身份证号集合任何两个元素都不能相同而且不保证顺序。Map键值对。每个 key 映射到一个 value就像查字典key 是词条value 是释义key 不能重复。实际写代码时你的第一步永远不是“我要用 ArrayList”而是“我的需求是排队场景还是要去重还是要按 key 查值”。2.3 常用实现类与适用边界接口确定之后再去选实现类就容易多了。我给自己做了个表照着选基本不会出错接口常用实现类特点适用场景ListArrayList底层是数组查询快插入删除慢读多写少、随机访问频繁ListLinkedList底层是双向链表插入删除快随机访问慢频繁头部/尾部插入删除SetHashSet基于 HashMap无序去重只要去重不关心顺序SetLinkedHashSet基于 LinkedHashMap能保持插入顺序需要去重且希望按插入顺序遍历SetTreeSet基于 TreeMap默认排序需要去重且想自动排序MapHashMap无序基于哈希表查询 O(1)绝大多数键值对场景MapLinkedHashMap能保持插入顺序支持访问顺序LRU 缓存等MapTreeMap按 key 自然顺序或自定义排序需要按 key 排序遍历这张表不是背下来的而是用出来的。我一开始总记混 TreeSet 和 TreeMap后来自己写了个小程序往里面塞一组随机数循环打印出来看顺序马上就记住了。2.4 我当初用错实现类的两个例子光看表格还是没有体感我分享两个自己踩过的真实例子。第一个例子是我用ArrayList做“插入排序”练习。当时想在列表头部反复插入数据写完一执行数据量小感觉还行数据量一大就明显变慢。查了源码才发现ArrayList的头部插入需要把后面所有元素全部后移一位。在头部插入一次代价是 O(n)连续插入 n 次代价就是 O(n²)。后来换成LinkedList头部插入是 O(1)速度直接起飞。第二个例子是我用HashSet装一组对象想去重。结果发现同名字、同学号的两个对象居然都进去了。原因是我没有重写equals和hashCode。HashSet判断重复不是靠“看着像”而是先比较hashCode是否相同再用equals进一步确认。自定义对象如果不重写这两个方法那么每个对象默认都是“独立个体”根本无法去重。这个坑后面我再细说。3. 从源码看ArrayList扩容和HashMap的哈希散列3.1 ArrayList的扩容机制新手用ArrayList的时候很少会去想它到底是怎么做到“长度可变”的我第一次看源码的时候恍然大悟ArrayList底层其实还是一个Object[]数组。当元素数量超过数组长度时它会创建一个更大的新数组再把旧数组里的所有元素复制过去最后替换引用。这就是扩容。关键细节有几个无参构造创建ArrayList时初始数组其实是空的只有在第一次添加元素时才初始化为容量 10 的数组。之后每次扩容都是“旧容量 旧容量右移一位”也就是扩大为原来的 1.5 倍不是翻倍。扩容的核心方法是Arrays.copyOf(elementData, newCapacity)本质是创建新数组并批量复制。知道了这个机制你就能理解为什么很多老手会在创建ArrayList的时候显式指定初始容量了。如果你提前知道要存 10 万条数据却不指定容量那它会从 10 开始不断扩容扩容到 10 万的过程里会做十几次数组复制白白浪费时间。new ArrayList(100000)直接一步到位性能差别肉眼可见。3.2 ArrayList与LinkedList的复杂度差异很多面试八股会问ArrayList 和 LinkedList 有什么区别我一开始只会背“数组 vs 链表”“查询快 vs 插入快”直到我亲自写代码验证才对复杂度有了真实感受。我用 10 万条数据做测试分别测试随机按下标取数据ArrayList耗时极短因为底层数组按下标访问是 O(1)LinkedList需要从头或尾开始遍历耗时明显更高。在列表头部添加数据ArrayList极慢所有元素都要后移LinkedList很快改几个指针就行。在列表中间插入数据理论上是LinkedList快但前提是已经拿到了对应位置的节点如果按下标插入它首先也要从头遍历找到那个位置实际效率不一定比ArrayList快。所以后来我的结论是不要迷信“LinkedList 插入快”要看具体操作。高频随机访问优先ArrayList高频头部插入且不关心随机访问才考虑LinkedList。绝大多数业务场景ArrayList是更稳妥的默认选择。3.3 HashMap的存储结构和put流程再说说HashMap。刚开始我以为它就是一张“大表”后来翻源码才知道它在 JDK 8 以后是“数组 链表 红黑树”三层结构。put 一个 key-value 的流程大致是这样对 key 的hashCode()做一次扰动计算得到最终哈希值。用哈希值和数组长度做位运算确定该元素落在数组的哪个下标。如果该下标位置为空直接放入。如果该下标位置已经有元素用equals比较 key。如果 key 相同就覆盖旧值如果 key 不同就挂在链表的尾部。如果链表长度超过阈值 8而且数组长度达到 64链表会转成红黑树避免查询退化成 O(n)。这个流程里为什么数组长度总是 2 的幂次方因为(数组长度 - 1) 哈希值相当于取模运算但前提是数组长度必须是 2 的幂。这样做位运算比取模更快而且能让元素分布更均匀。这是我以前完全没留意过的细节也是 HashMap 面试题里常见的考点。3.4 负载因子0.75为什么是经验值HashMap 还有一个默认参数叫负载因子默认是 0.75。意思是当元素数量达到数组长度的 75% 时就会触发扩容。为什么是 0.75 而不是 1 或者 0.5这其实是个时间和空间的折中。负载因子太小比如 0.5那么空间浪费严重明明数组还有一半的位置就要开始扩容。负载因子太大比如 1虽然空间用满了但哈希冲突会变得非常频繁链表变长查询效率下降。0.75 是大量实践总结出来的经验值在大多数场景下既能保证空间利用率又能让哈希冲突概率处在一个可接受的范围。如果你大概知道数据量也可以通过构造器指定初始容量和负载因子。比如new HashMap(16, 0.75f)这种写法正常业务很少去动负载因子但面试的时候能说出这层“为什么”会显得你对源码是真的理解了。4. 边学边踩集合操作里的五个高频坑4.1 循环中删除元素触发的ConcurrentModificationException这个坑应该每个 Java 新手都见过在 for-each 循环里直接调用list.remove()结果抛出ConcurrentModificationException。我一开始看异常栈一头雾水因为在单线程环境下我明明没有“并发”修改。后来查源码才知道ArrayList内部有一个modCount字段用来记录结构性修改次数。每次 add、remove 都会让modCount。而 for-each 循环底层用的是IteratorIterator 内部额外保存了一个expectedModCount在循环最开始时会等于modCount。每次调用next()都会检查这两个值是否一致一旦发现不一致就认为集合被“并发修改”了于是立刻抛异常。那怎么正确删除我总结了三种常见方式使用Iterator的remove()方法因为它会同步修改expectedModCount。使用 JDK 8 的removeIf()比如list.removeIf(item - item.equals(xx))源码内部帮你做了安全处理。使用倒序 for 循环从后往前遍历这样删除当前元素不会影响前面元素的下标。这三种方式我都跑过能正常删除。踩过这个坑之后我再也不会在 for-each 里直接调集合的 remove 方法了。4.2 不重写equals和hashCode导致去重失效前面提到过我用HashSet放自定义对象时去重失败。这里展开说一下。最开始我写了一个Student类里面有学号和姓名然后创建两个学号相同的对象放到同一个HashSet里。我以为它们会被当成同一个元素结果打印 set 的大小是 2。原因很好理解Object默认的hashCode()是基于内存地址算出来的两个不同对象即使内容一样哈希值也不一样。equals()默认是比较引用只有同一个对象才会返回 true。所以HashSet在去重时先因为哈希值不同直接跳过根本不会调用equals。解决办法就是在自定义类里重写equals和hashCode并且保证“相等的对象必须返回相同的 hashCode”。当时我只重写了equals没重写hashCode结果依然去重失败。后来重写了hashCode用学号作为哈希依据问题才彻底解决。这个知识点不只是HashSet的凡是依赖哈希的集合比如HashMap的 key、LinkedHashSet、HashTable都会受影响。如果你要用对象做 key也一定要重写这两个方法。4.3 Arrays.asList返回的“假List”顺手记录一个我用Arrays.asList踩的坑。我一开始以为Arrays.asList(a, b, c)返回的就是普通ArrayList于是写完直接对它执行add(d)结果抛了UnsupportedOperationException。后来看源码才发现Arrays.asList返回的是Arrays内部自己实现的一个私有ArrayList它继承自AbstractList但没有重写add和remove方法。也就是说它和java.util.ArrayList只是同名不是同一个类。这个“假 List”本质上是固定长度的数组视图只能遍历和修改已有元素不能增加或减少元素。解决方法是把它重新包一层new ArrayList(Arrays.asList(a, b, c))这样你就拿到真正的可变ArrayList了。另外还有一个经典坑如果传入的是int[]数组Arrays.asList(intArray)生成的 List 元素类型会是int[]也就是说这个 List 里只有一个元素。要处理基础类型数组需要转成Integer[]或者用循环一个个添加。4.4 集合转数组的数组转型坑集合转数组新手容易直接写成(String[]) list.toArray()然后程序运行时报ClassCastException。原因是toArray()无参方法返回的是Object[]Java 不允许直接把Object[]强转成String[]。正确做法是调用带泛型的版本list.toArray(new String[0])。这里也有个面试爱问的细节new String[0]和new String[list.size()]哪个性能更好以前的建议是传入容量为 list.size() 的数组避免内部再次创建数组。但从 JDK 8 开始源码对传入数组容量小于集合大小的场景做了优化如果传入数组容量不够会重新创建一个同类型、容量等于集合大小的数组。所以我现在都直接写new String[0]代码简洁也能避免多线程下集合大小变化导致的数组长度猜测问题。4.5 HashMap多线程并发数据丢失第五个坑不是我自己写多线程代码踩的而是一个朋友说他们生产环境出现了偶发数据丢失最后定位到 HashMap 并发 put 导致的。JDK 7 及以前HashMap 并发扩容时可能会产生循环链表导致 get 的时候死循环。JDK 8 修复了这个问题改用尾插法但并不是说 JDK 8 的 HashMap 就线程安全了。并发情况下多个线程同时 put 可能触发扩容扩容过程中会重建内部数组和链表/红黑树这期间其他线程读到的可能是不完整的结构轻则数据不一致重则直接丢数据。所以一旦明确有多个线程会同时访问同一个 Map千万不要用HashMap。要么用Collections.synchronizedMap(new HashMap())要么直接用ConcurrentHashMap。我后来写并发测试直接统一用ConcurrentHashMap省心很多。5. 用一道经典面试题检验学习成果手写简单LRU缓存5.1 为什么选这道题学了集合总得找道题来验证自己是不是真的理解了。我特意选了 LRU 缓存这道题。因为 LRU 全称是 Least Recently Used最近最少使用淘汰策略它同时用到了 HashMap 的快速查询能力和链表的顺序维护能力正好把集合框架的核心知识组合起来。很多 java 面试题和八股文里都有这道题网上也有各种实现方案。我决定先不看答案自己试一遍。5.2 基于LinkedHashMap的实现第一版实现我用的是LinkedHashMap。只要把构造参数accessOrder设为true它就会在最开始的时候按照插入顺序维护链表一旦某个 entry 被访问就会被移动到链表尾部。每次插入新元素时再判断当前大小是否超过容量如果超过就把链表头部最久未使用淘汰掉。代码写出来很简单import java.util.LinkedHashMap; import java.util.Map; public class LRUCacheK, V extends LinkedHashMapK, V { private final int capacity; public LRUCache(int capacity) { super(capacity, 0.75f, true); this.capacity capacity; } Override protected boolean removeEldestEntry(Map.EntryK, V eldest) { return size() capacity; } public static void main(String[] args) { LRUCacheInteger, String cache new LRUCache(2); cache.put(1, one); cache.put(2, two); System.out.println(cache.get(1)); cache.put(3, three); System.out.println(cache.keySet()); } }运行结果打印出来是[2, 3]。因为容量只有 2先放 1 和 2然后访问了 key11 被移到链表尾部链表顺序变成 2 - 1。接着插入 3容量超了淘汰链表头部 2剩下 1 和 3但刚才我只打印了 keySet理想情况应该是 [1, 3] 才对为什么输出是 [2, 3]这里我踩了一个细节坑LinkedHashMap的accessOrdertrue是在 get 的时候更新顺序。而get(1)返回了 value确实把 key1 移到了尾部但我的输出类名用了 keySet 的 toString它内部也有可能触发访问顺序更新吗正确答案是keySet()返回的集合视图迭代遍历时会按照当前链表顺序走不会触发访问顺序更新。所以输出 [2, 3] 说明 1 被淘汰了这不对。后来我发现问题出在我重写removeEldestEntry时用的条件size() capacity这个逻辑本身没错。但我的测试代码里先 put(1,1) 再 put(2,2)链表顺序是 1 - 2。然后 get(1)访问顺序更新为 2 - 1。接着 put(3,3)size 变成 3淘汰链表头部 2。所以最终是 1 - 3而不是 2 - 3。那我打印为什么是 [2, 3]我重新跑了一遍发现是因为我没有在 put(3) 之前打印 keySet而是只打印了一次。输出 [2, 3] 意味着 1 被淘汰了。想了半天原来问题出在构造参数上super(capacity, 0.75f, true)中的 initialCapacity 传的是 2扩容阈值是0.75 * 2 1put 第二个元素时就已经触发扩容不对LinkedHashMap 继承 HashMap扩容和链表顺序没有直接关系。再仔细一看我的测试代码里System.out.println(cache.keySet())是在 put(3) 之后此时 2 应该被淘汰但输出是 [2,3]说明 1 被淘汰了。唯一合理的解释是在调用get(1)之前链表顺序其实是 1 - 2get(1) 之后变为 2 - 1put(3) 之后 size3 2淘汰头部 2最终 keySet 理应是 [1, 3]。如果输出 [2, 3]只有一种可能accessOrder 没有真正生效所以 get 没有改变顺序put(3) 时淘汰了最老的 1。后来仔细检查发现我测试代码第一版写的是new LRUCache(2)但super(capacity, 0.75f, true)里的 accessOrder 参数传反了。我把true写成了false。改正后输出确实变成了 [1, 3]。这个调试过程虽然绕但也让我记住了LinkedHashMap的accessOrderfalse是默认的插入顺序true才是访问顺序。构造参数顺序千万别记错。5.3 复盘与收获通过这道题我至少搞明白了三件事LinkedHashMap的链表顺序维护原理其实就是每个 entry 多了 before 和 after 两个指针。removeEldestEntry的返回值决定是否移除最老的 entry默认返回 false也就是“永远不移除”。写 LRU 缓存时如果追求更好的并发性能和更清晰的结构可以用HashMap 双向链表自己实现但基于LinkedHashMap的方式确实是最简单的入门方案。这种“做一道题把多个知识点串起来”的学习方式比单纯背 API 效率高得多。我强烈建议新手学到集合后都用这种题来自测一下。6. 下一步计划从集合到反射、Lambda、Stream6.1 反射动态代理为什么是绕不开的下一个山头集合框架学完我大概知道自己离“能看懂框架源码”还差多远。下一步我准备啃反射和动态代理。可能很多新手和我一样觉得反射用不到。但只要你去看 Spring 的依赖注入、MyBatis 的 Mapper 代理、Hibernate 的实体映射到处都能看到反射。它们是框架能“偷懒”的基础也是很多 java 面试题里“动态代理”和“设计模式”的底层支撑。想理解动态代理还得先理解 Java 的类加载和反射机制。我现在计划是自己动手写一个简单的InvocationHandler实现一个小工具类看看同一个接口的方法调用是如何被拦截的。先不求弄懂全部细节但至少要能在 IDE 里跑通一个动态代理 Demo。6.2 Lambda和Stream如何重塑集合操作方式集合这一块还有一个绕不开的新语法Lambda 表达式和 Stream 流。JDK 8 引入这些特性之后集合操作可以写得非常简洁。比如从一堆数字里过滤出偶数并求和传统写法要写循环、if、临时变量用 Stream 可以写成一行int sum numbers.stream() .filter(n - n % 2 0) .mapToInt(Integer::intValue) .sum();我第一次看到这种写法的时候第一反应是“这真的是 Java 吗”后来学习 Lambda 语法才明白它本质上是对匿名内部类的简化。Stream 则是把集合当作数据流支持链式调用各种算子比如 filter、map、sorted、collect 等。不过我提醒自己不要为了用而用。数据量小时普通循环和 Stream 性能差距不大数据量大且使用parallelStream()时还要考虑线程安全的问题。对我来说先把函数式写法看明白再逐步用到实际练习里这个节奏比较稳。6.3 给同样在自学Java的人几条建议最后分享几条这阶段学习的体会也算给同路人提个醒。第一不要只囤资料。我刚学的时候收藏了几十个教程、几百道面试题真正打开看的没几个。后来改成“带着问题学”比如遇到ConcurrentModificationException就把源码翻出来看进步要快得多。第二一定要亲手跑代码。看一百遍别人的代码不如自己动手敲一遍。特别是集合这几个高频坑只有自己触发过一次异常印象才深。第三看异常堆栈信息时先别慌。新手遇到NullPointerException、ArrayIndexOutOfBoundsException就懵了其实只要顺着堆栈提示找到出错的哪一行往往一眼就能发现问题。这个习惯越早养成越好。第四可以像我一样把学习过程写成记录。写的时候你会发现很多你以为懂了的东西其实根本讲不清楚。讲不清楚的部分就是需要回头补课的地方。学完集合框架我对 Java 的整体认识终于清晰了一点。虽然知道自己离熟练开发还有不小的距离但至少再看到集合相关的代码时不会心里发怵了。下一步继续朝反射、Lambda 和框架源码的方向走。
返回列表