首页 / 资讯中心 / 文章详情

Java开发认证必考:数组与集合核心考点全拆解

Java开发认证必考:数组与集合核心考点全拆解 ★ FEATURED ARTICLE
上周刚把公司内部那场 Java 专业力认证考完出考场第一件事就是凭记忆把题目复原了一遍。整套卷子里Java 开发方向的数组和集合这两块占了将近四成从这段代码输出什么到下面哪个实现是线程安全的几乎把日常写业务代码时那些能跑就行的地方全翻出来重新问了一次。说实话平时 CRUD 写多了ArrayList顺手就 newHashMap顺手就 put真被追着问扩容倍率、树化阈值、fail-fast 触发条件的时候脑子里是要卡壳半秒的。这篇东西写给两类人一类是同样要应付企业级 Java 专业力测评、技术定级考试的朋友另一类是工作一两年、集合用得挺溜但底层原理一直没系统补过的同行。我会把这场考试的题型套路拆开讲然后把数组和集合里真正会被问到的技术点一个个抠出来——不是罗列 API而是讲清楚为什么这么设计这道题为什么这么挖坑考场上怎么在三十秒内判断出答案。里面所有的代码我都跑过输出结果都是实测的。你按这个思路过一遍无论是应付测评还是准备跳槽面这两块基本不会再被问住。1. 先摸清这场考试到底考什么动手复习之前最忌讳的就是抱着一本厚书从第一章开始看。专业力认证这类考试和学校期末考不太一样它的出题逻辑是岗位能力画像倒推题目也就是说出题人心里有一张表写着一个合格的中级 Java 开发应该具备哪些能力然后每项能力配几道题。摸清这张表比刷一百道偏题有用得多。1.1 题型分布与能力分层我回忆整理了一下这套卷子的结构大致是这样的题型数量占比单题分值特点单选题约 40%1 分概念辨析为主干扰项设计得很有迷惑性多选题约 25%2 分少选不得分考的是知识边界的完整性代码阅读题约 20%2-3 分给一段代码问输出坑基本都在细节上判断题约 10%1 分多数是绝对化表述看到一定必然要警惕编程/填空约 5%5 分通常是集合转换或排序去重的小场景从分值结构能看出来多选题和代码阅读题才是拉开差距的地方。单选大家蒙一蒙也能对个七七八八多选题少选一个就零分代码阅读题则完全没有运气成分。我在备考时把 70% 的精力压在多选和代码阅读上事后证明这个分配是对的。能力分层上卷子明显分了三档第一档是会不会用比如List和Set的区别、ArrayList和LinkedList怎么选第二档是知不知道为什么比如HashMap容量为什么必须是 2 的幂、负载因子为什么是 0.75第三档是踩没踩过坑比如Arrays.asList()返回的列表能不能 add。第三档的题最容易失分因为它们的正确答案往往和直觉相反。一个很实际的建议复习前先找一份岗位 JD 或者自己的能力项清单对照着标一遍完全不懂/知道名词/能讲清楚/能写代码只复习前两档第三档直接跳过。时间有限的时候广度比深度更划算。1.2 为什么数组和集合是绕不开的必考区这个问题的答案其实挺朴素数组和集合是 Java 业务代码里出现频率最高的数据结构没有之一。你随便打开一个项目ListDTO传参、Map做分组聚合、Set做去重到处都是。出题人不需要刻意设计场景日常代码本身就是题库。更关键的是这块知识天然具备层层递进的特性非常适合做能力分层的载体。同样是问HashMap初级题问它是不是有序的中级题问它线程安全吗高级题问JDK8 里面红黑树的树化条件是什么、为什么退化阈值是 6 而树化阈值是 8。一道题能把人的水平卡得很准这是出题人最喜欢的题型。另外一个隐性原因是集合框架里塞满了设计模式的活教材——迭代器模式、适配器模式、模板方法、策略模式全都能在java.util包里找到对应实现。所以集合这块答得好往往能给面试官或阅卷人传递一个信号这个人读过源码不是只会调 API。1.3 我的三周复习节奏安排我给自己排了三周每晚一到两小时周末加量。具体节奏是这样的第一周只做扫盲把Collection和Map两条继承线上的所有接口和常用实现类画成一张图强迫自己默写一遍。这一步的目的不是记住而是让脑子里有个索引——看到TreeSet能立刻反应出它底层是TreeMap看到LinkedHashSet能反应出它多了个双向链表。第二周专攻源码细节重点是ArrayList的扩容、HashMap的 put 流程和 resize 流程。我的方法是对着 JDK 源码一个方法一个方法地读读完立刻合上源码自己写一遍伪代码写不出来的地方就是没读懂。这周最痛苦但也最有效。第三周纯刷题和复盘把历年真题和网上能找到的模拟题做一遍每道错题都写清楚错在哪一步推理形成自己的错题本。最后两天只翻错题本不再看新东西。2. 数组被低估的基本功很多人觉得数组太简单了int[] arr new int[10]谁不会写。但考下来我发现数组这块的失分率其实不低因为它简单在表面、复杂在边界。尤其是数组和集合互相转换的那几个 API坑多到离谱。2.1 从内存布局理解数组的快与死数组在 JVM 里的结构是三段式对象头、长度字段、连续的元素数据。关键就在连续这两个字上——所有元素在堆内存里挨着放只要知道首地址和元素类型大小第 n 个元素的地址就是首地址 n * 元素大小一次寻址就到位时间复杂度 O(1)。这个特性带来的好处是 CPU 缓存友好。现代 CPU 读内存是按缓存行通常 64 字节整块读的你访问arr[0]的时候arr[1]到arr[7]假设是 long 数组大概率已经被顺带装进缓存了后续访问快到几乎无感。这也是为什么ArrayList在绝大多数遍历场景下性能碾压LinkedList——后者每个节点在堆上东一个西一个缓存命中率极低。但连续也带来了死穴长度一旦确定就不可变。你要加元素只能新建一个更大的数组然后把数据搬过去。ArrayList的所谓动态扩容本质上就是新建数组 拷贝 换引用一次扩容就是一次 O(n) 的操作。所以如果你的场景是元素数量可预估直接new int[预期大小]是最省事的做法。还有一个容易被忽略的点数组的length是属性不是方法arr.length后面不加括号字符串的length()是方法集合的size()是方法。这三个混着写在代码阅读题里经常出现看到arr.length()就知道这行编译不过。2.2 数组协变编译期放过你运行期找你算账数组有个特性叫协变意思是如果String是Object的子类那么String[]也是Object[]的子类。听起来挺合理但它埋了个大雷Object[] arr new String[3]; arr[0] hello; // 正常 arr[1] Integer.valueOf(1); // 编译通过运行抛 ArrayStoreException编译期完全不管因为编译器只检查你往 Object[] 里放 ObjectInteger确实是Object。但运行期 JVM 会做数组元素类型检查因为它记住这个数组的真实类型是String[]塞Integer进去就炸。这就是数组和泛型的一个根本差异。泛型是编译期擦除ListString在运行时就是List你塞个Integer进去通过反射或原始类型引用编译器拦不住、运行期也没人管只能靠你自己保证类型正确。而数组是运行期携带类型信息的安全但不够灵活。考题里常见的变体是Number[] nums new Integer[2]; nums[0] 1.5; // 这里抛 ArrayStoreException因为1.5被自动装箱成Double而数组的真实类型是Integer[]Double不是Integer直接抛异常。这类题只要抓住new的时候是什么类型运行时就是什么类型这一条就不会错。2.3 数组拷贝的三种姿势与性能差异数组拷贝是高频操作Java 提供了三条路性能差着量级System.arraycopy(src, srcPos, dest, destPos, length)native 方法最快但要求源和目标都是数组且类型兼容否则抛ArrayStoreException或IndexOutOfBoundsException。Arrays.copyOf(original, newLength)内部就是调System.arraycopy好处是能自动创建新数组并处理长度变化的截断或补零代码更干净。手动 for 循环最慢但能做深拷贝和元素转换灵活性最高。需要特别强调的是这三种方式都是浅拷贝。如果数组元素是引用类型拷贝过去的只是引用两个数组指向同一批对象。下面这段代码可以验证StringBuilder[] a {new StringBuilder(x)}; StringBuilder[] b Arrays.copyOf(a, 1); b[0].append(y); System.out.println(a[0]); // 输出 xy不是 x要深拷贝只能自己遍历每个元素new一份或者走序列化。这一点在考对象数组拷贝后修改原数组是否影响新数组这类题时是核心判断依据。还有一个排序细节值得记Arrays.sort(int[])底层用的是双轴快排DualPivotQuicksort不稳定Arrays.sort(Object[])底层用的是 TimSort归并插入的混合算法稳定。所以当你既需要按分数排序、又希望同分的人保持原有顺序时一定要用对象数组或者List.sort()别用基本类型数组。2.4 数组转集合三个能把人送走的写法这块是代码阅读题的重灾区我统计了一下光这一个知识点就能衍生出五六道不同的题。第一个坑Arrays.asList()返回的不是java.util.ArrayList而是Arrays类内部的一个私有静态类java.util.Arrays$ArrayList。它内部直接持有原数组的引用长度固定。你可以set但不能add、不能remove否则抛UnsupportedOperationException。ListString list Arrays.asList(a, b, c); list.set(0, z); // OK list.add(d); // 抛 UnsupportedOperationException还有个更隐蔽的点因为它持有原数组引用所以你改原数组list 里的内容也跟着变反过来也一样。这在考下面代码输出什么的时候经常出现。第二个坑基本类型数组传进去会得到一个只有 1 个元素的 Listint[] arr {1, 2, 3}; Listint[] list Arrays.asList(arr); System.out.println(list.size()); // 输出 1原因很简单Arrays.asList的签名是asList(T... a)泛型参数T必须是引用类型。你把int[]传进去整个数组被当成一个Object塞进了变长参数里所以只有一个元素。这种题在卷子上出现的时候很多人第一反应是输出 3直接丢分。第三个坑List.of()JDK9返回的是完全不可变的集合连set都不行而且不接受null元素传 null 直接抛NullPointerException。有些老项目还在 JDK8答案就会不一样考试时如果没标明版本一般以 8 为准但遇到List.of就说明是 9 以上。正确的转法我都写在下面了可以直接抄// 方式一包一层得到可变的 ArrayList ListString mutable1 new ArrayList(Arrays.asList(a, b, c)); // 方式二Stream 收集JDK8 ListString mutable2 Arrays.stream(new int[]{1, 2, 3}) .boxed() .collect(Collectors.toList()); // 方式三手动循环最笨但最不会错 ListInteger mutable3 new ArrayList(); for (int v : new int[]{1, 2, 3}) { mutable3.add(v); }反过来List 转数组推荐list.toArray(new String[0])。虽然老教程教的是new String[list.size()]但从 JDK6 开始JVM 对new T[0]这个写法做了优化发现数组不够大会自己新建性能反而更好代码也简洁。别用(String[]) list.toArray()那样拿到的是Object[]强转直接抛ClassCastException。3. 集合框架把继承线理顺再谈细节集合这块的知识点密度远高于数组硬背是背不完的。我的做法是先花半小时把两条继承线的骨架画出来之后每学一个实现类就往骨架上挂一个节点。骨架立住了细节才有地方放。3.1 两条主干线与选型决策树java.util包里的集合分两大阵营Collection和Map。Collection下面又分List有序可重复、Set无序不重复、Queue队列。Map则是独立的一支存键值对。选型的时候我脑子里跑的是一条很快的决策链先问要不要存 key要就是Map不要就是Collection再问允不允许重复不允许就Set允许就List最后问是不是多线程环境是就切到java.util.concurrent包下的实现。这条链跑完基本不会选错。场景推荐实现一句话理由需要按下标随机访问ArrayList数组结构O(1) 随机访问频繁在头部插入删除ArrayDeque循环数组比 LinkedList 快需要保持插入顺序的 MapLinkedHashMap双向链表维护顺序需要按 key 排序TreeMap红黑树O(log n) 有序去重且要求顺序LinkedHashSet哈希 链表读多写少的并发场景CopyOnWriteArrayList写时复制读不加锁高并发 MapConcurrentHashMapCAS 分段锁头节点一个很反直觉但确实成立的结论LinkedList在实际项目里几乎不该被用作 List。教科书上说它插入删除快但那是有前提的——你得已经持有那个位置的节点引用。如果没有光是get(i)走到那个位置就要 O(n)。再加上每个Node对象的内存开销对象头 16 字节 三个引用 24 字节一个节点至少 40 字节起步以及糟糕的缓存局部性实测下来LinkedList在尾部插入的场景都比ArrayList慢。真要当队列用选ArrayDeque。3.2 ArrayList 扩容一次扩容到底发生什么ArrayList的扩容是整个集合框架里最常被考的计算题必须滚瓜烂熟。先看基础参数默认容量DEFAULT_CAPACITY 10但懒初始化——无参构造出来的ArrayList里是个空数组只有第一次add的时候才会真正分配 10 个槽位。这个设计是为了省内存毕竟空列表太常见了。扩容公式是$$ newCapacity oldCapacity (oldCapacity 1) $$也就是1.5 倍向下取整。实际走一遍初始 10第 11 个元素进来时扩容到 15第 16 个元素进来时扩容到 2215 的 1.5 倍是 22.5右移运算向下取整得 22第 23 个进来时到 33。你可以自己写个小程序把elementData的长度打印出来验证用反射能拿到。扩容过程中做三件事算新容量、判断是否超过上限Integer.MAX_VALUE - 8留 8 个位置是因为某些 JVM 实现会在数组对象头里存额外信息、调Arrays.copyOf把老数据搬过去。这里有个很实用的技巧如果你能预估元素数量一定要用带参构造。// 已知要塞 1000 条数据 ListOrder list new ArrayList(1000);这能省掉中间十几次扩容和几十万次元素拷贝。如果只用new ArrayList()从 10 涨到 1000 需要扩容 6 次10→15→22→33→49→73→109→163→244→366→549→823→1234累计搬运约 3700 次引用。数据量小的时候无所谓量大就是实打实的性能损耗。注意一个细节如果你预估的容量是 n而实际元素会略超一点建议直接给n * 4 / 3 1这样最后一次扩容能省掉。经验值不用较真但也确实有效。另一个常考的点是remove的重载ListInteger list new ArrayList(Arrays.asList(1, 2, 3)); list.remove(1); // 删的是下标 1即元素 2 list.remove(Integer.valueOf(1)); // 删的是元素 1当元素类型是Integer时传 int 走的是remove(int index)传 Integer 走的是remove(Object o)。代码阅读题里如果给的是list.remove(1)而列表里恰好有元素 1选项里一定会有两个都是删掉数字 1的迷惑项。3.3 HashMap 的三道防线哈希扰动、索引计算、扩容迁移HashMap是集合里出题密度最高的必须拆到底层。它的结构演进分两代JDK7 是数组 链表头插法JDK8 改成数组 链表 红黑树尾插法。考试默认考 JDK8。第一道防线是哈希扰动static final int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); }把 hashCode 的高 16 位右移下来和低 16 位做异或。为什么要这么干因为计算下标的时候用的是(n - 1) hashn 是数组长度通常不大16、32、64n - 1的二进制高位全是 0参与运算的只有 hash 的低几位。如果两个 key 的 hashCode 只有高位不同、低位相同它们就会撞到同一个桶里。扰动函数把高位特征混进低位能显著降低碰撞率。第二道防线是容量必须是 2 的幂。因为(n - 1) hash只有在 n 是 2 的幂时才等价于取模而且位运算比取模快得多。这就是为什么HashMap即使你传new HashMap(100)内部也会通过tableSizeFor把你拉到一个不小于 100 的 2 的幂——128。第三道防线是负载因子 0.75。这个值的取舍逻辑是太小浪费空间太大碰撞率高。0.75 是在空间和时间之间找的平衡点源码注释里提到在 0.75 的负载因子下桶中元素数量达到 8 的概率大约是千万分之六低到可以接受所以树化阈值定在了 8。树化的完整条件有两条必须同时满足链表长度 ≥ 8TREEIFY_THRESHOLD数组长度 ≥ 64MIN_TREEIFY_CAPACITY如果链表长度到了 8 但数组长度还没到 64HashMap不会树化而是直接调用resize()扩容。这个细节我见过太多人记错只记得长度 8 变红黑树忽略了数组容量这个前提。退化阈值是 6中间留了个 7 作为缓冲避免在 7、8 之间反复树化和退化。扩容流程在 JDK8 里有个非常巧妙的设计因为容量是翻倍的元素的新位置要么在原地要么在原位置 旧容量。判断方式就是看e.hash oldCap的结果是 0 还是 1是 0 就留在低位链是 1 就挪到高位链。这样就避免了 JDK7 里每个节点都要重新计算一次 hash 和 index。// JDK8 resize 的核心逻辑简化版 if ((e.hash oldCap) 0) { // 留在原索引 j } else { // 移到新索引 j oldCap }这个话题再延伸一步HashMap线程不安全。JDK7 里因为头插法并发扩容可能形成环形链表导致get死循环CPU 打满 100%。JDK8 改成尾插法解决了成环问题但并发 put 仍然会丢数据。所以多线程环境必须用ConcurrentHashMap没有例外。3.4 Set 家族与 Map 的孪生关系HashSet底层就是HashMap每次add实际上是往 map 里put(key, PRESENT)那个PRESENT是个静态的空 Object 常量。所以HashSet的所有特性都能从HashMap推出来无序、允许一个 null、O(1) 增删查、非线程安全。LinkedHashSet继承自HashSet但内部用的是LinkedHashMap多维护了一条双向链表来记录插入顺序。遍历的时候按插入顺序输出这一点在考下面代码输出顺序时是判断依据。TreeSet底层是TreeMap红黑树实现元素必须可比较——要么实现Comparable接口要么构造时传Comparator。它的操作是 O(log n)支持排序相关的 APIfirst()、last()、headSet()、tailSet()、subSet()。关于 TreeMap 有个高频考点key 不能为 null自然排序的情况下。因为插入时要用compareTo或者compare比大小null 没法比较直接抛NullPointerException。而HashMap是允许一个 null key 的因为 null 的 hash 被特殊处理成 0 了。还有个可以直接抄作业的实现——用LinkedHashMap手搓 LRU 缓存一共十几行class LRUCacheK, V extends LinkedHashMapK, V { private final int capacity; public LRUCache(int capacity) { super(capacity, 0.75f, true); // accessOrder true this.capacity capacity; } Override protected boolean removeEldestEntry(Map.EntryK, V eldest) { return size() capacity; } }关键在于第三个参数accessOrder传true这样每次get都会把该节点移到链表末尾最近最少使用的自然就跑到链表头部removeEldestEntry返回 true 时就被淘汰。这段代码几乎每年都会在笔试题的编程部分换个马甲出现。3.5 迭代器与 fail-fast报错其实是保护你ConcurrentModificationException这个异常很多人第一次遇到时一脸懵其实是集合在保护你。ArrayList内部有个modCount字段记录结构性修改的次数add、remove 会加set 不会。每次拿到迭代器时expectedModCount modCount会被记录下来。之后每次调next()都会检查这两个值是否相等不相等就抛异常。ListString list new ArrayList(Arrays.asList(a, b, c)); for (String s : list) { if (b.equals(s)) { list.remove(s); // 抛 ConcurrentModificationException } }增强 for 循环底层就是迭代器所以这么写必炸。正确的删除方式有三种// 方式一显式迭代器兼容所有版本 IteratorString it list.iterator(); while (it.hasNext()) { if (b.equals(it.next())) { it.remove(); // 会同步 expectedModCount } } // 方式二removeIfJDK8最简洁 list.removeIf(s - b.equals(s)); // 方式三倒序 for 循环按索引删 for (int i list.size() - 1; i 0; i--) { if (b.equals(list.get(i))) { list.remove(i); } }注意第三种必须倒序正序删会导致后面的元素前移、索引错位出现漏删。这个坑我早年在做批量审核的时候踩过删完发现列表里还有一堆没删干净的脏数据排查了半天才发现是索引问题。另外要区分fail-fast和fail-safe。ArrayList、HashMap是 fail-fast检测到并发修改立刻抛异常。CopyOnWriteArrayList、ConcurrentHashMap的迭代器是 fail-safe 的——CopyOnWriteArrayList的迭代器持有的是创建那一刻的数组快照遍历过程中别人怎么改都不影响你也不会抛异常代价是拿不到最新数据。4. 高频考点拆解与代码实操前面讲的是知识框架这一节讲的是考场上真正会出现的题目长什么样以及怎么用最短的路径得出答案。4.1 equals 与 hashCode 的契约不守约的代价这条契约只有两句话但违反了就会出一些神奇的 bug如果两个对象equals返回 true那么它们的hashCode必须相等。 如果两个对象的hashCode相等equals不一定返回 true。违反第一条会导致什么假设你自定义了一个User类只重写了equals没重写hashCode然后往HashSet里塞两个逻辑上相同的实例。HashSet判断元素是否存在的流程是先算hashCode定位到桶再在桶里用equals逐个比较。因为hashCode没重写两个对象的哈希值不同它们会被放进不同的桶equals根本没机会执行于是Set里出现了两个重复元素。class User { private String name; public User(String name) { this.name name; } Override public boolean equals(Object o) { if (this o) return true; if (!(o instanceof User)) return false; return Objects.equals(name, ((User) o).name); } // 故意不写 hashCode } SetUser set new HashSet(); set.add(new User(张三)); set.add(new User(张三)); System.out.println(set.size()); // 输出 2不是 1补上hashCode用Objects.hash(name)就行之后输出才会变成 1。这个知识点在考题里经常包装成以下哪个类可以作为 HashMap 的 key 且行为正确选项里会有只重写 equals 的类这种陷阱。顺带记一个实用的如果你用 LombokData会自动生成equals和hashCode。但如果是子类继承父类默认生成的equals不会调用父类的需要用EqualsAndHashCode(callSuper true)显式打开否则父类字段不参与比较又是一个隐形的坑。4.2 自动装箱缓存一道题分出两种人Integer a 127, b 127; Integer c 128, d 128; System.out.println(a b); // true System.out.println(c d); // false这题考的是IntegerCache。Integer类在初始化时会缓存 -128 到 127 之间的所有实例Integer.valueOf()在这个范围内直接返回缓存对象所以a和b指向同一个对象为 true。超出范围就new新对象c d为 false。要比较值一定用equals或者先intValue()再。这个坑在集合里尤其常见因为ListInteger里存的都是装箱对象list.get(0) 128这种写法必错。在考试里这类题通常会叠一层循环或者集合比如ListInteger list new ArrayList(); for (int i 0; i 200; i) list.add(i); SetInteger set new HashSet(list); System.out.println(set.size()); // 200虽然元素在 -128 到 127 之间有缓存但HashSet是按equals去重的不会因为对象相同就少算所以还是 200。别被缓存两个字带偏。4.3 并发场景下的集合选择考卷上关于并发的题答案模式非常固定记住这张表基本就能拿分非线程安全线程安全替代方案适用场景ArrayListCopyOnWriteArrayList读多写极少如监听器列表ArrayListCollections.synchronizedList()写操作也多但要手动同步遍历HashMapConcurrentHashMap高并发读写首选HashMapCollections.synchronizedMap()遗留代码兼容性能一般HashSetConcurrentHashMap.newKeySet()JDK8 推荐做法ConcurrentHashMap在 JDK8 的实现有几个必记点get 不加锁因为Node的val和next都是 volatile 的put 用 CAS synchronized 锁头节点锁粒度是单个桶而不是整张表size 用CounterCell数组分散计数类似LongAdder避免多线程同时更新一个变量导致 CAS 自旋。Collections.synchronizedList()有个非常容易忽略的坑它保证了单次操作是原子的但遍历不是。你必须自己加synchronized (list) { ... }把整个遍历包起来否则遍历过程被别的线程改了照样抛ConcurrentModificationException。ListString syncList Collections.synchronizedList(new ArrayList()); // 错误写法 for (String s : syncList) { ... } // 正确写法 synchronized (syncList) { for (String s : syncList) { ... } }4.4 Stream 收集器API 好用但不完全等价JDK8 之后集合转换里Stream的出场率越来越高考题也跟上了。Collectors.toMap有个必须知道的坑key 重复会抛异常。ListUser users ...; // 如果两个 user 的 name 相同这行抛 IllegalStateException: Duplicate key MapString, User map users.stream() .collect(Collectors.toMap(User::getName, u - u)); // 正确写法指定冲突时保留哪一个 MapString, User safeMap users.stream() .collect(Collectors.toMap(User::getName, u - u, (oldV, newV) - oldV));另外Collectors.toList()返回的ArrayList是可变的但Stream.toList()JDK16返回的是不可变的两者在能不能 add的题里答案相反。还有一点Collectors.toMap生成的HashMap是无序的如果题目要求输出有序结果得用LinkedHashMap::new作为第四个参数。分组聚合是另一个高频点MapString, ListUser byDept users.stream() .collect(Collectors.groupingBy(User::getDept)); MapString, Long countByDept users.stream() .collect(Collectors.groupingBy(User::getDept, Collectors.counting())); // 分组后保持顺序用三参数版本 MapString, ListUser ordered users.stream() .collect(Collectors.groupingBy(User::getDept, LinkedHashMap::new, Collectors.toList()));这几行建议直接背下来考试和工作中都用得上属于投入产出比最高的代码片段。4.5 Collections 工具类的隐藏用法java.util.Collections这个类名带 s是工具类和Collection接口不是一回事考卷上经常拿这个做文字游戏。常用方法里unmodifiableList是个视图不是拷贝原列表改了它跟着变只是你不能通过它来改ListString origin new ArrayList(Arrays.asList(a, b)); ListString view Collections.unmodifiableList(origin); origin.add(c); System.out.println(view.size()); // 输出 3 view.add(d); // 抛 UnsupportedOperationExceptionCollections.sort(list)和list.sort(comparator)完全等价后者是 JDK8 加的默认方法。Collections.emptyList()返回的是全局单例的空列表比new ArrayList()省内存在返回空结果的场景下推荐用。Collections.nCopies(n, obj)生成 n 个相同元素的列表创建全零初始化的列表时很好用。5. 常见问题与排查技巧实录这一节是我认为整篇最有价值的部分因为下面这些问题几乎全是我自己或者身边同事真实踩过的不是从文档里抄的。5.1 我踩过的五个坑第一个坑Arrays.asList之后直接add。刚工作那年写一个标签处理的需求Arrays.asList(split)拿到结果后顺手add了一个默认标签本地跑测试直接抛UnsupportedOperationException盯了半天才反应过来。现在我的习惯是只要用Arrays.asList外面必包一层new ArrayList(...)。第二个坑subList是视图。list.subList(0, 5)返回的不是新列表而是原列表的一个视图。你对它set原列表跟着变你对原列表做了结构性修改再操作subList就抛ConcurrentModificationException。要独立副本就new ArrayList(list.subList(0, 5))。第三个坑HashMap在遍历中put。这个和ArrayList一样会触发 fail-fast但HashMap更隐蔽——如果put的是已存在的 key只改 value 不增加 modCount不会抛异常如果put的是新 key必抛。这种有时候报错有时候不报的 bug 最难查。第四个坑TreeMap的 comparator 返回 0。我写过一个按金额排序的TreeMapcomparator直接(a, b) - a.getAmount() - b.getAmount()结果金额相同的两个 key 被当成同一个后面的把前面的覆盖了。TreeMap判断 key 相等的依据是compare返回 0和equals无关这是它和HashMap的一个根本差异。第五个坑数组和 List 的toString差异。int[]直接打印是[I1b6d3586这种鬼东西必须用Arrays.toString(arr)。但List直接打印是[1, 2, 3]因为它重写了toString。二维数组要用Arrays.deepToString。这个在调试的时候浪费的时间远比想象中多。5.2 问题速查表报错信息最常见原因快速定位方法UnsupportedOperationException对Arrays.asList/List.of结果做增删看 List 是怎么来的ConcurrentModificationException增强 for 循环中修改集合结构搜for (里有没有remove/put/addArrayStoreException数组协变后存入不兼容类型检查new时的实际类型ClassCastExceptionlist.toArray()后直接强转改用toArray(new T[0])NullPointerExceptionTreeMap插入 null key或List.of传 null检查 key 来源IllegalStateException: Duplicate keyCollectors.toMap遇到重复 key加 merge function元素莫名其妙丢失TreeMapcomparator 返回 0或HashSet元素没重写hashCode检查比较逻辑和 equals/hashCode5.3 考场上的时间分配与答题策略这部分纯经验。我给自己定的节奏是单选题每题不超过 40 秒多选题 90 秒代码阅读题 2 分钟编程题留 15 分钟收尾最后留 10 分钟检查。几个具体策略遇到输出什么的题先扫有没有这三个特征——有没有Integer比较装箱缓存、有没有remove带 int 参数重载歧义、有没有在循环里修改集合fail-fast。这三类占了代码阅读题的大头扫到就能直接锁定考点。多选题宁可少选不要多选吗不一定。要看评分规则如果少选得部分分那就只选百分百确定的如果少选不得分那就得把模棱两可的也赌上因为全错的期望值和少选一样是 0 分但赌对了能拿满分。判断题看到绝对化表述先打问号。ArrayList 是线程安全的这种明显错误好判断难的是HashMap 的 key 一定不能为 null——这题答案是错的因为HashMap允许一个 null key不能为 null 的是Hashtable和自然排序的TreeMap。凡是出现一定必然所有的八成是错的但要确认清楚错在哪。编程题先把能跑通的版本写出来再优化。我见过太多人在编程题上纠结用不用 Stream、要不要考虑空指针结果时间不够连个能编译的版本都没交。先把for循环写出来跑通再看时间加优化这是最稳的策略。6. 一份可以直接照着过的复习清单最后我把整个复习内容整理成一张打钩清单。你可以把它当成自测表每一行都能讲清楚原理并且写出代码这块就算过关了。模块需要掌握的具体点自检数组基础length是属性、协变与ArrayStoreException、浅拷贝能写代码复现异常数组工具Arrays.sort稳定性差异、copyOfvsarraycopy、toStringvsdeepToString能说清各自适用场景数组转集合Arrays.asList的三个坑、toArray(new T[0])能背出三个坑List 实现ArrayList1.5 倍扩容、懒初始化、remove重载能算出扩容序列集合选型LinkedList为什么通常不选、ArrayDeque的优势能给出三条理由HashMap扰动函数、2 的幂、0.75 负载因子、树化双条件、JDK8 扩容高低位能手画 put 流程Set 家族与 Map 的孪生关系、TreeSet排序要求、LinkedHashSet顺序能说出底层实现有序 MapTreeMapcomparator 返回 0 的陷阱、LinkedHashMap实现 LRU能默写 LRU 代码迭代器fail-fast 原理、三种安全删除方式、fail-safe 对比能写出正确删除契约equals/hashCode 契约、只重写 equals 的后果能复现 HashSet 重复装箱IntegerCache-128~127、集合里的陷阱能说出输出结果并发集合ConcurrentHashMapJDK8 实现要点、CopyOnWriteArrayList适用场景能对比三种方案StreamtoMap重复 key、groupingBy三参数版本能写出分组代码工具类unmodifiableList是视图、CollectionsvsCollection能说清区别我个人在实际操作中的体会是这套东西真正吃透的标志不是你记住了多少结论而是看到一个陌生的代码片段时脑子里能自动跑出它内部大概在做什么。比如看到map.put你会条件反射地想到 hash 扰动、桶定位、链表尾插、可能触发扩容或树化这一整套流程那才算真的懂了。考前那一周我每天早上花二十分钟随机挑一个集合类合上所有资料在白纸上把它的核心方法流程默写一遍写不出来的地方就是当天要补的点。这个方法看着笨但对代码阅读题的效果好得出奇因为出题人的思路和你默写时踩的坑往往是同一批。如果后续还想往深了挖建议顺着两条线扩展一条是去看java.util.concurrent包里其他实现ConcurrentSkipListMap、BlockingQueue家族的实现思路都很有意思另一条是去读一下Stream的惰性求值机制理解了Stream怎么把操作串成流水线再看Collectors的各种实现就通透了。这两块内容在高级别的测评里出现的频率不低值得单独花时间。
阅读完成 · 觉得有帮助?
咨询建站